feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in#8329
feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in#8329zourzouvillys wants to merge 25 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 3d499af The changes in this PR will be included in the next version bump. This PR includes changesets to release 23 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
24c3383 to
8529397
Compare
8529397 to
63aa1cd
Compare
63aa1cd to
a8d12d1
Compare
a8d12d1 to
2e82e39
Compare
2e82e39 to
ac07df4
Compare
ac07df4 to
b7e6942
Compare
…gn-up and sign-in Adds client-side support for mid-flow SDK challenges issued by the antifraud service during sign-up and sign-in. - New `protectCheck` field and `submitProtectCheck()` method on SignUp and SignIn resources - New `'needs_protect_check'` value on the SignInStatus union - New `protect-check` route on the prebuilt `<SignIn />` and `<SignUp />` components that loads the challenge SDK, submits the proof token, and resumes the flow
b7e6942 to
0f726f7
Compare
|
@zourzouvillys The core stuff looks good. I think the biggest gap is the routing logic integration. Feels like it this is targeting the standalone |
…k-support # Conflicts: # packages/shared/src/types/signInFuture.ts # packages/shared/src/types/signUpCommon.ts # packages/shared/src/types/signUpFuture.ts # packages/ui/src/elements/contexts/index.tsx
The merged clerk.legacy.browser.js bundle is 114.4KB (over the 114KB budget) now that the protect-check core code (SignUp/SignIn protectCheck + submitProtectCheck, clerk.ts gate routing) lands alongside main's growth. Measured locally; modern clerk.browser.js stays within its 74KB budget. Claude-Session: https://claude.ai/code/session_01AgSx5coETQG4ShH1qWSYVd
| ), | ||
| ); | ||
|
|
||
| const navigateToSignUpProtectCheck = makeNavigate( |
There was a problem hiding this comment.
Was working through this with Claude and it flagged that in combined flow, a Protect-gated OAuth/SAML sign-up routes to the standalone /sign-up#/protect-check instead of /sign-in#/create/protect-check, popping the user out of <SignIn/>. Looks like navigateToSignUpProtectCheck uses the absolute displayConfig.signUpUrl with no relative override, unlike the web3 path / continueSignUpUrl. Can you take a look?
There was a problem hiding this comment.
Good catch — fixed in 48d4a00df. navigateToSignUpProtectCheck now honors a new params.signUpProtectCheckUrl override (falling back to the displayConfig URL as before). The SignIn context builds it off signUpUrl, which is rewritten to <signInUrl>#/create in the combined flow, so a gated combined-flow OAuth/SAML sign-up now lands on …/create/protect-check and stays inside <SignIn/> instead of popping out to the standalone /sign-up.
|
Trying to trace how the protect gate interacts with the MFA email-link card. The other second-factor cards route through it now, but |
| customNavigate: router.navigate, | ||
| redirectUrl: ctx.afterSignInUrl || '/', | ||
| secondFactorUrl: 'factor-two', | ||
| protectCheckUrl: 'protect-check', |
There was a problem hiding this comment.
isn't this card mounted under choose-wallet? so the bare protect-check would resolve to /sign-in/choose-wallet/protect-check, not the actual route.
There was a problem hiding this comment.
Right — this card mounts at /sign-in/choose-wallet, so the bare paths were off by one level (they were copied verbatim from SignInSocialButtons, which lives at the sign-in root). Fixed in 48d4a00df: protectCheckUrl → ../protect-check and signUpProtectCheckUrl → ../create/protect-check. I also fixed the pre-existing secondFactorUrl/signUpContinueUrl, which had the same off-by-one from #7450 — happy to pull those two back out if you'd rather keep this PR strictly protect-check. Added a render test asserting the literal params forwarded to authenticateWithWeb3.
| .then(async res => { | ||
| await resolve(); | ||
|
|
||
| if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) { |
There was a problem hiding this comment.
The attempt is gated here, but the resend and initial prepare just above drop the response. Can a protect gate come back on prepare (the code-send itself)? The docstring on navigateOnSignInProtectGate lists prepare as a call site, so these might need to route through it too, unless the server never gates that step.
There was a problem hiding this comment.
Routed both prepare and the initial useFetch prepare through navigateOnSignInProtectGate in 48d4a00df (resend reuses the same prepare handler, so it's covered too). It's a no-op when the response isn't gated, so it's safe whether or not the server actually gates the prepare step — defensive and matches the docstring's call-site list. Added a render test driving the fetch onSuccess with a gated response.
The dynamic-import failure message embeds the sdk_url in Chromium/Firefox; stop interpolating it into the user-facing ClerkRuntimeError, and make the test assert the invariant rather than relying on Node's URL-free import error.
|
The routing tests mostly take the path as an argument and assert it back, so they can't catch a wrong literal when invoked. |
| buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }), | ||
| ); | ||
|
|
||
| const navigateToSignInProtectCheck = makeNavigate( |
There was a problem hiding this comment.
This one has the same gap as the sign-up nav below, and it generalizes: the factor/continue/verify navs all honor a params.*Url override, but both protect-check navs always use the displayConfig URL. A custom-mounted or path-based flow keeps every step in context except the gate. There's also no protect-check field on the callback params to override it.
There was a problem hiding this comment.
Agreed, generalized it. Both protect-check navs now take per-caller overrides — signInProtectCheckUrl / signUpProtectCheckUrl added to HandleOAuthCallbackParams, mirroring the existing firstFactorUrl/continueSignUpUrl pattern. They're threaded through the callback builders (buildOAuthCallbackParams), the SignIn context, the standalone SignUp SSO callback, and Google One Tap, so a custom-mounted / path-based flow keeps the gate in context too. Fixed in 48d4a00df.
| setIsRunning(true); | ||
| void (async () => { | ||
| try { | ||
| await reload(); |
There was a problem hiding this comment.
After the reload, only the still-expired case is handled. If the reload instead clears the gate or completes the flow, nothing calls onResolved, so the route guard bounces to flow start instead of continuing (and a completed sign-in wouldn't get setActive). Worth routing on the refreshed resource here, not just failing when still expired.
There was a problem hiding this comment.
Fixed in 48d4a00df. After the expired-challenge reload, if the gate clears or the flow completes we now route on the refreshed resource (so a completed sign-in still gets setActive instead of the route guard bouncing to flow start).
One subtlety worth calling out: clearing protectCheck flips the effect's token dependency, which re-runs and cancels the in-flight reload effect — so I keyed the routing on a separate mountedRef (true until real unmount) rather than the per-run cancelled flag. Otherwise that re-run would abort the very routing it's supposed to trigger. Added a test for the expired-reload-clears-gate case.
| // Fail closed in no-RHC builds (chrome extension / clerk.no-rhc.js): the gate requires a | ||
| // remote `import(sdk_url)` we must not perform there. This guard MUST live in the component | ||
| // layer — `executeProtectCheck` is in `@clerk/shared`, compiled once with the flag hard-coded | ||
| // `false`, so a guard there would never trip. | ||
| if (__BUILD_DISABLE_RHC__) { | ||
| failWith( | ||
| ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, | ||
| 'Protect verification is not supported in this environment', | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
I expect this will still get flagged, even though the import() is dead code. One solution would be to load executeProtectCheck() async behind the build-time flag.
There was a problem hiding this comment.
Done in 48d4a00df — executeProtectCheck is now lazy-import()ed inside runChallenge, gated on __BUILD_DISABLE_RHC__. In no-RHC builds the flag is true, so the branch (and the dynamic import below it) is dead-code-eliminated — the loader and its remote import(sdk_url) are tree-shaken out of those bundles entirely rather than shipped-but-unused, instead of being pulled in by the old static top-level import.
…overrides; address review Addresses review feedback on #8329: - OAuth/SAML callback: add signInProtectCheckUrl/signUpProtectCheckUrl overrides to HandleOAuthCallbackParams and thread them through the callback builders, SignIn context, standalone SignUp SSO callback, and Google One Tap, so a gated sign-up in the combined flow stays inside <SignIn/> instead of ejecting to the standalone /sign-up. - Solana wallet card: fix protect-check (and the pre-existing second-factor / continue) targets to be relative to the choose-wallet mount. - Route the first-factor prepare/resend and the 2FA email-link result through the protect gate choke point so a mid-flow gate isn't dropped. - useProtectCheckRunner: route on the refreshed resource when an expired-challenge reload clears the gate or completes the flow (keyed on mount, not the effect cancel flag); lazy-load executeProtectCheck behind __BUILD_DISABLE_RHC__ so the remote import is tree-shaken out of no-RHC builds. - Add per-entry-point render tests (prepare gate, Solana card, 2FA email link, expired-reload-clears-gate) and lock the new callback-param literals. - Revert a stray mosaic-architecture.md edit. Claude-Session: https://claude.ai/code/session_01Qy3HfvkryrkWfx9qjEFHkM
|
Pushed
Test gap (wrong-literal slips through arg-passthrough tests) — agreed. Added per-entry-point render/assertion tests that pin the actual target rather than echoing the passed arg:
Verification: 124 UI + 275 clerk-js tests pass · One open call: I also fixed the pre-existing |
jacekradko
left a comment
There was a problem hiding this comment.
I think this is in a good enough place to merge 👍🏼
The protect-check routes guarded on `canActivate={!!signIn.protectCheck}` (and
the signUp equivalent). But submitProtectCheck clears protectCheck on the
resolving PATCH, so the guard flipped false at the exact moment the card was
navigating to the next step: RouteGuard unmounted the card and redirected to
flow-start, leaving a blank #/protect-check. Unlike the sibling
verify-email/verify-phone steps, protect-check clears its own prerequisite as it
succeeds, so the shared "guard on prerequisite presence" convention broke it.
- Remove the canActivate guard from all three protect-check routes (top-level
sign-in, create, create/continue). The card owns its own post-resolution
routing; the verify-* siblings keep their guards (they don't clear their field).
- useProtectCheckRunner: route on real unmount (mountedRef), not the per-run
`cancelled` flag. Clearing protectCheck re-runs the effect (which sets
`cancelled`), and that re-run is the cue to route — it must not abort the
navigation. Applies the expired-reload path's existing rationale to the
success + already-resolved paths.
Claude-Session: https://claude.ai/code/session_01RWf2ttyUV8EnqhTBazHuZC
The protect-check SDK support grew ui.browser.js to 42.04KB gzip, just over the 42KB limit. Bump to 44KB, matching the bundlewatch:fix convention (ceil(size + 1KB)), so the feature's code fits with headroom. Claude-Session: https://claude.ai/code/session_01RWf2ttyUV8EnqhTBazHuZC
Summary
Adds client-side support for Clerk Protect mid-flow SDK challenges (
protect_check) during both sign-up and sign-in. When the antifraud service gates a step, the SDK exposes the challenge, surfaces a card that loads and runs the challenge script, submits the resulting proof token, and resumes the original flow.protectCheckfield andsubmitProtectCheck()method on bothSignUpandSignInresources (and their future variants), mirrored on the@clerk/reactstate proxies.'needs_protect_check'value on theSignInStatusunion.protect-checkroute on the prebuilt<SignIn />and<SignUp />components (standalone,continue, and combined-flowcreate/create/continuedepths).Background
Previously anti-fraud blocks could only happen at sign-in/sign-up create time. This mechanism lets the service gate at any step. When gated, the response carries:
{ "protect_check": { "status": "pending", "token": "<challenge token>", "sdk_url": "https://.../sdk.js", "expires_at": 1700000000000, "ui_hints": { "reason": "device_new" } } }expires_atis a Unix epoch timestamp in milliseconds (documented on the type). The client loads the SDK atsdk_url, runs the challenge withtoken, and submits the proof token toPATCH /v1/client/sign_{ins,ups}/{id}/protect_check. The response clears the gate, issues a chained challenge, or completes the flow.Implementation
Types (
@clerk/shared)ProtectCheckJSON/ProtectCheckResource{ status: 'pending', token, sdkUrl, expiresAt?, uiHints? };expires_atis optional on bothSignUpJSONandSignInJSON(older FAPI versions omit it).'protect_check'added toSignUpField;'needs_protect_check'added toSignInStatus.submitProtectCheckadded to the sign-up/sign-in resource + future interfaces.Core resources (
@clerk/clerk-js)SignUp/SignInexposeprotectCheckandsubmitProtectCheck({ proofToken });fromJSON/__internal_toSnapshotround-trip the field; future variants mirror the API.SDK loader helper (
@clerk/shared/internal/clerk-js/protectCheck)executeProtectCheck(protectCheck, container, { signal })— validatessdkUrl(must behttps:, no credentials, rejectsdata:/blob:/javascript:), runs the spec-compliant script contract(container, { token, uiHints, signal }), forwards theAbortSignal, and wraps failures in typed error codes without leaking the URL.Shared card runner (
@clerk/ui)Both protect-check cards share one
useProtectCheckRunnerhook so the lifecycle can't drift:protectCheck.token(not object identity) so an unrelated resource refresh doesn't restart the challenge.__BUILD_DISABLE_RHC__) before the remoteimport(sdk_url)— the guard lives in the component layer because@clerk/sharedis compiled once with the flagfalse.setActive) thecompletecase from both the normal success and theprotect_check_already_resolvedreload, so neither strands the user.descriptors.spinnerspinner in anaria-liveregion.Sign-in gate routing — single choke point
navigateOnSignInProtectGate(res, navigate, protectCheckPath)is the one place that turns a gated sign-in response into navigation. Every dispatch site routes through it (start ×2, passkey, password, code, alt-channel, backup-code, factor-two code, reset-password), with the protect-check path passed per caller (index route →'protect-check', factor cards →'../protect-check'). Also wired into the previously-missed email-link result handler and the inline web3/Solana path (clerk.authenticateWithWeb3, which doesn't redirect through_handleRedirectCallback): it takesprotectCheckUrl/signUpProtectCheckUrlparams and routes a gated attempt to the sign-in or sign-up challenge depending on which resource the attempt resolved through (theidentifier_not_found → signUpfallback is covered).OAuth / SAML callback (
clerk.ts)_handleRedirectCallbackchecks the gate before its transfer/missing-fields logic, scoped to the callback intent (reloadResource) so an abandoned sign-in's staleprotect_checkcan't hijack a sign-up callback (and vice versa). The sign-up gate check runs before themissing_fieldsshort-circuit so a gatedsignUp.create({ transfer })routes to the challenge instead of/continue.Prebuilt UI routes (
@clerk/ui)protect-checkroutes registered on<SignIn />/<SignUp />at every depth the flow can mount sign-up at;SignUpProtectChecktakes per-mount continuation paths (thecontinue-nested mounts passcontinuePath='..').Localization (
@clerk/localizations,@clerk/shared)Typed
signUp.protectCheck.{title,subtitle,loading,retryButton}/signIn.protectCheck.*keys andunstable__errorsentries for the runtime error codes (protect_check_execution_failed,…_invalid_script,…_invalid_sdk_url,…_script_load_failed,…_timed_out,…_unsupported_environment;…_aborted/…_already_resolvedintentionally undefined).Backwards compatibility
'needs_protect_check'is type-additive — runtime behavior is unchanged (the server emits it only behind a feature gate, andprotectCheckis the authoritative field). Strict-TypeScript consumers with an exhaustiveswitch (signIn.status)will get a new unhandled-branch hint, hence theminorbump.Risks
signIn.statusneed to handle'needs_protect_check'(or theprotectCheckfield). Documented on the resource interface.(container, { token, uiHints, signal }) => Promise<string>. Coordinate with the Protect SDK team before deploying.script-src; the load-failure error calls this out.Test plan
SignUp.test.ts/SignIn.test.ts— serialization, optional fields, snapshot round-trip,submitProtectCheckpath/method/bodyprotectCheck.test.ts— URL validation, script contract, cancellation, error wrappingcompleteSignUpFlow.test.ts— routing priorityclerk.test.ts— gate routing scoped to the callback intent (stale sign-in not picked up by a sign-up callback; sign-in callback routes to the gate)handleProtectCheck.test.ts—navigateOnSignInProtectGate/isSignInProtectGated(both gate signals, per-caller path, no navigation when ungated)SignInFactorOne.test.tsx— a gated first-factor attempt routes to../protect-checkinstead of dispatching on the underlying statusSignUpProtectCheck.test.tsx/SignInProtectCheck.test.tsx— run/expiry/already-resolved/chained/abort/no-submit-on-failure, finalize-on-reload-complete, retry control@clerk/clerk-js,@clerk/shared,@clerk/localizations,@clerk/uiclean; lint cleanFollow-ups (out of scope)
authenticateWithWeb3sign-up-gate regression test, an email-link gate-routing test, and the hook's no-RHC / timeout branches (not exercisable in the current ui vitest setup).@clerk/backendresource model updates (the backend SDK doesn't drive end-user flows).protect_check(additive when the server starts emitting it).Summary by CodeRabbit
New Features
Localization
Tests