Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/late-results-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

[Experimental] Signal transfer support
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "629KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "78KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "120KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
Expand Down
17 changes: 17 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,22 @@ class SignInFuture implements SignInFutureResource {
return this.resource.supportedFirstFactors ?? [];
}

get isTransferable() {
return this.resource.firstFactorVerification.status === 'transferable';
}

get existingSession() {
if (
this.resource.firstFactorVerification.status === 'failed' &&
this.resource.firstFactorVerification.error?.code === 'identifier_already_signed_in' &&
this.resource.firstFactorVerification.error?.meta?.sessionId
) {
return { sessionId: this.resource.firstFactorVerification.error?.meta?.sessionId };
}

return undefined;
}

async sendResetPasswordEmailCode(): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
if (!this.resource.id) {
Expand Down Expand Up @@ -556,6 +572,7 @@ class SignInFuture implements SignInFutureResource {
strategy?: OAuthStrategy | 'saml' | 'enterprise_sso';
redirectUrl?: string;
actionCompleteRedirectUrl?: string;
transfer?: boolean;
}): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
Expand Down
64 changes: 64 additions & 0 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,27 @@ class SignUpFuture implements SignUpFutureResource {
return this.resource.unverifiedFields;
}

get isTransferable() {
// TODO: we can likely remove the error code check as the status should be sufficient
return (
this.resource.verifications.externalAccount.status === 'transferable' &&
this.resource.verifications.externalAccount.error?.code === 'external_account_exists'
Comment on lines +494 to +495
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect the transferable status is all we need to check for here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

happy to take your word for it, but this is a direct copy-paste of the existing logic https://github.com/clerk/javascript/blob/main/packages/clerk-js/src/core/clerk.ts#L1935-L1936

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, just thinking from first principles here 😅 I'm not sure why the extra check exists

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a TODO note to it that we can likely drop the extra check!

);
}

get existingSession() {
if (
(this.resource.verifications.externalAccount.status === 'failed' ||
this.resource.verifications.externalAccount.status === 'unverified') &&
this.resource.verifications.externalAccount.error?.code === 'identifier_already_signed_in' &&
this.resource.verifications.externalAccount.error?.meta?.sessionId
) {
return { sessionId: this.resource.verifications.externalAccount.error?.meta?.sessionId };
}

return undefined;
}

private async getCaptchaToken(): Promise<{
captchaToken?: string;
captchaWidgetType?: CaptchaWidgetType;
Expand All @@ -503,6 +524,16 @@ class SignUpFuture implements SignUpFutureResource {
return { captchaToken, captchaWidgetType, captchaError };
}

async create({ transfer }: { transfer?: boolean }): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: { transfer, captchaToken, captchaWidgetType, captchaError },
});
});
}
Comment on lines +527 to +535
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Unconditional captcha breaks OAuth bypass for transfer flows; mirror SignUp.create semantics

This always triggers a captcha challenge, diverging from SignUp.create (Lines 108-120) which bypasses captcha for OAuth strategies and transfer flows. In environments relying on captchaOauthBypass, this can fail with “Captcha challenge failed” and/or add unnecessary friction. Also, when transfer is true we should inject the first-factor strategy to align with the bypass logic.

Apply this diff to:

  • Respect captchaBypass and captchaOauthBypass
  • Inject the first-factor strategy during transfer bypass
  • Only fetch captcha when needed
   async create({ transfer }: { transfer?: boolean }): Promise<{ error: unknown }> {
     return runAsyncResourceTask(this.resource, async () => {
-      const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
-      await this.resource.__internal_basePost({
-        path: this.resource.pathRoot,
-        body: { transfer, captchaToken, captchaWidgetType, captchaError },
-      });
+      // Mirror SignUp.create captcha-bypass semantics for transfer flows
+      const captchaBypass = SignUp.clerk.client?.captchaBypass;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const captchaOauthBypass = SignUp.clerk.__unstable__environment!.displayConfig.captchaOauthBypass;
+      const firstFactorStrategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
+      const shouldBypassCaptcha = Boolean(
+        transfer && firstFactorStrategy && captchaOauthBypass.some(s => s === firstFactorStrategy),
+      );
+
+      const body: Record<string, unknown> = { transfer };
+      if (!__BUILD_DISABLE_RHC__ && !captchaBypass && !shouldBypassCaptcha) {
+        const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
+        Object.assign(body, { captchaToken, captchaWidgetType, captchaError });
+      } else if (transfer && firstFactorStrategy) {
+        Object.assign(body, { strategy: firstFactorStrategy });
+      }
+
+      await this.resource.__internal_basePost({
+        path: this.resource.pathRoot,
+        body,
+      });
     });
   }

Optional follow-up: factor the bypass computation into a shared helper to keep SignUp and SignUpFuture in lockstep.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async create({ transfer }: { transfer?: boolean }): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: { transfer, captchaToken, captchaWidgetType, captchaError },
});
});
}
async create({ transfer }: { transfer?: boolean }): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
// Mirror SignUp.create captcha-bypass semantics for transfer flows
const captchaBypass = SignUp.clerk.client?.captchaBypass;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignUp.clerk.__unstable__environment!.displayConfig.captchaOauthBypass;
const firstFactorStrategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
const shouldBypassCaptcha = Boolean(
transfer && firstFactorStrategy && captchaOauthBypass.some(s => s === firstFactorStrategy),
);
const body: Record<string, unknown> = { transfer };
if (!__BUILD_DISABLE_RHC__ && !captchaBypass && !shouldBypassCaptcha) {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
Object.assign(body, { captchaToken, captchaWidgetType, captchaError });
} else if (transfer && firstFactorStrategy) {
Object.assign(body, { strategy: firstFactorStrategy });
}
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body,
});
});
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/SignUp.ts around lines 527 to 535, the
create method always requests a captcha which breaks OAuth/transfer bypass
behavior; change it to mirror SignUp.create (lines 108-120) by computing captcha
bypass (respecting captchaBypass and captchaOauthBypass and bypassing when
strategy is OAuth or transfer flow), only call getCaptchaToken when captcha is
needed, and when transfer===true include the firstFactorStrategy in the request
body to enable transfer bypass; update the body passed to __internal_basePost to
conditionally include captchaToken/captchaWidgetType/captchaError and
firstFactorStrategy so transfer and OAuth flows skip the captcha challenge.


async password({ emailAddress, password }: { emailAddress: string; password: string }): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
Expand Down Expand Up @@ -539,6 +570,39 @@ class SignUpFuture implements SignUpFutureResource {
});
}

async sso({
strategy,
redirectUrl,
redirectUrlComplete,
}: {
strategy: string;
redirectUrl: string;
redirectUrlComplete: string;
}): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: {
strategy,
redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl),
redirectUrlComplete,
captchaToken,
captchaWidgetType,
captchaError,
},
});

const { status, externalVerificationRedirectURL } = this.resource.verifications.externalAccount;

if (status === 'unverified' && !!externalVerificationRedirectURL) {
windowNavigate(externalVerificationRedirectURL);
} else {
clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
}
});
}
Comment on lines +573 to +604
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix SSO body field name, use union type for strategy, and mirror captcha-bypass + one-retry-on-captcha-error semantics

  • Server expects actionCompleteRedirectUrl (not redirectUrlComplete) when posting to FAPI (see authenticateWithRedirectOrPopup above).
  • Use the union type for strategy instead of string.
  • Respect captchaOauthBypass for SSO strategies and implement the environment-reload-on-captcha-error retry used elsewhere for resilience.
   async sso({
     strategy,
     redirectUrl,
     redirectUrlComplete,
   }: {
-    strategy: string;
+    strategy: SignUpCreateParams['strategy'];
     redirectUrl: string;
     redirectUrlComplete: string;
   }): Promise<{ error: unknown }> {
     return runAsyncResourceTask(this.resource, async () => {
-      const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
-      await this.resource.__internal_basePost({
-        path: this.resource.pathRoot,
-        body: {
-          strategy,
-          redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl),
-          redirectUrlComplete,
-          captchaToken,
-          captchaWidgetType,
-          captchaError,
-        },
-      });
+      const redirectUrlWithAuth = SignUp.clerk.buildUrlWithAuth(redirectUrl);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const captchaOauthBypass = SignUp.clerk.__unstable__environment!.displayConfig.captchaOauthBypass;
+      const shouldBypassCaptcha = captchaOauthBypass.some(s => s === strategy);
+
+      const postOnce = async () => {
+        const body: Record<string, unknown> = {
+          strategy,
+          redirectUrl: redirectUrlWithAuth,
+          actionCompleteRedirectUrl: redirectUrlComplete,
+        };
+        if (!shouldBypassCaptcha) {
+          const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
+          Object.assign(body, { captchaToken, captchaWidgetType, captchaError });
+        }
+        await this.resource.__internal_basePost({
+          path: this.resource.pathRoot,
+          body,
+        });
+      };
+
+      try {
+        await postOnce();
+      } catch (e) {
+        if (isClerkAPIResponseError(e) && isCaptchaError(e)) {
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          await SignUp.clerk.__unstable__environment!.reload();
+          await postOnce();
+        } else {
+          throw e;
+        }
+      }
 
       const { status, externalVerificationRedirectURL } = this.resource.verifications.externalAccount;
 
       if (status === 'unverified' && !!externalVerificationRedirectURL) {
         windowNavigate(externalVerificationRedirectURL);
       } else {
         clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
       }
     });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async sso({
strategy,
redirectUrl,
redirectUrlComplete,
}: {
strategy: string;
redirectUrl: string;
redirectUrlComplete: string;
}): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: {
strategy,
redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl),
redirectUrlComplete,
captchaToken,
captchaWidgetType,
captchaError,
},
});
const { status, externalVerificationRedirectURL } = this.resource.verifications.externalAccount;
if (status === 'unverified' && !!externalVerificationRedirectURL) {
windowNavigate(externalVerificationRedirectURL);
} else {
clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
}
});
}
async sso({
strategy,
redirectUrl,
redirectUrlComplete,
}: {
strategy: SignUpCreateParams['strategy'];
redirectUrl: string;
redirectUrlComplete: string;
}): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
const redirectUrlWithAuth = SignUp.clerk.buildUrlWithAuth(redirectUrl);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignUp.clerk.__unstable__environment!.displayConfig.captchaOauthBypass;
const shouldBypassCaptcha = captchaOauthBypass.some(s => s === strategy);
const postOnce = async () => {
const body: Record<string, unknown> = {
strategy,
redirectUrl: redirectUrlWithAuth,
actionCompleteRedirectUrl: redirectUrlComplete,
};
if (!shouldBypassCaptcha) {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
Object.assign(body, { captchaToken, captchaWidgetType, captchaError });
}
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body,
});
};
try {
await postOnce();
} catch (e) {
if (isClerkAPIResponseError(e) && isCaptchaError(e)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await SignUp.clerk.__unstable__environment!.reload();
await postOnce();
} else {
throw e;
}
}
const { status, externalVerificationRedirectURL } = this.resource.verifications.externalAccount;
if (status === 'unverified' && !!externalVerificationRedirectURL) {
windowNavigate(externalVerificationRedirectURL);
} else {
clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
}
});
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/SignUp.ts around lines 573 to 604, the
SSO method needs three fixes: change the POST body field redirectUrlComplete to
actionCompleteRedirectUrl (server expects actionCompleteRedirectUrl), change the
strategy parameter type from string to the existing strategy union type used
elsewhere in the codebase, and implement captcha-bypass +
one-retry-on-captcha-error semantics for SSO: include the captchaOauthBypass
flag (read from the same config/source used elsewhere) in the POST body, and if
getCaptchaToken returns a captchaError, reload the environment/state and retry
the POST exactly once before failing. Ensure the rest of the flow (handling
externalVerificationRedirectURL and clerkInvalidFAPIResponse) remains unchanged.


async finalize({ navigate }: { navigate?: SetActiveNavigate } = {}): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
if (!this.resource.createdSessionId) {
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class State implements StateInterface {
}

if (payload.resource instanceof SignUp) {
this.signUpResourceSignal({ resource: payload.resource });
this.signUpErrorSignal({ error: payload.error });
}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/experimental.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { CheckoutButton } from './components/CheckoutButton';
export { PlanDetailsButton } from './components/PlanDetailsButton';
export { SubscriptionDetailsButton } from './components/SubscriptionDetailsButton';
export { useSignInSignal } from './hooks/useClerkSignal';
export { useSignInSignal, useSignUpSignal } from './hooks/useClerkSignal';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Ensure the hook is actually exported from hooks/useClerkSignal and add JSDoc at the source.

Add or verify JSDoc on the hook definition so consumers see guidance in IDEs (experimental, subject to change).


🏁 Script executed:

#!/bin/bash
# Confirm the named export exists and is correctly spelled.
rg -n "^export\s+\{\s*useSignInSignal\s*,\s*useSignUpSignal\s*\}" packages/react/src/hooks/useClerkSignal.ts
rg -n "export function useSignUpSignal|export const useSignUpSignal" packages/react/src/hooks/useClerkSignal.ts

Length of output: 263


🏁 Script executed:

#!/bin/bash
# Verify presence of useSignInSignal and its export
rg -n "useSignInSignal" packages/react/src/hooks/useClerkSignal.ts
rg -C3 -n "export function useSignInSignal" packages/react/src/hooks/useClerkSignal.ts

# Verify presence of useSignUpSignal export context and surrounding JSDoc
rg -C3 -n "export function useSignUpSignal" packages/react/src/hooks/useClerkSignal.ts
rg -C3 -n "/\*\*" packages/react/src/hooks/useClerkSignal.ts

Length of output: 589


Add JSDoc to exported hooks in useClerkSignal.ts

Both useSignInSignal (line 58) and useSignUpSignal (line 62) are correctly exported, but neither has preceding JSDoc comments. Please add descriptive JSDoc blocks above each function so IDEs surface usage details and mark these as experimental. For example:

• File: packages/react/src/hooks/useClerkSignal.ts
– Line 57–58 (before export function useSignInSignal):

/**
 * Returns a signal that tracks the sign-in process.
 *
 * @remarks
 * Experimental: subject to change.
 *
 * @returns A signal object for sign-in state.
 */
export function useSignInSignal() {  }

• File: packages/react/src/hooks/useClerkSignal.ts
– Line 61–62 (before export function useSignUpSignal):

/**
 * Returns a signal that tracks the sign-up process.
 *
 * @remarks
 * Experimental: subject to change.
 *
 * @returns A signal object for sign-up state.
 */
export function useSignUpSignal() {  }

This ensures consumers see the experimental-status notice and return type guidance in their IDEs.

🤖 Prompt for AI Agents
In packages/react/src/hooks/useClerkSignal.ts around lines 57–62, the exported
functions useSignInSignal and useSignUpSignal lack JSDoc comments; add
descriptive JSDoc blocks immediately above each export that briefly describe
what the hook returns, include an "@remarks Experimental: subject to change."
line, and an "@returns" annotation describing the returned signal object so IDEs
surface usage and the experimental status.


export type {
__experimental_CheckoutButtonProps as CheckoutButtonProps,
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class StateProxy implements State {
signIn: {
status: 'needs_identifier' as const,
availableStrategies: [],
isTransferable: false,

create: this.gateMethod(target, 'create'),
password: this.gateMethod(target, 'password'),
Expand All @@ -68,7 +69,10 @@ export class StateProxy implements State {
signUp: {
status: 'missing_requirements' as const,
unverifiedFields: [],
isTransferable: false,

create: this.gateMethod(target, 'create'),
sso: this.gateMethod(target, 'sso'),
password: this.gateMethod(target, 'password'),
finalize: this.gateMethod(target, 'finalize'),

Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/signIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,14 @@ export interface SignInResource extends ClerkResource {
export interface SignInFutureResource {
availableStrategies: SignInFirstFactor[];
status: SignInStatus | null;
isTransferable: boolean;
existingSession?: { sessionId: string };
create: (params: {
identifier?: string;
strategy?: OAuthStrategy | 'saml' | 'enterprise_sso';
redirectUrl?: string;
actionCompleteRedirectUrl?: string;
transfer?: boolean;
}) => Promise<{ error: unknown }>;
password: (params: { identifier?: string; password: string }) => Promise<{ error: unknown }>;
emailCode: {
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/signUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,15 @@ export interface SignUpResource extends ClerkResource {
export interface SignUpFutureResource {
status: SignUpStatus | null;
unverifiedFields: SignUpIdentificationField[];
isTransferable: boolean;
existingSession?: { sessionId: string };
create: (params: { transfer?: boolean }) => Promise<{ error: unknown }>;
verifications: {
sendEmailCode: () => Promise<{ error: unknown }>;
verifyEmailCode: (params: { code: string }) => Promise<{ error: unknown }>;
};
password: (params: { emailAddress: string; password: string }) => Promise<{ error: unknown }>;
sso: (params: { strategy: string; redirectUrl: string; redirectUrlComplete: string }) => Promise<{ error: unknown }>;
finalize: (params?: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>;
}

Expand Down
Loading