diff --git a/.changeset/curly-cameras-laugh.md b/.changeset/curly-cameras-laugh.md new file mode 100644 index 00000000000..b7d34bc84ff --- /dev/null +++ b/.changeset/curly-cameras-laugh.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Fix Core 3 OAuth retry routing to the previously selected provider after an abandoned redirect. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f6540d06eb9..76070ee456a 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1145,7 +1145,12 @@ class SignInFuture implements SignInFutureResource { routes.actionCompleteRedirectUrl = wrappedRoutes.redirectUrl; } - if (!this.#resource.id) { + // Enterprise SSO can be entered with a pre-existing sign-in (e.g. from a ticket + // or identifier-based discovery), in which case `prepare_first_factor` must run + // against that resource. All other strategies always start fresh. + const shouldCreateSignIn = !this.#resource.id || strategy !== 'enterprise_sso'; + + if (shouldCreateSignIn) { await this._create({ strategy, ...routes, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 76d82b08de8..e49e369dc83 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -2110,6 +2110,131 @@ describe('SignIn', () => { }); }); + it('reuses an existing ticket sign-in when preparing enterprise SSO', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + + SignIn.clerk = { + buildUrlWithAuth: vi.fn().mockReturnValue('https://example.com/sso-callback'), + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_ticket', + status: 'needs_first_factor', + supported_first_factors: [{ strategy: 'enterprise_sso' }], + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_ticket', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://sso.example.com/auth', + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.__internal_future.ticket({ ticket: 'ticket_123' }); + await signIn.__internal_future.sso({ + strategy: 'enterprise_sso', + redirectUrl: 'https://complete.example.com', + redirectCallbackUrl: '/sso-callback', + }); + + expect(mockFetch).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: '/client/sign_ins/signin_ticket/prepare_first_factor', + body: { + strategy: 'enterprise_sso', + redirectUrl: 'https://example.com/sso-callback', + actionCompleteRedirectUrl: 'https://complete.example.com', + }, + }); + }); + + it('reuses an existing enterprise SSO sign-in and uses the fresh redirect URL when retrying after an abandoned attempt', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + + const mockPopup = { location: { href: '' } } as Window; + + SignIn.clerk = { + buildUrlWithAuth: vi.fn().mockReturnValue('https://example.com/sso-callback'), + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_enterprise', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://sso.example.com/auth/fresh', + }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_enterprise', + status: 'complete', + }, + }); + BaseResource._fetch = mockFetch; + + vi.mocked(_futureAuthenticateWithPopup).mockImplementation((_clerk, params) => { + params.popup.location.href = params.externalVerificationRedirectURL.toString(); + return Promise.resolve(); + }); + + const signIn = new SignIn({ + id: 'signin_enterprise', + object: 'sign_in', + status: 'needs_first_factor', + first_factor_verification: { + status: 'unverified', + strategy: 'enterprise_sso', + external_verification_redirect_url: 'https://sso.example.com/auth/stale', + }, + } as any); + + const result = await signIn.__internal_future.sso({ + strategy: 'enterprise_sso', + redirectUrl: 'https://complete.example.com', + redirectCallbackUrl: '/sso-callback', + popup: mockPopup, + }); + + expect(result.error).toBeNull(); + expect(mockFetch).toHaveBeenNthCalledWith(1, { + method: 'POST', + path: '/client/sign_ins/signin_enterprise/prepare_first_factor', + body: expect.objectContaining({ + strategy: 'enterprise_sso', + }), + }); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST', path: '/client/sign_ins' }), + ); + expect(mockPopup.location.href).toBe('https://sso.example.com/auth/fresh'); + }); + it('handles relative redirectUrl by converting to absolute', async () => { vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); @@ -2153,6 +2278,82 @@ describe('SignIn', () => { }); }); + it('creates a new OAuth sign-in when retrying after a previous provider redirect was abandoned', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + + const mockPopup = { location: { href: '' } } as Window; + const mockBuildUrlWithAuth = vi.fn().mockImplementation(url => { + if (url.startsWith('/')) { + return 'https://example.com' + url; + } + return url; + }); + + SignIn.clerk = { + buildUrlWithAuth: mockBuildUrlWithAuth, + buildUrl: vi.fn().mockImplementation(path => 'https://example.com' + path), + frontendApi: 'clerk.example.com', + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + + const mockFetch = vi.fn(); + mockFetch.mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_github', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://github.com/login/oauth/authorize', + }, + }, + }); + mockFetch.mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_github', + status: 'complete', + }, + }); + BaseResource._fetch = mockFetch; + + vi.mocked(_futureAuthenticateWithPopup).mockImplementation((_clerk, params) => { + params.popup.location.href = params.externalVerificationRedirectURL.toString(); + return Promise.resolve(); + }); + + const signIn = new SignIn({ + id: 'signin_google', + object: 'sign_in', + status: 'needs_first_factor', + first_factor_verification: { + status: 'unverified', + strategy: 'oauth_google', + external_verification_redirect_url: 'https://accounts.google.com/o/oauth2/auth', + }, + } as any); + + const result = await signIn.__internal_future.sso({ + strategy: 'oauth_github', + redirectUrl: 'https://complete.example.com', + redirectCallbackUrl: '/sso-callback', + popup: mockPopup, + }); + + expect(result.error).toBeNull(); + expect(mockFetch).toHaveBeenNthCalledWith(1, { + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ + strategy: 'oauth_github', + }), + }); + expect(mockPopup.location.href).toBe('https://github.com/login/oauth/authorize'); + }); + it('uses popup when provided', async () => { vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); @@ -2198,9 +2399,10 @@ describe('SignIn', () => { }); BaseResource._fetch = mockFetch; - vi.mocked(_futureAuthenticateWithPopup).mockImplementation(async (_clerk, params) => { + vi.mocked(_futureAuthenticateWithPopup).mockImplementation((_clerk, params) => { // Simulate the actual behavior of setting popup href params.popup.location.href = params.externalVerificationRedirectURL.toString(); + return Promise.resolve(); }); const signIn = new SignIn();