-
Notifications
You must be signed in to change notification settings - Fork 12k
fix: Salesforce tokens add token_lifetime
#27264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cf0b8d9
c6b55a8
336aa63
f8e793d
0d83165
8e5f38b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
|
||
| private getClient = async (credential: CredentialPayload) => { | ||
| /** | ||
| * 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 credentialKey = credential.key as unknown as ExtendedTokenResponse; | ||
|
|
||
| if (!credentialKey.refresh_token) | ||
| throw new Error(`Refresh token is missing for credential ${credential.id}`); | ||
| 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, | ||
| }), | ||
| }); | ||
|
|
||
| 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 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) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll query the introspection endpoint if there is no |
||
| tokenLifetime = await getSalesforceTokenLifetime({ | ||
| accessToken: accessTokenParsed.access_token, | ||
| instanceUrl: accessTokenParsed.instance_url, | ||
| }); | ||
| if (!response.ok) { | ||
| const message = `${response.statusText}: ${JSON.stringify(await response.json())}`; | ||
| throw new Error(message); | ||
| } | ||
| } | ||
|
|
||
| const accessTokenJson = await response.json(); | ||
| // Update credential in database | ||
| const updatedKey = { | ||
| ...accessTokenParsed, | ||
| refresh_token: refreshToken, | ||
| token_lifetime: tokenLifetime, | ||
| }; | ||
|
|
||
| const accessTokenParsed: ParseRefreshTokenResponse<typeof salesforceTokenSchema> = | ||
| parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema); | ||
| await CredentialRepository.updateWhereId({ | ||
| id: this.credentialId, | ||
| data: { key: updatedKey }, | ||
| }); | ||
|
|
||
| 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 { | ||
| accessToken: accessTokenParsed.access_token, | ||
| instanceUrl: accessTokenParsed.instance_url, | ||
| issuedAt: accessTokenParsed.issued_at, | ||
| tokenLifetime, | ||
| }; | ||
| }; | ||
|
|
||
| private getClient = async (credential: CredentialPayload) => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same method as before to get the jsforce client |
||
| const { consumer_key, consumer_secret } = await getSalesforceAppKeys(); | ||
| 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}`); | ||
|
|
||
| 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added new check to see if the token is still valid to avoid making a token call on every request. All access tokens are still valid until they are expired. Issuing a new token does not invalidate any existing tokens. |
||
| 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 | ||
| } | ||
| } | ||
|
|
||
| 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) => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New addition to the |
||
| // Only attempt refresh once to avoid infinite loops | ||
| if (this.hasAttemptedRefresh) { | ||
joeauyeung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return callback(new Error("Token refresh already attempted")); | ||
| } | ||
| this.hasAttemptedRefresh = true; | ||
|
|
||
| try { | ||
| // Force introspection to recalibrate token_lifetime after unexpected expiry | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While we handle this case. It will be very unlikely. |
||
| 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))); | ||
| } | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Extract the call to refresh the access token