From 16d2e4d573b140153058a5d4dc3f07f50efd25bd Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 25 Sep 2026 17:12:45 -0300 Subject: [PATCH 1/2] feat(clerk-js): route OAuth callbacks to the enterprise connection chooser When the email an OAuth provider returns matches more than one enterprise connection, the callback now routes a `needs_first_factor` sign-in to `/factor-one` and a sign-up that is missing `enterprise_sso` to `/enterprise-connections`. Both screens already exist in `@clerk/ui`. `hasMultipleEnterpriseConnections` moves to `@clerk/shared/internal/clerk-js/enterpriseSSOFactors` so clerk-js and ui share it. `navigateToNextStepSignUp` takes an optional `enterpriseConnectionsUrl`; without it the helper behaves as before. The server does not produce either state from an OAuth callback yet, so the new branches are inert until clerk_go stops auto-picking a connection. Co-Authored-By: Claude Fable 5.1 --- .../oauth-callback-enterprise-chooser.md | 7 + .../clerk-js/src/core/__tests__/clerk.test.ts | 152 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 13 ++ .../__tests__/enterpriseSSOFactors.test.ts | 36 +++++ .../navigateToNextStepSignUp.test.ts | 55 +++++++ .../internal/clerk-js/enterpriseSSOFactors.ts | 18 +++ .../clerk-js/navigateToNextStepSignUp.ts | 14 +- .../components/SignIn/enterpriseSSOFactors.ts | 27 +--- 8 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 .changeset/oauth-callback-enterprise-chooser.md create mode 100644 packages/shared/src/internal/clerk-js/__tests__/enterpriseSSOFactors.test.ts create mode 100644 packages/shared/src/internal/clerk-js/enterpriseSSOFactors.ts diff --git a/.changeset/oauth-callback-enterprise-chooser.md b/.changeset/oauth-callback-enterprise-chooser.md new file mode 100644 index 00000000000..dd2a6c8affb --- /dev/null +++ b/.changeset/oauth-callback-enterprise-chooser.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Route an OAuth callback to the enterprise connection chooser when the returned email matches more than one enterprise connection, instead of the sign-in start page or the sign-up continue page. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 18451d7bc03..18484a023af 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2819,6 +2819,158 @@ describe('Clerk singleton', () => { }); }); + it('redirects user to factor-one to choose between multiple enterprise connections', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn({ + status: 'needs_first_factor', + supported_first_factors: [ + { strategy: 'enterprise_sso', enterprise_connection_id: 'ec_1', enterprise_connection_name: 'A' }, + { strategy: 'enterprise_sso', enterprise_connection_id: 'ec_2', enterprise_connection_name: 'B' }, + ], + } as unknown as SignInJSON), + signUp: new SignUp(null), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/factor-one'); + }); + }); + + it('redirects user to sign-in when a single bare enterprise_sso factor needs the first factor', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn({ + status: 'needs_first_factor', + supported_first_factors: [{ strategy: 'enterprise_sso' }], + } as unknown as SignInJSON), + signUp: new SignUp(null), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in'); + }); + }); + + it('redirects user to the enterprise connections url if the sign-up is missing enterprise_sso', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn(null), + signUp: new SignUp({ + status: 'missing_requirements', + missing_fields: ['enterprise_sso'], + verifications: { + external_account: { + status: 'verified', + strategy: 'oauth_google', + external_verification_redirect_url: '', + error: null, + }, + }, + } as any as SignUpJSON), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-up#/enterprise-connections'); + }); + }); + + it('redirects user to the protect-check url before the enterprise connections url if the sign-up is protect-gated', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn(null), + signUp: new SignUp({ + status: 'missing_requirements', + missing_fields: ['protect_check', 'enterprise_sso'], + protect_check: { status: 'pending', token: 't', sdk_url: 'https://example.com/sdk.js' }, + verifications: { + external_account: { + status: 'verified', + strategy: 'oauth_google', + external_verification_redirect_url: '', + error: null, + }, + }, + } as any as SignUpJSON), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-up#/protect-check'); + }); + }); + it('redirects user to the verify-email-address url if the external account has unverified email and there are no missing requirements', async () => { mockEnvironmentFetch.mockReturnValue( Promise.resolve({ diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index aa2cb555cd7..88ad1cf0838 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -30,6 +30,7 @@ import { CLERK_SYNCED_STATUS, ERROR_CODES, } from '@clerk/shared/internal/clerk-js/constants'; +import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint, @@ -2665,6 +2666,10 @@ export class Clerk implements ClerkInterface { const signUpProtectCheckUrl = params.signUpProtectCheckUrl || buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }); + const enterpriseConnectionsUrl = buildURL( + { base: displayConfig.signUpUrl, hashPath: '/enterprise-connections' }, + { stringify: true }, + ); const navigateToSignUpProtectCheck = makeNavigate(signUpProtectCheckUrl); @@ -2785,6 +2790,13 @@ export class Clerk implements ClerkInterface { return navigateToFactorOne(); } + const userMustChooseEnterpriseConnection = + si.status === 'needs_first_factor' && hasMultipleEnterpriseConnections(signIn.supportedFirstFactors); + + if (userMustChooseEnterpriseConnection) { + return navigateToFactorOne(); + } + const userNeedsNewPassword = si.status === 'needs_new_password'; if (userNeedsNewPassword) { @@ -2881,6 +2893,7 @@ export class Clerk implements ClerkInterface { verifyEmailAddressUrl, verifyPhoneNumberUrl, signUpProtectCheckUrl, + enterpriseConnectionsUrl, navigate, }); } diff --git a/packages/shared/src/internal/clerk-js/__tests__/enterpriseSSOFactors.test.ts b/packages/shared/src/internal/clerk-js/__tests__/enterpriseSSOFactors.test.ts new file mode 100644 index 00000000000..2d66ee2579b --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/enterpriseSSOFactors.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { SignInFirstFactor } from '@/types'; + +import { hasMultipleEnterpriseConnections } from '../enterpriseSSOFactors'; + +const connectionA = { + strategy: 'enterprise_sso', + enterpriseConnectionId: 'ec_1', + enterpriseConnectionName: 'A', +} as SignInFirstFactor; + +const connectionB = { + strategy: 'enterprise_sso', + enterpriseConnectionId: 'ec_2', + enterpriseConnectionName: 'B', +} as SignInFirstFactor; + +const bareEnterpriseSSO = { strategy: 'enterprise_sso' } as SignInFirstFactor; + +describe('hasMultipleEnterpriseConnections', () => { + it.each([ + ['null', null, false], + ['no factors', [], false], + ['one bare enterprise_sso factor', [bareEnterpriseSSO], false], + ['two bare enterprise_sso factors', [bareEnterpriseSSO, bareEnterpriseSSO], false], + ['one enterprise connection', [connectionA], false], + ['two enterprise connections', [connectionA, connectionB], true], + ['a password factor plus two enterprise connections', [{ strategy: 'password' }, connectionA, connectionB], true], + ] as Array<[string, SignInFirstFactor[] | null, boolean]>)( + 'returns the expected value for %s', + (_, factors, expected) => { + expect(hasMultipleEnterpriseConnections(factors)).toBe(expected); + }, + ); +}); diff --git a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts index 99f560a5389..f6a769f412c 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts @@ -13,6 +13,8 @@ const URLS = { signUpProtectCheckUrl: 'https://app.test/sign-up/protect-check', }; +const ENTERPRISE_CONNECTIONS_URL = 'https://app.test/sign-up/enterprise-connections'; + describe('navigateToNextStepSignUp', () => { beforeEach(() => { mockNavigate.mockReset(); @@ -56,6 +58,59 @@ describe('navigateToNextStepSignUp', () => { expect(mockNavigate).toHaveBeenCalledWith(URLS.signUpProtectCheckUrl); }); + it('navigates to the enterprise connections page when enterprise_sso is missing and the url is provided', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['enterprise_sso'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + enterpriseConnectionsUrl: ENTERPRISE_CONNECTIONS_URL, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ENTERPRISE_CONNECTIONS_URL); + }); + + it('navigates to the protect-check page before the enterprise connections page', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['protect_check', 'enterprise_sso'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + enterpriseConnectionsUrl: ENTERPRISE_CONNECTIONS_URL, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.signUpProtectCheckUrl); + }); + + it('navigates to the continue page when enterprise_sso is missing and no enterprise connections url is provided', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['enterprise_sso'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + it('navigates to verify-email-address when email is unverified and there are no missing fields', async () => { const signUp = { status: 'missing_requirements', diff --git a/packages/shared/src/internal/clerk-js/enterpriseSSOFactors.ts b/packages/shared/src/internal/clerk-js/enterpriseSSOFactors.ts new file mode 100644 index 00000000000..9941c772c16 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/enterpriseSSOFactors.ts @@ -0,0 +1,18 @@ +import type { EnterpriseSSOFactor, SignInFirstFactor } from '../../types'; + +export function hasMultipleEnterpriseConnections( + factors: SignInFirstFactor[] | null, +): factors is Array { + if (!factors?.length) { + return false; + } + + return ( + factors.filter( + factor => + factor.strategy === 'enterprise_sso' && + 'enterpriseConnectionId' in factor && + 'enterpriseConnectionName' in factor, + ).length > 1 + ); +} diff --git a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts index 277b8ede011..65923a424e6 100644 --- a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts +++ b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts @@ -7,18 +7,13 @@ type NavigateToNextStepSignUpProps = { verifyEmailAddressUrl: string; verifyPhoneNumberUrl: string; signUpProtectCheckUrl: string; + enterpriseConnectionsUrl?: string; navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; }; /** * Routes a sign-up that's still in `missing_requirements` to the appropriate - * next step: - * - * - If the sign-up is protect-gated, go to the protect-check challenge. - * - Otherwise, if there are missing fields, go straight to the continue page so - * the user can fill them in. - * - Otherwise, hand off to `completeSignUpFlow` which routes unverified email - * or phone identifications to their respective verify pages. + * next step. * * Used by both the OAuth callback handler and the sign-in `signUpIfMissing` * transfer flow so they stay in lockstep. @@ -31,6 +26,7 @@ export const navigateToNextStepSignUp = ({ verifyEmailAddressUrl, verifyPhoneNumberUrl, signUpProtectCheckUrl, + enterpriseConnectionsUrl, navigate, }: NavigateToNextStepSignUpProps): Promise | undefined => { // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate @@ -40,6 +36,10 @@ export const navigateToNextStepSignUp = ({ return navigate(signUpProtectCheckUrl); } + if (enterpriseConnectionsUrl && signUp.missingFields.includes('enterprise_sso')) { + return navigate(enterpriseConnectionsUrl); + } + if (signUp.missingFields.length) { return navigate(continueSignUpUrl); } diff --git a/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts b/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts index 7c9dcab8dba..5def04507d6 100644 --- a/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts +++ b/packages/ui/src/components/SignIn/enterpriseSSOFactors.ts @@ -1,4 +1,5 @@ -import type { EmailCodeFactor, EnterpriseSSOFactor, SignInFirstFactor, SignInResource } from '@clerk/shared/types'; +import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors'; +import type { EmailCodeFactor, SignInResource } from '@clerk/shared/types'; /** * Whether every supported first factor hands off to an enterprise connection, i.e. there is no @@ -12,30 +13,6 @@ function hasOnlyEnterpriseSSOFirstFactors(signIn: SignInResource): boolean { return signIn.supportedFirstFactors.every(ff => ff.strategy === 'enterprise_sso'); } -/** - * Type guard that checks if all factors in the array are enterprise SSO factors - * with both `enterpriseConnectionId` and `enterpriseConnectionName` properties. - * This is used to determine if the user should be presented with a choice - * between multiple enterprise connections. - * @experimental - */ -function hasMultipleEnterpriseConnections( - factors: SignInFirstFactor[] | null, -): factors is Array { - if (!factors?.length) { - return false; - } - - return ( - factors.filter( - factor => - factor.strategy === 'enterprise_sso' && - 'enterpriseConnectionId' in factor && - 'enterpriseConnectionName' in factor, - ).length > 1 - ); -} - /** * Returns the email code factor a sign-in may fall back to, or `null`. * @experimental From c6bfd482901bd3f88bbed1bf3a3029242332811e Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 28 Sep 2026 21:54:36 -0300 Subject: [PATCH 2/2] chore(clerk-js): raise the clerk.native.js bundle limit to 80.25KB The OAuth callback routing adds 0.04KB gzipped to the native bundle. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a421c0f931c..19e64d5bba6 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "80KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "80.25KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },