diff --git a/.changeset/enterprise-sso-hand-off-challenge.md b/.changeset/enterprise-sso-hand-off-challenge.md new file mode 100644 index 00000000000..6625e315976 --- /dev/null +++ b/.changeset/enterprise-sso-hand-off-challenge.md @@ -0,0 +1,12 @@ +--- +'@clerk/clerk-js': patch +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider. + +If you use the prebuilt `` component, there is nothing to do. If you have Clerk Protect enabled and call `signIn.authenticateWithRedirect()` or `signIn.authenticateWithPopup()` from a custom sign-in flow, catch a `ClerkRuntimeError` with code `protect_check_required` and show the verification challenge, to avoid a stalled sign-in. + +That error means a verification challenge has to be completed before the sign-in can redirect. It replaces the generic "not supported" error these methods threw before. When it is thrown, the sign-in is gated: `signIn.protectCheck` is set, or its status is `needs_protect_check`. For enterprise SSO, run the challenge and then call `authenticateWithRedirect()` again with `continueSignIn: true`. If the server has already prepared the redirect, the sign-in continues to the identity provider and the challenge runs when it returns, so no error is thrown. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index d6369a138a8..8f3e8ab0414 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1,5 +1,6 @@ import { inBrowser } from '@clerk/shared/browser'; import { type ClerkError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -389,6 +390,27 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); + const isChallengePending = () => !!this.protectCheck || this.status === 'needs_protect_check'; + const pendingHandOff = () => { + const { status, externalVerificationRedirectURL } = this.firstFactorVerification; + return status === 'unverified' ? externalVerificationRedirectURL : null; + }; + + // A pending challenge with nowhere to navigate to. Throw rather than return, so the method still + // either navigates or throws: a caller that doesn't handle challenges gets an error it can + // recognise instead of a silent success. A caller that does runs the challenge and calls back + // in with `continueSignIn`. + const throwChallengeRequired = (): never => { + throw new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { + code: ERROR_CODES.PROTECT_CHECK_REQUIRED, + }); + }; + + // The hand-off a challenged create built, if any. The server builds it before deciding, so a + // challenge on create can arrive with a usable redirect: that means "go to the identity + // provider first" and the challenge runs on the way back, where the callback routes to it. + let challengedCreateHandOff: URL | null = null; + if (!this.id || !continueSignIn) { await this.create({ strategy, @@ -396,6 +418,13 @@ export class SignIn extends BaseResource implements SignInResource { redirectUrl, actionCompleteRedirectUrl, }); + + if (isChallengePending()) { + challengedCreateHandOff = pendingHandOff(); + if (!challengedCreateHandOff) { + throwChallengeRequired(); + } + } } if (strategy === 'enterprise_sso') { @@ -406,6 +435,17 @@ export class SignIn extends BaseResource implements SignInResource { oidcPrompt, enterpriseConnectionId, }); + + // A challenged prepare builds no verification, so any redirect left on the sign-in is from an + // earlier attempt and may be for another connection. Only this call's create hand-off is safe + // to follow. + if (isChallengePending()) { + if (challengedCreateHandOff) { + navigateCallback(challengedCreateHandOff); + return; + } + throwChallengeRequired(); + } } const { status, externalVerificationRedirectURL } = this.firstFactorVerification; 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 120aafdc753..374b11163a1 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -311,6 +311,189 @@ describe('SignIn', () => { }); }); + describe('authenticateWithRedirect with a pending challenge', () => { + const originalFetch = BaseResource._fetch; + + afterEach(() => { + BaseResource._fetch = originalFetch; + vi.clearAllMocks(); + SignIn.clerk = {} as any; + }); + + const gatedResponse = { + client: null, + response: { + id: 'signin_123', + status: 'needs_protect_check', + first_factor_verification: null, + protect_check: { + status: 'pending', + token: 'challenge-token-abc', + sdk_url: 'https://sdk.example.com/challenge.js', + }, + }, + }; + + const setupClerk = () => { + const windowNavigate = vi.fn(); + SignIn.clerk = { + buildUrlWithAuth: vi.fn(u => u), + __internal_windowNavigate: windowNavigate, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + return windowNavigate; + }; + + // The server builds the hand-off before it decides, so a challenged create can also carry a + // usable redirect. + const gatedWithHandOff = (url: string) => ({ + client: null, + response: { + ...gatedResponse.response, + first_factor_verification: { status: 'unverified', external_verification_redirect_url: url }, + }, + }); + + it('follows the hand-off a challenged OAuth create built, leaving the challenge for the way back', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://accounts.google.example/auth')); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://accounts.google.example/auth')); + }); + + it('follows the create hand-off when the enterprise SSO prepare after it hits the same pending challenge', async () => { + const windowNavigate = setupClerk(); + // Create builds the hand-off and is challenged; the prepare that follows lands on the same + // pending gate and builds nothing new. + const mockFetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://idp.example/from-create')); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/from-create')); + }); + + it('does not follow a hand-off left from an earlier attempt when the prepare is challenged', async () => { + const windowNavigate = setupClerk(); + // The challenged prepare builds no verification, so the redirect on the sign-in is stale and + // may be for a different connection. + BaseResource._fetch = vi.fn().mockResolvedValue(gatedWithHandOff('https://idp.example/earlier-attempt')); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + enterpriseConnectionId: 'ent_other', + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + expect(windowNavigate).not.toHaveBeenCalled(); + }); + + it('throws protect_check_required when a challenged create built no hand-off', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedResponse); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + // Only the create call — the prepare is not attempted while the challenge is pending. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(windowNavigate).not.toHaveBeenCalled(); + expect(signIn.protectCheck?.status).toBe('pending'); + }); + + it('throws protect_check_required when preparing the enterprise SSO hand-off returns a challenge', async () => { + const windowNavigate = setupClerk(); + const mockFetch = vi.fn().mockResolvedValue(gatedResponse); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + expect(windowNavigate).not.toHaveBeenCalled(); + expect(signIn.protectCheck?.status).toBe('pending'); + }); + + it('surfaces protect_check_required through an OAuth transport instead of opening it', async () => { + // The transport expects a URL back. Returning without one used to surface as + // `oauth_transport_missing_verification_url`, which hid the real reason. + setupClerk(); + const transport = { getRedirectUrl: vi.fn().mockResolvedValue('app://callback'), open: vi.fn() }; + (SignIn.clerk as any).__internal_oauthTransport = transport; + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await expect( + signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }), + ).rejects.toMatchObject({ code: 'protect_check_required' }); + + expect(transport.open).not.toHaveBeenCalled(); + }); + + it('follows the hand-off once no challenge is pending', async () => { + const windowNavigate = setupClerk(); + BaseResource._fetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signin_123', + status: 'needs_first_factor', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://idp.example/auth', + }, + }, + }); + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }); + + expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/auth')); + }); + }); + describe('signIn.create', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index b170384b18e..79464cff73d 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -2061,6 +2061,7 @@ export const arSA: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index a487e4c1a91..0499ceeee98 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -2072,6 +2072,7 @@ export const beBY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 5667ae5a7b5..c3202e61755 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -2065,6 +2065,7 @@ export const bgBG: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 709c93aaebe..a5e177ae7dd 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -2090,6 +2090,7 @@ export const bnIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index cb05017c14f..be31010d7ed 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -2078,6 +2078,7 @@ export const caES: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index b996c0ce0fc..018f7b4a436 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -2077,6 +2077,7 @@ export const csCZ: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 29e042758f5..a34d0a620d6 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -2062,6 +2062,7 @@ export const daDK: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index b44f723fb29..f9b838ef47e 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -2095,6 +2095,7 @@ export const deDE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 3c2456d3edd..50f0672c06e 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -2086,6 +2086,7 @@ export const elGR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index c35c73fb1fd..f41dfdd3265 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -2069,6 +2069,7 @@ export const enGB: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 367ccc7fd4b..0ec04871220 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -2118,6 +2118,8 @@ export const enUS: LocalizationResource = { protect_check_execution_failed: "Verification didn't complete. Please try again.", protect_check_invalid_script: "Couldn't load verification. Please contact support if this persists.", protect_check_invalid_sdk_url: "Verification couldn't start. Please contact support.", + protect_check_required: + "This sign-in needs an extra verification step that can't be shown here. Please try again or use a different sign-in method.", protect_check_script_load_failed: "Couldn't load verification. This may be caused by a network issue or a Content Security Policy that blocks the verification script. Please try again or contact support.", protect_check_timed_out: "Verification didn't complete in time. Please try again.", diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 206c578c1d8..0d8ea7143cf 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -2077,6 +2077,7 @@ export const esCR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 4cf19bd234f..4ef9b4bd31b 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -2078,6 +2078,7 @@ export const esES: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index f70472db2a7..0f71e3df409 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -2078,6 +2078,7 @@ export const esMX: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index f9dd35007b5..398fe3a806c 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2079,6 +2079,7 @@ export const esUY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 7922218161c..69f31d17907 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -2076,6 +2076,7 @@ export const faIR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 4cf9bc33f64..92673e1d8fd 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -2092,6 +2092,7 @@ export const fiFI: LocalizationResource = { protect_check_execution_failed: 'Tarkistus ei valmistunut. Yritä uudelleen.', protect_check_invalid_script: 'Tarkistusta ei voitu ladata. Ota yhteyttä tukeen, jos ongelma jatkuu.', protect_check_invalid_sdk_url: 'Tarkistusta ei voitu käynnistää. Ota yhteyttä tukeen.', + protect_check_required: undefined, protect_check_script_load_failed: 'Tarkistusta ei voitu ladata. Syynä voi olla verkkoyhteys tai sisällön suojauskäytäntö, joka estää tarkistuksen skriptin. Yritä uudelleen tai ota yhteyttä tukeen.', protect_check_timed_out: 'Tarkistus ei valmistunut ajoissa. Yritä uudelleen.', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index da5192566a5..d0da663e7f1 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -2087,6 +2087,7 @@ export const frFR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 93e2b829ee1..620cf66b1e7 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -2053,6 +2053,7 @@ export const heIL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index b916ac42fcd..36678670448 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -2091,6 +2091,7 @@ export const hiIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 724ffa36afd..0dc97826c30 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -2096,6 +2096,7 @@ export const hrHR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 1a706959464..68784c5f74b 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -2095,6 +2095,7 @@ export const huHU: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 95e2115583f..0d7501697e3 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -2075,6 +2075,7 @@ export const idID: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index acdb6e234ec..447a608a5ae 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -2093,6 +2093,7 @@ export const isIS: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 93fb06cc64a..85c063ec9e5 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -2074,6 +2074,7 @@ export const itIT: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index c9064dccb59..4f35db7734a 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -2089,6 +2089,7 @@ export const jaJP: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index f6bfd105eff..46121895fee 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -2055,6 +2055,7 @@ export const kkKZ: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 8f897540d61..03da92dd60f 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -2062,6 +2062,7 @@ export const koKR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 22628699d22..ff13835bc24 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -2067,6 +2067,7 @@ export const mnMN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 9e6f2196d2c..ec8f6d5ebba 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -2100,6 +2100,7 @@ export const msMY: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 656d86c9abd..cedb85df992 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -2093,6 +2093,7 @@ export const nbNO: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 058d0566e3f..cbe40324bdf 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -2066,6 +2066,7 @@ export const nlBE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index a51c1995653..81c6dc0e1f0 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -2066,6 +2066,7 @@ export const nlNL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 24c93f5909b..104d1190f30 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -2074,6 +2074,7 @@ export const plPL: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index ff44fd059d6..4e3f15b9ea2 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -2084,6 +2084,7 @@ export const ptBR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index d9b75a842ac..6d6200408f7 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -2090,6 +2090,7 @@ export const ptPT: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index 3af154f9b57..d8f2a12cfe0 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -2085,6 +2085,7 @@ export const roRO: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 2e8589012b4..7a2630db832 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -2082,6 +2082,7 @@ export const ruRU: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 8a7fa661be9..f17f98c54a1 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -2073,6 +2073,7 @@ export const skSK: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 3cb05958a6f..27654d0c457 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -2065,6 +2065,7 @@ export const srRS: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index d30a15de531..a0805ff84ae 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -2068,6 +2068,7 @@ export const svSE: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index b8c369c0280..bb91d5af794 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -2101,6 +2101,7 @@ export const taIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index cf3e3b183ec..2cbe9dd2e0d 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -2095,6 +2095,7 @@ export const teIN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 83b15833bb5..28bd03c214e 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2063,6 +2063,7 @@ export const thTH: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index a09e323e061..73d3d25c826 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -2069,6 +2069,7 @@ export const trTR: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 5b5283e1b6c..f590cf5557d 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -2063,6 +2063,7 @@ export const ukUA: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 028d41cf23d..5cb8bc9b8dd 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -2086,6 +2086,7 @@ export const viVN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 2c3ee135aa3..bed7dffb089 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -2046,6 +2046,7 @@ export const zhCN: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 7cbca618cb1..e5275233143 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -2051,6 +2051,7 @@ export const zhTW: LocalizationResource = { protect_check_execution_failed: undefined, protect_check_invalid_script: undefined, protect_check_invalid_sdk_url: undefined, + protect_check_required: undefined, protect_check_script_load_failed: undefined, protect_check_timed_out: undefined, protect_check_unsupported_environment: undefined, diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..b87fbaa578f 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -47,6 +47,7 @@ export const ERROR_CODES = { FRAUD_DEVICE_BLOCKED: 'device_blocked', FRAUD_ACTION_BLOCKED: 'action_blocked', PROTECT_CHECK_ALREADY_RESOLVED: 'protect_check_already_resolved', + PROTECT_CHECK_REQUIRED: 'protect_check_required', PROTECT_CHECK_TIMED_OUT: 'protect_check_timed_out', PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT: 'protect_check_unsupported_environment', SIGNUP_RATE_LIMIT_EXCEEDED: 'signup_rate_limit_exceeded', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 16c2296d41f..15eaa32b173 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -2410,6 +2410,7 @@ type UnstableErrors = WithParamName<{ protect_check_execution_failed: LocalizationValue; protect_check_invalid_script: LocalizationValue; protect_check_invalid_sdk_url: LocalizationValue; + protect_check_required: LocalizationValue; protect_check_script_load_failed: LocalizationValue; protect_check_timed_out: LocalizationValue; protect_check_unsupported_environment: LocalizationValue; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx index 770f60ce70f..5f9072e411e 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx @@ -9,6 +9,8 @@ import { handleError } from '@/ui/utils/errorHandler'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Button, Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { useRouter } from '../../router'; +import { isProtectCheckRequiredError, navigateOnSignInProtectGate } from './handleProtectCheck'; import { hasMultipleEnterpriseConnections } from './shared'; import { SignInFactorOneCodeForm } from './SignInFactorOneCodeForm'; @@ -30,6 +32,7 @@ export const SignInFactorOneSSOBypass = (props: SignInFactorOneSSOBypassProps) = const card = useCardState(); const ctx = useSignInContext(); const signIn = useCoreSignIn(); + const { navigate } = useRouter(); const [step, setStep] = React.useState('sso'); const [isRedirecting, setIsRedirecting] = React.useState(false); @@ -39,14 +42,23 @@ export const SignInFactorOneSSOBypass = (props: SignInFactorOneSSOBypassProps) = }; const authenticateWithEnterpriseSSO = async (enterpriseConnectionId?: string) => { - await signIn.authenticateWithRedirect({ - strategy: 'enterprise_sso', - redirectUrl: ctx.ssoCallbackUrl, - redirectUrlComplete: ctx.afterSignInUrl || '/', - oidcPrompt: ctx.oidcPrompt, - continueSignIn: true, - ...(enterpriseConnectionId && { enterpriseConnectionId }), - }); + try { + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: ctx.ssoCallbackUrl, + redirectUrlComplete: ctx.afterSignInUrl || '/', + oidcPrompt: ctx.oidcPrompt, + continueSignIn: true, + ...(enterpriseConnectionId && { enterpriseConnectionId }), + }); + } catch (err) { + // Preparing the hand-off can itself raise a challenge. No redirect was issued and the sign-in + // is sitting on the gate instead: run the challenge rather than showing it as an error. + if (isProtectCheckRequiredError(err) && navigateOnSignInProtectGate(signIn, navigate, '../protect-check')) { + return; + } + throw err; + } }; const handleSSOError = (err: Error) => handleError(err, [], card.setError); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 5e55b7223c6..3349ae5d541 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -39,7 +39,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; -import { navigateOnSignInProtectGate } from './handleProtectCheck'; +import { isProtectCheckRequiredError, navigateOnSignInProtectGate } from './handleProtectCheck'; import { getSSOBypassFactor, hasMultipleEnterpriseConnections, @@ -428,7 +428,8 @@ function SignInStartInternal(): JSX.Element { return navigate('factor-one'); } - return authenticateWithEnterpriseSSO(); + // Awaited so a failed hand-off reaches the catch below instead of escaping this try. + return await authenticateWithEnterpriseSSO(); } case 'needs_second_factor': return navigate('factor-two'); @@ -455,13 +456,23 @@ function SignInStartInternal(): JSX.Element { const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; - return signIn.authenticateWithRedirect({ - strategy: 'enterprise_sso', - redirectUrl, - redirectUrlComplete, - oidcPrompt: ctx.oidcPrompt, - continueSignIn: true, - }); + try { + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl, + redirectUrlComplete, + oidcPrompt: ctx.oidcPrompt, + continueSignIn: true, + }); + } catch (err) { + // Preparing the hand-off can itself raise a challenge. No redirect was issued and the sign-in + // is sitting on the gate instead. Handled here because the callers' recovery path drops + // errors that didn't come from the API. + if (isProtectCheckRequiredError(err) && navigateOnSignInProtectGate(signIn, navigate, 'protect-check')) { + return; + } + throw err; + } }; const attemptToRecoverFromSignInError = async (e: any) => { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx index ce14de6e081..10bceebcec0 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSSOBypass.test.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import type { SignInResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; @@ -66,6 +67,24 @@ describe('SignInFactorOne SSO bypass', () => { ); }); + it('routes to the challenge when preparing the hand-off raises one', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.startSignInWithEnterpriseSSO({ supportSSOBypass: true }); + }); + // No redirect is issued: the sign-in comes back sitting on the challenge and the call throws. + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(() => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + throw new ClerkRuntimeError('challenge required', { code: 'protect_check_required' }); + }); + + const { userEvent } = render(, { wrapper }); + await userEvent.click(await screen.findByText('Continue with SSO')); + + expect(fixtures.router.navigate).toHaveBeenCalledWith('../protect-check'); + expect(screen.queryByText(/needs an extra verification step/i)).not.toBeInTheDocument(); + }); + it('prepares the email code with the handle and warns on the code screen', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress(); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7c26db967fb..1da263fbd58 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError, ClerkWebAuthnError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error'; import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignInResource } from '@clerk/shared/types'; @@ -394,6 +394,23 @@ describe('SignInStart', () => { }); }); }); + + it('explains a challenge the social button cannot run, instead of showing the raw error', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + fixtures.signIn.authenticateWithRedirect.mockRejectedValueOnce( + new ClerkRuntimeError('A verification challenge must be completed before this sign-in can continue.', { + code: 'protect_check_required', + }), + ); + + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByText('Continue with Google')); + + expect(await screen.findByText(/needs an extra verification step/i)).toBeInTheDocument(); + expect(screen.queryByText(/code="protect_check_required"/)).not.toBeInTheDocument(); + }); }); describe('navigation', () => { @@ -496,6 +513,49 @@ describe('SignInStart', () => { expect(fixtures.signIn.authenticateWithRedirect).not.toHaveBeenCalled(); expect(fixtures.router.navigate).toHaveBeenCalledWith('factor-one'); }); + + it('routes to the challenge when preparing the hand-off raises one', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + fixtures.signIn.create.mockReturnValueOnce( + Promise.resolve({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'enterprise_sso' }], + } as unknown as SignInResource), + ); + // No redirect is issued: the sign-in comes back sitting on the challenge and the call throws. + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(async () => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + throw new ClerkRuntimeError('challenge required', { code: 'protect_check_required' }); + }); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalled(); + expect(fixtures.router.navigate).toHaveBeenCalledWith('protect-check'); + }); + + it('does not route to the challenge for other hand-off errors', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + fixtures.signIn.create.mockReturnValueOnce( + Promise.resolve({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'enterprise_sso' }], + } as unknown as SignInResource), + ); + fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(async () => { + (fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' }; + throw new ClerkRuntimeError('something else', { code: 'captcha_unavailable' }); + }); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalled(); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('protect-check'); + }); }); describe('Identifier switching', () => { diff --git a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts index 154bd70731d..63d6e306358 100644 --- a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts @@ -1,7 +1,8 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; import type { ProtectCheckResource, SignInResource } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; -import { isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck'; +import { isProtectCheckRequiredError, isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck'; const PENDING_CHECK: ProtectCheckResource = { status: 'pending', @@ -25,6 +26,22 @@ describe('isSignInProtectGated', () => { }); }); +describe('isProtectCheckRequiredError', () => { + it('is true for the runtime error authenticateWithRedirect throws on a pending challenge', () => { + expect(isProtectCheckRequiredError(new ClerkRuntimeError('x', { code: 'protect_check_required' }))).toBe(true); + }); + + it('is false for other runtime errors, API errors and non-errors', () => { + expect(isProtectCheckRequiredError(new ClerkRuntimeError('x', { code: 'captcha_unavailable' }))).toBe(false); + expect( + isProtectCheckRequiredError( + new ClerkAPIResponseError('x', { data: [{ code: 'protect_check_required', message: 'x' }], status: 400 }), + ), + ).toBe(false); + expect(isProtectCheckRequiredError(undefined)).toBe(false); + }); +}); + describe('navigateOnSignInProtectGate', () => { it('navigates to the provided path and returns true when gated by the protectCheck field', () => { const navigate = vi.fn().mockResolvedValue(undefined); diff --git a/packages/ui/src/components/SignIn/handleProtectCheck.ts b/packages/ui/src/components/SignIn/handleProtectCheck.ts index 71004dd29b5..3936927b235 100644 --- a/packages/ui/src/components/SignIn/handleProtectCheck.ts +++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts @@ -1,3 +1,5 @@ +import { isClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { SignInResource } from '@clerk/shared/types'; /** @@ -37,6 +39,19 @@ export function navigateOnSignInProtectGate( return false; } +/** + * Whether `err` is the error `authenticateWithRedirect` throws when a challenge stopped it before it + * could redirect. The sign-in has already been updated and is sitting on the gate, so the caller + * routes to the challenge rather than showing the error. + */ +export function isProtectCheckRequiredError(err: unknown): boolean { + // The type guard throws on a non-object, and a catch block can receive anything. + if (typeof err !== 'object' || err === null) { + return false; + } + return isClerkRuntimeError(err) && err.code === ERROR_CODES.PROTECT_CHECK_REQUIRED; +} + /** * Whether this sign-in is waiting to become a sign-up. */