diff --git a/.changeset/strong-schools-shine.md b/.changeset/strong-schools-shine.md new file mode 100644 index 00000000000..e0f0d7d1555 --- /dev/null +++ b/.changeset/strong-schools-shine.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fixing redirect behavior when signing out from a multisession app with multple singed in accounts diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 8dee5fba0a6..68b53bf1b44 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -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 = { @@ -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/'); }); }); @@ -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'); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 190328ec6bf..5520a780b3f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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 => { @@ -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(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 3308c9a9cb3..ad887186c81 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -26,6 +26,7 @@ import type { } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; +import { debugLogger } from '../../utils/debug'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -74,6 +75,14 @@ export class Session extends BaseResource implements SessionResource { }; remove = (): Promise => { + debugLogger.debug( + 'remove()', + { + sessionId: this.id, + }, + 'Session', + ); + SessionTokenCache.clear(); return this._basePost({ action: 'remove',