Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/satellite-sign-out-device-trust.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
326 changes: 326 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts

Large diffs are not rendered by default.

49 changes: 42 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}

Expand All @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type FapiRequestCallback<T> = (request: FapiRequestInit, response?: FapiR
export interface FapiResponseJSON<T> {
response: T;
client?: ClientJSON;
sign_out_url?: string;
errors?: ClerkAPIErrorJSON[];
meta?: {
client?: ClientJSON;
Expand Down
33 changes: 24 additions & 9 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}

Expand All @@ -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<ClientJSON>({
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)
Expand Down
67 changes: 67 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ export type SignOutCallback = () => void | Promise<any>;
* Configuration options.
*/
export type SignOutOptions = {
/** @internal */
__internal_satelliteSignOutRedirectUrl?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is on the public SignOutOptions type but has no /** @internal */ tag, unlike the other __internal_ fields in this file. Our typedoc config uses excludeInternal: true, which hides members by that tag, so this one will appear in the generated signOut() reference docs and in customers' autocomplete. Customers could then pass it to opt arbitrary callbacks into repair, which contradicts the rule that custom callbacks don't opt in. The backend redirect allowlist still limits where users can be sent, so this is about the API contract, not security.

Suggested change
__internal_satelliteSignOutRedirectUrl?: string;
/** @internal */
__internal_satelliteSignOutRedirectUrl?: string;

~ 🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added /** @internal */ to the optional metadata field, matching the existing annotations and excludeInternal configuration. Public signOut overloads and callback signatures remain unchanged. This hides the field from generated reference docs; it is metadata, not a runtime access restriction.

~ 🤖

/**
* Specify a specific session to sign out. Useful for multi-session applications.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/ui/src/components/ImpersonationFab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,15 +27,16 @@ 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<UserResource['updatePassword']>) => user.updatePassword(...opts),
);
const { navigateOnSetActive } = useSessionTasksContext();

const handleSignOut = () => {
if (otherSessions.length === 0) {
return clerk?.signOut(navigateAfterSignOut);
return signOutWithRedirect(navigateAfterSignOut, afterSignOutUrl);
}

return clerk?.signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: clerk.session?.id });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TaskSetupMFA />, { 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 => {
Expand Down
Loading
Loading