Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/sour-walls-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fixes issue where captcha was always called during signup.
37 changes: 34 additions & 3 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,11 +786,42 @@ class SignUpFuture implements SignUpFutureResource {
return this.#canBeDiscarded;
}

private async getCaptchaToken(): Promise<{
private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) {
if (!params.strategy) {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass;

if (captchaOauthBypass.some(strategy => strategy === params.strategy)) {
return true;
}

if (
params.transfer &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy)
) {
return true;
}

return false;
}

private async getCaptchaToken(params: { strategy?: string; transfer?: boolean } = {}): Promise<{
captchaToken?: string;
captchaWidgetType?: CaptchaWidgetType;
captchaError?: unknown;
}> {
if (__BUILD_DISABLE_RHC__ || SignUp.clerk.client?.captchaBypass || this.shouldBypassCaptchaForAttempt(params)) {
return {
captchaToken: undefined,
captchaWidgetType: undefined,
captchaError: undefined,
};
}

const captchaChallenge = new CaptchaChallenge(SignUp.clerk);
const response = await captchaChallenge.managedOrInvisible({ action: 'signup' });
if (!response) {
Expand All @@ -802,7 +833,7 @@ class SignUpFuture implements SignUpFutureResource {
}

private async _create(params: SignUpFutureCreateParams): Promise<void> {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params);

const body: Record<string, unknown> = {
transfer: params.transfer,
Expand Down Expand Up @@ -955,7 +986,7 @@ class SignUpFuture implements SignUpFutureResource {
popup,
} = params;
return runAsyncResourceTask(this.#resource, async () => {
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken();
const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken({ strategy });

let redirectUrlComplete = redirectUrl;
try {
Expand Down
99 changes: 99 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock('../../../utils/authenticateWithPopup', async () => {

// Import the mocked function after mocking
import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup';
import { CaptchaChallenge } from '../../../utils/captcha/CaptchaChallenge';

// Mock the CaptchaChallenge module
vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({
Expand All @@ -42,9 +43,14 @@ describe('SignUp', () => {
});

describe('create', () => {
beforeEach(() => {
SignUp.clerk = {} as any;
});

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
SignUp.clerk = {} as any;
});

it('includes locale in request when navigator.language is available', async () => {
Expand Down Expand Up @@ -106,6 +112,46 @@ describe('SignUp', () => {

expect(result).toHaveProperty('error', null);
});

it('runs captcha challenge when bypass is not enabled', async () => {
const mockFetch = vi.fn().mockResolvedValue({
client: null,
response: { id: 'signup_123', status: 'missing_requirements' },
});
BaseResource._fetch = mockFetch;

const signUp = new SignUp();
await signUp.__internal_future.create({ emailAddress: 'user@example.com' });

expect(CaptchaChallenge).toHaveBeenCalledWith(SignUp.clerk);
const challengeInstance = vi.mocked(CaptchaChallenge).mock.results[0]?.value as {
managedOrInvisible: ReturnType<typeof vi.fn>;
};
expect(challengeInstance.managedOrInvisible).toHaveBeenCalledWith({ action: 'signup' });
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', 'mock_token');
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', 'invisible');
});

it('skips captcha challenge when client captcha bypass is enabled', async () => {
const mockFetch = vi.fn().mockResolvedValue({
client: null,
response: { id: 'signup_123', status: 'missing_requirements' },
});
BaseResource._fetch = mockFetch;
SignUp.clerk = {
client: {
captchaBypass: true,
},
} as any;

const signUp = new SignUp();
await signUp.__internal_future.create({ emailAddress: 'user@example.com' });

expect(CaptchaChallenge).not.toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', undefined);
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined);
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined);
});
});

describe('sendPhoneCode', () => {
Expand Down Expand Up @@ -333,6 +379,7 @@ describe('SignUp', () => {
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
SignUp.clerk = {} as any;
});

it('handles relative redirectUrl by converting to absolute', async () => {
Expand All @@ -341,6 +388,11 @@ describe('SignUp', () => {
const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback');
SignUp.clerk = {
buildUrlWithAuth: mockBuildUrlWithAuth,
__internal_environment: {
displayConfig: {
captchaOauthBypass: [],
},
},
} as any;

const mockFetch = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -383,6 +435,11 @@ describe('SignUp', () => {
const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback');
SignUp.clerk = {
buildUrlWithAuth: mockBuildUrlWithAuth,
__internal_environment: {
displayConfig: {
captchaOauthBypass: [],
},
},
} as any;

const mockFetch = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -436,6 +493,9 @@ describe('SignUp', () => {
buildUrl: vi.fn().mockImplementation(path => 'https://example.com' + path),
frontendApi: 'clerk.example.com',
__internal_environment: {
displayConfig: {
captchaOauthBypass: [],
},
reload: vi.fn().mockResolvedValue({}),
},
} as any;
Expand Down Expand Up @@ -487,6 +547,45 @@ describe('SignUp', () => {
}),
);
});

it('skips captcha challenge for strategies configured in captcha oauth bypass', async () => {
vi.stubGlobal('window', { location: { origin: 'https://example.com' } });

const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback');
SignUp.clerk = {
buildUrlWithAuth: mockBuildUrlWithAuth,
__internal_environment: {
displayConfig: {
captchaOauthBypass: ['oauth_google'],
},
},
} as any;

const mockFetch = vi.fn().mockResolvedValue({
client: null,
response: {
id: 'signup_123',
verifications: {
externalAccount: {
status: 'complete',
},
},
},
});
BaseResource._fetch = mockFetch;

const signUp = new SignUp();
await signUp.__internal_future.sso({
strategy: 'oauth_google',
redirectUrl: '/complete',
redirectCallbackUrl: '/sso-callback',
});

expect(CaptchaChallenge).not.toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', undefined);
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined);
expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined);
});
});

describe('web3', () => {
Expand Down
Loading