Skip to content

Commit

Permalink
feat(clerk-js): Add experimental support for hCaptcha (#3422)
Browse files Browse the repository at this point in the history
Co-authored-by: Nikos Douvlis <[email protected]>
  • Loading branch information
anagstef and nikosdouvlis authored May 27, 2024
1 parent 8529e41 commit 4beb006
Show file tree
Hide file tree
Showing 15 changed files with 186 additions and 14 deletions.
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]> => {
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

0 comments on commit 4beb006

Please sign in to comment.