Skip to content

Commit 0f726f7

Browse files
committed
feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-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
1 parent 45b773a commit 0f726f7

43 files changed

Lines changed: 2002 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/localizations': minor
4+
'@clerk/shared': minor
5+
'@clerk/ui': minor
6+
---
7+
8+
Add support for Clerk Protect mid-flow SDK challenges (`protect_check`) on both sign-up and sign-in.
9+
10+
When the Protect antifraud service issues a challenge, responses now carry a `protectCheck` field
11+
with `{ status, token, sdkUrl, expiresAt?, uiHints? }`. Clients resolve the gate by loading the
12+
SDK at `sdkUrl`, executing the challenge, and submitting the resulting proof token via
13+
`signUp.submitProtectCheck({ proofToken })` or `signIn.submitProtectCheck({ proofToken })`. The
14+
response may carry a chained challenge, which the SDK resolves iteratively.
15+
16+
Sign-in adds a new `'needs_protect_check'` value to the `SignInStatus` union, surfaced when the
17+
server-side SDK-version gate is enabled. Clients should treat the `protectCheck` field as the
18+
authoritative gate signal and fall back to the status value for defense in depth.
19+
20+
The pre-built `<SignIn />` and `<SignUp />` components handle the gate automatically by routing
21+
to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2241,6 +2241,7 @@ export class Clerk implements ClerkInterface {
22412241
firstFactorVerificationErrorCode: firstFactorVerification.error?.code,
22422242
firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId,
22432243
sessionId: signIn.createdSessionId,
2244+
protectCheck: signIn.protectCheck,
22442245
};
22452246

22462247
const makeNavigate = (to: string) => () => navigate(to);
@@ -2264,6 +2265,10 @@ export class Clerk implements ClerkInterface {
22642265
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
22652266
);
22662267

2268+
const navigateToSignInProtectCheck = makeNavigate(
2269+
buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
2270+
);
2271+
22672272
const redirectUrls = new RedirectUrls(this.#options, params);
22682273

22692274
const navigateToContinueSignUp = makeNavigate(
@@ -2296,6 +2301,7 @@ export class Clerk implements ClerkInterface {
22962301
verifyPhonePath:
22972302
params.verifyPhoneNumberUrl ||
22982303
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
2304+
protectCheckPath: buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
22992305
navigate,
23002306
});
23012307
};
@@ -2332,11 +2338,20 @@ export class Clerk implements ClerkInterface {
23322338
});
23332339
}
23342340

2341+
// Per Protect spec §4.4: OAuth/SAML callbacks can result in a protect_check gate that
2342+
// surfaces on the next /v1/client read. Honor either the field or the status override.
2343+
if (si.protectCheck || si.status === 'needs_protect_check') {
2344+
return navigateToSignInProtectCheck();
2345+
}
2346+
23352347
const userExistsButNeedsToSignIn =
23362348
su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists';
23372349

23382350
if (userExistsButNeedsToSignIn) {
23392351
const res = await signIn.create({ transfer: true });
2352+
if (res.protectCheck || res.status === 'needs_protect_check') {
2353+
return navigateToSignInProtectCheck();
2354+
}
23402355
switch (res.status) {
23412356
case 'complete':
23422357
return this.setActive({

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
PhoneCodeFactor,
3232
PrepareFirstFactorParams,
3333
PrepareSecondFactorParams,
34+
ProtectCheckResource,
3435
ResetPasswordEmailCodeFactorConfig,
3536
ResetPasswordParams,
3637
ResetPasswordPhoneCodeFactorConfig,
@@ -112,6 +113,7 @@ export class SignIn extends BaseResource implements SignInResource {
112113
createdSessionId: string | null = null;
113114
userData: UserData = new UserData(null);
114115
clientTrustState?: ClientTrustState;
116+
protectCheck: ProtectCheckResource | null = null;
115117

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

158+
/**
159+
* @internal Only used for internal purposes, and is not intended to be used directly.
160+
*
161+
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
162+
* of `SignIn`.
163+
*/
164+
__internal_basePatch = this._basePatch.bind(this);
165+
156166
/**
157167
* @internal Only used for internal purposes, and is not intended to be used directly.
158168
*
@@ -257,6 +267,14 @@ export class SignIn extends BaseResource implements SignInResource {
257267
});
258268
};
259269

270+
submitProtectCheck = (params: { proofToken: string }): Promise<SignInResource> => {
271+
debugLogger.debug('SignIn.submitProtectCheck', { id: this.id });
272+
return this._basePatch({
273+
action: 'protect_check',
274+
body: { proof_token: params.proofToken },
275+
});
276+
};
277+
260278
attemptFirstFactor = (params: AttemptFirstFactorParams): Promise<SignInResource> => {
261279
debugLogger.debug('SignIn.attemptFirstFactor', { id: this.id, strategy: params.strategy });
262280
let config;
@@ -594,6 +612,15 @@ export class SignIn extends BaseResource implements SignInResource {
594612
this.createdSessionId = data.created_session_id;
595613
this.userData = new UserData(data.user_data);
596614
this.clientTrustState = data.client_trust_state ?? undefined;
615+
this.protectCheck = data.protect_check
616+
? {
617+
status: data.protect_check.status,
618+
token: data.protect_check.token,
619+
sdkUrl: data.protect_check.sdk_url,
620+
expiresAt: data.protect_check.expires_at,
621+
uiHints: data.protect_check.ui_hints,
622+
}
623+
: null;
597624
}
598625

599626
eventBus.emit('resource:update', { resource: this });
@@ -654,6 +681,15 @@ export class SignIn extends BaseResource implements SignInResource {
654681
identifier: this.identifier,
655682
created_session_id: this.createdSessionId,
656683
user_data: this.userData.__internal_toSnapshot(),
684+
protect_check: this.protectCheck
685+
? {
686+
status: this.protectCheck.status,
687+
token: this.protectCheck.token,
688+
sdk_url: this.protectCheck.sdkUrl,
689+
...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }),
690+
...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }),
691+
}
692+
: null,
657693
};
658694
}
659695
}
@@ -783,6 +819,19 @@ class SignInFuture implements SignInFutureResource {
783819
return this.#resource.secondFactorVerification;
784820
}
785821

822+
get protectCheck() {
823+
return this.#resource.protectCheck;
824+
}
825+
826+
async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> {
827+
return runAsyncResourceTask(this.#resource, async () => {
828+
await this.#resource.__internal_basePatch({
829+
action: 'protect_check',
830+
body: { proof_token: params.proofToken },
831+
});
832+
});
833+
}
834+
786835
get canBeDiscarded() {
787836
return this.#canBeDiscarded;
788837
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
PreparePhoneNumberVerificationParams,
1818
PrepareVerificationParams,
1919
PrepareWeb3WalletVerificationParams,
20+
ProtectCheckResource,
2021
SignUpAuthenticateWithSolanaParams,
2122
SignUpAuthenticateWithWeb3Params,
2223
SignUpCreateParams,
@@ -92,6 +93,7 @@ export class SignUp extends BaseResource implements SignUpResource {
9293
externalAccount: any;
9394
hasPassword = false;
9495
unsafeMetadata: SignUpUnsafeMetadata = {};
96+
protectCheck: ProtectCheckResource | null = null;
9597
createdSessionId: string | null = null;
9698
createdUserId: string | null = null;
9799
abandonAt: number | null = null;
@@ -195,6 +197,14 @@ export class SignUp extends BaseResource implements SignUpResource {
195197
});
196198
};
197199

200+
submitProtectCheck = (params: { proofToken: string }): Promise<SignUpResource> => {
201+
debugLogger.debug('SignUp.submitProtectCheck', { id: this.id });
202+
return this._basePatch({
203+
action: 'protect_check',
204+
body: { proof_token: params.proofToken },
205+
});
206+
};
207+
198208
prepareEmailAddressVerification = (params?: PrepareEmailAddressVerificationParams): Promise<SignUpResource> => {
199209
return this.prepareVerification(params || { strategy: 'email_code' });
200210
};
@@ -495,6 +505,15 @@ export class SignUp extends BaseResource implements SignUpResource {
495505
this.missingFields = data.missing_fields;
496506
this.unverifiedFields = data.unverified_fields;
497507
this.verifications = new SignUpVerifications(data.verifications);
508+
this.protectCheck = data.protect_check
509+
? {
510+
status: data.protect_check.status,
511+
token: data.protect_check.token,
512+
sdkUrl: data.protect_check.sdk_url,
513+
expiresAt: data.protect_check.expires_at,
514+
uiHints: data.protect_check.ui_hints,
515+
}
516+
: null;
498517
this.username = data.username;
499518
this.firstName = data.first_name;
500519
this.lastName = data.last_name;
@@ -528,6 +547,15 @@ export class SignUp extends BaseResource implements SignUpResource {
528547
missing_fields: this.missingFields,
529548
unverified_fields: this.unverifiedFields,
530549
verifications: this.verifications.__internal_toSnapshot(),
550+
protect_check: this.protectCheck
551+
? {
552+
status: this.protectCheck.status,
553+
token: this.protectCheck.token,
554+
sdk_url: this.protectCheck.sdkUrl,
555+
...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }),
556+
...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }),
557+
}
558+
: null,
531559
username: this.username,
532560
first_name: this.firstName,
533561
last_name: this.lastName,
@@ -778,6 +806,10 @@ class SignUpFuture implements SignUpFutureResource {
778806
return this.#resource.unverifiedFields;
779807
}
780808

809+
get protectCheck() {
810+
return this.#resource.protectCheck;
811+
}
812+
781813
get isTransferable() {
782814
// TODO: we can likely remove the error code check as the status should be sufficient
783815
return (
@@ -1133,6 +1165,15 @@ class SignUpFuture implements SignUpFutureResource {
11331165
});
11341166
}
11351167

1168+
async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> {
1169+
return runAsyncResourceTask(this.#resource, async () => {
1170+
await this.#resource.__internal_basePatch({
1171+
action: 'protect_check',
1172+
body: { proof_token: params.proofToken },
1173+
});
1174+
});
1175+
}
1176+
11361177
async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> {
11371178
const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket');
11381179
return this.create({ ...params, ticket: ticket ?? undefined });

0 commit comments

Comments
 (0)