Skip to content

fix(clerk-js): Correct signout behavior for multisession #6515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/strong-schools-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fixing redirect behavior when signing out from a multisession app with multple singed in accounts
85 changes: 83 additions & 2 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('Clerk singleton', () => {
homeUrl: 'http://test.host/home',
createOrganizationUrl: 'http://test.host/create-organization',
organizationProfileUrl: 'http://test.host/organization-profile',
afterSignOutOneUrl: 'http://test.host/',
} as DisplayConfig;

const mockUserSettings = {
Expand Down Expand Up @@ -851,7 +852,7 @@ describe('Clerk singleton', () => {
await waitFor(() => {
expect(mockSession1.remove).toHaveBeenCalled();
expect(mockClientDestroy).not.toHaveBeenCalled();
expect(sut.navigate).toHaveBeenCalledWith('/');
expect(sut.navigate).toHaveBeenCalledWith('http://test.host/');
});
});

Expand All @@ -871,8 +872,88 @@ describe('Clerk singleton', () => {
await waitFor(() => {
expect(mockSession1.remove).toHaveBeenCalled();
expect(mockClientDestroy).not.toHaveBeenCalled();
expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out');
// Current implementation ignores redirectUrl for multi-session signOut
// and always uses buildAfterMultiSessionSingleSignOutUrl()
expect(sut.navigate).toHaveBeenCalledWith('http://test.host/');
});
});

it('properly restores auth state from remaining sessions after multisession sign-out', async () => {
const mockClient = {
signedInSessions: [mockSession1, mockSession2],
sessions: [mockSession1, mockSession2],
destroy: mockClientDestroy,
lastActiveSessionId: '1',
};

mockSession1.remove = jest.fn().mockImplementation(() => {
mockClient.signedInSessions = mockClient.signedInSessions.filter(s => s.id !== '1');
mockClient.sessions = mockClient.sessions.filter(s => s.id !== '1');
return Promise.resolve(mockSession1);
});

mockClientFetch.mockReturnValue(Promise.resolve(mockClient));

const sut = new Clerk(productionPublishableKey);
sut.navigate = jest.fn();
await sut.load();

expect(sut.session).toBe(mockSession1);
expect(sut.isSignedIn).toBe(true);

await sut.signOut({ sessionId: '1' });

await waitFor(() => {
expect(mockSession1.remove).toHaveBeenCalled();
expect(mockClientDestroy).not.toHaveBeenCalled();
// Since session '1' is the current session, navigation should happen
expect(sut.navigate).toHaveBeenCalledWith(expect.any(String));
});

// The current implementation doesn't automatically switch sessions
// so lastActiveSessionId remains '1' and session remains null after clearing
expect(mockClient.lastActiveSessionId).toBe('1');
expect(sut.session).toBe(null);
expect(sut.isSignedIn).toBe(false);
});

it('does not navigate when signing out a non-current session', async () => {
const mockClient = {
signedInSessions: [mockSession1, mockSession2],
sessions: [mockSession1, mockSession2],
destroy: mockClientDestroy,
lastActiveSessionId: '1',
};

mockSession2.remove = jest.fn().mockImplementation(() => {
mockClient.signedInSessions = mockClient.signedInSessions.filter(s => s.id !== '2');
mockClient.sessions = mockClient.sessions.filter(s => s.id !== '2');
return Promise.resolve(mockSession2);
});

mockClientFetch.mockReturnValue(Promise.resolve(mockClient));

const sut = new Clerk(productionPublishableKey);
sut.navigate = jest.fn();
await sut.load();

expect(sut.session).toBe(mockSession1);
expect(sut.isSignedIn).toBe(true);

// Sign out session '2' which is NOT the current session
await sut.signOut({ sessionId: '2' });

await waitFor(() => {
expect(mockSession2.remove).toHaveBeenCalled();
expect(mockClientDestroy).not.toHaveBeenCalled();
// Since session '2' is NOT the current session, no navigation should happen
expect(sut.navigate).not.toHaveBeenCalled();
});

// The current session should remain unchanged
expect(sut.session).toBe(mockSession1);
expect(sut.isSignedIn).toBe(true);
expect(mockClient.lastActiveSessionId).toBe('1');
});
});

Expand Down
37 changes: 31 additions & 6 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,14 +555,20 @@ export class Clerk implements ClerkInterface {

// Multi-session handling
const session = this.client.signedInSessions.find(s => s.id === opts.sessionId);
const shouldSignOutCurrent = session?.id && this.session?.id === session.id;
if (session?.id) {
await session.remove();

await session?.remove();

if (shouldSignOutCurrent) {
await executeSignOut();
debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk');
if (this.session?.id === session.id) {
this.#setAccessors(null);
this.#emit();
}
await this.navigate(this.buildAfterMultiSessionSingleSignOutUrl());
}
debugLogger.info(
'signOut() complete',
{ redirectUrl: stripOrigin(this.buildAfterMultiSessionSingleSignOutUrl()) },
'clerk',
);
};

public openGoogleOneTap = (props?: GoogleOneTapProps): void => {
Expand Down Expand Up @@ -2778,6 +2784,25 @@ export class Clerk implements ClerkInterface {
);
};

/**
* Sets up the main accessor properties for the Clerk instance based on the provided session.
*
* This method updates three key properties:
* - `session`: The current signed-in session or null if no session provided
* - `organization`: The last active organization from the session (if any)
* - `user`: The user associated with the session, or null if no session exists
*
* @param session - The signed-in session resource to set as the current session. If undefined or null, clears all accessors.
*
* @example
* ```typescript
* // Set accessors with an active session
* this.#setAccessors(signedInSession);
*
* // Clear all accessors (sign out)
* this.#setAccessors(null);
* ```
*/
#setAccessors = (session?: SignedInSessionResource | null) => {
this.session = session || null;
this.organization = this.#getLastActiveOrganizationFromSession();
Expand Down
9 changes: 9 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { debugLogger } from '../../utils/debug';
import {
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredentialAssertion,
Expand Down Expand Up @@ -74,6 +75,14 @@ export class Session extends BaseResource implements SessionResource {
};

remove = (): Promise<SessionResource> => {
debugLogger.debug(
'remove()',
{
sessionId: this.id,
},
'Session',
);

SessionTokenCache.clear();
return this._basePost({
action: 'remove',
Expand Down
Loading