Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/enterprise-sso-hand-off-challenge.md
Original file line number Diff line number Diff line change
@@ -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 `<SignIn />` 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.
40 changes: 40 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -389,13 +390,41 @@ 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,
identifier,
redirectUrl,
actionCompleteRedirectUrl,
});

if (isChallengePending()) {
challengedCreateHandOff = pendingHandOff();
if (!challengedCreateHandOff) {
throwChallengeRequired();
}
}
Comment thread
zourzouvillys marked this conversation as resolved.
}

if (strategy === 'enterprise_sso') {
Expand All @@ -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;
Expand Down
183 changes: 183 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/bn-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/el-GR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/en-GB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/es-CR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/es-MX.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/es-UY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/fa-IR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading