From 0e4b153e7fd49ca3a5c64ec877e2d9ff9f3d2e6d Mon Sep 17 00:00:00 2001
From: "gcp-cherry-pick-bot[bot]"
<98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com>
Date: Tue, 10 Dec 2024 00:28:51 +0100
Subject: [PATCH] web/flows: resize captcha iframes (cherry-pick #12260)
(#12304)
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>
---
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 {