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.
This commit is contained in:
Teffen Ellis
2025-08-18 15:24:14 +02:00
committed by GitHub
parent 80b84fa8a8
commit a63c5b1846
3 changed files with 171 additions and 27 deletions
+4 -2
View File
@@ -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(/<!--\?lit\$\d+\$-->/g, "");
return result;
}
+113 -20
View File
@@ -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<CaptchaChallenge, CaptchaChallengeRe
//#region State
@state()
protected activeHandler: CaptchaHandler | null = null;
protected activeHandler: CaptchaProvider | null = null;
@state()
protected error: string | null = null;
@@ -265,7 +265,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#endregion
#handlers = new Map<string, CaptchaHandler>([
/**
* 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<CaptchaProvider, CaptchaHandler>([
[
"grecaptcha",
{
@@ -415,7 +420,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#endregion
//#region Listeners
//#region Resizing
#loadListener = () => {
const iframe = this.#iframeRef.value;
@@ -423,17 +428,73 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
if (!iframe || !contentDocument) return;
const resizeListener: ResizeObserverCallback = () => {
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<HTMLIFrameElement>(
'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<HTMLElement>) {
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<CaptchaChallenge, CaptchaChallengeRe
});
};
//#endregion
//#region Loading
#scriptLoadListener = async (): Promise<void> => {
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<CaptchaChallenge, CaptchaChallengeRe
}
};
async #run(handler: CaptchaHandler) {
async #run(captchaProvider: CaptchaProvider) {
const handler = this.#handlers.get(captchaProvider)!;
if (this.challenge.interactive) {
const iframe = this.#iframeRef.value;
@@ -478,18 +545,44 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return;
}
const { contentDocument } = iframe;
if (!contentDocument) {
console.debug(
`authentik/stages/captcha: No iframe content window found, skipping.`,
);
return;
}
console.debug(`authentik/stages/captcha: Rendering interactive.`);
const captchaElement = handler.interactive();
const template = iframeTemplate(captchaElement, this.challenge.jsUrl);
const template = iframeTemplate(captchaElement, {
challengeURL: this.challenge.jsUrl,
theme: this.activeTheme,
});
URL.revokeObjectURL(this.#iframeSource);
if (captchaProvider === CaptchaProvider.reCAPTCHA) {
// reCAPTCHA's domain verification can't seem to penetrate the true origin
// of the page when loaded from a blob URL, likely due to their double-nested
// iframe structure.
// We fallback to the deprecated `document.write` to get around this.
this.#iframeSource = "about:blank";
contentDocument.open();
contentDocument.write(template);
contentDocument.close();
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
// this.#loadListener();
} else {
URL.revokeObjectURL(this.#iframeSource);
this.#iframeSource = url;
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
iframe.src = url;
this.#iframeSource = url;
iframe.src = url;
}
return;
}
+54 -5
View File
@@ -1,7 +1,20 @@
import type { ResolvedUITheme } from "#common/theme";
import { createDocumentTemplate } from "#elements/utils/iframe";
import { html, TemplateResult } from "lit";
/**
* Mapping of captcha provider names to their respective JS API global.
*/
export const CaptchaProvider = {
reCAPTCHA: "grecaptcha",
hCaptcha: "hcaptcha",
Turnstile: "turnstile",
} as const satisfies Record<string, string>;
export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider];
export interface CaptchaHandler {
interactive(): TemplateResult;
execute(): Promise<void>;
@@ -9,6 +22,29 @@ export interface CaptchaHandler {
refresh(): Promise<void>;
}
const ThemeColor = {
dark: "#18191a",
light: "#ffffff",
} as const satisfies Record<ResolvedUITheme, string>;
export function themeMeta(theme: ResolvedUITheme) {
switch (theme) {
case "dark":
return html`
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content=${ThemeColor.dark} />
`;
case "light":
return html` <meta name="color-scheme" content="light" />
<meta name="theme-color" content=${ThemeColor.light} />`;
}
}
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`<meta charset="UTF-8" />
head: html`
<meta charset="UTF-8" />
${themeMeta(theme)}
`,
body: html`
<script>
"use strict";
@@ -43,6 +86,11 @@ export function iframeTemplate(children: TemplateResult, challengeURL: string):
</script>
<style>
html,
body {
background: ${ThemeColor[theme]};
}
body {
margin: 0;
padding: 0;
@@ -58,8 +106,9 @@ export function iframeTemplate(children: TemplateResult, challengeURL: string):
align-items: center;
justify-content: center;
}
</style>`,
body: html`${children}
<script onload="loadListener()" src="${challengeURL}"></script> `,
</style>
${children}
<script onload="loadListener()" src="${challengeURL}"></script>
`,
});
}