web/flows: resize captcha iframes (#12260) * web: Add InvalidationFlow to Radius Provider dialogues ## What - Bugfix: adds the InvalidationFlow to the Radius Provider dialogues - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated to the Notification. - Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/` ## Note Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current dialogues at the moment. * web: streamline CaptchaStage # What This commit: 1. Replaces the mass of `if () { if() { if() } }` with two state tables: - One for `render()` - One for `renderBody()` 2. Breaks each Captcha out into "interactive" and "executive" versions 3. Creates a handler table for each Captcha type 4. Replaces the `.forEach` expression with a `for` loop. 5. Move `updated` to the end of the class. 6. Make captchDocument and captchaFrame constructed-on-demand with a cache. 7. Remove a lot of `@state` handlers 8. Give IframeMessageEvent its own type. 9. Removed `this.scriptElement` 10. Replaced `window.removeEventListener` with an `AbortController()` # Why 1. **Replacing `if` trees with a state table.** The logic of the original was really hard to follow. With the state table, we can clearly see now that for the `render()` function, we care about the Boolean flags `[embedded, challenged, interactive]` and have appropriate effects for each. With `renderBody()`, we can see that we care about the Boolean flags `[hasError, challenged]`, and can see the appropriate effects for each one. 2. (and 3.) **Breaking each Captcha clause into separate handlers.** Again, the logic was convoluted, when what we really care about is "Does this captcha have a corresponding handler attached to `window`, and, if so, should we run the interactive or executive version of it?" By putting all of that into a table of `[name, challenge, execute]`, we can clearly see what's being handled when. 4. **Replacing `foreach()` with `for()`**: [You cannot use a `forEach()` with async expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#:~:text=does%20not%20wait%20for%20promises). If you need asynchronous behavior in an ordered loop, `for()` is the safest way to handle it; if you need asynchronous behavior from multiple promises, `Promise.allSettled(handlers.map())` is the way to go. I tried to tell if this function *meant* to run every handler it found simultaneously, or if it meant to test them in order; I went with the second option, breaking and exiting the loop once a handler had run successfully. 5. **Reordered the code a bit**. We're trying to evolve a pattern in our source code: styles, properties, states, internal variables, constructor, getters & setters that are not `@property()` or `@state()`, DOM-related lifecycle handlers, event handlers, pre-render lifecycle handlers, renderers, and post-render lifecycle handlers. Helper methods (including subrenderers) go above the method(s) they help. 6. **Constructing Elements on demand with a cache**. It is not guaranteed that we will actually need either of those. Constructing them on demand with a cache is both performant and cleaner. Likewise, I removed these from the Lit object's `state()` table, since they're constructed once and never change over the lifetime of an instance of `ak-stage-captcha`. 9. **Remove `this.scriptElement`**: It was never referenced outside the one function where it was used. 10. **Remove `removeEventListener()`**: `AbortController()` is a bit more verbose for small event handler collections, but it's considered much more reliable and much cleaner. * Didn't save the extracted ListenerController. Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
This commit is contained in:
		
				
					committed by
					
						
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							efac5ce7bd
						
					
				
				
					commit
					0e4b153e7f
				
			
							
								
								
									
										41
									
								
								web/src/elements/utils/listenerController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web/src/elements/utils/listenerController.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,41 @@
 | 
			
		||||
// This is a more modern way to handle disconnecting listeners on demand.
 | 
			
		||||
 | 
			
		||||
// example usage:
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
export class MyElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
   this.listenerController = new ListenerController();
 | 
			
		||||
 | 
			
		||||
   connectedCallback() {
 | 
			
		||||
      super.connectedCallback();
 | 
			
		||||
      window.addEventListener("event-1", handler1, { signal: this.listenerController.signal });
 | 
			
		||||
      window.addEventListener("event-2", handler2, { signal: this.listenerController.signal });
 | 
			
		||||
      window.addEventListener("event-3", handler3, { signal: this.listenerController.signal });
 | 
			
		||||
   }
 | 
			
		||||
 | 
			
		||||
   disconnectedCallback() {
 | 
			
		||||
      // This will disconnect *all* the event listeners at once, and resets the listenerController,
 | 
			
		||||
      // releasing the memory used for the signal as well. No more trying to map all the
 | 
			
		||||
      // `addEventListener` to `removeEventListener` tediousness!
 | 
			
		||||
      this.listenerController.abort();
 | 
			
		||||
      super.disconnectedCallback();
 | 
			
		||||
   }
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
export class ListenerController {
 | 
			
		||||
    listenerController?: AbortController;
 | 
			
		||||
 | 
			
		||||
    get signal() {
 | 
			
		||||
        if (!this.listenerController) {
 | 
			
		||||
            this.listenerController = new AbortController();
 | 
			
		||||
        }
 | 
			
		||||
        return this.listenerController.signal;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    abort() {
 | 
			
		||||
        this.listenerController?.abort();
 | 
			
		||||
        this.listenerController = undefined;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -1,14 +1,18 @@
 | 
			
		||||
///<reference types="@hcaptcha/types"/>
 | 
			
		||||
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 { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
 | 
			
		||||
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 +27,72 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/
 | 
			
		||||
interface TurnstileWindow extends Window {
 | 
			
		||||
    turnstile: TurnstileObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type TokenHandler = (token: string) => void;
 | 
			
		||||
 | 
			
		||||
type Dims = { height: number };
 | 
			
		||||
 | 
			
		||||
type IframeCaptchaMessage = {
 | 
			
		||||
    source?: string;
 | 
			
		||||
    context?: string;
 | 
			
		||||
    message: "captcha";
 | 
			
		||||
    token: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type IframeResizeMessage = {
 | 
			
		||||
    source?: string;
 | 
			
		||||
    context?: string;
 | 
			
		||||
    message: "resize";
 | 
			
		||||
    size: Dims;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type IframeMessageEvent = MessageEvent<IframeCaptchaMessage | IframeResizeMessage>;
 | 
			
		||||
 | 
			
		||||
type CaptchaHandler = {
 | 
			
		||||
    name: string;
 | 
			
		||||
    interactive: () => Promise<unknown>;
 | 
			
		||||
    execute: () => Promise<unknown>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
 | 
			
		||||
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
 | 
			
		||||
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
 | 
			
		||||
// rendering.
 | 
			
		||||
 | 
			
		||||
const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
 | 
			
		||||
    html`<!doctype html>
 | 
			
		||||
        <head>
 | 
			
		||||
            <html>
 | 
			
		||||
                <body style="display:flex;flex-direction:row;justify-content:center;">
 | 
			
		||||
                    ${captchaElement}
 | 
			
		||||
                    <script>
 | 
			
		||||
                        new ResizeObserver((entries) => {
 | 
			
		||||
                            const height =
 | 
			
		||||
                                document.body.offsetHeight +
 | 
			
		||||
                                parseFloat(getComputedStyle(document.body).fontSize) * 2;
 | 
			
		||||
                            window.parent.postMessage({
 | 
			
		||||
                                message: "resize",
 | 
			
		||||
                                source: "goauthentik.io",
 | 
			
		||||
                                context: "flow-executor",
 | 
			
		||||
                                size: { height },
 | 
			
		||||
                            });
 | 
			
		||||
                        }).observe(document.querySelector(".ak-captcha-container"));
 | 
			
		||||
                    </script>
 | 
			
		||||
                    <script src=${challengeUrl}></script>
 | 
			
		||||
                    <script>
 | 
			
		||||
                        function callback(token) {
 | 
			
		||||
                            window.parent.postMessage({
 | 
			
		||||
                                message: "captcha",
 | 
			
		||||
                                source: "goauthentik.io",
 | 
			
		||||
                                context: "flow-executor",
 | 
			
		||||
                                token: token,
 | 
			
		||||
                            });
 | 
			
		||||
                        }
 | 
			
		||||
                    </script>
 | 
			
		||||
                </body>
 | 
			
		||||
            </html>
 | 
			
		||||
        </head>`;
 | 
			
		||||
 | 
			
		||||
@customElement("ak-stage-captcha")
 | 
			
		||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
 | 
			
		||||
    static get styles(): CSSResult[] {
 | 
			
		||||
@ -37,26 +105,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
 | 
			
		||||
            css`
 | 
			
		||||
                iframe {
 | 
			
		||||
                    width: 100%;
 | 
			
		||||
                    height: 73px; /* tmp */
 | 
			
		||||
                    height: 0;
 | 
			
		||||
                }
 | 
			
		||||
            `,
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    error?: string;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    captchaFrame: HTMLIFrameElement;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    captchaDocumentContainer: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    scriptElement?: HTMLScriptElement;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    embedded = false;
 | 
			
		||||
 | 
			
		||||
@ -65,209 +119,177 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
 | 
			
		||||
        this.host.submit({ component: "ak-stage-captcha", token });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.captchaFrame = document.createElement("iframe");
 | 
			
		||||
        this.captchaFrame.src = "about:blank";
 | 
			
		||||
        this.captchaFrame.id = `ak-captcha-${randomId()}`;
 | 
			
		||||
    @state()
 | 
			
		||||
    error?: string;
 | 
			
		||||
 | 
			
		||||
        this.captchaDocumentContainer = document.createElement("div");
 | 
			
		||||
        this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
 | 
			
		||||
        this.messageCallback = this.messageCallback.bind(this);
 | 
			
		||||
    }
 | 
			
		||||
    handlers: CaptchaHandler[] = [
 | 
			
		||||
        {
 | 
			
		||||
            name: "grecaptcha",
 | 
			
		||||
            interactive: this.renderGReCaptchaFrame,
 | 
			
		||||
            execute: this.executeGReCaptcha,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            name: "hcaptcha",
 | 
			
		||||
            interactive: this.renderHCaptchaFrame,
 | 
			
		||||
            execute: this.executeHCaptcha,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            name: "turnstile",
 | 
			
		||||
            interactive: this.renderTurnstileFrame,
 | 
			
		||||
            execute: this.executeTurnstile,
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    _captchaFrame?: HTMLIFrameElement;
 | 
			
		||||
    _captchaDocumentContainer?: HTMLDivElement;
 | 
			
		||||
    _listenController = new ListenerController();
 | 
			
		||||
 | 
			
		||||
    connectedCallback(): void {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
        window.addEventListener("message", this.messageCallback);
 | 
			
		||||
        window.addEventListener("message", this.onIframeMessage, {
 | 
			
		||||
            signal: this._listenController.signal,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback(): void {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        window.removeEventListener("message", this.messageCallback);
 | 
			
		||||
        if (!this.challenge.interactive) {
 | 
			
		||||
            document.body.removeChild(this.captchaDocumentContainer);
 | 
			
		||||
        this._listenController.abort();
 | 
			
		||||
        if (!this.challenge?.interactive) {
 | 
			
		||||
            if (document.body.contains(this.captchaDocumentContainer)) {
 | 
			
		||||
                document.body.removeChild(this.captchaDocumentContainer);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    messageCallback(
 | 
			
		||||
        ev: MessageEvent<{
 | 
			
		||||
            source?: string;
 | 
			
		||||
            context?: string;
 | 
			
		||||
            message: string;
 | 
			
		||||
            token: string;
 | 
			
		||||
        }>,
 | 
			
		||||
    ) {
 | 
			
		||||
        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;
 | 
			
		||||
        this._captchaDocumentContainer = document.createElement("div");
 | 
			
		||||
        this._captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
 | 
			
		||||
        return this._captchaDocumentContainer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get captchaFrame() {
 | 
			
		||||
        if (this._captchaFrame) {
 | 
			
		||||
            return this._captchaFrame;
 | 
			
		||||
        }
 | 
			
		||||
        this.onTokenChange(msg.token);
 | 
			
		||||
        this._captchaFrame = document.createElement("iframe");
 | 
			
		||||
        this._captchaFrame.src = "about:blank";
 | 
			
		||||
        this._captchaFrame.id = `ak-captcha-${randomId()}`;
 | 
			
		||||
        return this._captchaFrame;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onFrameResize({ height }: Dims) {
 | 
			
		||||
        this.captchaFrame.style.height = `${height}px`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ADR: Did not to put anything into `otherwise` or `exhaustive` here because iframe messages
 | 
			
		||||
    // that were not of interest to us also weren't necessarily corrupt or suspicious. For example,
 | 
			
		||||
    // during testing Storybook throws a lot of cross-iframe messages that we don't care about.
 | 
			
		||||
 | 
			
		||||
    @bound
 | 
			
		||||
    onIframeMessage({ data }: IframeMessageEvent) {
 | 
			
		||||
        match(data)
 | 
			
		||||
            .with(
 | 
			
		||||
                { source: "goauthentik.io", context: "flow-executor", message: "captcha" },
 | 
			
		||||
                ({ token }) => this.onTokenChange(token),
 | 
			
		||||
            )
 | 
			
		||||
            .with(
 | 
			
		||||
                { source: "goauthentik.io", context: "flow-executor", message: "resize" },
 | 
			
		||||
                ({ size }) => this.onFrameResize(size),
 | 
			
		||||
            )
 | 
			
		||||
            .with(
 | 
			
		||||
                { source: "goauthentik.io", context: "flow-executor", message: P.any },
 | 
			
		||||
                ({ message }) => {
 | 
			
		||||
                    console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
            .otherwise(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderGReCaptchaFrame() {
 | 
			
		||||
        this.renderFrame(
 | 
			
		||||
            html`<div
 | 
			
		||||
                class="g-recaptcha ak-captcha-container"
 | 
			
		||||
                data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                data-callback="callback"
 | 
			
		||||
            ></div>`,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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`<div
 | 
			
		||||
                class="h-captcha ak-captcha-container"
 | 
			
		||||
                data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                data-theme="${this.activeTheme ? this.activeTheme : "light"}"
 | 
			
		||||
                data-callback="callback"
 | 
			
		||||
            ></div> `,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async executeHCaptcha() {
 | 
			
		||||
        return hcaptcha.execute(
 | 
			
		||||
            hcaptcha.render(this.captchaDocumentContainer, {
 | 
			
		||||
                sitekey: this.challenge.siteKey,
 | 
			
		||||
                callback: this.onTokenChange,
 | 
			
		||||
                size: "invisible",
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderTurnstileFrame() {
 | 
			
		||||
        this.renderFrame(
 | 
			
		||||
            html`<div
 | 
			
		||||
                class="cf-turnstile ak-captcha-container"
 | 
			
		||||
                data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                data-callback="callback"
 | 
			
		||||
            ></div>`,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async executeTurnstile() {
 | 
			
		||||
        return (window as unknown as TurnstileWindow).turnstile.render(
 | 
			
		||||
            this.captchaDocumentContainer,
 | 
			
		||||
            {
 | 
			
		||||
                sitekey: this.challenge.siteKey,
 | 
			
		||||
                callback: this.onTokenChange,
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async renderFrame(captchaElement: TemplateResult) {
 | 
			
		||||
        this.captchaFrame.contentWindow?.document.open();
 | 
			
		||||
        this.captchaFrame.contentWindow?.document.write(
 | 
			
		||||
            await renderStatic(
 | 
			
		||||
                html`<!doctype html>
 | 
			
		||||
                    <html>
 | 
			
		||||
                        <body style="display:flex;flex-direction:row;justify-content:center;">
 | 
			
		||||
                            ${captchaElement}
 | 
			
		||||
                            <script src=${this.challenge.jsUrl}></script>
 | 
			
		||||
                            <script>
 | 
			
		||||
                                function callback(token) {
 | 
			
		||||
                                    window.parent.postMessage({
 | 
			
		||||
                                        message: "captcha",
 | 
			
		||||
                                        source: "goauthentik.io",
 | 
			
		||||
                                        context: "flow-executor",
 | 
			
		||||
                                        token: token,
 | 
			
		||||
                                    });
 | 
			
		||||
                                }
 | 
			
		||||
                            </script>
 | 
			
		||||
                        </body>
 | 
			
		||||
                    </html>`,
 | 
			
		||||
            ),
 | 
			
		||||
            await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
 | 
			
		||||
        );
 | 
			
		||||
        this.captchaFrame.contentWindow?.document.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated(changedProperties: PropertyValues<this>) {
 | 
			
		||||
        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<boolean> {
 | 
			
		||||
        if (!Object.hasOwn(window, "grecaptcha")) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.challenge.interactive) {
 | 
			
		||||
            this.renderFrame(
 | 
			
		||||
                html`<div
 | 
			
		||||
                    class="g-recaptcha"
 | 
			
		||||
                    data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                    data-callback="callback"
 | 
			
		||||
                ></div>`,
 | 
			
		||||
            );
 | 
			
		||||
        } 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<boolean> {
 | 
			
		||||
        if (!Object.hasOwn(window, "hcaptcha")) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.challenge.interactive) {
 | 
			
		||||
            this.renderFrame(
 | 
			
		||||
                html`<div
 | 
			
		||||
                    class="h-captcha"
 | 
			
		||||
                    data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                    data-theme="${this.activeTheme ? this.activeTheme : "light"}"
 | 
			
		||||
                    data-callback="callback"
 | 
			
		||||
                ></div> `,
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
            const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
 | 
			
		||||
                sitekey: this.challenge.siteKey,
 | 
			
		||||
                callback: this.onTokenChange,
 | 
			
		||||
                size: "invisible",
 | 
			
		||||
            });
 | 
			
		||||
            hcaptcha.execute(captchaId);
 | 
			
		||||
        }
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleTurnstile(): Promise<boolean> {
 | 
			
		||||
        if (!Object.hasOwn(window, "turnstile")) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.challenge.interactive) {
 | 
			
		||||
            this.renderFrame(
 | 
			
		||||
                html`<div
 | 
			
		||||
                    class="cf-turnstile"
 | 
			
		||||
                    data-sitekey="${this.challenge.siteKey}"
 | 
			
		||||
                    data-callback="callback"
 | 
			
		||||
                ></div>`,
 | 
			
		||||
            );
 | 
			
		||||
        } 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`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.challenge.interactive) {
 | 
			
		||||
            return html`${this.captchaFrame}`;
 | 
			
		||||
        }
 | 
			
		||||
        return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`;
 | 
			
		||||
        // [hasError, isInteractive]
 | 
			
		||||
        // 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`<ak-empty-state loading> </ak-empty-state>`;
 | 
			
		||||
        }
 | 
			
		||||
    renderMain() {
 | 
			
		||||
        return html`<header class="pf-c-login__main-header">
 | 
			
		||||
                <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
 | 
			
		||||
            </header>
 | 
			
		||||
@ -291,6 +313,63 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
 | 
			
		||||
                <ul class="pf-c-login__main-footer-links"></ul>
 | 
			
		||||
            </footer>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        // [isEmbedded, hasChallenge, isInteractive]
 | 
			
		||||
        // 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<this>) {
 | 
			
		||||
        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 {
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user