From 01ae6f1b9ea421bba8fa931c51e6f4cd80fae707 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:00:48 -0500 Subject: [PATCH] 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 * chore: re-trigger CI Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/app-store/salesforce/api/callback.ts | 14 +- .../app-store/salesforce/lib/CrmService.ts | 155 +++++++++++---- .../__tests__/CrmService.integration.test.ts | 177 +++++++++++++++++- .../getSalesforceTokenLifetime.test.ts | 125 +++++++++++++ .../lib/getSalesforceTokenLifetime.ts | 41 ++++ 5 files changed, 478 insertions(+), 34 deletions(-) create mode 100644 packages/app-store/salesforce/lib/__tests__/getSalesforceTokenLifetime.test.ts create mode 100644 packages/app-store/salesforce/lib/getSalesforceTokenLifetime.ts diff --git a/packages/app-store/salesforce/api/callback.ts b/packages/app-store/salesforce/api/callback.ts index 8c22c0ea57..90fc87c053 100644 --- a/packages/app-store/salesforce/api/callback.ts +++ b/packages/app-store/salesforce/api/callback.ts @@ -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" }) diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index d10884679c..3c725d7e17 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -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, 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 = - 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))); + } + }, }); }; diff --git a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts index cef574ab3f..d9dc5ed662 100644 --- a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts +++ b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts @@ -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); + }); + }); }); diff --git a/packages/app-store/salesforce/lib/__tests__/getSalesforceTokenLifetime.test.ts b/packages/app-store/salesforce/lib/__tests__/getSalesforceTokenLifetime.test.ts new file mode 100644 index 0000000000..5d2c2ee05f --- /dev/null +++ b/packages/app-store/salesforce/lib/__tests__/getSalesforceTokenLifetime.test.ts @@ -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); + }); +}); diff --git a/packages/app-store/salesforce/lib/getSalesforceTokenLifetime.ts b/packages/app-store/salesforce/lib/getSalesforceTokenLifetime.ts new file mode 100644 index 0000000000..8a32caa2ac --- /dev/null +++ b/packages/app-store/salesforce/lib/getSalesforceTokenLifetime.ts @@ -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 { + 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; +}