Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
0f726f7f641da5f013b20ccab663ead7059e61ce4e7e14a2ed3e2c2b0f1b746305b7a466da1982a9bb5ba8c7dffdba89a23a36fc8b74532303b90bb15eea0ec2795e4dcefd053963323bb425b348d4a001bfc9273d499afa60d2bbFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| --- | ||
| '@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. **Upgrading this | ||
| package is type-only and does not change runtime behavior**: the server returns the new status | ||
| (and the `protectCheck` field) only for instances where Protect mid-flow challenges have been | ||
| explicitly enabled — the feature is off by default and is not enabled for existing instances by | ||
| upgrading. The server additionally only emits the new status value to SDK versions that | ||
| understand it, so older clients never receive an unknown status. | ||
| If an exhaustive `switch` on `signIn.status` flags the new value after upgrading, handle it by | ||
| running the challenge described by `protectCheck` and submitting the proof via | ||
| `submitProtectCheck()`. 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 |
|---|---|---|
| @@ -2379,6 +2379,7 @@ export class Clerk implements ClerkInterface { | ||
| externalAccountErrorCode: externalAccount.error?.code, | ||
| externalAccountSessionId: externalAccount.error?.meta?.sessionId, | ||
| sessionId: signUp.createdSessionId, | ||
| protectCheck: signUp.protectCheck, | ||
| }; | ||
| const si = { | ||
| @@ -2387,6 +2388,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); | ||
| @@ -2410,6 +2412,11 @@ export class Clerk implements ClerkInterface { | ||
| buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }), | ||
| ); | ||
| const navigateToSignInProtectCheck = makeNavigate( | ||
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. This one has the same gap as the sign-up nav below, and it generalizes: the factor/continue/verify navs all honor a ContributorAuthor 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. Agreed, generalized it. Both protect-check navs now take per-caller overrides — | ||
| params.signInProtectCheckUrl || | ||
| buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }), | ||
| ); | ||
| const redirectUrls = new RedirectUrls(this.#options, params); | ||
| const navigateToContinueSignUp = makeNavigate( | ||
| @@ -2423,7 +2430,19 @@ export class Clerk implements ClerkInterface { | ||
| ), | ||
| ); | ||
| const navigateToSignUpProtectCheck = makeNavigate( | ||
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. Was working through this with Claude and it flagged that in combined flow, a Protect-gated OAuth/SAML sign-up routes to the standalone ContributorAuthor 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 — fixed in | ||
| params.signUpProtectCheckUrl || | ||
| 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(); | ||
| } | ||
| @@ -2442,6 +2461,9 @@ export class Clerk implements ClerkInterface { | ||
| verifyPhonePath: | ||
| params.verifyPhoneNumberUrl || | ||
| buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), | ||
| protectCheckPath: | ||
| params.signUpProtectCheckUrl || | ||
| buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), | ||
| navigate, | ||
| }); | ||
| }; | ||
| @@ -2492,12 +2514,36 @@ 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 === ERROR_CODES.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({ | ||
| @@ -2756,6 +2802,8 @@ export class Clerk implements ClerkInterface { | ||
| strategy, | ||
| legalAccepted, | ||
| secondFactorUrl, | ||
| protectCheckUrl, | ||
| signUpProtectCheckUrl, | ||
| walletName, | ||
| }: ClerkAuthenticateWithWeb3Params): Promise<void> => { | ||
| if (!this.client || !this.environment) { | ||
| @@ -2798,6 +2846,15 @@ 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 navigateToSignUpProtectCheck = makeNavigate( | ||
| signUpProtectCheckUrl || | ||
| buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), | ||
| ); | ||
| const navigateToContinueSignUp = makeNavigate( | ||
| signUpContinueUrl || | ||
| buildURL( | ||
| @@ -2810,6 +2867,7 @@ export class Clerk implements ClerkInterface { | ||
| ); | ||
| let signInOrSignUp: SignInResource | SignUpResource; | ||
| let viaSignUp = false; | ||
| try { | ||
| signInOrSignUp = await this.client.signIn.authenticateWithWeb3({ | ||
| identifier, | ||
| @@ -2819,6 +2877,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, | ||
| @@ -2831,7 +2890,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(); | ||
| } | ||
| @@ -2852,6 +2914,15 @@ export class Clerk implements ClerkInterface { | ||
| }); | ||
| }; | ||
| // A Clerk Protect challenge can gate the inline web3 attempt (no redirect happens, so the | ||
| // centralized _handleRedirectCallback check never runs). Route to the challenge before the | ||
| // status switch below, otherwise the user is stranded on the wallet step. The sign-up fallback | ||
| // gates as `missing_requirements` + `protectCheck`, so it has no status branch below either. | ||
| if (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check') { | ||
| await (viaSignUp ? navigateToSignUpProtectCheck : navigateToSignInProtectCheck)(); | ||
| return; | ||
| } | ||
| switch (signInOrSignUp.status) { | ||
| case 'needs_second_factor': | ||
| await navigateToFactorTwo(); | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.