Skip to content

Commit f2d9e4b

Browse files
feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in (#8329)
Co-authored-by: Jacek Radko <jacek@clerk.dev>
1 parent 7c56495 commit f2d9e4b

62 files changed

Lines changed: 2925 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/localizations': minor
4+
'@clerk/react': minor
5+
'@clerk/shared': minor
6+
'@clerk/ui': minor
7+
---
8+
9+
Add support for Clerk Protect mid-flow SDK challenges (`protect_check`) on both sign-up and sign-in.
10+
11+
When the Protect antifraud service issues a challenge, responses now carry a `protectCheck` field
12+
with `{ status, token, sdkUrl, expiresAt?, uiHints? }`. Clients resolve the gate by loading the
13+
SDK at `sdkUrl`, executing the challenge, and submitting the resulting proof token via
14+
`signUp.submitProtectCheck({ proofToken })` or `signIn.submitProtectCheck({ proofToken })`. The
15+
response may carry a chained challenge, which the SDK resolves iteratively.
16+
17+
Sign-in adds a new `'needs_protect_check'` value to the `SignInStatus` union. **Upgrading this
18+
package is type-only and does not change runtime behavior**: the server returns the new status
19+
(and the `protectCheck` field) only for instances where Protect mid-flow challenges have been
20+
explicitly enabled — the feature is off by default and is not enabled for existing instances by
21+
upgrading. The server additionally only emits the new status value to SDK versions that
22+
understand it, so older clients never receive an unknown status.
23+
24+
If an exhaustive `switch` on `signIn.status` flags the new value after upgrading, handle it by
25+
running the challenge described by `protectCheck` and submitting the proof via
26+
`submitProtectCheck()`. Clients should treat the `protectCheck` field as the authoritative gate
27+
signal and fall back to the status value for defense in depth.
28+
29+
The pre-built `<SignIn />` and `<SignUp />` components handle the gate automatically by routing
30+
to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion.

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,6 +2418,97 @@ describe('Clerk singleton', () => {
24182418
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/reset-password');
24192419
});
24202420
});
2421+
2422+
it('does not route a sign-up callback into a stale sign-in protect_check gate', async () => {
2423+
mockEnvironmentFetch.mockReturnValue(
2424+
Promise.resolve({
2425+
authConfig: {},
2426+
userSettings: mockUserSettings,
2427+
displayConfig: mockDisplayConfig,
2428+
isSingleSession: () => false,
2429+
isProduction: () => false,
2430+
isDevelopmentOrStaging: () => true,
2431+
onWindowLocationHost: () => false,
2432+
}),
2433+
);
2434+
2435+
// An abandoned sign-in keeps serializing its pending protect_check on the client.
2436+
const staleSignIn = new SignIn({
2437+
status: 'needs_protect_check',
2438+
identifier: 'user@example.com',
2439+
first_factor_verification: null,
2440+
second_factor_verification: null,
2441+
user_data: null,
2442+
created_session_id: null,
2443+
created_user_id: null,
2444+
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
2445+
} as any as SignInJSON);
2446+
const completeSignUp = new SignUp({ status: 'complete', created_session_id: 'sess_signup' } as any as SignUpJSON);
2447+
// The intent-driven reload at the top of the handler is a no-op here; keep the state stable.
2448+
(staleSignIn as any).reload = vi.fn().mockResolvedValue(staleSignIn);
2449+
(completeSignUp as any).reload = vi.fn().mockResolvedValue(completeSignUp);
2450+
2451+
mockClientFetch.mockReturnValue(
2452+
Promise.resolve({
2453+
signedInSessions: [],
2454+
signIn: staleSignIn,
2455+
signUp: completeSignUp,
2456+
}),
2457+
);
2458+
2459+
const mockSetActive = vi.fn();
2460+
const sut = new Clerk(productionPublishableKey);
2461+
await sut.load(mockedLoadOptions);
2462+
sut.setActive = mockSetActive;
2463+
2464+
await sut.handleRedirectCallback({ reloadResource: 'signUp' });
2465+
2466+
await waitFor(() => {
2467+
// Completes the sign-up rather than routing into the stale sign-in's challenge.
2468+
expect(mockSetActive).toHaveBeenCalled();
2469+
expect(mockNavigate).not.toHaveBeenCalledWith('/sign-in#/protect-check');
2470+
});
2471+
});
2472+
2473+
it('routes a sign-in callback to the protect-check gate', async () => {
2474+
mockEnvironmentFetch.mockReturnValue(
2475+
Promise.resolve({
2476+
authConfig: {},
2477+
userSettings: mockUserSettings,
2478+
displayConfig: mockDisplayConfig,
2479+
isSingleSession: () => false,
2480+
isProduction: () => false,
2481+
isDevelopmentOrStaging: () => true,
2482+
onWindowLocationHost: () => false,
2483+
}),
2484+
);
2485+
2486+
mockClientFetch.mockReturnValue(
2487+
Promise.resolve({
2488+
signedInSessions: [],
2489+
signIn: new SignIn({
2490+
status: 'needs_protect_check',
2491+
identifier: 'user@example.com',
2492+
first_factor_verification: null,
2493+
second_factor_verification: null,
2494+
user_data: null,
2495+
created_session_id: null,
2496+
created_user_id: null,
2497+
protect_check: { status: 'pending', token: 'fresh-token', sdk_url: 'https://example.com/sdk.js' },
2498+
} as any as SignInJSON),
2499+
signUp: new SignUp(null),
2500+
}),
2501+
);
2502+
2503+
const sut = new Clerk(productionPublishableKey);
2504+
await sut.load(mockedLoadOptions);
2505+
2506+
await sut.handleRedirectCallback();
2507+
2508+
await waitFor(() => {
2509+
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/protect-check');
2510+
});
2511+
});
24212512
});
24222513

24232514
describe('.handleEmailLinkVerification()', () => {

packages/clerk-js/src/core/clerk.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2379,6 +2379,7 @@ export class Clerk implements ClerkInterface {
23792379
externalAccountErrorCode: externalAccount.error?.code,
23802380
externalAccountSessionId: externalAccount.error?.meta?.sessionId,
23812381
sessionId: signUp.createdSessionId,
2382+
protectCheck: signUp.protectCheck,
23822383
};
23832384

23842385
const si = {
@@ -2387,6 +2388,7 @@ export class Clerk implements ClerkInterface {
23872388
firstFactorVerificationErrorCode: firstFactorVerification.error?.code,
23882389
firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId,
23892390
sessionId: signIn.createdSessionId,
2391+
protectCheck: signIn.protectCheck,
23902392
};
23912393

23922394
const makeNavigate = (to: string) => () => navigate(to);
@@ -2410,6 +2412,11 @@ export class Clerk implements ClerkInterface {
24102412
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
24112413
);
24122414

2415+
const navigateToSignInProtectCheck = makeNavigate(
2416+
params.signInProtectCheckUrl ||
2417+
buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
2418+
);
2419+
24132420
const redirectUrls = new RedirectUrls(this.#options, params);
24142421

24152422
const navigateToContinueSignUp = makeNavigate(
@@ -2423,7 +2430,19 @@ export class Clerk implements ClerkInterface {
24232430
),
24242431
);
24252432

2433+
const navigateToSignUpProtectCheck = makeNavigate(
2434+
params.signUpProtectCheckUrl ||
2435+
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
2436+
);
2437+
24262438
const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
2439+
// A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate
2440+
// check must run BEFORE the generic missing-fields short-circuit below — otherwise the
2441+
// OAuth/SAML callback would land on /continue instead of the challenge.
2442+
if (signUp.protectCheck || missingFields.includes('protect_check')) {
2443+
return navigateToSignUpProtectCheck();
2444+
}
2445+
24272446
if (missingFields.length) {
24282447
return navigateToContinueSignUp();
24292448
}
@@ -2442,6 +2461,9 @@ export class Clerk implements ClerkInterface {
24422461
verifyPhonePath:
24432462
params.verifyPhoneNumberUrl ||
24442463
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
2464+
protectCheckPath:
2465+
params.signUpProtectCheckUrl ||
2466+
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
24452467
navigate,
24462468
});
24472469
};
@@ -2492,12 +2514,36 @@ export class Clerk implements ClerkInterface {
24922514
});
24932515
}
24942516

2517+
// OAuth/SAML callbacks can resolve into a protect_check gate that surfaces on the next
2518+
// /v1/client read, so check for it here before continuing with the transfer logic below.
2519+
// Honor either the explicit `protectCheck` field or the `needs_protect_check` status override.
2520+
//
2521+
// Scope to the callback's intent: an abandoned sign-in keeps serializing its pending
2522+
// `protect_check` on the client for up to a day (and a later sign-up doesn't clear it in
2523+
// multi-session mode), so an unscoped check would route a *sign-up* callback into the stale
2524+
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
2525+
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
2526+
// response for the gate.
2527+
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
2528+
return navigateToSignInProtectCheck();
2529+
}
2530+
2531+
// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
2532+
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
2533+
// shouldn't hijack a sign-in callback.
2534+
if (params.reloadResource !== 'signIn' && su.protectCheck) {
2535+
return navigateToSignUpProtectCheck();
2536+
}
2537+
24952538
const userExistsButNeedsToSignIn =
24962539
su.externalAccountStatus === 'transferable' &&
24972540
su.externalAccountErrorCode === ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS;
24982541

24992542
if (userExistsButNeedsToSignIn) {
25002543
const res = await signIn.create({ transfer: true });
2544+
if (res.protectCheck || res.status === 'needs_protect_check') {
2545+
return navigateToSignInProtectCheck();
2546+
}
25012547
switch (res.status) {
25022548
case 'complete':
25032549
return this.setActive({
@@ -2756,6 +2802,8 @@ export class Clerk implements ClerkInterface {
27562802
strategy,
27572803
legalAccepted,
27582804
secondFactorUrl,
2805+
protectCheckUrl,
2806+
signUpProtectCheckUrl,
27592807
walletName,
27602808
}: ClerkAuthenticateWithWeb3Params): Promise<void> => {
27612809
if (!this.client || !this.environment) {
@@ -2798,6 +2846,15 @@ export class Clerk implements ClerkInterface {
27982846
secondFactorUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/factor-two' }, { stringify: true }),
27992847
);
28002848

2849+
const navigateToSignInProtectCheck = makeNavigate(
2850+
protectCheckUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
2851+
);
2852+
2853+
const navigateToSignUpProtectCheck = makeNavigate(
2854+
signUpProtectCheckUrl ||
2855+
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
2856+
);
2857+
28012858
const navigateToContinueSignUp = makeNavigate(
28022859
signUpContinueUrl ||
28032860
buildURL(
@@ -2810,6 +2867,7 @@ export class Clerk implements ClerkInterface {
28102867
);
28112868

28122869
let signInOrSignUp: SignInResource | SignUpResource;
2870+
let viaSignUp = false;
28132871
try {
28142872
signInOrSignUp = await this.client.signIn.authenticateWithWeb3({
28152873
identifier,
@@ -2819,6 +2877,7 @@ export class Clerk implements ClerkInterface {
28192877
});
28202878
} catch (err) {
28212879
if (isError(err, ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND)) {
2880+
viaSignUp = true;
28222881
signInOrSignUp = await this.client.signUp.authenticateWithWeb3({
28232882
identifier,
28242883
generateSignature,
@@ -2831,7 +2890,10 @@ export class Clerk implements ClerkInterface {
28312890
if (
28322891
signUpContinueUrl &&
28332892
signInOrSignUp.status === 'missing_requirements' &&
2834-
signInOrSignUp.verifications.web3Wallet.status === 'verified'
2893+
signInOrSignUp.verifications.web3Wallet.status === 'verified' &&
2894+
// A protect_check gate also surfaces as missing_requirements; don't skip past it into
2895+
// the continue step. The gate is handled by the sign-up protect-check route instead.
2896+
!signInOrSignUp.protectCheck
28352897
) {
28362898
await navigateToContinueSignUp();
28372899
}
@@ -2852,6 +2914,15 @@ export class Clerk implements ClerkInterface {
28522914
});
28532915
};
28542916

2917+
// A Clerk Protect challenge can gate the inline web3 attempt (no redirect happens, so the
2918+
// centralized _handleRedirectCallback check never runs). Route to the challenge before the
2919+
// status switch below, otherwise the user is stranded on the wallet step. The sign-up fallback
2920+
// gates as `missing_requirements` + `protectCheck`, so it has no status branch below either.
2921+
if (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check') {
2922+
await (viaSignUp ? navigateToSignUpProtectCheck : navigateToSignInProtectCheck)();
2923+
return;
2924+
}
2925+
28552926
switch (signInOrSignUp.status) {
28562927
case 'needs_second_factor':
28572928
await navigateToFactorTwo();

0 commit comments

Comments
 (0)