From 451396b5cd462c2883881547474bd4a25b31f415 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 10:06:51 -0400 Subject: [PATCH 1/6] fix(clerk-js): repair primary client after satellite sign-out --- .changeset/satellite-sign-out-device-trust.md | 7 + .../clerk-js/src/core/__tests__/clerk.test.ts | 152 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 21 ++- packages/clerk-js/src/core/fapiClient.ts | 1 + .../clerk-js/src/core/resources/Client.ts | 42 +++-- .../core/resources/__tests__/Client.test.ts | 72 +++++++++ packages/shared/src/types/client.ts | 1 + .../__tests__/ImpersonationFab.test.tsx | 2 +- .../src/components/ImpersonationFab/index.tsx | 4 +- .../__tests__/TaskChooseOrganization.test.tsx | 2 +- .../tasks/TaskChooseOrganization/index.tsx | 4 +- .../__tests__/TaskResetPassword.test.tsx | 2 +- .../tasks/TaskResetPassword/index.tsx | 4 +- .../__tests__/TaskSetupMfa.test.tsx | 14 ++ .../tasks/TaskSetupMfa/shared.tsx | 4 +- .../SignIn/SignInAccountSwitcher.tsx | 4 +- .../__tests__/SignInAccountSwitcher.test.tsx | 2 +- .../UserButton/__tests__/UserButton.test.tsx | 58 +++---- .../UserButton/useMultisessionActions.tsx | 6 +- 19 files changed, 343 insertions(+), 59 deletions(-) create mode 100644 .changeset/satellite-sign-out-device-trust.md diff --git a/.changeset/satellite-sign-out-device-trust.md b/.changeset/satellite-sign-out-device-trust.md new file mode 100644 index 00000000000..e48423b3186 --- /dev/null +++ b/.changeset/satellite-sign-out-device-trust.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Fixes signing out of all accounts on satellite domains so sign-out completes across domains and preserves device recognition when client persistence is enabled. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..e9d5637d0ee 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1273,6 +1273,7 @@ describe('Clerk singleton', () => { describe('.signOut()', () => { const mockClientDestroy = vi.fn(); const mockClientRemoveSessions = vi.fn(); + const mockClientSignOut = vi.fn(); 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() }; @@ -1287,11 +1288,162 @@ describe('Clerk singleton', () => { beforeEach(() => { mockClientDestroy.mockReset(); mockClientRemoveSessions.mockReset(); + mockClientSignOut.mockReset(); mockSession1.remove.mockReset(); mockSession2.remove.mockReset(); mockSession3.remove.mockReset(); }); + describe('satellite sign-out', () => { + beforeEach(() => { + mockClientSignOut.mockResolvedValue({ signOutUrl: 'https://primary.test/v1/client/sign_out' }); + mockClientFetch.mockResolvedValue({ + ...clientTouchDefaults, + cookieExpiresAt: new Date(Date.now() + 300 * 24 * 60 * 60 * 1000), + signedInSessions: [mockSession1, mockSession2], + sessions: [mockSession1, mockSession2], + destroy: mockClientDestroy, + removeSessions: mockClientRemoveSessions, + __internal_signOut: mockClientSignOut, + }); + }); + + it.each([true, false])( + 'uses the backend URL even with a long-lived cookie and persistClient=%s', + async persistClient => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, experimental: { persistClient } }); + await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' }); + + expect(mockClientSignOut).toHaveBeenCalledWith({ + persistClient, + redirectUrl: 'http://test.host/after-sign-out?tab=security#settings', + }); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + expect(mockClientDestroy).not.toHaveBeenCalled(); + }, + ); + + it('prefers the backend URL over a cookie refresh', async () => { + const client = await mockClientFetch(); + client.isEligibleForTouch = () => true; + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(); + + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); + }); + + it('keeps the existing redirect when an older backend omits the URL', async () => { + mockClientSignOut.mockResolvedValue({}); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut({ redirectUrl: '/after?tab=security#settings' }); + + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/after?tab=security#settings'); + }); + + it('keeps the cookie refresh fallback when an older backend omits the URL', async () => { + mockClientSignOut.mockResolvedValue({}); + const client = await mockClientFetch(); + client.isEligibleForTouch = () => true; + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(); + + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://clerk.example.com/v1/client/touch'); + }); + + it.each([true, false])( + 'completes local sign-out after a network error with persistClient=%s', + async persistClient => { + mockClientSignOut.mockRejectedValue(new ClerkRuntimeError('Offline', { code: 'network_error' })); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, experimental: { persistClient } }); + await expect(sut.signOut()).resolves.toBeUndefined(); + + expect(sut.session).toBeNull(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }, + ); + + it('preserves arbitrary callbacks without opting in', async () => { + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(callback); + + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).not.toHaveBeenCalled(); + }); + + it('does not opt in on a primary domain', async () => { + const sut = new Clerk(productionPublishableKey); + sut.navigate = vi.fn(); + await sut.load(); + await sut.signOut(); + + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }); + + it('does not opt in for a nonstandard browser', async () => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, standardBrowser: false }); + await sut.signOut(); + + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }); + + it('does not opt in for development satellites', async () => { + const sut = new Clerk(developmentPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, signInUrl: 'https://primary.test/sign-in' }); + await sut.signOut(); + + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }); + + it('keeps targeted multisession removal on a satellite', async () => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut({ sessionId: '1' }); + + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockSession1.remove).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }); + + mockNativeRuntime(() => { + it('does not opt in for native runtimes', async () => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, standardBrowser: false }); + await sut.signOut(); + + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }); + }); + }); + it('has no effect if called when no sessions exist', async () => { const sut = new Clerk(productionPublishableKey); mockClientFetch.mockReturnValue( diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5ca5d2e97c..cc809d4542f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -760,7 +760,7 @@ export class Clerk implements ClerkInterface { ); const signOutCallback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; - const executeSignOut = async () => { + const executeSignOut = async (signOutUrl?: string) => { const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); // Notify other tabs that user is signing out and clean up cookies. @@ -772,7 +772,7 @@ export class Clerk implements ClerkInterface { } else { // 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)); + await this.navigate(signOutUrl || this.#decorateUrlWithTouch(redirectUrl)); } }); @@ -795,8 +795,21 @@ export class Clerk implements ClerkInterface { if (!opts.sessionId || this.client.signedInSessions.length === 1) { this.#setTransitiveState(); + let signOutUrl: string | undefined; try { - if (this.#options.experimental?.persistClient ?? true) { + if ( + !signOutCallback && + this.#instanceType === 'production' && + this.#options.standardBrowser && + inBrowser() && + typeof window.location !== 'undefined' && + this.isSatellite + ) { + ({ signOutUrl } = await this.client.__internal_signOut({ + persistClient: this.#options.experimental?.persistClient ?? true, + redirectUrl: new URL(redirectUrl, window.location.href).toString(), + })); + } else if (this.#options.experimental?.persistClient ?? true) { await this.client.removeSessions(); } else { await this.client.destroy(); @@ -807,7 +820,7 @@ export class Clerk implements ClerkInterface { } } - await executeSignOut(); + await executeSignOut(signOutUrl); debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); return; diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 81203f29e8b..a5cf4e36fad 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -43,6 +43,7 @@ export type FapiRequestCallback = (request: FapiRequestInit, response?: FapiR export interface FapiResponseJSON { response: T; client?: ClientJSON; + sign_out_url?: string; errors?: ClerkAPIErrorJSON[]; meta?: { client?: ClientJSON; diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 899600f1b8d..ae5f229c6b9 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -82,15 +82,7 @@ export class Client extends BaseResource implements ClientResource { // TODO: Make it restful by introducing a DELETE /client/:id endpoint return this._baseDelete({ path: '/client' }).then(() => { SessionTokenCache.clear(); - this.id = ''; - this.sessions = []; - this.signUp = new SignUp(null); - this.signIn = new SignIn(null); - this.lastActiveSessionId = null; - this.lastAuthenticationStrategy = null; - this.cookieExpiresAt = null; - this.createdAt = null; - this.updatedAt = null; + this.resetClient(); }); } @@ -103,6 +95,38 @@ export class Client extends BaseResource implements ClientResource { }); } + async __internal_signOut({ + persistClient, + redirectUrl, + }: { + persistClient: boolean; + redirectUrl: string; + }): Promise<{ signOutUrl?: string }> { + const json = await BaseResource._fetch({ + method: 'DELETE', + path: persistClient ? this.path() + '/sessions' : '/client', + search: { sign_out_redirect_url: redirectUrl }, + }); + this.fromJSON((json?.response || json) as ClientJSON | null); + SessionTokenCache.clear(); + if (!persistClient) { + this.resetClient(); + } + return { signOutUrl: json?.sign_out_url }; + } + + private resetClient(): void { + this.id = ''; + this.sessions = []; + this.signUp = new SignUp(null); + this.signIn = new SignIn(null); + this.lastActiveSessionId = null; + this.lastAuthenticationStrategy = null; + this.cookieExpiresAt = null; + this.createdAt = null; + this.updatedAt = null; + } + resetSignIn(): void { this.signIn = new SignIn(null); // Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource) diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index 5514e1cc855..a8287078d46 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createSession, createSignIn, createSignUp, createUser } from '@/test/core-fixtures'; +import { SessionTokenCache } from '../../tokenCache'; import { BaseResource, Client } from '../internal'; const FIXED_DATE = new Date('2025-01-01T00:00:00Z'); @@ -148,6 +149,77 @@ describe('Client Singleton', () => { }); }); + describe('__internal_signOut', () => { + it.each([true, false])( + 'returns the response URL and clears sessions with persistClient=%s', + async persistClient => { + const user = createUser({ id: 'user_1' }); + const initial: ClientJSON = { + object: 'client', + id: 'client_before', + sessions: [createSession({ id: 'session_1' }, user)], + sign_in: createSignIn({ id: 'sign_in_before' }, user), + sign_up: createSignUp({ id: 'sign_up_before' }), + last_active_session_id: 'session_1', + last_authentication_strategy: 'password', + cookie_expires_at: Date.now() + 30 * 24 * 60 * 60 * 1000, + created_at: Date.now() - 1000, + updated_at: Date.now(), + }; + const response = { ...initial, id: 'client_after', sessions: [], last_active_session_id: null }; + const request = vi.fn().mockResolvedValue({ response, sign_out_url: 'https://primary.test/sign-out' }); + BaseResource._fetch = request; + const clearCache = vi.spyOn(SessionTokenCache, 'clear'); + const client = Client.getOrCreateInstance().fromJSON(initial); + const result = await client.__internal_signOut({ + persistClient, + redirectUrl: 'https://satellite.test/after?tab=security#settings', + }); + + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: persistClient ? '/client/sessions' : '/client', + search: { sign_out_redirect_url: 'https://satellite.test/after?tab=security#settings' }, + }); + expect(result).toEqual({ signOutUrl: 'https://primary.test/sign-out' }); + expect(clearCache).toHaveBeenCalledOnce(); + expect(client.sessions).toEqual([]); + expect(client.id).toBe(persistClient ? 'client_after' : ''); + expect(client.signIn.id).toBe(persistClient ? 'sign_in_before' : undefined); + expect(client.signUp.id).toBe(persistClient ? 'sign_up_before' : undefined); + expect(client.lastActiveSessionId).toBeNull(); + expect(client.lastAuthenticationStrategy).toBe(persistClient ? 'password' : null); + expect(client.cookieExpiresAt).toEqual(persistClient ? new Date(initial.cookie_expires_at!) : null); + expect(client.createdAt).toEqual(persistClient ? new Date(initial.created_at!) : null); + expect(client.updatedAt).toEqual(persistClient ? new Date(initial.updated_at!) : null); + clearCache.mockRestore(); + }, + ); + + it.each([null, { response: null }])('accepts a response without a sign-out URL', async response => { + BaseResource._fetch = vi.fn().mockResolvedValue(response); + const result = await Client.getOrCreateInstance().__internal_signOut({ + persistClient: true, + redirectUrl: 'https://satellite.test/', + }); + expect(result).toEqual({ signOutUrl: undefined }); + }); + + it('does not reuse the URL from an earlier request', async () => { + BaseResource._fetch = vi + .fn() + .mockResolvedValueOnce({ response: null, sign_out_url: 'https://primary.test/sign-out' }) + .mockResolvedValueOnce(null); + const client = Client.getOrCreateInstance(); + expect(await client.__internal_signOut({ persistClient: true, redirectUrl: 'https://satellite.test/' })).toEqual({ + signOutUrl: 'https://primary.test/sign-out', + }); + expect(await client.__internal_signOut({ persistClient: true, redirectUrl: 'https://satellite.test/' })).toEqual({ + signOutUrl: undefined, + }); + }); + }); + it('preserves sign up and sign in identity when fromJSON receives matching ids', () => { const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' }); const session = createSession({ id: 'session_1' }, user); diff --git a/packages/shared/src/types/client.ts b/packages/shared/src/types/client.ts index 6032ee44a6c..561cf55b494 100644 --- a/packages/shared/src/types/client.ts +++ b/packages/shared/src/types/client.ts @@ -47,6 +47,7 @@ export interface ClientResource extends ClerkResource { * Removes all sessions created on the client. */ removeSessions: () => Promise; + __internal_signOut: (params: { persistClient: boolean; redirectUrl: string }) => Promise<{ signOutUrl?: string }>; /** * Clears any locally cached session data for the current client. */ diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx index 61a27f5ba4a..a0bb411cf01 100644 --- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx +++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx @@ -77,7 +77,7 @@ describe('ImpersonationFab', () => { it('keeps the sign-out action clickable', async () => { const { fixtures, getByText, userEvent } = await renderDraggableFab(); await userEvent.click(getByText('Sign out')); - expect(fixtures.clerk.signOut).toHaveBeenCalled(); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); }); it('settles inside the viewport inset when released off-screen', async () => { diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx index 864ba7d2954..c3ad69b796e 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -65,11 +65,11 @@ const FabContent = ({ title, signOutText }: FabContentProps) => { const { user } = useUser(); const { signOut } = useClerk(); const { otherSessions } = useMultipleSessions({ user }); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { - return signOut(navigateAfterSignOut); + return signOut({ redirectUrl: afterSignOutUrl }); } return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session.id }); }; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index c910527f0be..187ce74f612 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -245,7 +245,7 @@ describe('TaskChooseOrganization', () => { await userEvent.click(signOutButton); - expect(fixtures.clerk.signOut).toHaveBeenCalled(); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); }); it('renders with username when email is not available', async () => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index 57cc61fc215..e13a8657d31 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -125,11 +125,11 @@ const TaskChooseOrganizationCardFooter = () => { const { user } = useUser(); const { session } = useSession(); const { otherSessions } = useMultipleSessions({ user }); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); const handleSignOut = () => { if (otherSessions.length === 0) { - return signOut(navigateAfterSignOut); + return signOut({ redirectUrl: afterSignOutUrl }); } return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx index 465465abc63..28ea1d03346 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx @@ -108,7 +108,7 @@ describe('TaskResetPassword', () => { await userEvent.click(signOutButton); - expect(fixtures.clerk.signOut).toHaveBeenCalled(); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); }); it('renders with username when email is not available', async () => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx index 9efa92d60fe..99b941f9236 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx @@ -26,7 +26,7 @@ const TaskResetPasswordInternal = () => { const { t, locale } = useLocalizations(); const { redirectUrlComplete } = useTaskResetPasswordContext(); const { otherSessions } = useMultipleSessions({ user: clerk.user }); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); const updatePasswordWithReverification = useReverification( (user: UserResource, opts: Parameters) => user.updatePassword(...opts), ); @@ -34,7 +34,7 @@ const TaskResetPasswordInternal = () => { const handleSignOut = () => { if (otherSessions.length === 0) { - return clerk?.signOut(navigateAfterSignOut); + return clerk?.signOut({ redirectUrl: afterSignOutUrl }); } return clerk?.signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: clerk.session?.id }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx index 21cb1714c82..f9adab6a502 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx @@ -44,6 +44,20 @@ describe('TaskSetupMFA', () => { }); }); + it('passes the final destination when signing out the last account', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + email_addresses: ['test@clerk.com'], + tasks: [{ key: 'setup-mfa' }], + }); + f.withAuthenticatorApp({ enabled: true }); + f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); + }); + const { getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('link', { name: /sign out/i })); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + }); + describe('main flow', () => { it('should render SMS code and TOTP items on the first screen if both are enabled', async () => { const { wrapper } = await createFixtures(f => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx index 92f2288a71b..a143b891380 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx @@ -20,11 +20,11 @@ export function SharedFooterActionForSignOut() { const clerk = useClerk(); const { session } = useSession(); const { otherSessions } = useMultipleSessions({ user }); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); const handleSignOut = () => { if (otherSessions.length === 0) { - return clerk.signOut(navigateAfterSignOut); + return clerk.signOut({ redirectUrl: afterSignOutUrl }); } return clerk.signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); }; diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx index 3540530b6c7..176e4d52861 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -16,11 +16,11 @@ const SignInAccountSwitcherInternal = () => { const card = useCardState(); const { userProfileUrl } = useEnvironment().displayConfig; const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext(); - const { navigateAfterSignOut } = useSignOutContext(); + const { afterSignOutUrl } = useSignOutContext(); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, - navigateAfterSignOut, + afterSignOutUrl, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, signInUrl: signInPath ?? signInUrl, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx index 54a8cd799de..d822ca6eec5 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx @@ -51,6 +51,6 @@ describe('SignInAccountSwitcher', () => { expect(getByText('Mike Lamar')).toBeDefined(); expect(getByText('Graciela Brennan')).toBeDefined(); await userEvent.click(getByText('Sign out of all accounts')); - expect(fixtures.clerk.signOut).toHaveBeenCalled(); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); }); }); diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index 06d19655215..ec11eb5f02d 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,6 +1,6 @@ import { UNSAFE_PortalProvider } from '@clerk/shared/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen, waitFor } from '@/test/utils'; @@ -68,24 +68,26 @@ describe('UserButton', () => { expect(fixtures.clerk.openUserProfile).toHaveBeenCalled(); }); - it('signs out user when "Sign out" is clicked', async () => { - const { wrapper, fixtures } = await createFixtures(f => { - f.withUser({ - first_name: 'First', - last_name: 'Last', - username: 'username1', - email_addresses: ['test@clerk.com'], + it.each(['/', '/signed-out?tab=security#settings'])( + 'passes destination %s when signing out the last account', + async redirectUrl => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); }); - }); - - fixtures.clerk.signOut.mockImplementationOnce(callback => callback()); - const { getByText, getByRole, userEvent } = render(, { wrapper }); - await userEvent.click(getByRole('button', { name: 'Open user menu' })); - await userEvent.click(getByText('Sign out')); + vi.spyOn(fixtures.clerk, 'buildAfterSignOutUrl').mockReturnValue(redirectUrl); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user menu' })); + await userEvent.click(getByText('Sign out')); - expect(fixtures.router.navigate).toHaveBeenCalledWith('/'); - }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl }); + }, + ); it.todo('navigates to sign in url when "Add account" is clicked'); @@ -182,19 +184,17 @@ describe('UserButton', () => { }); }); - it('signs out of all currently active session when clicking "Sign out of all accounts"', async () => { - const { wrapper, fixtures } = await createFixtures(initConfig); - fixtures.clerk.signOut.mockImplementationOnce(callback => { - return Promise.resolve(callback()); - }); - const { getByText, getByRole, userEvent } = render(, { wrapper }); - await userEvent.click(getByRole('button', { name: 'Open user menu' })); - await userEvent.click(getByText('Sign out of all accounts')); - await waitFor(() => { - expect(fixtures.clerk.signOut).toHaveBeenCalledWith(expect.any(Function)); - expect(fixtures.router.navigate).toHaveBeenCalledWith('/'); - }); - }); + it.each(['/', 'https://satellite.test/signed-out?tab=security#settings'])( + 'passes destination %s when signing out all accounts', + async redirectUrl => { + const { wrapper, fixtures } = await createFixtures(initConfig); + vi.spyOn(fixtures.clerk, 'buildAfterSignOutUrl').mockReturnValue(redirectUrl); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user menu' })); + await userEvent.click(getByText('Sign out of all accounts')); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl }); + }, + ); }); describe('Accessibility', () => { diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index bb46235f382..9858feafca7 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -13,7 +13,7 @@ import { useRouter } from '../../router'; type UseMultisessionActionsParams = { user: UserResource | null | undefined; actionCompleteCallback?: () => void; - navigateAfterSignOut?: () => any; + afterSignOutUrl?: string; navigateAfterMultiSessionSingleSignOut?: () => any; afterSwitchSessionUrl?: string; userProfileUrl?: string; @@ -32,7 +32,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { - return signOut(opts.navigateAfterSignOut); + return signOut({ redirectUrl: opts.afterSignOutUrl }); } return signOut(opts.navigateAfterMultiSessionSingleSignOut, { sessionId: session.id }).finally(() => card.setIdle(), @@ -71,7 +71,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleSignOutAllClicked = () => { - return signOut(opts.navigateAfterSignOut); + return signOut({ redirectUrl: opts.afterSignOutUrl }); }; const handleSessionClicked = (session: SignedInSessionResource) => async () => { From 67fb8ba201f2443661451b1353571e8ab242b4d2 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 12:50:18 -0400 Subject: [PATCH 2/6] fix: preserve UI sign-out navigation during satellite repair --- .changeset/satellite-sign-out-device-trust.md | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 106 ++++++++++++++ packages/clerk-js/src/core/clerk.ts | 18 ++- packages/shared/src/types/clerk.ts | 1 + packages/shared/src/types/client.ts | 1 - .../__tests__/ImpersonationFab.test.tsx | 2 +- .../src/components/ImpersonationFab/index.tsx | 7 +- .../__tests__/TaskChooseOrganization.test.tsx | 2 +- .../tasks/TaskChooseOrganization/index.tsx | 6 +- .../__tests__/TaskResetPassword.test.tsx | 2 +- .../tasks/TaskResetPassword/index.tsx | 6 +- .../__tests__/TaskSetupMfa.test.tsx | 2 +- .../tasks/TaskSetupMfa/shared.tsx | 6 +- .../SignIn/SignInAccountSwitcher.tsx | 3 +- .../__tests__/SignInAccountSwitcher.test.tsx | 2 +- .../UserButton/__tests__/UserButton.test.tsx | 18 ++- .../UserButton/useMultisessionActions.tsx | 7 +- .../__tests__/useSignOutWithRedirect.test.tsx | 131 ++++++++++++++++++ .../ui/src/hooks/useSignOutWithRedirect.ts | 34 +++++ packages/ui/src/router/BaseRouter.tsx | 8 +- 20 files changed, 335 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/hooks/__tests__/useSignOutWithRedirect.test.tsx create mode 100644 packages/ui/src/hooks/useSignOutWithRedirect.ts diff --git a/.changeset/satellite-sign-out-device-trust.md b/.changeset/satellite-sign-out-device-trust.md index e48423b3186..2e65ceed47b 100644 --- a/.changeset/satellite-sign-out-device-trust.md +++ b/.changeset/satellite-sign-out-device-trust.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Fixes signing out of all accounts on satellite domains so sign-out completes across domains and preserves device recognition when client persistence is enabled. +Fixes signing out of all accounts on satellite domains so sign-out completes across domains and preserves device recognition when client persistence is enabled. Preserves existing navigation callbacks when cross-domain repair is unavailable. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index e9d5637d0ee..7eb1d475859 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1289,6 +1289,7 @@ describe('Clerk singleton', () => { mockClientDestroy.mockReset(); mockClientRemoveSessions.mockReset(); mockClientSignOut.mockReset(); + vi.mocked(Client.isClientResource).mockReturnValue(false); mockSession1.remove.mockReset(); mockSession2.remove.mockReset(); mockSession3.remove.mockReset(); @@ -1296,6 +1297,7 @@ describe('Clerk singleton', () => { describe('satellite sign-out', () => { beforeEach(() => { + vi.mocked(Client.isClientResource).mockReturnValue(true); mockClientSignOut.mockResolvedValue({ signOutUrl: 'https://primary.test/v1/client/sign_out' }); mockClientFetch.mockResolvedValue({ ...clientTouchDefaults, @@ -1373,6 +1375,110 @@ describe('Clerk singleton', () => { }, ); + it.each([true, false])('repairs a built-in callback sign-out with persistClient=%s', async persistClient => { + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, experimental: { persistClient } }); + await sut.signOut(callback, { + __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out?tab=security#settings', + }); + + expect(mockClientSignOut).toHaveBeenCalledExactlyOnceWith({ + persistClient, + redirectUrl: 'https://satellite.test/signed-out?tab=security#settings', + }); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); + expect(callback).not.toHaveBeenCalled(); + }); + + it.each([ + undefined, + '', + '/relative', + 'not a URL', + 'https://', + 'javascript:alert(1)', + 'http://primary.test/repair', + ])('preserves the original callback when the repair URL is %s', async signOutUrl => { + mockClientSignOut.mockResolvedValue({ signOutUrl }); + const client = await mockClientFetch(); + client.isEligibleForTouch = () => true; + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(callback, { + __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out', + }); + + expect(mockClientSignOut).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(sut.navigate).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + }); + + it('propagates the original callback rejection without retrying sign-out', async () => { + mockClientSignOut.mockResolvedValue({}); + const error = new Error('Callback failed'); + const callback = vi.fn().mockRejectedValue(error); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await expect( + sut.signOut(callback, { + __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out', + }), + ).rejects.toBe(error); + + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(mockClientSignOut).toHaveBeenCalledOnce(); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + expect(sut.navigate).not.toHaveBeenCalled(); + }); + + it('preserves the callback after a repair request network error', async () => { + mockClientSignOut.mockRejectedValue(new ClerkRuntimeError('Offline', { code: 'network_error' })); + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(callback, { __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out' }); + + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(mockClientSignOut).toHaveBeenCalledOnce(); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + expect(sut.navigate).not.toHaveBeenCalled(); + }); + + it('preserves a custom callback with redirect options and an expiring cookie', async () => { + const client = await mockClientFetch(); + client.isEligibleForTouch = () => true; + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut(callback, { redirectUrl: '/signed-out' }); + + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).not.toHaveBeenCalled(); + }); + + it('keeps sign-out working when an older client resource has no repair method', async () => { + const client = await mockClientFetch(); + delete client.__internal_signOut; + vi.mocked(Client.isClientResource).mockReturnValue(false); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut({ redirectUrl: '/signed-out' }); + + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/signed-out'); + }); + it('preserves arbitrary callbacks without opting in', async () => { const callback = vi.fn(); const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index cc809d4542f..67bc823b5b0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -37,6 +37,7 @@ import { navigateIfTaskExists, warnMissingPendingTaskHandlers, } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { isValidUrl } from '@clerk/shared/internal/clerk-js/url'; import { warnings } from '@clerk/shared/internal/clerk-js/warnings'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { parsePublishableKey } from '@clerk/shared/keys'; @@ -767,12 +768,14 @@ export class Clerk implements ClerkInterface { eventBus.emit(events.UserSignOut, null); await tracker.track(async () => { - if (signOutCallback) { + if (signOutUrl) { + await this.navigate(signOutUrl); + } else if (signOutCallback) { await signOutCallback(); } else { // 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(signOutUrl || this.#decorateUrlWithTouch(redirectUrl)); + await this.navigate(this.#decorateUrlWithTouch(redirectUrl)); } }); @@ -798,7 +801,8 @@ export class Clerk implements ClerkInterface { let signOutUrl: string | undefined; try { if ( - !signOutCallback && + (!signOutCallback || !!opts.__internal_satelliteSignOutRedirectUrl) && + Client.isClientResource(this.client) && this.#instanceType === 'production' && this.#options.standardBrowser && inBrowser() && @@ -807,7 +811,10 @@ export class Clerk implements ClerkInterface { ) { ({ signOutUrl } = await this.client.__internal_signOut({ persistClient: this.#options.experimental?.persistClient ?? true, - redirectUrl: new URL(redirectUrl, window.location.href).toString(), + redirectUrl: new URL( + opts.__internal_satelliteSignOutRedirectUrl || redirectUrl, + window.location.href, + ).toString(), })); } else if (this.#options.experimental?.persistClient ?? true) { await this.client.removeSessions(); @@ -820,7 +827,8 @@ export class Clerk implements ClerkInterface { } } - await executeSignOut(signOutUrl); + const repairUrl = isValidUrl(signOutUrl) && signOutUrl?.startsWith('https://') ? signOutUrl : undefined; + await executeSignOut(repairUrl); debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); return; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 069bc460a14..8dc92836cf7 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -201,6 +201,7 @@ export type SignOutCallback = () => void | Promise; * Configuration options. */ export type SignOutOptions = { + __internal_satelliteSignOutRedirectUrl?: string; /** * Specify a specific session to sign out. Useful for multi-session applications. */ diff --git a/packages/shared/src/types/client.ts b/packages/shared/src/types/client.ts index 561cf55b494..6032ee44a6c 100644 --- a/packages/shared/src/types/client.ts +++ b/packages/shared/src/types/client.ts @@ -47,7 +47,6 @@ export interface ClientResource extends ClerkResource { * Removes all sessions created on the client. */ removeSessions: () => Promise; - __internal_signOut: (params: { persistClient: boolean; redirectUrl: string }) => Promise<{ signOutUrl?: string }>; /** * Clears any locally cached session data for the current client. */ diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx index a0bb411cf01..0c114da27ca 100644 --- a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx +++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx @@ -77,7 +77,7 @@ describe('ImpersonationFab', () => { it('keeps the sign-out action clickable', async () => { const { fixtures, getByText, userEvent } = await renderDraggableFab(); await userEvent.click(getByText('Sign out')); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); }); it('settles inside the viewport inset when released off-screen', async () => { diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx index c3ad69b796e..55b5c43d3ee 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -3,6 +3,8 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SignedInSessionResource } from '@clerk/shared/types'; import { useEffect, useRef } from 'react'; +import { useSignOutWithRedirect } from '@/ui/hooks/useSignOutWithRedirect'; + import { useSignOutContext, withCoreUserGuard } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { @@ -65,11 +67,12 @@ const FabContent = ({ title, signOutText }: FabContentProps) => { const { user } = useUser(); const { signOut } = useClerk(); const { otherSessions } = useMultipleSessions({ user }); - const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { - return signOut({ redirectUrl: afterSignOutUrl }); + return signOutWithRedirect(navigateAfterSignOut, afterSignOutUrl); } return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session.id }); }; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index 187ce74f612..a4399307003 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -245,7 +245,7 @@ describe('TaskChooseOrganization', () => { await userEvent.click(signOutButton); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); }); it('renders with username when email is not available', async () => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index e13a8657d31..99571b8ec1a 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -16,6 +16,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView'; +import { useSignOutWithRedirect } from '@/ui/hooks/useSignOutWithRedirect'; import { handleError } from '@/ui/utils/errorHandler'; import { withTaskGuard } from '../shared'; @@ -125,11 +126,12 @@ const TaskChooseOrganizationCardFooter = () => { const { user } = useUser(); const { session } = useSession(); const { otherSessions } = useMultipleSessions({ user }); - const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOut = () => { if (otherSessions.length === 0) { - return signOut({ redirectUrl: afterSignOutUrl }); + return signOutWithRedirect(navigateAfterSignOut, afterSignOutUrl); } return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx index 28ea1d03346..0e025395576 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx @@ -108,7 +108,7 @@ describe('TaskResetPassword', () => { await userEvent.click(signOutButton); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); }); it('renders with username when email is not available', async () => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx index 99b941f9236..56fd401cdc6 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx @@ -10,6 +10,7 @@ import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import { useConfirmPassword } from '@/ui/hooks'; import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; +import { useSignOutWithRedirect } from '@/ui/hooks/useSignOutWithRedirect'; import { handleError } from '@/ui/utils/errorHandler'; import { createPasswordError } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -26,7 +27,8 @@ const TaskResetPasswordInternal = () => { const { t, locale } = useLocalizations(); const { redirectUrlComplete } = useTaskResetPasswordContext(); const { otherSessions } = useMultipleSessions({ user: clerk.user }); - const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const updatePasswordWithReverification = useReverification( (user: UserResource, opts: Parameters) => user.updatePassword(...opts), ); @@ -34,7 +36,7 @@ const TaskResetPasswordInternal = () => { const handleSignOut = () => { if (otherSessions.length === 0) { - return clerk?.signOut({ redirectUrl: afterSignOutUrl }); + return signOutWithRedirect(navigateAfterSignOut, afterSignOutUrl); } return clerk?.signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: clerk.session?.id }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx index f9adab6a502..72bd97ed290 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/__tests__/TaskSetupMfa.test.tsx @@ -55,7 +55,7 @@ describe('TaskSetupMFA', () => { }); const { getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('link', { name: /sign out/i })); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); }); describe('main flow', () => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx index a143b891380..51e556e11db 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/shared.tsx @@ -6,6 +6,7 @@ import { useSignOutContext } from '@/contexts'; import { Card } from '@/elements/Card'; import { useMultipleSessions } from '@/hooks/useMultipleSessions'; import { localizationKeys } from '@/localization'; +import { useSignOutWithRedirect } from '@/ui/hooks/useSignOutWithRedirect'; import { stringToFormattedPhoneString } from '@/utils/phoneUtils'; export const commonIdentifier = (user: UserResource) => { @@ -20,11 +21,12 @@ export function SharedFooterActionForSignOut() { const clerk = useClerk(); const { session } = useSession(); const { otherSessions } = useMultipleSessions({ user }); - const { afterSignOutUrl, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOut = () => { if (otherSessions.length === 0) { - return clerk.signOut({ redirectUrl: afterSignOutUrl }); + return signOutWithRedirect(navigateAfterSignOut, afterSignOutUrl); } return clerk.signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); }; diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx index 176e4d52861..3bb87f71efd 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -16,11 +16,12 @@ const SignInAccountSwitcherInternal = () => { const card = useCardState(); const { userProfileUrl } = useEnvironment().displayConfig; const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext(); - const { afterSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut } = useSignOutContext(); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, afterSignOutUrl, + navigateAfterSignOut, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, signInUrl: signInPath ?? signInUrl, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx index d822ca6eec5..9c7bb03dbe5 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx @@ -51,6 +51,6 @@ describe('SignInAccountSwitcher', () => { expect(getByText('Mike Lamar')).toBeDefined(); expect(getByText('Graciela Brennan')).toBeDefined(); await userEvent.click(getByText('Sign out of all accounts')); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl: '/' }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); }); }); diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index ec11eb5f02d..3e81dc1683e 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -81,11 +81,18 @@ describe('UserButton', () => { }); vi.spyOn(fixtures.clerk, 'buildAfterSignOutUrl').mockReturnValue(redirectUrl); + fixtures.clerk.signOut.mockImplementation(async callback => { + if (typeof callback !== 'function') { + throw new Error('Expected the legacy navigation callback'); + } + await callback(); + }); const { getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button', { name: 'Open user menu' })); await userEvent.click(getByText('Sign out')); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(redirectUrl); }, ); @@ -189,10 +196,17 @@ describe('UserButton', () => { async redirectUrl => { const { wrapper, fixtures } = await createFixtures(initConfig); vi.spyOn(fixtures.clerk, 'buildAfterSignOutUrl').mockReturnValue(redirectUrl); + fixtures.clerk.signOut.mockImplementation(async callback => { + if (typeof callback !== 'function') { + throw new Error('Expected the legacy navigation callback'); + } + await callback(); + }); const { getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button', { name: 'Open user menu' })); await userEvent.click(getByText('Sign out of all accounts')); - expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith({ redirectUrl }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(redirectUrl); }, ); }); diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index 9858feafca7..9fad58480b4 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -8,12 +8,14 @@ import { sleep } from '@/ui/utils/sleep'; import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import { useMultipleSessions } from '../../hooks/useMultipleSessions'; +import { useSignOutWithRedirect } from '../../hooks/useSignOutWithRedirect'; import { useRouter } from '../../router'; type UseMultisessionActionsParams = { user: UserResource | null | undefined; actionCompleteCallback?: () => void; afterSignOutUrl?: string; + navigateAfterSignOut?: () => any; navigateAfterMultiSessionSingleSignOut?: () => any; afterSwitchSessionUrl?: string; userProfileUrl?: string; @@ -23,6 +25,7 @@ type UseMultisessionActionsParams = { export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const clerk = useClerk(); + const signOutWithRedirect = useSignOutWithRedirect(); const { setActive, signOut, openUserProfile } = clerk; const card = useCardState(); const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); @@ -32,7 +35,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { - return signOut({ redirectUrl: opts.afterSignOutUrl }); + return signOutWithRedirect(opts.navigateAfterSignOut, opts.afterSignOutUrl); } return signOut(opts.navigateAfterMultiSessionSingleSignOut, { sessionId: session.id }).finally(() => card.setIdle(), @@ -71,7 +74,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleSignOutAllClicked = () => { - return signOut({ redirectUrl: opts.afterSignOutUrl }); + return signOutWithRedirect(opts.navigateAfterSignOut, opts.afterSignOutUrl); }; const handleSessionClicked = (session: SignedInSessionResource) => async () => { diff --git a/packages/ui/src/hooks/__tests__/useSignOutWithRedirect.test.tsx b/packages/ui/src/hooks/__tests__/useSignOutWithRedirect.test.tsx new file mode 100644 index 00000000000..5c0b2539519 --- /dev/null +++ b/packages/ui/src/hooks/__tests__/useSignOutWithRedirect.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HashRouter, PathRouter, Route, useRouter, VirtualRouter } from '@/ui/router'; +import { bindCreateFixtures } from '@/ui/test/create-fixtures'; +import { render, screen, waitFor } from '@/ui/test/utils'; + +import { useSignOutWithRedirect } from '../useSignOutWithRedirect'; + +const { createFixtures } = bindCreateFixtures('UserButton'); +const originalLocation = Object.getOwnPropertyDescriptor(window, 'location'); + +const SignOutButton = ({ to }: { to: string }) => { + const { navigate } = useRouter(); + const signOut = useSignOutWithRedirect(); + return ( + + ); +}; + +afterEach(() => { + if (originalLocation) { + Object.defineProperty(window, 'location', originalLocation); + } + vi.restoreAllMocks(); +}); + +describe('sign-out callback routing', () => { + it.each(['path', 'hash', 'virtual'] as const)('preserves %s internal navigation and query parameters', async kind => { + Object.defineProperty(window, 'location', { + configurable: true, + value: new URL( + kind === 'hash' + ? 'https://satellite.test/sign-in#/start?preserved=1' + : 'https://satellite.test/sign-in/start?preserved=1', + ), + }); + const { fixtures, wrapper } = await createFixtures(f => f.withUser({ email_addresses: ['test@clerk.com'] })); + vi.spyOn(fixtures.clerk, 'isSatellite', 'get').mockReturnValue(true); + fixtures.clerk.signOut.mockImplementation(async callback => { + if (typeof callback !== 'function') { + throw new Error('Expected the legacy navigation callback'); + } + await callback(); + }); + const content = ( + <> + + + + +
Signed out
+
+ + ); + const tree = + kind === 'path' ? ( + + {content} + + ) : kind === 'hash' ? ( + {content} + ) : ( + + {content} + + ); + const { userEvent } = render(tree, { wrapper }); + await userEvent.click(screen.getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByText('Signed out')).toBeInTheDocument()); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); + if (kind === 'path') { + expect(fixtures.clerk.navigate).toHaveBeenCalledExactlyOnceWith('/sign-in/done?preserved=1', { + metadata: { navigationType: 'internal' }, + }); + } + if (kind === 'hash') { + expect(window.location.hash).toBe('#/done?preserved=1'); + } + if (kind === 'virtual') { + expect(fixtures.clerk.navigate).not.toHaveBeenCalled(); + } + }); + + it('lets an older runtime ignore repair metadata and execute the original callback', async () => { + const { fixtures, wrapper } = await createFixtures(f => f.withUser({ email_addresses: ['test@clerk.com'] })); + vi.spyOn(fixtures.clerk, 'isSatellite', 'get').mockReturnValue(true); + fixtures.clerk.signOut.mockImplementation(async callback => { + if (typeof callback !== 'function') { + throw new Error('Expected the legacy navigation callback'); + } + await callback(); + }); + const to = 'https://satellite.test/signed-out?keep=1#section'; + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByRole('button', { name: 'Sign out' })); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { + __internal_satelliteSignOutRedirectUrl: to, + }); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(to); + }); + + it.each(['/CLERK-ROUTER/HASH/other', '/CLERK-ROUTER/VIRTUAL/other', 'https://', 'javascript:alert(1)'])( + 'keeps destination %s on the original callback path', + async to => { + const { fixtures, wrapper } = await createFixtures(f => f.withUser({ email_addresses: ['test@clerk.com'] })); + vi.spyOn(fixtures.clerk, 'isSatellite', 'get').mockReturnValue(true); + fixtures.clerk.signOut.mockImplementation(async callback => { + if (typeof callback !== 'function') { + throw new Error('Expected the legacy navigation callback'); + } + await callback(); + }); + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByRole('button', { name: 'Sign out' })); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(to); + }, + ); +}); diff --git a/packages/ui/src/hooks/useSignOutWithRedirect.ts b/packages/ui/src/hooks/useSignOutWithRedirect.ts new file mode 100644 index 00000000000..d2f32a757d1 --- /dev/null +++ b/packages/ui/src/hooks/useSignOutWithRedirect.ts @@ -0,0 +1,34 @@ +import { isVirtualRouterPath } from '@clerk/shared/internal/clerk-js/url'; +import { useClerk } from '@clerk/shared/react'; +import type { SignOutCallback } from '@clerk/shared/types'; + +import { hashRouterBase, useRouter } from '../router'; +import { isInternalRoute } from '../router/BaseRouter'; + +export const useSignOutWithRedirect = () => { + const clerk = useClerk(); + const { resolve, basePath } = useRouter(); + + return (callback?: SignOutCallback, redirectUrl?: string) => { + let repairRedirectUrl: string | undefined; + if (callback && redirectUrl && clerk.isSatellite && typeof window !== 'undefined' && window.location) { + try { + const target = resolve(redirectUrl); + if ( + target.protocol === 'https:' && + !isInternalRoute(target, basePath) && + !isVirtualRouterPath(target.pathname) && + !target.pathname.startsWith('/' + hashRouterBase) + ) { + repairRedirectUrl = target.href; + } + } catch { + repairRedirectUrl = undefined; + } + } + + return repairRedirectUrl + ? clerk.signOut(callback, { __internal_satelliteSignOutRedirectUrl: repairRedirectUrl }) + : clerk.signOut(callback); + }; +}; diff --git a/packages/ui/src/router/BaseRouter.tsx b/packages/ui/src/router/BaseRouter.tsx index 8d401d38a4e..19b4a288c02 100644 --- a/packages/ui/src/router/BaseRouter.tsx +++ b/packages/ui/src/router/BaseRouter.tsx @@ -148,6 +148,9 @@ interface BaseRouterProps { children: React.ReactNode; } +export const isInternalRoute = (toURL: URL, basePath: string): boolean => + toURL.origin === window.location.origin && toURL.pathname.startsWith(withLeadingSlash(basePath)); + export const BaseRouter = ({ basePath, startPath, @@ -237,10 +240,7 @@ export const BaseRouter = ({ return; } - const isCrossOrigin = toURL.origin !== window.location.origin; - const isOutsideOfUIComponent = !toURL.pathname.startsWith(normalizedBasePath); - - if (isOutsideOfUIComponent || isCrossOrigin) { + if (!isInternalRoute(toURL, basePath)) { isNavigatingRef.current = true; try { return await clerkNavigate(toURL.href); From 09724f97f7711ccc870efa42389fca394e09852f Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 14:28:02 -0400 Subject: [PATCH 3/6] fix: preserve sign-out for unsupported repair destinations Satellite sign-out could fail before removing sessions when its destination was malformed, and deliberately discarded clients took an unnecessary trip through the primary. Use the original removal and navigation paths for unsupported destinations and disabled client persistence, preserving callback and navigation error behavior. Part of CORE-4146 --- .changeset/satellite-sign-out-device-trust.md | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 96 ++++++++++++++++--- packages/clerk-js/src/core/clerk.ts | 51 ++++++---- 3 files changed, 117 insertions(+), 32 deletions(-) diff --git a/.changeset/satellite-sign-out-device-trust.md b/.changeset/satellite-sign-out-device-trust.md index 2e65ceed47b..d836bd8018f 100644 --- a/.changeset/satellite-sign-out-device-trust.md +++ b/.changeset/satellite-sign-out-device-trust.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Fixes signing out of all accounts on satellite domains so sign-out completes across domains and preserves device recognition when client persistence is enabled. Preserves existing navigation callbacks when cross-domain repair is unavailable. +Fixes signing out of all accounts on satellite domains to preserve device recognition when Device Trust and client persistence are enabled. Preserves existing navigation callbacks when cross-domain repair is unavailable, and skips the repair redirect when Device Trust or client persistence is disabled. Malformed redirect URLs no longer prevent the sign-out request. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 7eb1d475859..dd6673e6824 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1297,6 +1297,7 @@ describe('Clerk singleton', () => { describe('satellite sign-out', () => { beforeEach(() => { + Object.defineProperty(window, 'location', { value: new URL('https://satellite.test') }); vi.mocked(Client.isClientResource).mockReturnValue(true); mockClientSignOut.mockResolvedValue({ signOutUrl: 'https://primary.test/v1/client/sign_out' }); mockClientFetch.mockResolvedValue({ @@ -1310,21 +1311,88 @@ describe('Clerk singleton', () => { }); }); + it('uses the backend URL even with a long-lived cookie', async () => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' }); + + expect(mockClientSignOut).toHaveBeenCalledWith({ + persistClient: true, + redirectUrl: 'https://satellite.test/after-sign-out?tab=security#settings', + }); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + expect(mockClientDestroy).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'does not request repair when persistence is disabled and callback=%s', + async withCallback => { + const callback = withCallback ? vi.fn() : undefined; + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, experimental: { persistClient: false } }); + const options = { __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out' }; + await (callback ? sut.signOut(callback, options) : sut.signOut(options)); + + expect(mockClientDestroy).toHaveBeenCalledOnce(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(mockClientRemoveSessions).not.toHaveBeenCalled(); + if (callback) { + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(sut.navigate).not.toHaveBeenCalled(); + } else { + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + } + }, + ); + + it.each(['http://satellite.test/signed-out', 'my-app://signed-out', 'https://user:password@satellite.test/'])( + 'keeps unsupported destination %s on the existing sign-out path', + async redirectUrl => { + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true }); + await sut.signOut({ redirectUrl }); + + expect(mockClientRemoveSessions).toHaveBeenCalledOnce(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith(redirectUrl); + }, + ); + it.each([true, false])( - 'uses the backend URL even with a long-lived cookie and persistClient=%s', + 'removes sessions before malformed destination navigation fails with persistClient=%s', async persistClient => { + const error = new TypeError('Invalid navigation URL'); const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); - sut.navigate = vi.fn(); + sut.navigate = vi.fn().mockRejectedValue(error); await sut.load({ isSatellite: true, experimental: { persistClient } }); - await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' }); + await expect(sut.signOut({ redirectUrl: 'https://' })).rejects.toBe(error); - expect(mockClientSignOut).toHaveBeenCalledWith({ - persistClient, - redirectUrl: 'http://test.host/after-sign-out?tab=security#settings', + expect(persistClient ? mockClientRemoveSessions : mockClientDestroy).toHaveBeenCalledOnce(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://'); + }, + ); + + it.each([true, false])( + 'preserves callbacks with malformed redirect options and persistClient=%s', + async persistClient => { + const callback = vi.fn(); + const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); + sut.navigate = vi.fn(); + await sut.load({ isSatellite: true, experimental: { persistClient } }); + await sut.signOut(callback, { + redirectUrl: 'https://', + __internal_satelliteSignOutRedirectUrl: 'https://', }); - expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); - expect(mockClientRemoveSessions).not.toHaveBeenCalled(); - expect(mockClientDestroy).not.toHaveBeenCalled(); + + expect(persistClient ? mockClientRemoveSessions : mockClientDestroy).toHaveBeenCalledOnce(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(sut.navigate).not.toHaveBeenCalled(); }, ); @@ -1364,7 +1432,9 @@ describe('Clerk singleton', () => { it.each([true, false])( 'completes local sign-out after a network error with persistClient=%s', async persistClient => { - mockClientSignOut.mockRejectedValue(new ClerkRuntimeError('Offline', { code: 'network_error' })); + (persistClient ? mockClientSignOut : mockClientDestroy).mockRejectedValue( + new ClerkRuntimeError('Offline', { code: 'network_error' }), + ); const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); sut.navigate = vi.fn(); await sut.load({ isSatellite: true, experimental: { persistClient } }); @@ -1375,17 +1445,17 @@ describe('Clerk singleton', () => { }, ); - it.each([true, false])('repairs a built-in callback sign-out with persistClient=%s', async persistClient => { + it('repairs a built-in callback sign-out', async () => { const callback = vi.fn(); const sut = new Clerk(productionPublishableKey, { domain: 'satellite.test' }); sut.navigate = vi.fn(); - await sut.load({ isSatellite: true, experimental: { persistClient } }); + await sut.load({ isSatellite: true }); await sut.signOut(callback, { __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out?tab=security#settings', }); expect(mockClientSignOut).toHaveBeenCalledExactlyOnceWith({ - persistClient, + persistClient: true, redirectUrl: 'https://satellite.test/signed-out?tab=security#settings', }); expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 67bc823b5b0..5e67b86d85b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -749,12 +749,18 @@ export class Clerk implements ClerkInterface { const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {}; const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl(); + let debugRedirectUrl: string | undefined; + try { + debugRedirectUrl = stripOrigin(redirectUrl); + } catch { + debugRedirectUrl = undefined; + } debugLogger.debug( 'signOut() start', { hasClient: Boolean(this.client), multiSessionCount: this.client?.signedInSessions.length ?? 0, - redirectUrl, + redirectUrl: debugRedirectUrl, sessionTarget: opts?.sessionId ?? null, }, 'clerk', @@ -798,25 +804,34 @@ export class Clerk implements ClerkInterface { if (!opts.sessionId || this.client.signedInSessions.length === 1) { this.#setTransitiveState(); + const persistClient = this.#options.experimental?.persistClient ?? true; + let repairRedirectUrl: URL | undefined; + if ( + persistClient && + (!signOutCallback || !!opts.__internal_satelliteSignOutRedirectUrl) && + this.#instanceType === 'production' && + this.#options.standardBrowser && + inBrowser() && + typeof window.location !== 'undefined' && + this.isSatellite + ) { + try { + const target = new URL(opts.__internal_satelliteSignOutRedirectUrl || redirectUrl, window.location.href); + if (target.protocol === 'https:' && !target.username && !target.password) { + repairRedirectUrl = target; + } + } catch { + repairRedirectUrl = undefined; + } + } let signOutUrl: string | undefined; try { - if ( - (!signOutCallback || !!opts.__internal_satelliteSignOutRedirectUrl) && - Client.isClientResource(this.client) && - this.#instanceType === 'production' && - this.#options.standardBrowser && - inBrowser() && - typeof window.location !== 'undefined' && - this.isSatellite - ) { + if (repairRedirectUrl && Client.isClientResource(this.client)) { ({ signOutUrl } = await this.client.__internal_signOut({ - persistClient: this.#options.experimental?.persistClient ?? true, - redirectUrl: new URL( - opts.__internal_satelliteSignOutRedirectUrl || redirectUrl, - window.location.href, - ).toString(), + persistClient, + redirectUrl: repairRedirectUrl.href, })); - } else if (this.#options.experimental?.persistClient ?? true) { + } else if (persistClient) { await this.client.removeSessions(); } else { await this.client.destroy(); @@ -830,7 +845,7 @@ export class Clerk implements ClerkInterface { const repairUrl = isValidUrl(signOutUrl) && signOutUrl?.startsWith('https://') ? signOutUrl : undefined; await executeSignOut(repairUrl); - debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); + debugLogger.info('signOut() complete', { redirectUrl: debugRedirectUrl }, 'clerk'); return; } @@ -842,7 +857,7 @@ export class Clerk implements ClerkInterface { this.#setTransitiveState(); await session?.remove(); await executeSignOut(); - debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); + debugLogger.info('signOut() complete', { redirectUrl: debugRedirectUrl }, 'clerk'); } else { await session?.remove(); } From 5134d678016be4d1bd7d093488035b3739d84c60 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 16:24:27 -0400 Subject: [PATCH 4/6] chore(clerk-js): account for satellite sign-out bundle growth --- packages/ui/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3bfffc77315..75f8dd99c17 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "141KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, From a0e6cf26dd23cf17c72f0e467ced5d7269b45e7f Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 30 Oct 2026 15:02:32 -0400 Subject: [PATCH 5/6] chore(clerk-js): allow measured sign-out bundle growth --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 076e4eca625..e94e55b7d4d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,9 +2,9 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "556KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "128KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "324KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "82.5KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "84KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, From fcbf7737d43196e81b58234472928d780be353e9 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 30 Oct 2026 18:04:24 -0400 Subject: [PATCH 6/6] fix(clerk-js): narrow satellite repair to retained clients --- .../clerk-js/src/core/__tests__/clerk.test.ts | 2 - packages/clerk-js/src/core/clerk.ts | 1 - .../clerk-js/src/core/resources/Client.ts | 13 +-- .../core/resources/__tests__/Client.test.ts | 91 +++++++++---------- packages/shared/src/types/clerk.ts | 1 + 5 files changed, 46 insertions(+), 62 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index dd6673e6824..054de04d33c 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1318,7 +1318,6 @@ describe('Clerk singleton', () => { await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' }); expect(mockClientSignOut).toHaveBeenCalledWith({ - persistClient: true, redirectUrl: 'https://satellite.test/after-sign-out?tab=security#settings', }); expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); @@ -1455,7 +1454,6 @@ describe('Clerk singleton', () => { }); expect(mockClientSignOut).toHaveBeenCalledExactlyOnceWith({ - persistClient: true, redirectUrl: 'https://satellite.test/signed-out?tab=security#settings', }); expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('https://primary.test/v1/client/sign_out'); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5e67b86d85b..ed640f19c76 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -828,7 +828,6 @@ export class Clerk implements ClerkInterface { try { if (repairRedirectUrl && Client.isClientResource(this.client)) { ({ signOutUrl } = await this.client.__internal_signOut({ - persistClient, redirectUrl: repairRedirectUrl.href, })); } else if (persistClient) { diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index ae5f229c6b9..3912f37c107 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -95,23 +95,14 @@ export class Client extends BaseResource implements ClientResource { }); } - async __internal_signOut({ - persistClient, - redirectUrl, - }: { - persistClient: boolean; - redirectUrl: string; - }): Promise<{ signOutUrl?: string }> { + async __internal_signOut({ redirectUrl }: { redirectUrl: string }): Promise<{ signOutUrl?: string }> { const json = await BaseResource._fetch({ method: 'DELETE', - path: persistClient ? this.path() + '/sessions' : '/client', + path: this.path() + '/sessions', search: { sign_out_redirect_url: redirectUrl }, }); this.fromJSON((json?.response || json) as ClientJSON | null); SessionTokenCache.clear(); - if (!persistClient) { - this.resetClient(); - } return { signOutUrl: json?.sign_out_url }; } diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index a8287078d46..ec4bc4fbe42 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -150,56 +150,51 @@ describe('Client Singleton', () => { }); describe('__internal_signOut', () => { - it.each([true, false])( - 'returns the response URL and clears sessions with persistClient=%s', - async persistClient => { - const user = createUser({ id: 'user_1' }); - const initial: ClientJSON = { - object: 'client', - id: 'client_before', - sessions: [createSession({ id: 'session_1' }, user)], - sign_in: createSignIn({ id: 'sign_in_before' }, user), - sign_up: createSignUp({ id: 'sign_up_before' }), - last_active_session_id: 'session_1', - last_authentication_strategy: 'password', - cookie_expires_at: Date.now() + 30 * 24 * 60 * 60 * 1000, - created_at: Date.now() - 1000, - updated_at: Date.now(), - }; - const response = { ...initial, id: 'client_after', sessions: [], last_active_session_id: null }; - const request = vi.fn().mockResolvedValue({ response, sign_out_url: 'https://primary.test/sign-out' }); - BaseResource._fetch = request; - const clearCache = vi.spyOn(SessionTokenCache, 'clear'); - const client = Client.getOrCreateInstance().fromJSON(initial); - const result = await client.__internal_signOut({ - persistClient, - redirectUrl: 'https://satellite.test/after?tab=security#settings', - }); - - expect(request).toHaveBeenCalledWith({ - method: 'DELETE', - path: persistClient ? '/client/sessions' : '/client', - search: { sign_out_redirect_url: 'https://satellite.test/after?tab=security#settings' }, - }); - expect(result).toEqual({ signOutUrl: 'https://primary.test/sign-out' }); - expect(clearCache).toHaveBeenCalledOnce(); - expect(client.sessions).toEqual([]); - expect(client.id).toBe(persistClient ? 'client_after' : ''); - expect(client.signIn.id).toBe(persistClient ? 'sign_in_before' : undefined); - expect(client.signUp.id).toBe(persistClient ? 'sign_up_before' : undefined); - expect(client.lastActiveSessionId).toBeNull(); - expect(client.lastAuthenticationStrategy).toBe(persistClient ? 'password' : null); - expect(client.cookieExpiresAt).toEqual(persistClient ? new Date(initial.cookie_expires_at!) : null); - expect(client.createdAt).toEqual(persistClient ? new Date(initial.created_at!) : null); - expect(client.updatedAt).toEqual(persistClient ? new Date(initial.updated_at!) : null); - clearCache.mockRestore(); - }, - ); + it('returns the response URL and clears sessions while retaining the client', async () => { + const user = createUser({ id: 'user_1' }); + const initial: ClientJSON = { + object: 'client', + id: 'client_before', + sessions: [createSession({ id: 'session_1' }, user)], + sign_in: createSignIn({ id: 'sign_in_before' }, user), + sign_up: createSignUp({ id: 'sign_up_before' }), + last_active_session_id: 'session_1', + last_authentication_strategy: 'password', + cookie_expires_at: Date.now() + 30 * 24 * 60 * 60 * 1000, + created_at: Date.now() - 1000, + updated_at: Date.now(), + }; + const response = { ...initial, id: 'client_after', sessions: [], last_active_session_id: null }; + const request = vi.fn().mockResolvedValue({ response, sign_out_url: 'https://primary.test/sign-out' }); + BaseResource._fetch = request; + const clearCache = vi.spyOn(SessionTokenCache, 'clear'); + const client = Client.getOrCreateInstance().fromJSON(initial); + const result = await client.__internal_signOut({ + redirectUrl: 'https://satellite.test/after?tab=security#settings', + }); + + expect(request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/client/sessions', + search: { sign_out_redirect_url: 'https://satellite.test/after?tab=security#settings' }, + }); + expect(result).toEqual({ signOutUrl: 'https://primary.test/sign-out' }); + expect(clearCache).toHaveBeenCalledOnce(); + expect(client.sessions).toEqual([]); + expect(client.id).toBe('client_after'); + expect(client.signIn.id).toBe('sign_in_before'); + expect(client.signUp.id).toBe('sign_up_before'); + expect(client.lastActiveSessionId).toBeNull(); + expect(client.lastAuthenticationStrategy).toBe('password'); + expect(client.cookieExpiresAt).toEqual(new Date(initial.cookie_expires_at!)); + expect(client.createdAt).toEqual(new Date(initial.created_at!)); + expect(client.updatedAt).toEqual(new Date(initial.updated_at!)); + clearCache.mockRestore(); + }); it.each([null, { response: null }])('accepts a response without a sign-out URL', async response => { BaseResource._fetch = vi.fn().mockResolvedValue(response); const result = await Client.getOrCreateInstance().__internal_signOut({ - persistClient: true, redirectUrl: 'https://satellite.test/', }); expect(result).toEqual({ signOutUrl: undefined }); @@ -211,10 +206,10 @@ describe('Client Singleton', () => { .mockResolvedValueOnce({ response: null, sign_out_url: 'https://primary.test/sign-out' }) .mockResolvedValueOnce(null); const client = Client.getOrCreateInstance(); - expect(await client.__internal_signOut({ persistClient: true, redirectUrl: 'https://satellite.test/' })).toEqual({ + expect(await client.__internal_signOut({ redirectUrl: 'https://satellite.test/' })).toEqual({ signOutUrl: 'https://primary.test/sign-out', }); - expect(await client.__internal_signOut({ persistClient: true, redirectUrl: 'https://satellite.test/' })).toEqual({ + expect(await client.__internal_signOut({ redirectUrl: 'https://satellite.test/' })).toEqual({ signOutUrl: undefined, }); }); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 8dc92836cf7..db161fc52e0 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -201,6 +201,7 @@ export type SignOutCallback = () => void | Promise; * Configuration options. */ export type SignOutOptions = { + /** @internal */ __internal_satelliteSignOutRedirectUrl?: string; /** * Specify a specific session to sign out. Useful for multi-session applications.