From a63c5b18464d1115dc6e4283eee0a95ac25d21d4 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:24:14 +0200 Subject: [PATCH] web: Improvements to ReCaptcha resizing (#16171) * web: Remove comments from serialized HTML. * web: Apply color theme to iframe. * web: Fix issues surrounding reCaptcha resize events not propagating. --- web/src/common/purify.ts | 6 +- web/src/flow/stages/captcha/CaptchaStage.ts | 133 +++++++++++++++++--- web/src/flow/stages/captcha/shared.ts | 59 ++++++++- 3 files changed, 171 insertions(+), 27 deletions(-) diff --git a/web/src/common/purify.ts b/web/src/common/purify.ts index abd5a75b22..1c88ddb38b 100644 --- a/web/src/common/purify.ts +++ b/web/src/common/purify.ts @@ -121,7 +121,9 @@ export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string { render(untrustedHTML, container); - const result = container.innerHTML; - + const result = container.innerHTML + // Remove all comments as they can interfere with the styles. + .replaceAll("", "") + .replaceAll(//g, ""); return result; } diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 500662b574..83c0085372 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -9,7 +9,7 @@ import { ListenerController } from "#elements/utils/listenerController"; import { randomId } from "#elements/utils/randomId"; import { BaseStage } from "#flow/stages/base"; -import { CaptchaHandler, iframeTemplate } from "#flow/stages/captcha/shared"; +import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared"; import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api"; @@ -109,7 +109,7 @@ export class CaptchaStage extends BaseStage([ + /** + * Mapping of captcha provider names to their respective JS API global. + * + * Note that this is a `Map` to ensure the preferred order of discovering provider globals. + */ + #handlers = new Map([ [ "grecaptcha", { @@ -415,7 +420,7 @@ export class CaptchaStage extends BaseStage { const iframe = this.#iframeRef.value; @@ -423,17 +428,73 @@ export class CaptchaStage extends BaseStage { - if (!this.#iframeRef) return; + let synchronizeHeight: () => void; - const target = contentDocument.getElementById("ak-container"); + if (this.activeHandler === CaptchaProvider.reCAPTCHA) { + // reCAPTCHA's use of nested iframes prevents their internal resize observer from + // reporting the correct height back to our iframe, so we have to do it ourselves. - if (!target) return; + synchronizeHeight = () => { + if (!this.#iframeRef) return; - this.iframeHeight = Math.round(target.clientHeight); - }; + const target = contentDocument.getElementById("ak-container"); - const resizeObserver = new ResizeObserver(resizeListener); + if (!target) return; + + const innerIFrame = contentDocument.querySelector( + 'iframe[style~="height:"]', + ); + + const innerBottom = innerIFrame?.getBoundingClientRect().bottom ?? 0; + + const actualHeight = Math.max(innerBottom, target.clientHeight); + + this.iframeHeight = Math.round(actualHeight * 1.1); + + if (innerIFrame?.parentElement) { + innerIFrame.parentElement.style.height = `${actualHeight}px`; + } + }; + + // We watch for any newly inserted iframes, as they may alter the height + // of the parent iframe... + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type !== "childList") continue; + + for (const node of mutation.addedNodes as NodeListOf) { + if (node.tagName !== "IFRAME") continue; + + // And then resize the iframe to match the new size. + // + // This doesn't fix the issue entirely since the challenge frame + // doesn't yet know the correct height, but at least the user can + // try to load the challenge again with the correct height. + + resizeObserver.observe(node as HTMLIFrameElement); + + requestAnimationFrame(synchronizeHeight); + } + } + }); + + mutationObserver.observe(contentDocument.body, { + childList: true, + subtree: true, + }); + } else { + synchronizeHeight = () => { + if (!this.#iframeRef) return; + + const target = contentDocument.getElementById("ak-container"); + + if (!target) return; + + this.iframeHeight = Math.round(target.clientHeight); + }; + } + + const resizeObserver = new ResizeObserver(synchronizeHeight); requestAnimationFrame(() => { resizeObserver.observe(contentDocument.body); @@ -442,22 +503,26 @@ export class CaptchaStage extends BaseStage => { console.debug("authentik/stages/captcha: script loaded"); this.error = null; this.#iframeLoaded = false; - for (const [name, handler] of this.#handlers) { + for (const name of this.#handlers.keys()) { if (!Object.hasOwn(window, name)) { continue; } try { - await this.#run(handler); + await this.#run(name); console.debug(`authentik/stages/captcha[${name}]: handler succeeded`); - this.activeHandler = handler; + this.activeHandler = name; return; } catch (error) { @@ -469,7 +534,9 @@ export class CaptchaStage extends BaseStage; + +export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider]; + export interface CaptchaHandler { interactive(): TemplateResult; execute(): Promise; @@ -9,6 +22,29 @@ export interface CaptchaHandler { refresh(): Promise; } +const ThemeColor = { + dark: "#18191a", + light: "#ffffff", +} as const satisfies Record; + +export function themeMeta(theme: ResolvedUITheme) { + switch (theme) { + case "dark": + return html` + + + `; + case "light": + return html` + `; + } +} + +export interface IFrameTemplateInit { + challengeURL: string; + theme: ResolvedUITheme; +} + /** * A container iframe for a hosted Captcha, with an event emitter to monitor * when the Captcha forces a resize. @@ -17,10 +53,17 @@ export interface CaptchaHandler { * margin, adding 2rem of height to our container adds padding and prevents scrollbars * or hidden rendering. */ -export function iframeTemplate(children: TemplateResult, challengeURL: string): string { +export function iframeTemplate( + children: TemplateResult, + { challengeURL, theme }: IFrameTemplateInit, +) { return createDocumentTemplate({ - head: html` + head: html` + + ${themeMeta(theme)} + `, + body: html` `, - body: html`${children} - `, + + ${children} + + `, }); }