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..66412e0a5e4 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,40 @@ 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, + // Encodes the nested URL the way the real Client.buildTouchUrl() does, so a + // destination carrying its own query or hash survives the round trip. + buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) => { + const touchUrl = new URL('https://clerk.example.com/v1/client/touch'); + touchUrl.searchParams.set('redirect_url', redirectUrl.toString()); + return touchUrl.toString(); + }, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = vi.fn(); + await sut.load(); + await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' }); + + 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?tab=security#settings', 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..9eaa2d6732c 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -656,6 +656,25 @@ export class Clerk implements ClerkInterface { return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); } + /** + * Routes `url` through `/v1/client/touch` when the client cookie is close to expiring, + * and returns it unchanged otherwise. + * + * In practice this only adds a redirect on Safari. A cookie re-issued from a fetch is + * capped at 7 days by ITP; every other browser gets the full 400 days and is never + * eligible. Touch is a top-level navigation, which ITP does not cap, so it restores + * the full lifetime. + */ + #decorateUrlWithTouch(url: string): string { + // In React Native, window exists but window.location does not, so the URL cannot be + // resolved to an absolute one. ITP does not apply there either. + if (!inBrowser() || typeof window.location === 'undefined' || !this.client?.isEligibleForTouch()) { + return url; + } + + return this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: new URL(url, window.location.href) })); + } + public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => { if (!this.client || this.client.sessions.length === 0) { return; @@ -696,7 +715,9 @@ export class Clerk implements ClerkInterface { if (signOutCallback) { await signOutCallback(); } else { - await this.navigate(redirectUrl); + // The client outlives sign-out and is what Client Trust uses to recognize a + // returning device, so the re-issued cookie has to keep its full lifetime. + await this.navigate(this.#decorateUrlWithTouch(redirectUrl)); } }); @@ -1826,21 +1847,9 @@ export class Clerk implements ClerkInterface { // Track whether decorateUrl was called for dev-mode warning let decorateUrlCalled = false; - /** - * Creates a URL that goes through the /v1/client/touch endpoint when Safari ITP fix is needed. - * This allows the session cookie to be refreshed via a full page navigation, bypassing - * Safari's 7-day cap on cookies set via fetch/XHR. - */ const decorateUrl = (url: string): string => { decorateUrlCalled = true; - - if (!this.client?.isEligibleForTouch()) { - return url; - } - - const absoluteUrl = new URL(url, window.location.href); - const touchUrl = this.client.buildTouchUrl({ redirectUrl: absoluteUrl }); - return this.buildUrlWithAuth(touchUrl); + return this.#decorateUrlWithTouch(url); }; await setActiveNavigate({ session: newSession, decorateUrl });