From 1bb5d93a0b1760a5d6ac6cc13cf04bb61c016d23 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 9 Jul 2025 16:23:25 -0500 Subject: [PATCH 1/4] chore(clerk-js): Extend `buildRedirectToHandshake` to accept additional search parameters for improved observability and add a query parameter for tracking delta between `session.iat` and `client_uat` --- .../backend/src/tokens/__tests__/handshake.test.ts | 12 ++++++++++++ packages/backend/src/tokens/handshake.ts | 11 ++++++++++- packages/backend/src/tokens/request.ts | 13 +++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 51c26f45ba1..4520360913d 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -179,6 +179,18 @@ 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); + expect(location).toBeDefined(); + if (!location) { + throw new Error('Location header is missing'); + } + 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 { From 639d5209626d2679bf41076c62a7162e401f05f1 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 9 Jul 2025 16:35:37 -0500 Subject: [PATCH 2/4] Create changeset --- .changeset/wet-dryers-dance.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wet-dryers-dance.md 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` From 1d2e6f8fddef855035c43fce1d52b4d3e1aafc20 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 9 Jul 2025 16:56:52 -0500 Subject: [PATCH 3/4] remove redundant location verification Co-authored-by: Tom Milewski --- packages/backend/src/tokens/__tests__/handshake.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 4520360913d..92a96b8016f 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -183,9 +183,6 @@ describe('HandshakeService', () => { const headers = handshakeService.buildRedirectToHandshake('test-reason', { iat_uat_delta: '100' }); const location = headers.get(constants.Headers.Location); expect(location).toBeDefined(); - if (!location) { - throw new Error('Location header is missing'); - } const url = new URL(location); expect(url.searchParams.get('iat_uat_delta')).toBe('100'); From e54086cd7cc6a9c0783162338f787c3d2816e812 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 9 Jul 2025 17:12:57 -0500 Subject: [PATCH 4/4] work-around we can't assert non-null with `!` --- packages/backend/src/tokens/__tests__/handshake.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 92a96b8016f..c8caa372a4c 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -181,8 +181,7 @@ 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); - expect(location).toBeDefined(); + const location = headers.get(constants.Headers.Location) ?? ''; const url = new URL(location); expect(url.searchParams.get('iat_uat_delta')).toBe('100');