Skip to content

Commit

Permalink
Backport to Core 1: feat(clerkjs): Add support for different Bot Prot…
Browse files Browse the repository at this point in the history
…ection widget types (#3216)

* feat(clerkjs): Add support for different CAPTCHA widget types

* chore(clerk-js): Fix changeset wording
  • Loading branch information
anagstef authored Apr 18, 2024
1 parent c8ba96b commit 220b813
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 17 deletions.
6 changes: 6 additions & 0 deletions .changeset/eighty-pens-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Add support for different Bot Protection widget types
12 changes: 11 additions & 1 deletion packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { deprecatedProperty } from '@clerk/shared/deprecated';
import type { DisplayConfigJSON, DisplayConfigResource, DisplayThemeJSON, PreferredSignInStrategy } from '@clerk/types';
import type {
CaptchaWidgetType,
DisplayConfigJSON,
DisplayConfigResource,
DisplayThemeJSON,
PreferredSignInStrategy,
} from '@clerk/types';

import { BaseResource } from './internal';

Expand All @@ -15,6 +21,8 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
backendHost!: string;
branded!: boolean;
captchaPublicKey: string | null = null;
captchaWidgetType: CaptchaWidgetType = null;
captchaPublicKeyInvisible: string | null = null;
homeUrl!: string;
instanceEnvironmentType!: string;
faviconImageUrl!: string;
Expand Down Expand Up @@ -71,6 +79,8 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
this.afterSwitchSessionUrl = data.after_switch_session_url;
this.branded = data.branded;
this.captchaPublicKey = data.captcha_public_key;
this.captchaWidgetType = data.captcha_widget_type;
this.captchaPublicKeyInvisible = data.captcha_public_key_invisible;
this.supportEmail = data.support_email || '';
this.clerkJSVersion = data.clerk_js_version;
this.organizationProfileUrl = data.organization_profile_url;
Expand Down
11 changes: 8 additions & 3 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ export class SignUp extends BaseResource implements SignUpResource {

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

if (canUseCaptcha && captchaSiteKey && captchaURL) {
if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
try {
paramsWithCaptcha.captchaToken = await getCaptchaToken({
const { captchaToken, captchaWidgetTypeUsed } = await getCaptchaToken({
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
});
paramsWithCaptcha.captchaToken = captchaToken;
paramsWithCaptcha.captchaWidgetType = captchaWidgetTypeUsed;
} catch (e) {
if (e.captchaError) {
paramsWithCaptcha.captchaError = e.captchaError;
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/common/SSOCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';
import { useCoreClerk } from '../contexts';
import { Flow } from '../customizables';
import { Card, CardAlert, LoadingCardContainer, useCardState, withCardStateProvider } from '../elements';
import { CaptchaElement } from '../elements/CaptchaElement';
import { useRouter } from '../router';
import { handleError } from '../utils';

Expand Down Expand Up @@ -35,6 +36,7 @@ export const SSOCallbackCard = (props: HandleOAuthCallbackParams | HandleSamlCal
<Card>
<CardAlert>{card.error}</CardAlert>
<LoadingCardContainer />
<CaptchaElement />
</Card>
</Flow.Part>
);
Expand Down
8 changes: 6 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';

import { useAppearance } from '../../customizables';
import { Col, useAppearance } from '../../customizables';
import { Form } from '../../elements';
import { CaptchaElement } from '../../elements/CaptchaElement';
import type { FormControlState } from '../../utils';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand Down Expand Up @@ -99,7 +100,10 @@ export const SignUpForm = (props: SignUpFormProps) => {
/>
</Form.ControlRow>
)}
<Form.SubmitButton>Continue</Form.SubmitButton>
<Col center>
<CaptchaElement />
<Form.SubmitButton>Continue</Form.SubmitButton>
</Col>
</Form.Root>
);
};
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SocialButtonsReversibleContainerWithDivider,
withCardStateProvider,
} from '../../elements';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { useCardState } from '../../elements/contexts';
import { useLoadingStatus } from '../../hooks';
import { useRouter } from '../../router';
Expand Down Expand Up @@ -272,6 +273,7 @@ function _SignUpStart(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<Footer.Root>
<Footer.Action elementId='signUp'>
Expand Down
9 changes: 9 additions & 0 deletions packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { CAPTCHA_ELEMENT_ID } from '../../utils';
import { Box } from '../customizables';

export const CaptchaElement = () => (
<Box
id={CAPTCHA_ELEMENT_ID}
sx={t => ({ display: 'none', marginBottom: t.space.$6, alignSelf: 'center' })}
/>
);
78 changes: 67 additions & 11 deletions packages/clerk-js/src/utils/captcha.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadScript } from '@clerk/shared/loadScript';
import type { CaptchaWidgetType } from '@clerk/types';

import { clerkFailedToLoadThirdPartyScript } from '../core/errors';

Expand Down Expand Up @@ -35,6 +36,18 @@ interface RenderOptions {
* @param errorCode string
*/
'error-callback'?: (errorCode: string) => void;
/**
* A JavaScript callback invoked when a given client/browser is not supported by the widget.
*/
'unsupported-callback'?: () => boolean;
/**
* Appearance controls when the widget is visible.
* It can be always (default), execute, or interaction-only.
* Refer to Appearance Modes for more information:
* https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#appearance-modes
* @default 'always'
*/
appearance?: 'always' | 'execute' | 'interaction-only';
}

interface Turnstile {
Expand All @@ -50,7 +63,8 @@ declare global {
}
}

const WIDGET_CLASSNAME = 'clerk-captcha';
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'];
Expand All @@ -70,14 +84,48 @@ export async function loadCaptcha(url: string) {
return window.turnstile;
}

export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string }) => {
const { siteKey: sitekey, scriptUrl } = captchaOptions;
/*
* How this function works:
* The widgetType is either 'invisible' or 'smart'.
* - If the widgetType is 'invisible', the captcha widget is rendered in a hidden div at the bottom of the body.
* - 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: {
siteKey: string;
scriptUrl: string;
widgetType: CaptchaWidgetType;
invisibleSiteKey: string;
}) => {
const { siteKey, scriptUrl, widgetType, invisibleSiteKey } = captchaOptions;
let captchaToken = '',
id = '';
let invisibleWidget = !widgetType || widgetType === 'invisible';
let turnstileSiteKey = siteKey;

let widgetDiv: HTMLElement | null = null;

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

if (invisibleWidget) {
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();
invisibleWidget = true;
turnstileSiteKey = invisibleSiteKey;
}
}

const captcha = await loadCaptcha(scriptUrl);
let retries = 0;
Expand All @@ -86,8 +134,9 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU
const handleCaptchaTokenGeneration = (): Promise<[string, string]> => {
return new Promise((resolve, reject) => {
try {
const id = captcha.render(`.${WIDGET_CLASSNAME}`, {
sitekey,
const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, {
sitekey: turnstileSiteKey,
appearance: 'interaction-only',
retry: 'never',
'refresh-expired': 'auto',
callback: function (token: string) {
Expand All @@ -108,6 +157,10 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU
}
reject([errorCodes.join(','), id]);
},
'unsupported-callback': function () {
reject(['This browser is not supported by the CAPTCHA.', id]);
return true;
},
});
} catch (e) {
/**
Expand All @@ -133,9 +186,12 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU
captchaError: e,
};
} finally {
// After challenge has run remove node element attached
document.body.removeChild(div);
if (invisibleWidget) {
document.body.removeChild(widgetDiv);
} else {
widgetDiv.style.display = 'none';
}
}

return captchaToken;
return { captchaToken, captchaWidgetTypeUsed: invisibleWidget ? 'invisible' : 'smart' };
};
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/retrieveCaptchaInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
const fapiClient = createFapiClient(clerk);
return {
captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null,
captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null,
captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : null,
canUseCaptcha: _environment
? _environment.userSettings.signUp.captcha_enabled &&
clerk.isStandardBrowser &&
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/displayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DisplayThemeJSON } from './json';
import type { ClerkResource } from './resource';

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

export interface DisplayConfigJSON {
object: 'display_config';
Expand All @@ -14,6 +15,8 @@ export interface DisplayConfigJSON {
application_name: string;
branded: boolean;
captcha_public_key: string | null;
captcha_widget_type: CaptchaWidgetType;
captcha_public_key_invisible: string | null;
home_url: string;
instance_environment_type: string;
/* @deprecated */
Expand Down Expand Up @@ -47,6 +50,8 @@ export interface DisplayConfigResource extends ClerkResource {
backendHost: string;
branded: boolean;
captchaPublicKey: string | null;
captchaWidgetType: CaptchaWidgetType;
captchaPublicKeyInvisible: string | null;
homeUrl: string;
instanceEnvironmentType: string;
logoImageUrl: string;
Expand Down

0 comments on commit 220b813

Please sign in to comment.