fix: Salesforce tokens add token_lifetime (#27264)

* Create `getSalesforceTokenLifetime`

* When connecting salesforce, add token_lifetime

* Migrate token_lifetime and refetch if token expiry doesn't match

* Add tests for Salesforce token lifetime feature

- Add unit tests for getSalesforceTokenLifetime function
- Add integration tests for token lifecycle management in CrmService
- Fix type error in CrmService.ts by extracting refreshToken variable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* chore: re-trigger CI

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2026-01-27 10:00:48 -03:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> joe@cal.com <j.auyeung419@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 13840764fb
commit 01ae6f1b9e
5 changed files with 478 additions and 34 deletions
+13 -1
View File
@@ -9,6 +9,7 @@ import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";
import { getSalesforceTokenLifetime } from "../lib/getSalesforceTokenLifetime";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
@@ -41,7 +42,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const salesforceTokenInfo = await conn.oauth2.requestToken(code as string);
await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, salesforceTokenInfo, req);
// Get token lifetime via introspection
const tokenLifetime = await getSalesforceTokenLifetime({
accessToken: salesforceTokenInfo.access_token,
instanceUrl: salesforceTokenInfo.instance_url,
});
// Store token with token_lifetime
await createOAuthAppCredential(
{ appId: appConfig.slug, type: appConfig.type },
{ ...salesforceTokenInfo, token_lifetime: tokenLifetime },
req
);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "salesforce" })
+123 -32
View File
@@ -17,6 +17,8 @@ import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import { findFieldValueByIdentifier } from "../../routing-forms/lib/findFieldValueByIdentifier";
@@ -50,6 +52,7 @@ export interface SalesforceCRM extends CRM {
getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string): string;
}
import { getSalesforceAppKeys } from "./getSalesforceAppKeys";
import { getSalesforceTokenLifetime } from "./getSalesforceTokenLifetime";
import { SalesforceGraphQLClient } from "./graphql/SalesforceGraphQLClient";
import getAllPossibleWebsiteValuesFromEmailDomain from "./utils/getAllPossibleWebsiteValuesFromEmailDomain";
import getDominantAccountId from "./utils/getDominantAccountId";
@@ -104,12 +107,13 @@ type Attendee = { email: string; name: string };
const salesforceTokenSchema = z.object({
id: z.string(),
issued_at: z.string(),
issued_at: z.string(), // Salesforce returns this in milliseconds as a string
instance_url: z.string(),
signature: z.string(),
access_token: z.string(),
scope: z.string(),
token_type: z.string(),
token_lifetime: z.number().optional(), // Token lifetime in seconds (from introspection)
});
class SalesforceCRMService implements CRM {
@@ -122,9 +126,12 @@ class SalesforceCRMService implements CRM {
private fallbackToContact = false;
private accessToken: string;
private instanceUrl: string;
private hasAttemptedRefresh = false;
private credentialId: number;
constructor(credential: CredentialPayload, appOptions: z.infer<typeof appDataSchema>, testMode = false) {
this.integrationName = "salesforce_other_calendar";
this.credentialId = credential.id;
if (!testMode) {
this.conn = this.getClient(credential).then((c) => c);
}
@@ -139,44 +146,106 @@ class SalesforceCRMService implements CRM {
return this.appOptions;
}
/**
* Refreshes the Salesforce access token and optionally introspects to get/update token_lifetime.
* @param forceIntrospection - If true, always introspect to recalibrate token_lifetime (e.g., after unexpected expiry)
* @param existingTokenLifetime - The current token_lifetime to reuse if not forcing introspection
*/
private refreshAccessToken = async ({
refreshToken,
forceIntrospection,
existingTokenLifetime,
}: {
refreshToken: string;
forceIntrospection: boolean;
existingTokenLifetime?: number;
}) => {
const { consumer_key, consumer_secret } = await getSalesforceAppKeys();
const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: consumer_key,
client_secret: consumer_secret,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const message = `${response.statusText}: ${JSON.stringify(await response.json())}`;
throw new Error(message);
}
const accessTokenJson = await response.json();
const accessTokenParsed = parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
// Introspect if forced or if we don't have a token_lifetime yet
let tokenLifetime = existingTokenLifetime;
if (forceIntrospection || !tokenLifetime) {
tokenLifetime = await getSalesforceTokenLifetime({
accessToken: accessTokenParsed.access_token,
instanceUrl: accessTokenParsed.instance_url,
});
}
// Update credential in database
const updatedKey = {
...accessTokenParsed,
refresh_token: refreshToken,
token_lifetime: tokenLifetime,
};
await CredentialRepository.updateWhereId({
id: this.credentialId,
data: { key: updatedKey },
});
return {
accessToken: accessTokenParsed.access_token,
instanceUrl: accessTokenParsed.instance_url,
issuedAt: accessTokenParsed.issued_at,
tokenLifetime,
};
};
private getClient = async (credential: CredentialPayload) => {
const { consumer_key, consumer_secret } = await getSalesforceAppKeys();
const credentialKey = credential.key as unknown as ExtendedTokenResponse;
const credentialKey = credential.key as unknown as ExtendedTokenResponse & { token_lifetime?: number };
if (!credentialKey.refresh_token)
throw new Error(`Refresh token is missing for credential ${credential.id}`);
try {
/* XXX: This code results in 'Bad Request', which indicates something is wrong with our salesforce integration.
Needs further investigation ASAP */
const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: consumer_key,
client_secret: consumer_secret,
refresh_token: credentialKey.refresh_token,
}),
});
if (!response.ok) {
const message = `${response.statusText}: ${JSON.stringify(await response.json())}`;
throw new Error(message);
const refreshToken = credentialKey.refresh_token;
// Check if token is still valid
// issued_at is in milliseconds (string), token_lifetime is in seconds
const BUFFER_MS = 5 * 60 * 1000; // 5 minutes buffer before expiry
const issuedAt = parseInt(credentialKey.issued_at, 10);
const tokenLifetimeMs = (credentialKey.token_lifetime || 0) * 1000;
const expiryTime = issuedAt + tokenLifetimeMs;
const isTokenValid = credentialKey.token_lifetime && Date.now() < expiryTime - BUFFER_MS;
if (!isTokenValid) {
try {
const result = await this.refreshAccessToken({
refreshToken,
forceIntrospection: false,
existingTokenLifetime: credentialKey.token_lifetime,
});
// Update instance variables and credentialKey for the connection
this.accessToken = result.accessToken;
this.instanceUrl = result.instanceUrl;
credentialKey.access_token = result.accessToken;
credentialKey.issued_at = result.issuedAt;
credentialKey.token_lifetime = result.tokenLifetime;
} catch (err: unknown) {
console.error(err); // log but proceed
}
const accessTokenJson = await response.json();
const accessTokenParsed: ParseRefreshTokenResponse<typeof salesforceTokenSchema> =
parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
await prisma.credential.update({
where: { id: credential.id },
data: { key: { ...accessTokenParsed, refresh_token: credentialKey.refresh_token } },
});
} catch (err: unknown) {
console.error(err); // log but proceed
}
return new jsforce.Connection({
@@ -188,6 +257,28 @@ class SalesforceCRMService implements CRM {
instanceUrl: credentialKey.instance_url,
accessToken: credentialKey.access_token,
refreshToken: credentialKey.refresh_token,
refreshFn: async (conn, callback) => {
// Only attempt refresh once to avoid infinite loops
if (this.hasAttemptedRefresh) {
return callback(new Error("Token refresh already attempted"));
}
this.hasAttemptedRefresh = true;
try {
// Force introspection to recalibrate token_lifetime after unexpected expiry
const result = await this.refreshAccessToken({
refreshToken,
forceIntrospection: true,
});
this.accessToken = result.accessToken;
this.instanceUrl = result.instanceUrl;
callback(null, result.accessToken);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
},
});
};
@@ -55,14 +55,27 @@ vi.mock("../getSalesforceAppKeys", () => ({
}),
}));
vi.mock("../getSalesforceTokenLifetime", () => ({
getSalesforceTokenLifetime: vi.fn().mockResolvedValue(7200),
}));
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => ({
CredentialRepository: {
updateWhereId: vi.fn().mockResolvedValue({}),
},
}));
// Helper to create mock credential
const createMockCredential = () => {
const createMockCredential = (options?: { tokenLifetime?: number; issuedAt?: string }) => {
const now = Date.now();
return {
id: 1,
key: {
access_token: "test_access_token",
refresh_token: "test_refresh_token",
instance_url: "https://test.salesforce.com",
issued_at: options?.issuedAt ?? String(now),
token_lifetime: options?.tokenLifetime ?? 7200,
},
type: "salesforce_other_calendar",
user: {
@@ -354,4 +367,166 @@ describe("SalesforceCRMService", () => {
});
});
});
describe("Token lifecycle management", () => {
it("should not refresh token when token_lifetime is valid and not expired", async () => {
const now = Date.now();
const credential = createMockCredential({
issuedAt: String(now),
tokenLifetime: 7200,
});
const { appOptions } = salesforceSettingScenario.createOnLeadAndSearchOnAccount();
fetchMock.mockReset();
const crmService = createSalesforceCrmServiceWithSalesforceType(credential, appOptions);
salesforceMock.addLead({
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
});
await crmService.getContacts({ emails: "test@example.com" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("should refresh token when token is expired", async () => {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const credential = createMockCredential({
issuedAt: String(oneHourAgo),
tokenLifetime: 1800,
});
const { appOptions } = salesforceSettingScenario.createOnLeadAndSearchOnAccount();
fetchMock.mockReset();
fetchMock.mockReturnValueOnce(
Promise.resolve(
new Response(
JSON.stringify({
id: "abc",
issued_at: String(Date.now()),
instance_url: "https://test.salesforce.com",
signature: "123",
access_token: "new_access_token",
scope: "123",
token_type: "123",
}),
{ status: 200 }
)
)
);
const crmService = createSalesforceCrmServiceWithSalesforceType(credential, appOptions);
salesforceMock.addLead({
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
});
await crmService.getContacts({ emails: "test@example.com" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://login.salesforce.com/services/oauth2/token",
expect.objectContaining({
method: "POST",
})
);
});
it("should refresh token when token_lifetime is missing (legacy credentials)", async () => {
const credential = {
id: 1,
key: {
access_token: "test_access_token",
refresh_token: "test_refresh_token",
instance_url: "https://test.salesforce.com",
issued_at: String(Date.now()),
},
type: "salesforce_other_calendar",
user: { email: "test@example.com" },
userId: 1,
teamId: 1,
appId: "test_app_id",
invalid: false,
delegationCredentialId: null,
};
const { appOptions } = salesforceSettingScenario.createOnLeadAndSearchOnAccount();
fetchMock.mockReset();
fetchMock.mockReturnValueOnce(
Promise.resolve(
new Response(
JSON.stringify({
id: "abc",
issued_at: String(Date.now()),
instance_url: "https://test.salesforce.com",
signature: "123",
access_token: "new_access_token",
scope: "123",
token_type: "123",
}),
{ status: 200 }
)
)
);
const crmService = createSalesforceCrmServiceWithSalesforceType(credential, appOptions);
salesforceMock.addLead({
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
});
await crmService.getContacts({ emails: "test@example.com" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("should use 5-minute buffer before token expiry", async () => {
const tokenLifetime = 7200;
const bufferMs = 5 * 60 * 1000;
const issuedAt = Date.now() - (tokenLifetime * 1000 - bufferMs + 1000);
const credential = createMockCredential({
issuedAt: String(issuedAt),
tokenLifetime,
});
const { appOptions } = salesforceSettingScenario.createOnLeadAndSearchOnAccount();
fetchMock.mockReset();
fetchMock.mockReturnValueOnce(
Promise.resolve(
new Response(
JSON.stringify({
id: "abc",
issued_at: String(Date.now()),
instance_url: "https://test.salesforce.com",
signature: "123",
access_token: "new_access_token",
scope: "123",
token_type: "123",
}),
{ status: 200 }
)
)
);
const crmService = createSalesforceCrmServiceWithSalesforceType(credential, appOptions);
salesforceMock.addLead({
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
});
await crmService.getContacts({ emails: "test@example.com" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getSalesforceTokenLifetime } from "../getSalesforceTokenLifetime";
vi.mock("../getSalesforceAppKeys", () => ({
getSalesforceAppKeys: vi.fn().mockResolvedValue({
consumer_key: "test_consumer_key",
consumer_secret: "test_consumer_secret",
}),
}));
describe("getSalesforceTokenLifetime", () => {
const mockFetch = vi.fn();
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockReset();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("should return token lifetime calculated from exp and iat", async () => {
const iat = 1700000000;
const exp = 1700003600; // 1 hour later (3600 seconds)
const expectedLifetime = exp - iat;
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ iat, exp }),
});
const result = await getSalesforceTokenLifetime({
accessToken: "test_access_token",
instanceUrl: "https://test.salesforce.com",
});
expect(result).toBe(expectedLifetime);
expect(result).toBe(3600);
});
it("should call the introspection endpoint with correct parameters", async () => {
const iat = 1700000000;
const exp = 1700007200;
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ iat, exp }),
});
await getSalesforceTokenLifetime({
accessToken: "my_access_token",
instanceUrl: "https://my-instance.salesforce.com",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://my-instance.salesforce.com/services/oauth2/introspect",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
"Content-Type": "application/x-www-form-urlencoded",
}),
})
);
const callArgs = mockFetch.mock.calls[0];
const body = callArgs[1].body as URLSearchParams;
expect(body.get("token")).toBe("my_access_token");
expect(body.get("token_type_hint")).toBe("access_token");
});
it("should use Basic auth with base64 encoded credentials", async () => {
const iat = 1700000000;
const exp = 1700007200;
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ iat, exp }),
});
await getSalesforceTokenLifetime({
accessToken: "test_token",
instanceUrl: "https://test.salesforce.com",
});
const expectedAuth = `Basic ${Buffer.from("test_consumer_key:test_consumer_secret").toString("base64")}`;
const callArgs = mockFetch.mock.calls[0];
expect(callArgs[1].headers.Authorization).toBe(expectedAuth);
});
it("should throw error when introspection fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: "Unauthorized",
});
await expect(
getSalesforceTokenLifetime({
accessToken: "invalid_token",
instanceUrl: "https://test.salesforce.com",
})
).rejects.toThrow("Token introspection failed: Unauthorized");
});
it("should handle different token lifetimes", async () => {
const iat = 1700000000;
const exp = 1700086400; // 24 hours later (86400 seconds)
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ iat, exp }),
});
const result = await getSalesforceTokenLifetime({
accessToken: "test_token",
instanceUrl: "https://test.salesforce.com",
});
expect(result).toBe(86400);
});
});
@@ -0,0 +1,41 @@
import { getSalesforceAppKeys } from "./getSalesforceAppKeys";
/**
* Calls Salesforce's token introspection endpoint to get the token lifetime.
* Returns token lifetime in seconds (calculated from exp - iat).
*
* @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oidc_token_introspection_endpoint.htm
*/
export async function getSalesforceTokenLifetime({
accessToken,
instanceUrl,
}: {
accessToken: string;
instanceUrl: string;
}): Promise<number> {
const { consumer_key, consumer_secret } = await getSalesforceAppKeys();
const response = await fetch(`${instanceUrl}/services/oauth2/introspect`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${consumer_key}:${consumer_secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
token: accessToken,
token_type_hint: "access_token",
}),
});
if (!response.ok) {
throw new Error(`Token introspection failed: ${response.statusText}`);
}
const data = await response.json();
// Calculate lifetime from exp and iat (both in seconds)
// exp = expiration timestamp, iat = issued at timestamp
const tokenLifetime = data.exp - data.iat;
return tokenLifetime;
}