-
Notifications
You must be signed in to change notification settings - Fork 453
feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in #8329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f726f7
f641da5
f013b20
ccab663
ead7059
e61ce4e
7e14a2e
d3e2c2b
0f1b746
305b7a4
66da198
2a9bb5b
a8c7dff
dba89a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| --- | ||
| '@clerk/clerk-js': minor | ||
| '@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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
|
@@ -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); | ||
|
|
@@ -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( | ||
|
|
@@ -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(); | ||
| } | ||
|
|
@@ -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 }), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. |
||
| navigate, | ||
| }); | ||
| }; | ||
|
|
@@ -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({ | ||
|
|
@@ -2702,6 +2744,7 @@ export class Clerk implements ClerkInterface { | |
| strategy, | ||
| legalAccepted, | ||
| secondFactorUrl, | ||
| protectCheckUrl, | ||
| walletName, | ||
| }: ClerkAuthenticateWithWeb3Params): Promise<void> => { | ||
| if (!this.client || !this.environment) { | ||
|
|
@@ -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( | ||
|
|
@@ -2756,6 +2803,7 @@ export class Clerk implements ClerkInterface { | |
| ); | ||
|
|
||
| let signInOrSignUp: SignInResource | SignUpResource; | ||
| let viaSignUp = false; | ||
| try { | ||
| signInOrSignUp = await this.client.signIn.authenticateWithWeb3({ | ||
| identifier, | ||
|
|
@@ -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, | ||
|
|
@@ -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(); | ||
| } | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle protect-gated sign-up fallback here too. When 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 🤖 Prompt for AI Agents |
||
|
|
||
| switch (signInOrSignUp.status) { | ||
| case 'needs_second_factor': | ||
| await navigateToFactorTwo(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@clerk/reactis missing here.stateProxy.tsaddsprotectCheck/submitProtectCheckto the public state proxies, so that's user-facing surface in@clerk/reactthat needs a'@clerk/react': minorentry, otherwise it ships with no changelog and only an implicit dependency bump.There was a problem hiding this comment.
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.tsputsprotectCheck/submitProtectCheckon the public state proxies, so it's user-facing surface that needs its own changelog entry.