From 0141915742a1b3604c90881f879fecf95c9fd2e4 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Mon, 27 Jul 2026 13:17:49 -0400 Subject: [PATCH] fix(clerk-js): apply the Safari ITP cookie refresh on sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing out re-issues the client cookie from a fetch, which Safari's ITP caps at 7 days. The client outlives sign-out — persistClient defaults to true, so signOut() removes the sessions and keeps the client — and the client is what Client Trust uses to recognise a known device. Leaving the cap in place meant a user who signed out and returned more than a week later had lost the cookie, arrived as a new client, and was challenged for a second factor. Route the sign-out redirect through /v1/client/touch when the cookie is close to expiring, which is the workaround setActive() already applies after sign-in: touch is reached by a top-level navigation, which ITP does not cap, so it restores the full lifetime. The redirect is returned unchanged when no refresh is needed, so this only adds a hop on Safari — every other browser gets a 400 day cookie and is never eligible. This covers the signOut() paths that navigate to a redirect URL, including and . signOut(callback) navigates on the caller's behalf and is left alone. The existing signOut() test clients gain the isEligibleForTouch and buildTouchUrl members that the real ClientResource always exposes. USER-5769 --- .../safari-itp-sign-out-cookie-refresh.md | 11 +++++ .../clerk-js/src/core/__tests__/clerk.test.ts | 41 +++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 24 ++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .changeset/safari-itp-sign-out-cookie-refresh.md diff --git a/.changeset/safari-itp-sign-out-cookie-refresh.md b/.changeset/safari-itp-sign-out-cookie-refresh.md new file mode 100644 index 00000000000..375c28825a3 --- /dev/null +++ b/.changeset/safari-itp-sign-out-cookie-refresh.md @@ -0,0 +1,11 @@ +--- +'@clerk/clerk-js': patch +--- + +Apply the Safari ITP cookie refresh on sign-out. + +Signing out re-issues the client cookie from a fetch, which Safari's ITP caps at 7 days. The client outlives sign-out, and it is what Client Trust uses to recognize a known device, so the cap meant a user who signed out and came back more than a week later was treated as being on a new device and challenged for a second factor. + +`signOut()` now routes its redirect through `/v1/client/touch` when the client cookie is close to expiring, the same workaround `setActive()` already applies after sign-in. The redirect is left unchanged when no refresh is needed. + +This covers `signOut()` calls that navigate to a redirect URL, including `` and ``. Passing your own callback to `signOut(callback)` still navigates on your behalf and is not decorated. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 98f3d3aac2a..5c59c1690ab 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1169,6 +1169,13 @@ describe('Clerk singleton', () => { const mockSession1 = { id: '1', remove: vi.fn(), status: 'active', user: {}, getToken: vi.fn() }; const mockSession2 = { id: '2', remove: vi.fn(), status: 'active', user: {}, getToken: vi.fn() }; const mockSession3 = { id: '3', remove: vi.fn(), status: 'pending', user: {}, getToken: vi.fn() }; + // Sign-out consults these to decide whether to route the redirect through + // /v1/client/touch. Default to "no refresh needed" so the existing cases + // assert against the plain redirect URL. + const clientTouchDefaults = { + isEligibleForTouch: () => false, + buildTouchUrl: () => 'https://clerk.example.com/v1/client/touch', + }; beforeEach(() => { mockClientDestroy.mockReset(); @@ -1198,6 +1205,7 @@ describe('Clerk singleton', () => { it('signs out all sessions if no sessionId is passed and multiple sessions have authenticated status', async () => { mockClientFetch.mockReturnValue( Promise.resolve({ + ...clientTouchDefaults, signedInSessions: [mockSession1, mockSession2, mockSession3], sessions: [mockSession1, mockSession2, mockSession3], destroy: mockClientDestroy, @@ -1221,6 +1229,7 @@ describe('Clerk singleton', () => { async status => { mockClientFetch.mockReturnValue( Promise.resolve({ + ...clientTouchDefaults, signedInSessions: [{ ...mockSession1, status }], sessions: [{ ...mockSession1, status }], destroy: mockClientDestroy, @@ -1244,6 +1253,7 @@ describe('Clerk singleton', () => { it('only removes the session that corresponds to the passed sessionId if it is not the current', async () => { mockClientFetch.mockReturnValue( Promise.resolve({ + ...clientTouchDefaults, signedInSessions: [mockSession1, mockSession2, mockSession3], sessions: [mockSession1, mockSession2, mockSession3], destroy: mockClientDestroy, @@ -1264,6 +1274,7 @@ describe('Clerk singleton', () => { it('removes and signs out the session that corresponds to the passed sessionId if it is the current', async () => { mockClientFetch.mockReturnValue( Promise.resolve({ + ...clientTouchDefaults, signedInSessions: [mockSession1, mockSession2, mockSession3], sessions: [mockSession1, mockSession2, mockSession3], destroy: mockClientDestroy, @@ -1284,6 +1295,7 @@ describe('Clerk singleton', () => { it('removes and signs out the session and redirects to the provided redirectUrl ', async () => { mockClientFetch.mockReturnValue( Promise.resolve({ + ...clientTouchDefaults, signedInSessions: [mockSession1, mockSession2, mockSession3], sessions: [mockSession1, mockSession2, mockSession3], destroy: mockClientDestroy, @@ -1300,6 +1312,35 @@ describe('Clerk singleton', () => { expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out'); }); }); + + it('routes the redirect through /v1/client/touch when the client cookie is close to expiring', async () => { + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession1], + sessions: [mockSession1], + destroy: mockClientDestroy, + removeSessions: mockClientRemoveSessions, + isEligibleForTouch: () => true, + buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) => + `https://clerk.example.com/v1/client/touch?redirect_url=${redirectUrl.toString()}`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = vi.fn(); + await sut.load(); + await sut.signOut({ redirectUrl: '/after-sign-out' }); + + await waitFor(() => { + expect(mockClientRemoveSessions).toHaveBeenCalled(); + const navigatedTo = new URL((sut.navigate as ReturnType).mock.calls[0][0]); + expect(navigatedTo.pathname).toEqual('/v1/client/touch'); + // The user still lands on the original destination, via the touch redirect. + expect(navigatedTo.searchParams.get('redirect_url')).toEqual( + new URL('/after-sign-out', mockWindowLocation.href).toString(), + ); + }); + }); }); describe('.navigate(to)', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b19474cdc65..517324abd1f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -656,6 +656,28 @@ export class Clerk implements ClerkInterface { return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); } + /** + * Routes the post-sign-out navigation through `/v1/client/touch` when the client + * cookie is close to expiring, and returns `redirectUrl` unchanged otherwise. + * + * In practice this only adds a redirect on Safari. Signing out re-issues the client + * cookie from a fetch, which ITP caps at 7 days; every other browser gets 400 days + * and is never eligible. The cap matters because the client outlives sign-out and is + * what Client Trust uses to recognize a known device, so a user returning a week + * later would be challenged for a second factor. Touch is a top-level navigation, + * which ITP does not cap, so it restores the full lifetime. + */ + #buildSignOutRedirectUrl(redirectUrl: string): string { + // In React Native, window exists but window.location does not, so the redirect + // cannot be resolved to an absolute URL. ITP does not apply there either. + if (!inBrowser() || typeof window.location === 'undefined' || !this.client?.isEligibleForTouch()) { + return redirectUrl; + } + + const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); + return this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl })); + } + public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => { if (!this.client || this.client.sessions.length === 0) { return; @@ -696,7 +718,7 @@ export class Clerk implements ClerkInterface { if (signOutCallback) { await signOutCallback(); } else { - await this.navigate(redirectUrl); + await this.navigate(this.#buildSignOutRedirectUrl(redirectUrl)); } });