From 4e85e518e37b7fafb459d8b6b8b8582a398c0ef5 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:54:07 -0300 Subject: [PATCH 1/5] Fix SSO callback for after-auth --- .../clerk-js/src/core/__tests__/clerk.test.ts | 112 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 8 ++ .../clerk-js/src/core/resources/SignIn.ts | 6 +- .../clerk-js/src/ui/common/SSOCallback.tsx | 8 +- 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 8e43fcfd3be..9b774e272c5 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -923,6 +923,118 @@ describe('Clerk singleton', () => { mockEnvironmentFetch.mockReset(); }); + describe('with after-auth flows', () => { + beforeEach(() => { + mockClientFetch.mockReset(); + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + organizationSettings: { + forceOrganizationSelection: true, + }, + }), + ); + }); + + it('redirects to pending task', async () => { + const mockSession = { + id: '1', + status: 'pending', + user: {}, + tasks: [{ key: 'select-organization' }], + currentTask: { key: 'select-organization', __internal_getUrl: () => 'https://sut/tasks/select-organization' }, + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + const mockResource = { + ...mockSession, + remove: jest.fn(), + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + reload: jest.fn(() => Promise.resolve(mockSession)), + }; + + mockResource.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockResource], + signIn: new SignIn(null), + signUp: new SignUp({ + status: 'complete', + } as any as SignUpJSON), + }), + ); + + const mockSetActive = jest.fn(); + const mockSignUpCreate = jest + .fn() + .mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + if (!sut.client) { + fail('we should always have a client'); + } + sut.client.signUp.create = mockSignUpCreate; + sut.setActive = mockSetActive; + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/tasks/select-organization'); + }); + }); + + it('redirects to after sign-in URL when task has been resolved', async () => { + const mockSession = { + id: '1', + status: 'active', + user: {}, + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + const mockResource = { + ...mockSession, + remove: jest.fn(), + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + reload: jest.fn(() => Promise.resolve(mockSession)), + }; + + mockResource.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockResource], + signIn: new SignIn(null), + signUp: new SignUp(null), + }), + ); + + const mockSetActive = jest.fn(); + const mockSignUpCreate = jest + .fn() + .mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + if (!sut.client) { + fail('we should always have a client'); + } + sut.client.signUp.create = mockSignUpCreate; + sut.setActive = mockSetActive; + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/'); + }); + }); + }); + it('creates a new user and calls setActive if the user was not found during sso signup', async () => { mockEnvironmentFetch.mockReturnValue( Promise.resolve({ diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7bcc3910e05..385af2d6729 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1801,6 +1801,10 @@ export class Clerk implements ClerkInterface { } } + if (this.session?.currentTask) { + return this.__internal_navigateToTaskIfAvailable(); + } + const { displayConfig } = this.environment; const { firstFactorVerification } = signIn; const { externalAccount } = signUp.verifications; @@ -1992,6 +1996,10 @@ export class Clerk implements ClerkInterface { return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); } + if (this.__internal_hasAfterAuthFlows && this.isSignedIn) { + return navigate(redirectUrls.getAfterSignInUrl()); + } + return navigateToSignIn(); }; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index fe4cb5c045c..544922fcb4c 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -41,6 +41,7 @@ import type { } from '@clerk/types'; import { + buildURL, generateSignatureWithCoinbaseWallet, generateSignatureWithMetamask, generateSignatureWithOKXWallet, @@ -239,7 +240,10 @@ export class SignIn extends BaseResource implements SignInResource { // This ensures organization selection tasks are displayed after sign-in, // rather than redirecting to potentially unprotected pages while the session is pending. const actionCompleteRedirectUrl = SignIn.clerk.__internal_hasAfterAuthFlows - ? redirectUrlWithAuthToken + ? buildURL({ + base: redirectUrlWithAuthToken, + search: `?redirect_url=${redirectUrlComplete}`, + }).toString() : redirectUrlComplete; if (!this.id || !continueSignIn) { diff --git a/packages/clerk-js/src/ui/common/SSOCallback.tsx b/packages/clerk-js/src/ui/common/SSOCallback.tsx index 27fba8c9e76..5dc908bdfb3 100644 --- a/packages/clerk-js/src/ui/common/SSOCallback.tsx +++ b/packages/clerk-js/src/ui/common/SSOCallback.tsx @@ -19,19 +19,13 @@ export const SSOCallback = withCardStateProvider { - const { handleRedirectCallback, __internal_setActiveInProgress, __internal_navigateToTaskIfAvailable, session } = - useClerk(); + const { handleRedirectCallback, __internal_setActiveInProgress } = useClerk(); const { navigate } = useRouter(); const card = useCardState(); React.useEffect(() => { let timeoutId: ReturnType; if (__internal_setActiveInProgress !== true) { - if (session?.currentTask) { - void __internal_navigateToTaskIfAvailable(); - return; - } - const intent = new URLSearchParams(window.location.search).get('intent'); const reloadResource = intent === 'signIn' || intent === 'signUp' ? intent : undefined; handleRedirectCallback({ ...props, reloadResource }, navigate).catch(e => { From 15c7a492bb7a9f1ecd4757f379a89d13df1d15f5 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:57:11 -0300 Subject: [PATCH 2/5] Add changeset --- .changeset/yummy-ghosts-share.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/yummy-ghosts-share.md diff --git a/.changeset/yummy-ghosts-share.md b/.changeset/yummy-ghosts-share.md new file mode 100644 index 00000000000..41f2d07a816 --- /dev/null +++ b/.changeset/yummy-ghosts-share.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix SSO callback for after-auth custom flows From 2d50e647957032d14657e83cc228d0b93f9aee08 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 29 Jul 2025 19:27:37 -0300 Subject: [PATCH 3/5] Call `__internal_navigateToTask` --- packages/clerk-js/src/core/clerk.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 385af2d6729..066200555bb 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1801,10 +1801,6 @@ export class Clerk implements ClerkInterface { } } - if (this.session?.currentTask) { - return this.__internal_navigateToTaskIfAvailable(); - } - const { displayConfig } = this.environment; const { firstFactorVerification } = signIn; const { externalAccount } = signUp.verifications; @@ -1896,10 +1892,11 @@ export class Clerk implements ClerkInterface { const res = await signIn.create({ transfer: true }); switch (res.status) { case 'complete': - return this.setActive({ + await this.setActive({ session: res.createdSessionId, redirectUrl: redirectUrls.getAfterSignInUrl(), }); + return this.__internal_navigateToTaskIfAvailable(); case 'needs_first_factor': return navigateToFactorOne(); case 'needs_second_factor': @@ -1945,10 +1942,11 @@ export class Clerk implements ClerkInterface { const res = await signUp.create({ transfer: true }); switch (res.status) { case 'complete': - return this.setActive({ + await this.setActive({ session: res.createdSessionId, redirectUrl: redirectUrls.getAfterSignUpUrl(), }); + return this.__internal_navigateToTaskIfAvailable(); case 'missing_requirements': return navigateToNextStepSignUp({ missingFields: res.missingFields }); default: @@ -1957,10 +1955,11 @@ export class Clerk implements ClerkInterface { } if (su.status === 'complete') { - return this.setActive({ + await this.setActive({ session: su.sessionId, redirectUrl: redirectUrls.getAfterSignUpUrl(), }); + return this.__internal_navigateToTaskIfAvailable(); } if (si.status === 'needs_second_factor') { @@ -1996,7 +1995,11 @@ export class Clerk implements ClerkInterface { return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); } - if (this.__internal_hasAfterAuthFlows && this.isSignedIn) { + if (this.__internal_hasAfterAuthFlows) { + if (this.session?.currentTask) { + return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); + } + return navigate(redirectUrls.getAfterSignInUrl()); } From 4871e4d29ca4afd95984ae115ead5ac2beec035e Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:28:44 -0300 Subject: [PATCH 4/5] Update JSDocs for `session` return from `useSession` --- .changeset/metal-geese-double.md | 5 +++++ packages/clerk-js/src/core/clerk.ts | 11 +++-------- packages/types/src/hooks.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 .changeset/metal-geese-double.md diff --git a/.changeset/metal-geese-double.md b/.changeset/metal-geese-double.md new file mode 100644 index 00000000000..19e8c5b35a3 --- /dev/null +++ b/.changeset/metal-geese-double.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': patch +--- + +Fix `UseSessionReturn['session']` JSDocs to not mention active status, since pending sessions are also returned diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 066200555bb..0e7809377c7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -400,7 +400,6 @@ export class Clerk implements ClerkInterface { }); this.#publicEventBus.emit(clerkEvents.Status, 'loading'); this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s)); - // This line is used for the piggy-backing mechanism BaseResource.clerk = this; } @@ -1896,7 +1895,7 @@ export class Clerk implements ClerkInterface { session: res.createdSessionId, redirectUrl: redirectUrls.getAfterSignInUrl(), }); - return this.__internal_navigateToTaskIfAvailable(); + return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); case 'needs_first_factor': return navigateToFactorOne(); case 'needs_second_factor': @@ -1959,7 +1958,7 @@ export class Clerk implements ClerkInterface { session: su.sessionId, redirectUrl: redirectUrls.getAfterSignUpUrl(), }); - return this.__internal_navigateToTaskIfAvailable(); + return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignUpUrl() }); } if (si.status === 'needs_second_factor') { @@ -1996,11 +1995,7 @@ export class Clerk implements ClerkInterface { } if (this.__internal_hasAfterAuthFlows) { - if (this.session?.currentTask) { - return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); - } - - return navigate(redirectUrls.getAfterSignInUrl()); + return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); } return navigateToSignIn(); diff --git a/packages/types/src/hooks.ts b/packages/types/src/hooks.ts index 7df8d93193d..47fd1f6f017 100644 --- a/packages/types/src/hooks.ts +++ b/packages/types/src/hooks.ts @@ -180,7 +180,7 @@ export type UseSessionReturn = */ isSignedIn: undefined; /** - * The current active session for the user. + * The current session for the user. */ session: undefined; } From 613f91663ab766d0c5df792a7a12ccc79525df27 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:33:10 -0300 Subject: [PATCH 5/5] Do not pass `redirectUrlComplete` to `navigateToTask` `handleRedirectCallback` calls `setActive` with `redirectUrl` - this gets used to navigate to once the session transitions to `active` status. For custom flows, we don't recommend using `redirectUrl`, neither mention in our docs. This commit avoids passing `redirectUrlComplete` to `navigateToTask` to prevent race conditions on the navigation if the session is `active`. --- packages/clerk-js/src/core/clerk.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0e7809377c7..ea5bb8074ed 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -400,6 +400,7 @@ export class Clerk implements ClerkInterface { }); this.#publicEventBus.emit(clerkEvents.Status, 'loading'); this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s)); + // This line is used for the piggy-backing mechanism BaseResource.clerk = this; } @@ -1878,10 +1879,11 @@ export class Clerk implements ClerkInterface { }; if (si.status === 'complete') { - return this.setActive({ + await this.setActive({ session: si.sessionId, redirectUrl: redirectUrls.getAfterSignInUrl(), }); + return this.__internal_navigateToTaskIfAvailable(); } const userExistsButNeedsToSignIn = @@ -1895,7 +1897,7 @@ export class Clerk implements ClerkInterface { session: res.createdSessionId, redirectUrl: redirectUrls.getAfterSignInUrl(), }); - return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); + return this.__internal_navigateToTaskIfAvailable(); case 'needs_first_factor': return navigateToFactorOne(); case 'needs_second_factor': @@ -1958,7 +1960,7 @@ export class Clerk implements ClerkInterface { session: su.sessionId, redirectUrl: redirectUrls.getAfterSignUpUrl(), }); - return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignUpUrl() }); + return this.__internal_navigateToTaskIfAvailable(); } if (si.status === 'needs_second_factor') {