diff --git a/.changeset/satellite-sign-out-device-trust.md b/.changeset/satellite-sign-out-device-trust.md new file mode 100644 index 00000000000..d836bd8018f --- /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 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/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" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..054de04d33c 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,336 @@ describe('Clerk singleton', () => { beforeEach(() => { mockClientDestroy.mockReset(); mockClientRemoveSessions.mockReset(); + mockClientSignOut.mockReset(); + vi.mocked(Client.isClientResource).mockReturnValue(false); mockSession1.remove.mockReset(); mockSession2.remove.mockReset(); mockSession3.remove.mockReset(); }); + 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({ + ...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('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({ + 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])( + '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().mockRejectedValue(error); + await sut.load({ isSatellite: true, experimental: { persistClient } }); + await expect(sut.signOut({ redirectUrl: 'https://' })).rejects.toBe(error); + + 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(persistClient ? mockClientRemoveSessions : mockClientDestroy).toHaveBeenCalledOnce(); + expect(mockClientSignOut).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(sut.navigate).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 => { + (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 } }); + await expect(sut.signOut()).resolves.toBeUndefined(); + + expect(sut.session).toBeNull(); + expect(sut.navigate).toHaveBeenCalledExactlyOnceWith('/'); + }, + ); + + 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 }); + await sut.signOut(callback, { + __internal_satelliteSignOutRedirectUrl: 'https://satellite.test/signed-out?tab=security#settings', + }); + + expect(mockClientSignOut).toHaveBeenCalledExactlyOnceWith({ + 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' }); + 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..ed640f19c76 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'; @@ -748,26 +749,34 @@ 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', ); 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. 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 @@ -795,8 +804,33 @@ 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 (this.#options.experimental?.persistClient ?? true) { + if (repairRedirectUrl && Client.isClientResource(this.client)) { + ({ signOutUrl } = await this.client.__internal_signOut({ + redirectUrl: repairRedirectUrl.href, + })); + } else if (persistClient) { await this.client.removeSessions(); } else { await this.client.destroy(); @@ -807,9 +841,10 @@ export class Clerk implements ClerkInterface { } } - await executeSignOut(); + 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; } @@ -821,7 +856,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(); } 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..3912f37c107 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,29 @@ export class Client extends BaseResource implements ClientResource { }); } + async __internal_signOut({ redirectUrl }: { redirectUrl: string }): Promise<{ signOutUrl?: string }> { + const json = await BaseResource._fetch({ + method: 'DELETE', + path: this.path() + '/sessions', + search: { sign_out_redirect_url: redirectUrl }, + }); + this.fromJSON((json?.response || json) as ClientJSON | null); + SessionTokenCache.clear(); + 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..ec4bc4fbe42 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,72 @@ describe('Client Singleton', () => { }); }); + describe('__internal_signOut', () => { + 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({ + 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({ redirectUrl: 'https://satellite.test/' })).toEqual({ + signOutUrl: 'https://primary.test/sign-out', + }); + expect(await client.__internal_signOut({ 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/clerk.ts b/packages/shared/src/types/clerk.ts index 069bc460a14..db161fc52e0 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -201,6 +201,8 @@ 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. */ 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" }, diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx index 61a27f5ba4a..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).toHaveBeenCalled(); + 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 864ba7d2954..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 { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { - return signOut(navigateAfterSignOut); + 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 c910527f0be..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).toHaveBeenCalled(); + 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 57cc61fc215..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 { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOut = () => { if (otherSessions.length === 0) { - return signOut(navigateAfterSignOut); + 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 465465abc63..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).toHaveBeenCalled(); + 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 9efa92d60fe..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 { navigateAfterSignOut, 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(navigateAfterSignOut); + 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 21cb1714c82..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 @@ -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(expect.any(Function)); + }); + 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..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 { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const signOutWithRedirect = useSignOutWithRedirect(); const handleSignOut = () => { if (otherSessions.length === 0) { - return clerk.signOut(navigateAfterSignOut); + 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 3540530b6c7..3bb87f71efd 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -16,10 +16,11 @@ const SignInAccountSwitcherInternal = () => { const card = useCardState(); const { userProfileUrl } = useEnvironment().displayConfig; const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext(); - const { navigateAfterSignOut } = useSignOutContext(); + const { afterSignOutUrl, navigateAfterSignOut } = useSignOutContext(); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, + afterSignOutUrl, navigateAfterSignOut, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx index 54a8cd799de..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).toHaveBeenCalled(); + 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 06d19655215..3e81dc1683e 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,33 @@ 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); + 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.router.navigate).toHaveBeenCalledWith('/'); - }); + expect(fixtures.clerk.signOut).toHaveBeenCalledExactlyOnceWith(expect.any(Function)); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(redirectUrl); + }, + ); it.todo('navigates to sign in url when "Add account" is clicked'); @@ -182,19 +191,24 @@ 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); + 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(expect.any(Function)); + expect(fixtures.router.navigate).toHaveBeenCalledExactlyOnceWith(redirectUrl); + }, + ); }); describe('Accessibility', () => { diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index bb46235f382..9fad58480b4 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -8,11 +8,13 @@ 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; @@ -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(opts.navigateAfterSignOut); + 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(opts.navigateAfterSignOut); + 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);