diff --git a/.changeset/wet-dryers-dance.md b/.changeset/wet-dryers-dance.md new file mode 100644 index 00000000000..97be3c516d2 --- /dev/null +++ b/.changeset/wet-dryers-dance.md @@ -0,0 +1,6 @@ +--- +"@clerk/backend": patch +--- + +Extend `buildRedirectToHandshake` to accept search params +and track delta between `session.iat` and `client.uat` in case `iat < uat` diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 51c26f45ba1..c8caa372a4c 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -179,6 +179,14 @@ describe('HandshakeService', () => { ); }); + it('should include additional search params when provided', () => { + const headers = handshakeService.buildRedirectToHandshake('test-reason', { iat_uat_delta: '100' }); + const location = headers.get(constants.Headers.Location) ?? ''; + const url = new URL(location); + + expect(url.searchParams.get('iat_uat_delta')).toBe('100'); + }); + it('should use proxy URL when available', () => { mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com'; // Simulate what parsePublishableKey does when proxy URL is provided diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 18ba6dc6080..c9e0562ec56 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -125,10 +125,11 @@ export class HandshakeService { /** * Builds the redirect headers for a handshake request * @param reason - The reason for the handshake (e.g. 'session-token-expired') + * @param additionalSearchParams - Additional search params to append to the handshake URL (e.g. to help with observability) * @returns Headers object containing the Location header for redirect * @throws Error if clerkUrl is missing in authenticateContext */ - buildRedirectToHandshake(reason: string): Headers { + buildRedirectToHandshake(reason: string, additionalSearchParams?: Record): Headers { if (!this.authenticateContext?.clerkUrl) { throw new Error('Missing clerkUrl in authenticateContext'); } @@ -163,6 +164,14 @@ export class HandshakeService { }); } + if (additionalSearchParams) { + Object.entries(additionalSearchParams).forEach(([key, value]) => { + if (typeof value !== 'undefined') { + url.searchParams.append(key, value); + } + }); + } + return new Headers({ [constants.Headers.Location]: url.href }); } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a8064934a8e..40f5c31c853 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -294,6 +294,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( reason: string, message: string, headers?: Headers, + handshakeSearchParams?: Record, ): SignedInState | SignedOutState | HandshakeState { if (!handshakeService.isRequestEligibleForHandshake()) { return signedOut({ @@ -306,7 +307,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( // Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else. // In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic. - const handshakeHeaders = headers ?? handshakeService.buildRedirectToHandshake(reason); + const handshakeHeaders = headers ?? handshakeService.buildRedirectToHandshake(reason, handshakeSearchParams); // Chrome aggressively caches inactive tabs. If we don't set the header here, // all 307 redirects will be cached and the handshake will end up in an infinite loop. @@ -534,7 +535,15 @@ export const authenticateRequest: AuthenticateRequest = (async ( } if (decodeResult.payload.iat < authenticateContext.clientUat) { - return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, ''); + // Track delta for observability + const delta = authenticateContext.clientUat - decodeResult.payload.iat; + return handleMaybeHandshakeStatus( + authenticateContext, + AuthErrorReason.SessionTokenIATBeforeClientUAT, + '', + undefined, + { iat_uat_delta: delta.toString() }, + ); } try {