diff --git a/.changeset/true-carpets-happen.md b/.changeset/true-carpets-happen.md new file mode 100644 index 00000000000..93e5054f142 --- /dev/null +++ b/.changeset/true-carpets-happen.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Stop the handshake redirect loop in development in browsers that block cross-origin redirect Strict cookies diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 0d140f8b093..1c61a25991c 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -877,6 +877,58 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState.toAuth()).toBeNull(); }); + test('cookieToken: returns signedIn when no clientUat but in redirect loop with valid session (Safari ITP workaround)', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + // Simulate Safari ITP scenario: valid session token, no client_uat, redirect loop detected + const requestState = await authenticateRequest( + mockRequestWithCookies( + {}, + { + __clerk_db_jwt: 'deadbeef', + __session: mockJwt, + __clerk_redirect_count: '1', // Redirect loop counter > 0 + }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + }), + ); + + expect(requestState).toBeSignedIn(); + expect(requestState.toAuth()).toBeSignedInToAuth(); + }); + + test('cookieToken: returns handshake when no clientUat and redirect loop but invalid session token', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + // Simulate scenario where we're in a redirect loop but the session token is invalid + const requestState = await authenticateRequest( + mockRequestWithCookies( + {}, + { + __clerk_db_jwt: 'deadbeef', + __session: mockMalformedJwt, + __clerk_redirect_count: '1', + }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + }), + ); + + // Should still return handshake since token verification failed + expect(requestState).toMatchHandshake({ reason: AuthErrorReason.SessionTokenWithoutClientUAT }); + expect(requestState.toAuth()).toBeNull(); + }); + test('cookieToken: returns handshake when no cookies in development [5y]', async () => { const requestState = await authenticateRequest( mockRequestWithCookies({}), diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 1d2aaaa6d1e..913c5a29780 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -538,8 +538,30 @@ export const authenticateRequest: AuthenticateRequest = (async ( }); } - // This can eagerly run handshake since client_uat is SameSite=Strict in dev + // This can eagerly run handshake since client_uat is SameSite=Strict in dev. + // However, Safari's ITP can block the client_uat cookie during cross-site redirects, + // causing infinite redirect loops. If we detect a redirect loop and have a valid + // session token, authenticate the user instead of triggering another handshake. if (!hasActiveClient && hasSessionToken) { + if (authenticateContext.handshakeRedirectLoopCounter > 0) { + const sessionToken = authenticateContext.sessionTokenInCookie; + if (sessionToken) { + try { + const { data } = await verifyToken(sessionToken, authenticateContext); + if (data) { + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext, + sessionClaims: data, + headers: new Headers(), + token: sessionToken, + }); + } + } catch { + // Token verification failed, proceed with normal handshake flow + } + } + } return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenWithoutClientUAT, ''); }