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`
${this.challenge.flowInfo?.title}
@@ -291,6 +272,62 @@ export class CaptchaStage extends BaseStage
`;
}
+
+ render() {
+ // prettier-ignore
+ return match([this.embedded, Boolean(this.challenge), Boolean(this.challenge?.interactive)])
+ .with([true, false, P.any], () => nothing)
+ .with([true, true, false], () => nothing)
+ .with([true, true, true], () => this.renderBody())
+ .with([false, false, P.any], () => akEmptyState({ loading: true }))
+ .with([false, true, P.any], () => this.renderMain())
+ .exhaustive();
+ }
+
+ updated(changedProperties: PropertyValues) {
+ if (!(changedProperties.has("challenge") && this.challenge !== undefined)) {
+ return;
+ }
+
+ const attachCaptcha = async () => {
+ console.debug("authentik/stages/captcha: script loaded");
+ const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
+ let lastError = undefined;
+ let found = false;
+ for (const { name, interactive, execute } of handlers) {
+ console.debug(`authentik/stages/captcha: trying handler ${name}`);
+ try {
+ const runner = this.challenge.interactive ? interactive : execute;
+ await runner.apply(this);
+ console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
+ found = true;
+ break;
+ } catch (exc) {
+ console.debug(`authentik/stages/captcha[${name}]: handler failed`);
+ console.debug(exc);
+ lastError = exc;
+ }
+ }
+ this.error = found ? undefined : (lastError ?? "Unspecified error").toString();
+ };
+
+ const scriptElement = document.createElement("script");
+ scriptElement.src = this.challenge.jsUrl;
+ scriptElement.async = true;
+ scriptElement.defer = true;
+ scriptElement.dataset.akCaptchaScript = "true";
+ scriptElement.onload = attachCaptcha;
+
+ document.head
+ .querySelectorAll("[data-ak-captcha-script=true]")
+ .forEach((el) => el.remove());
+
+ document.head.appendChild(scriptElement);
+
+ if (!this.challenge.interactive) {
+ document.body.appendChild(this.captchaDocumentContainer);
+ }
+ }
}
declare global {