Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions .changeset/protect-check-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@clerk/clerk-js': minor
'@clerk/localizations': 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.
15 changes: 15 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,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 @@ -2264,6 +2265,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 Down Expand Up @@ -2296,6 +2301,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 }),
navigate,
});
};
Expand Down Expand Up @@ -2332,11 +2338,20 @@ export class Clerk implements ClerkInterface {
});
}

// Per Protect spec §4.4: OAuth/SAML callbacks can result in a protect_check gate that
// surfaces on the next /v1/client read. Honor either the field or the status override.
if (si.protectCheck || si.status === 'needs_protect_check') {
return navigateToSignInProtectCheck();
}

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
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
41 changes: 41 additions & 0 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
PreparePhoneNumberVerificationParams,
PrepareVerificationParams,
PrepareWeb3WalletVerificationParams,
ProtectCheckResource,
SignUpAuthenticateWithSolanaParams,
SignUpAuthenticateWithWeb3Params,
SignUpCreateParams,
Expand Down Expand Up @@ -92,6 +93,7 @@ export class SignUp extends BaseResource implements SignUpResource {
externalAccount: any;
hasPassword = false;
unsafeMetadata: SignUpUnsafeMetadata = {};
protectCheck: ProtectCheckResource | null = null;
createdSessionId: string | null = null;
createdUserId: string | null = null;
abandonAt: number | null = null;
Expand Down Expand Up @@ -195,6 +197,14 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};

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

prepareEmailAddressVerification = (params?: PrepareEmailAddressVerificationParams): Promise<SignUpResource> => {
return this.prepareVerification(params || { strategy: 'email_code' });
};
Expand Down Expand Up @@ -495,6 +505,15 @@ export class SignUp extends BaseResource implements SignUpResource {
this.missingFields = data.missing_fields;
this.unverifiedFields = data.unverified_fields;
this.verifications = new SignUpVerifications(data.verifications);
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;
this.username = data.username;
this.firstName = data.first_name;
this.lastName = data.last_name;
Expand Down Expand Up @@ -528,6 +547,15 @@ export class SignUp extends BaseResource implements SignUpResource {
missing_fields: this.missingFields,
unverified_fields: this.unverifiedFields,
verifications: this.verifications.__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,
username: this.username,
first_name: this.firstName,
last_name: this.lastName,
Expand Down Expand Up @@ -778,6 +806,10 @@ class SignUpFuture implements SignUpFutureResource {
return this.#resource.unverifiedFields;
}

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

get isTransferable() {
// TODO: we can likely remove the error code check as the status should be sufficient
return (
Expand Down Expand Up @@ -1133,6 +1165,15 @@ class SignUpFuture implements SignUpFutureResource {
});
}

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 },
});
});
}

async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> {
const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket');
return this.create({ ...params, ticket: ticket ?? undefined });
Expand Down
Loading
Loading