From 28a23110c2187603490ee9024049de4e8de1c1bc Mon Sep 17 00:00:00 2001
From: Ken Sternberg
 <133134217+kensternberg-authentik@users.noreply.github.com>
Date: Mon, 9 Dec 2024 09:11:04 -0800
Subject: [PATCH] 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.
---
 web/src/elements/utils/listenerController.ts |  41 ++
 web/src/flow/stages/captcha/CaptchaStage.ts  | 469 +++++++++++--------
 2 files changed, 315 insertions(+), 195 deletions(-)
 create mode 100644 web/src/elements/utils/listenerController.ts
diff --git a/web/src/elements/utils/listenerController.ts b/web/src/elements/utils/listenerController.ts
new file mode 100644
index 0000000000..9b90602132
--- /dev/null
+++ b/web/src/elements/utils/listenerController.ts
@@ -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;
+    }
+}
diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts
index 24a19a6dab..c2934d6d92 100644
--- a/web/src/flow/stages/captcha/CaptchaStage.ts
+++ b/web/src/flow/stages/captcha/CaptchaStage.ts
@@ -1,14 +1,18 @@
 ///
 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;
+
+type CaptchaHandler = {
+    name: string;
+    interactive: () => Promise;
+    execute: () => Promise;
+};
+
+// 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`
+        
+            
+                
+                    ${captchaElement}
+                    
+                    
+                    
+                
+            
+        `;
+
 @customElement("ak-stage-captcha")
 export class CaptchaStage extends BaseStage {
     static get styles(): CSSResult[] {
@@ -37,26 +105,12 @@ 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;
+        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``,
+        );
+    }
+
+    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) {
         this.captchaFrame.contentWindow?.document.open();
         this.captchaFrame.contentWindow?.document.write(
-            await renderStatic(
-                html`
-                    
-                        
-                            ${captchaElement}
-                            
-                            
-                        
-                    `,
-            ),
+            await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
         );
         this.captchaFrame.contentWindow?.document.close();
     }
 
-    updated(changedProperties: PropertyValues) {
-        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``;
+        // [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` `;
-        }
+    renderMain() {
         return html`
                 ${this.challenge.flowInfo?.title}
             
@@ -291,6 +313,63 @@ export class CaptchaStage extends BaseStage
             `;
     }
+
+    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) {
+        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 {