Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/protect-check-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@clerk/clerk-js': minor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clerk/react is missing here. stateProxy.ts adds protectCheck/submitProtectCheck to the public state proxies, so that's user-facing surface in @clerk/react that needs a '@clerk/react': minor entry, otherwise it ships with no changelog and only an implicit dependency bump.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added '@clerk/react': minor. You're right — stateProxy.ts puts protectCheck/submitProtectCheck on the public state proxies, so it's user-facing surface that needs its own changelog entry.

'@clerk/localizations': minor
'@clerk/react': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add support for Clerk Protect mid-flow SDK challenges (`protect_check`) on both sign-up and sign-in.

When the Protect antifraud service issues a challenge, responses now carry a `protectCheck` field
with `{ status, token, sdkUrl, expiresAt?, uiHints? }`. Clients resolve the gate by loading the
SDK at `sdkUrl`, executing the challenge, and submitting the resulting proof token via
`signUp.submitProtectCheck({ proofToken })` or `signIn.submitProtectCheck({ proofToken })`. The
response may carry a chained challenge, which the SDK resolves iteratively.

Sign-in adds a new `'needs_protect_check'` value to the `SignInStatus` union, surfaced when the
server-side SDK-version gate is enabled. Clients should treat the `protectCheck` field as the
authoritative gate signal and fall back to the status value for defense in depth.

The pre-built `<SignIn />` and `<SignUp />` components handle the gate automatically by routing
to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion.
91 changes: 91 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,97 @@ describe('Clerk singleton', () => {
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/reset-password');
});
});

it('does not route a sign-up callback into a stale sign-in protect_check gate', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);

// An abandoned sign-in keeps serializing its pending protect_check on the client.
const staleSignIn = new SignIn({
status: 'needs_protect_check',
identifier: 'user@example.com',
first_factor_verification: null,
second_factor_verification: null,
user_data: null,
created_session_id: null,
created_user_id: null,
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
} as any as SignInJSON);
const completeSignUp = new SignUp({ status: 'complete', created_session_id: 'sess_signup' } as any as SignUpJSON);
// The intent-driven reload at the top of the handler is a no-op here; keep the state stable.
(staleSignIn as any).reload = vi.fn().mockResolvedValue(staleSignIn);
(completeSignUp as any).reload = vi.fn().mockResolvedValue(completeSignUp);

mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: staleSignIn,
signUp: completeSignUp,
}),
);

const mockSetActive = vi.fn();
const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
sut.setActive = mockSetActive;

await sut.handleRedirectCallback({ reloadResource: 'signUp' });

await waitFor(() => {
// Completes the sign-up rather than routing into the stale sign-in's challenge.
expect(mockSetActive).toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalledWith('/sign-in#/protect-check');
});
});

it('routes a sign-in callback to the protect-check gate', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);

mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_protect_check',
identifier: 'user@example.com',
first_factor_verification: null,
second_factor_verification: null,
user_data: null,
created_session_id: null,
created_user_id: null,
protect_check: { status: 'pending', token: 'fresh-token', sdk_url: 'https://example.com/sdk.js' },
} as any 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#/protect-check');
});
});
});

describe('.handleEmailLinkVerification()', () => {
Expand Down
62 changes: 61 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2352,6 +2352,7 @@ export class Clerk implements ClerkInterface {
externalAccountErrorCode: externalAccount.error?.code,
externalAccountSessionId: externalAccount.error?.meta?.sessionId,
sessionId: signUp.createdSessionId,
protectCheck: signUp.protectCheck,
};

const si = {
Expand All @@ -2360,6 +2361,7 @@ export class Clerk implements ClerkInterface {
firstFactorVerificationErrorCode: firstFactorVerification.error?.code,
firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId,
sessionId: signIn.createdSessionId,
protectCheck: signIn.protectCheck,
};

const makeNavigate = (to: string) => () => navigate(to);
Expand All @@ -2383,6 +2385,10 @@ export class Clerk implements ClerkInterface {
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
);

const navigateToSignInProtectCheck = makeNavigate(
buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
);

const redirectUrls = new RedirectUrls(this.#options, params);

const navigateToContinueSignUp = makeNavigate(
Expand All @@ -2396,7 +2402,18 @@ export class Clerk implements ClerkInterface {
),
);

const navigateToSignUpProtectCheck = makeNavigate(
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
);

const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
// A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate
// check must run BEFORE the generic missing-fields short-circuit below — otherwise the
// OAuth/SAML callback would land on /continue instead of the challenge.
if (signUp.protectCheck || missingFields.includes('protect_check')) {
return navigateToSignUpProtectCheck();
}

if (missingFields.length) {
return navigateToContinueSignUp();
}
Expand All @@ -2415,6 +2432,7 @@ export class Clerk implements ClerkInterface {
verifyPhonePath:
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
protectCheckPath: buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this protectCheckPath is reachable on a gated sign-up. A protect-gated response always carries 'protect_check' in missing_fields, so the if (missingFields.length) check above short-circuits to navigateToContinueSignUp() and OAuth/SAML callbacks land on /continue instead of the challenge. Could we check the gate before that short-circuit and route to protect-check first (same for the signUp.create({ transfer: true }) path)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. navigateToNextStepSignUp now checks signUp.protectCheck || missingFields.includes('protect_check') before the if (missingFields.length) short-circuit, so a gated OAuth/SAML callback routes to /sign-up/protect-check instead of /continue. This covers the signUp.create({ transfer: true }) path (which calls it). I also added an early su.protectCheck check next to the existing si one for a directly-gated sign-up — and scoped both to the callback intent (see the clerk.ts:2463 thread).

navigate,
});
};
Expand Down Expand Up @@ -2451,11 +2469,35 @@ export class Clerk implements ClerkInterface {
});
}

// OAuth/SAML callbacks can resolve into a protect_check gate that surfaces on the next
// /v1/client read, so check for it here before continuing with the transfer logic below.
// Honor either the explicit `protectCheck` field or the `needs_protect_check` status override.
//
// Scope to the callback's intent: an abandoned sign-in keeps serializing its pending
// `protect_check` on the client for up to a day (and a later sign-up doesn't clear it in
// multi-session mode), so an unscoped check would route a *sign-up* callback into the stale
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

const userExistsButNeedsToSignIn =
su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists';

if (userExistsButNeedsToSignIn) {
const res = await signIn.create({ transfer: true });
if (res.protectCheck || res.status === 'needs_protect_check') {
return navigateToSignInProtectCheck();
}
switch (res.status) {
case 'complete':
return this.setActive({
Expand Down Expand Up @@ -2702,6 +2744,7 @@ export class Clerk implements ClerkInterface {
strategy,
legalAccepted,
secondFactorUrl,
protectCheckUrl,
walletName,
}: ClerkAuthenticateWithWeb3Params): Promise<void> => {
if (!this.client || !this.environment) {
Expand Down Expand Up @@ -2744,6 +2787,10 @@ export class Clerk implements ClerkInterface {
secondFactorUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/factor-two' }, { stringify: true }),
);

const navigateToSignInProtectCheck = makeNavigate(
protectCheckUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
);

const navigateToContinueSignUp = makeNavigate(
signUpContinueUrl ||
buildURL(
Expand All @@ -2756,6 +2803,7 @@ export class Clerk implements ClerkInterface {
);

let signInOrSignUp: SignInResource | SignUpResource;
let viaSignUp = false;
try {
signInOrSignUp = await this.client.signIn.authenticateWithWeb3({
identifier,
Expand All @@ -2765,6 +2813,7 @@ export class Clerk implements ClerkInterface {
});
} catch (err) {
if (isError(err, ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND)) {
viaSignUp = true;
signInOrSignUp = await this.client.signUp.authenticateWithWeb3({
identifier,
generateSignature,
Expand All @@ -2777,7 +2826,10 @@ export class Clerk implements ClerkInterface {
if (
signUpContinueUrl &&
signInOrSignUp.status === 'missing_requirements' &&
signInOrSignUp.verifications.web3Wallet.status === 'verified'
signInOrSignUp.verifications.web3Wallet.status === 'verified' &&
// A protect_check gate also surfaces as missing_requirements; don't skip past it into
// the continue step. The gate is handled by the sign-up protect-check route instead.
!signInOrSignUp.protectCheck
) {
await navigateToContinueSignUp();
}
Expand All @@ -2798,6 +2850,14 @@ export class Clerk implements ClerkInterface {
});
};

// A Clerk Protect challenge can gate the inline web3 sign-in (no redirect happens, so the
// centralized _handleRedirectCallback check never runs). Route to the sign-in protect-check
// card before the status switch below, otherwise the user is stranded on the wallet step.
if (!viaSignUp && (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check')) {
await navigateToSignInProtectCheck();
return;
}
Comment on lines +2853 to +2859

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle protect-gated sign-up fallback here too.

When signIn.authenticateWithWeb3() falls back to signUp.authenticateWithWeb3(), a gated sign-up now skips both exits: Lines 2826-2833 suppress /continue when protectCheck is present, and Lines 2853-2859 skip protect-check navigation because viaSignUp is true. The status switch below has no missing_requirements branch, so the method returns and leaves the user stuck on the wallet step.

Suggested fix
+    const navigateToSignUpProtectCheck = makeNavigate(
+      buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
+    );
+
     // A Clerk Protect challenge can gate the inline web3 sign-in (no redirect happens, so the
     // centralized _handleRedirectCallback check never runs). Route to the sign-in protect-check
     // card before the status switch below, otherwise the user is stranded on the wallet step.
-    if (!viaSignUp && (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check')) {
-      await navigateToSignInProtectCheck();
+    if (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check') {
+      await (viaSignUp ? navigateToSignUpProtectCheck : navigateToSignInProtectCheck)();
       return;
     }

Please also add a regression test for identifier_not_found -> signUp.authenticateWithWeb3() -> protectCheck.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/clerk.ts` around lines 2853 - 2859, The guard that
skips navigateToSignInProtectCheck when viaSignUp is true causes a protect-gated
fallback from signIn.authenticateWithWeb3() -> signUp.authenticateWithWeb3() to
leave the user stranded; update the logic around the signInOrSignUp check so
that if signInOrSignUp.protectCheck is true or signInOrSignUp.status ===
'needs_protect_check' you always call await navigateToSignInProtectCheck()
(regardless of viaSignUp) before returning, and ensure the subsequent status
switch handles a missing_requirements case if applicable; also add a regression
test that simulates identifier_not_found leading to
signUp.authenticateWithWeb3() which returns protectCheck, asserting that
navigateToSignInProtectCheck() is invoked and the flow does not remain on the
wallet step.


switch (signInOrSignUp.status) {
case 'needs_second_factor':
await navigateToFactorTwo();
Expand Down
49 changes: 49 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
PhoneCodeFactor,
PrepareFirstFactorParams,
PrepareSecondFactorParams,
ProtectCheckResource,
ResetPasswordEmailCodeFactorConfig,
ResetPasswordParams,
ResetPasswordPhoneCodeFactorConfig,
Expand Down Expand Up @@ -112,6 +113,7 @@ export class SignIn extends BaseResource implements SignInResource {
createdSessionId: string | null = null;
userData: UserData = new UserData(null);
clientTrustState?: ClientTrustState;
protectCheck: ProtectCheckResource | null = null;

/**
* The current status of the sign-in process.
Expand Down Expand Up @@ -153,6 +155,14 @@ export class SignIn extends BaseResource implements SignInResource {
*/
__internal_basePost = this._basePost.bind(this);

/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
* of `SignIn`.
*/
__internal_basePatch = this._basePatch.bind(this);

/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
Expand Down Expand Up @@ -257,6 +267,14 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

submitProtectCheck = (params: { proofToken: string }): Promise<SignInResource> => {
debugLogger.debug('SignIn.submitProtectCheck', { id: this.id });
return this._basePatch({
action: 'protect_check',
body: { proof_token: params.proofToken },
});
};

attemptFirstFactor = (params: AttemptFirstFactorParams): Promise<SignInResource> => {
debugLogger.debug('SignIn.attemptFirstFactor', { id: this.id, strategy: params.strategy });
let config;
Expand Down Expand Up @@ -594,6 +612,15 @@ export class SignIn extends BaseResource implements SignInResource {
this.createdSessionId = data.created_session_id;
this.userData = new UserData(data.user_data);
this.clientTrustState = data.client_trust_state ?? undefined;
this.protectCheck = data.protect_check
? {
status: data.protect_check.status,
token: data.protect_check.token,
sdkUrl: data.protect_check.sdk_url,
expiresAt: data.protect_check.expires_at,
uiHints: data.protect_check.ui_hints,
}
: null;
}

eventBus.emit('resource:update', { resource: this });
Expand Down Expand Up @@ -654,6 +681,15 @@ export class SignIn extends BaseResource implements SignInResource {
identifier: this.identifier,
created_session_id: this.createdSessionId,
user_data: this.userData.__internal_toSnapshot(),
protect_check: this.protectCheck
? {
status: this.protectCheck.status,
token: this.protectCheck.token,
sdk_url: this.protectCheck.sdkUrl,
...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }),
...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }),
}
: null,
};
}
}
Expand Down Expand Up @@ -783,6 +819,19 @@ class SignInFuture implements SignInFutureResource {
return this.#resource.secondFactorVerification;
}

get protectCheck() {
return this.#resource.protectCheck;
}

async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this.#resource, async () => {
await this.#resource.__internal_basePatch({
action: 'protect_check',
body: { proof_token: params.proofToken },
});
});
}

get canBeDiscarded() {
return this.#canBeDiscarded;
}
Expand Down
Loading
Loading