diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 24a19a6dabd6..b696b8e9e5bd 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -1,14 +1,17 @@ /// import { renderStatic } from "@goauthentik/common/purify"; import "@goauthentik/elements/EmptyState"; +import { akEmptyState } from "@goauthentik/elements/EmptyState"; +import { bound } from "@goauthentik/elements/decorators/bound"; import "@goauthentik/elements/forms/FormElement"; import { randomId } from "@goauthentik/elements/utils/randomId"; import "@goauthentik/flow/FormStatic"; import { BaseStage } from "@goauthentik/flow/stages/base"; +import { P, match } from "ts-pattern"; import type { TurnstileObject } from "turnstile-types"; import { msg } from "@lit/localize"; -import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; +import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -23,8 +26,22 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/ interface TurnstileWindow extends Window { turnstile: TurnstileObject; } + type TokenHandler = (token: string) => void; +type IframeMessageEvent = MessageEvent<{ + source?: string; + context?: string; + message: string; + token: string; +}>; + +type CaptchaHandler = { + name: string; + interactive: () => Promise; + execute: () => Promise; +}; + @customElement("ak-stage-captcha") export class CaptchaStage extends BaseStage { static get styles(): CSSResult[] { @@ -43,19 +60,30 @@ export class CaptchaStage extends BaseStage, - ) { - const msg = ev.data; - if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") { - return; + get captchaDocumentContainer() { + if (this._captchaDocumentContainer) { + return this._captchaDocumentContainer; } - if (msg.message !== "captcha") { - return; + const cdc = document.createElement("div"); + cdc.id = `ak-captcha-${randomId()}`; + return (this._captchaDocumentContainer = cdc); + } + + get captchaFrame() { + if (this._captchaFrame) { + return this._captchaFrame; + } + const captchaFrame = document.createElement("iframe"); + captchaFrame.src = "about:blank"; + captchaFrame.id = `ak-captcha-${randomId()}`; + return (this._captchaFrame = captchaFrame); + } + + @bound + onIframeMessage({ data }: IframeMessageEvent) { + if ( + data.source === "goauthentik.io" && + data.context === "flow-executor" && + data.message === "captcha" + ) { + this.onTokenChange(data.token); } - this.onTokenChange(msg.token); + } + + async renderGReCaptchaFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeGReCaptcha() { + return grecaptcha.ready(() => { + grecaptcha.execute( + grecaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }), + ); + }); + } + + async renderHCaptchaFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeHCaptcha() { + return hcaptcha.execute( + hcaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }), + ); + } + + async renderTurnstileFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeTurnstile() { + return (window as unknown as TurnstileWindow).turnstile.render( + this.captchaDocumentContainer, + { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + }, + ); } async renderFrame(captchaElement: TemplateResult) { @@ -133,141 +239,16 @@ export class CaptchaStage extends BaseStage) { - if (changedProperties.has("challenge") && this.challenge !== undefined) { - this.scriptElement = document.createElement("script"); - this.scriptElement.src = this.challenge.jsUrl; - this.scriptElement.async = true; - this.scriptElement.defer = true; - this.scriptElement.dataset.akCaptchaScript = "true"; - this.scriptElement.onload = async () => { - console.debug("authentik/stages/captcha: script loaded"); - let found = false; - let lastError = undefined; - this.handlers.forEach(async (handler) => { - let handlerFound = false; - try { - console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`); - handlerFound = await handler.apply(this); - if (handlerFound) { - console.debug( - `authentik/stages/captcha[${handler.name}]: handler succeeded`, - ); - found = true; - } - } catch (exc) { - console.debug( - `authentik/stages/captcha[${handler.name}]: handler failed: ${exc}`, - ); - if (handlerFound) { - lastError = exc; - } - } - }); - if (!found && lastError) { - this.error = (lastError as Error).toString(); - } - }; - document.head - .querySelectorAll("[data-ak-captcha-script=true]") - .forEach((el) => el.remove()); - document.head.appendChild(this.scriptElement); - if (!this.challenge.interactive) { - document.body.appendChild(this.captchaDocumentContainer); - } - } - } - - async handleGReCaptcha(): Promise { - if (!Object.hasOwn(window, "grecaptcha")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - grecaptcha.ready(() => { - const captchaId = grecaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - size: "invisible", - }); - grecaptcha.execute(captchaId); - }); - } - return true; - } - - async handleHCaptcha(): Promise { - if (!Object.hasOwn(window, "hcaptcha")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - const captchaId = hcaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - size: "invisible", - }); - hcaptcha.execute(captchaId); - } - return true; - } - - async handleTurnstile(): Promise { - if (!Object.hasOwn(window, "turnstile")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - (window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - }); - } - return true; - } - renderBody() { - if (this.error) { - return html` `; - } - if (this.challenge.interactive) { - return html`${this.captchaFrame}`; - } - return html``; + // prettier-ignore + return match([Boolean(this.error), Boolean(this.challenge?.interactive)]) + .with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error })) + .with([false, true], () => html`${this.captchaFrame}`) + .with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") })) + .exhaustive(); } - render() { - if (this.embedded) { - if (!this.challenge.interactive) { - return html``; - } - return this.renderBody(); - } - if (!this.challenge) { - return html` `; - } + renderMain() { return html` @@ -291,6 +272,62 @@ export class CaptchaStage extends BaseStage