Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/app-store/salesforce/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" })
Expand Down
155 changes: 123 additions & 32 deletions packages/app-store/salesforce/lib/CrmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand All @@ -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 ({
Copy link
Contributor Author

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

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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll query the introspection endpoint if there is no token_lifetime property on the credential or if we get a token error if the credential was marked as not expired but was. This is to handle the unlikely case that an org changes their token life spans.

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) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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({
Expand All @@ -188,6 +257,28 @@ class SalesforceCRMService implements CRM {
instanceUrl: credentialKey.instance_url,
accessToken: credentialKey.access_token,
refreshToken: credentialKey.refresh_token,
refreshFn: async (conn, callback) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New addition to the jsforce.connection if we get an token error then retry to refetch the token only once

// 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)));
}
},
});
};

Expand Down
Loading
Loading