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
6 changes: 6 additions & 0 deletions .changeset/thick-days-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Add experimental support for hCaptcha captcha provider
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@commitlint/config-conventional": "^19.2.2",
"@emotion/jest": "^11.11.0",
"@faker-js/faker": "^8.1.0",
"@hcaptcha/types": "^1.0.3",
"@octokit/rest": "^20.0.2",
"@playwright/test": "^1.44.0",
"@testing-library/dom": "^8.19.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CaptchaProvider,
CaptchaWidgetType,
DisplayConfigJSON,
DisplayConfigResource,
Expand All @@ -21,6 +22,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
branded!: boolean;
captchaPublicKey: string | null = null;
captchaWidgetType: CaptchaWidgetType = null;
captchaProvider: CaptchaProvider = 'turnstile';
captchaPublicKeyInvisible: string | null = null;
homeUrl!: string;
instanceEnvironmentType!: string;
Expand Down Expand Up @@ -69,6 +71,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
this.branded = data.branded;
this.captchaPublicKey = data.captcha_public_key;
this.captchaWidgetType = data.captcha_widget_type;
this.captchaProvider = data.captcha_provider;
this.captchaPublicKeyInvisible = data.captcha_public_key_invisible;
this.supportEmail = data.support_email || '';
this.clerkJSVersion = data.clerk_js_version;
Expand Down
7 changes: 4 additions & 3 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import type {
StartEmailLinkFlowParams,
} from '@clerk/types';

import { generateSignatureWithMetamask, getCaptchaToken, getMetamaskIdentifier, windowNavigate } from '../../utils';
import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils';
import { getCaptchaToken, retrieveCaptchaInfo } from '../../utils/captcha';
import { createValidatePassword } from '../../utils/passwords/password';
import { normalizeUnsafeMetadata } from '../../utils/resourceParams';
import { retrieveCaptchaInfo } from '../../utils/retrieveCaptchaInfo';
import {
clerkInvalidFAPIResponse,
clerkVerifyEmailAddressCalledBeforeCreate,
Expand Down Expand Up @@ -68,7 +68,7 @@ export class SignUp extends BaseResource implements SignUpResource {

create = async (params: SignUpCreateParams): Promise<SignUpResource> => {
const paramsWithCaptcha: Record<string, unknown> = params;
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaPublicKeyInvisible } =
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
retrieveCaptchaInfo(SignUp.clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
Expand All @@ -78,6 +78,7 @@ export class SignUp extends BaseResource implements SignUpResource {
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
captchaProvider,
});
paramsWithCaptcha.captchaToken = captchaToken;
paramsWithCaptcha.captchaWidgetType = captchaWidgetTypeUsed;
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CAPTCHA_ELEMENT_ID } from '../../utils';
import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha';
import { Box } from '../customizables';

export const CaptchaElement = () => (
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/utils/__tests__/captcha.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { shouldRetryTurnstileErrorCode } from '../captcha';
import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile';

describe('shouldRetryTurnstileErrorCode', () => {
it.each([
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const CAPTCHA_ELEMENT_ID = 'clerk-captcha';
export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha';
24 changes: 24 additions & 0 deletions packages/clerk-js/src/utils/captcha/getCaptchaToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/types';

import { getHCaptchaToken } from './hcaptcha';
import { getTunstileToken } from './turnstile';

type CaptchaOptions = {
siteKey: string;
scriptUrl: string;
widgetType: CaptchaWidgetType;
invisibleSiteKey: string;
captchaProvider: CaptchaProvider;
};

/*
* This is a temporary solution to test different captcha providers, until we decide on a single one.
*/
export const getCaptchaToken = (captchaOptions: CaptchaOptions) => {
const { captchaProvider, ...captchaProviderOptions } = captchaOptions;
if (captchaProvider === 'hcaptcha') {
return getHCaptchaToken(captchaProviderOptions);
} else {
return getTunstileToken(captchaProviderOptions);
}
};
122 changes: 122 additions & 0 deletions packages/clerk-js/src/utils/captcha/hcaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
///<reference types="@hcaptcha/types"/>

import { loadScript } from '@clerk/shared/loadScript';
import type { CaptchaWidgetType } from '@clerk/types';

import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants';

async function loadCaptcha(url: string) {
if (!window.hcaptcha) {
try {
await loadScript(url, { defer: true });
} catch {
// Rethrow with specific message
console.error('Clerk: Failed to load the CAPTCHA script from the URL: ', url);
throw {
captchaError: 'captcha_script_failed_to_load',
};
}
}
return window.hcaptcha;
}

export const getHCaptchaToken = async (captchaOptions: {
siteKey: string;
scriptUrl: string;
widgetType: CaptchaWidgetType;
invisibleSiteKey: string;
}) => {
const { siteKey, scriptUrl, widgetType, invisibleSiteKey } = captchaOptions;
let captchaToken = '',
id = '';
let isInvisibleWidget = !widgetType || widgetType === 'invisible';
let hCaptchaSiteKey = siteKey;

let widgetDiv: HTMLElement | null = null;

const createInvisibleDOMElement = () => {
const div = document.createElement('div');
div.id = CAPTCHA_INVISIBLE_CLASSNAME;
document.body.appendChild(div);
return div;
};

const captcha: HCaptcha = await loadCaptcha(scriptUrl);
let retries = 0;
const errorCodes: (string | number)[] = [];

const handleCaptchaTokenGeneration = (): Promise<[string, string]> => {
Copy link
Member

Choose a reason for hiding this comment

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

❓ Can we extract parts from handleCaptchaTokenGeneration to avoid duplicate code?

Copy link
Member Author

Choose a reason for hiding this comment

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

These duplicated code fragments are temprary until we decide on the provider and will be removed soon. If we decide to provide multiple captcha providers, I will refactor the code, as you suggested!

return new Promise((resolve, reject) => {
try {
if (isInvisibleWidget) {
widgetDiv = createInvisibleDOMElement();
} else {
const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleDiv) {
visibleDiv.style.display = 'block';
widgetDiv = visibleDiv;
} else {
console.error('Captcha DOM element not found. Using invisible captcha widget.');
widgetDiv = createInvisibleDOMElement();
isInvisibleWidget = true;
hCaptchaSiteKey = invisibleSiteKey;
}
}

const id = captcha.render(isInvisibleWidget ? CAPTCHA_INVISIBLE_CLASSNAME : CAPTCHA_ELEMENT_ID, {
sitekey: hCaptchaSiteKey,
size: isInvisibleWidget ? 'invisible' : 'normal',
callback: function (token: string) {
resolve([token, id]);
},
'error-callback': function (errorCode) {
errorCodes.push(errorCode);
if (retries < 2) {
setTimeout(() => {
captcha.reset(id);
retries++;
}, 250);
return;
}
reject([errorCodes.join(','), id]);
},
});

if (isInvisibleWidget) {
captcha.execute(id);
}
} catch (e) {
/**
* There is a case the captcha may fail before the challenge has started.
* In such case the 'error-callback' does not fire.
* We should mark the promise as rejected.
*/
reject([e, undefined]);
}
});
};

try {
[captchaToken, id] = await handleCaptchaTokenGeneration();
// After a successful challenge remove it
captcha.remove(id);
} catch ([e, id]) {
if (id) {
// After a failed challenge remove it
captcha.remove(id);
}
throw {
captchaError: e,
};
} finally {
if (widgetDiv) {
if (isInvisibleWidget) {
document.body.removeChild(widgetDiv as HTMLElement);
} else {
(widgetDiv as HTMLElement).style.display = 'none';
}
}
}

return { captchaToken, captchaWidgetTypeUsed: isInvisibleWidget ? 'invisible' : 'smart' };
};
3 changes: 3 additions & 0 deletions packages/clerk-js/src/utils/captcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './retrieveCaptchaInfo';
export * from './constants';
export * from './getCaptchaToken';
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Clerk } from '../core/clerk';
import { createFapiClient } from '../core/fapiClient';
import type { Clerk } from '../../core/clerk';
import { createFapiClient } from '../../core/fapiClient';

export const retrieveCaptchaInfo = (clerk: Clerk) => {
const _environment = clerk.__unstable__environment;
const fapiClient = createFapiClient(clerk);
const captchaProvider = _environment ? _environment.displayConfig.captchaProvider : 'turnstile';
return {
captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null,
captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null,
captchaProvider,
captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : null,
canUseCaptcha: _environment
? _environment.userSettings.signUp.captcha_enabled &&
Expand All @@ -15,7 +17,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
: null,
captchaURL: fapiClient
.buildUrl({
path: 'cloudflare/turnstile/v0/api.js',
path: captchaProvider == 'hcaptcha' ? 'hcaptcha/1/api.js' : 'cloudflare/turnstile/v0/api.js',
pathPrefix: '',
search: '?render=explicit',
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { loadScript } from '@clerk/shared/loadScript';
import type { CaptchaWidgetType } from '@clerk/types';

import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants';

interface RenderOptions {
/**
* Every widget has a sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation.
Expand Down Expand Up @@ -61,16 +63,13 @@ declare global {
}
}

export const CAPTCHA_ELEMENT_ID = 'clerk-captcha';
export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha';

export const shouldRetryTurnstileErrorCode = (errorCode: string) => {
const codesWithRetries = ['crashed', 'undefined_error', '102', '103', '104', '106', '110600', '300', '600'];

return !!codesWithRetries.find(w => errorCode.startsWith(w));
};

export async function loadCaptcha(url: string) {
async function loadCaptcha(url: string) {
if (!window.turnstile) {
try {
await loadScript(url, { defer: true });
Expand All @@ -92,7 +91,7 @@ export async function loadCaptcha(url: string) {
* - If the widgetType is 'smart', the captcha widget is rendered in a div with the id 'clerk-captcha'. If the div does
* not exist, the invisibleSiteKey is used as a fallback and the widget is rendered in a hidden div at the bottom of the body.
*/
export const getCaptchaToken = async (captchaOptions: {
export const getTunstileToken = async (captchaOptions: {
siteKey: string;
scriptUrl: string;
widgetType: CaptchaWidgetType;
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,4 @@ export * from './componentGuards';
export * from './queryStateParams';
export * from './normalizeRoutingOptions';
export * from './image';
export * from './captcha';
export * from './completeSignUpFlow';
3 changes: 3 additions & 0 deletions packages/types/src/displayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ClerkResource } from './resource';

export type PreferredSignInStrategy = 'password' | 'otp';
export type CaptchaWidgetType = 'smart' | 'invisible' | null;
export type CaptchaProvider = 'hcaptcha' | 'turnstile';

export interface DisplayConfigJSON {
object: 'display_config';
Expand All @@ -17,6 +18,7 @@ export interface DisplayConfigJSON {
captcha_public_key: string | null;
captcha_widget_type: CaptchaWidgetType;
captcha_public_key_invisible: string | null;
captcha_provider: CaptchaProvider;
home_url: string;
instance_environment_type: string;
logo_image_url: string;
Expand Down Expand Up @@ -47,6 +49,7 @@ export interface DisplayConfigResource extends ClerkResource {
branded: boolean;
captchaPublicKey: string | null;
captchaWidgetType: CaptchaWidgetType;
captchaProvider: CaptchaProvider;
captchaPublicKeyInvisible: string | null;
homeUrl: string;
instanceEnvironmentType: string;
Expand Down