-
Notifications
You must be signed in to change notification settings - Fork 419
feat(clerk-js): Add experimental support for hCaptcha #3422
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
Changes from all commits
038cdae
25f7fe2
6791f41
3d9d896
d46314f
c611a37
84cdc13
f344f0c
819f6c9
0dd731a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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'; |
| 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); | ||
| } | ||
| }; |
| 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]> => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ Can we extract parts from
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export * from './retrieveCaptchaInfo'; | ||
| export * from './constants'; | ||
| export * from './getCaptchaToken'; |
Uh oh!
There was an error while loading. Please reload this page.