Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js): Add experimental support for hCaptcha #3422

Merged
merged 10 commits into from
May 27, 2024
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 @@ -55,6 +55,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.39.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';
26 changes: 26 additions & 0 deletions packages/clerk-js/src/utils/captcha/getCaptchaToken.ts
dimkl marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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;
};

export const getCaptchaToken = async (captchaOptions: CaptchaOptions) => {
const { captchaProvider, widgetType, invisibleSiteKey, siteKey, scriptUrl } = captchaOptions;
anagstef marked this conversation as resolved.
Show resolved Hide resolved
if (captchaProvider === 'hcaptcha') {
return await getHCaptchaToken({ siteKey, scriptUrl, widgetType, invisibleSiteKey });
} else {
return await getTunstileToken({
anagstef marked this conversation as resolved.
Show resolved Hide resolved
siteKey,
scriptUrl,
widgetType,
invisibleSiteKey,
});
}
};
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
Loading