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:
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
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user