From 486a21b7daec4637b91d02b024df0d88885b137f Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Mon, 20 Jul 2026 16:02:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(components):=20frontend=20captcha=20gate?= =?UTF-8?q?=20=E2=80=94=20invisible=20=20atom,=20patcher=20inj?= =?UTF-8?q?ection,=20mock=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the frontend half of ADR 019 on current main, replacing stale PR #159 (pre-rewrite history, dead template surface): - New invisible light-DOM atom: Altcha proof-of-work (Web Worker fast path, abortable main-thread fallback) and Turnstile / hCaptcha / reCAPTCHA widgets; emits string proofs (base64 Altcha payload / vendor token) per the existing gate_proofs contract — no OpenAPI change. - mandatory-gates patcher now injects for any step gate without a consumer (gates first, so solving starts on mount); the default template needs no gate markup. - Orchestrator collects proofs, sends gate_proofs on submit, clears them per step, and surfaces error.gate_failed (en/de/it) with the same anti-remount-loop guard as passkey errors. - Invisible atoms are uniformly null-safe: auto-start moves to the first update cycle and late-arriving options/config activate it; explicit start calls without data still error. - api-mock mints a real Altcha challenge on the identifier step and, with verifyGates: true (standalone server), verifies proofs and re-renders with a fresh challenge on failure. - jsdom 29 lacks crypto.subtle — unit project now backs it with Node's WebCrypto via vitest.setup.unit.ts. --- .changeset/frontend-captcha-gate.md | 23 + packages/api-mock/src/altcha.spec.ts | 40 ++ packages/api-mock/src/altcha.ts | 88 +++ packages/api-mock/src/fixtures/login.spec.ts | 17 +- packages/api-mock/src/fixtures/login.ts | 10 +- packages/api-mock/src/handlers.ts | 51 +- packages/api-mock/src/index.spec.ts | 84 +++ packages/api-mock/src/server.ts | 7 +- .../src/atoms/event-contract.spec.ts | 1 + packages/components/src/atoms/index.ts | 6 + .../components/src/atoms/zl-captcha.spec.ts | 248 ++++++++ packages/components/src/atoms/zl-captcha.ts | 538 ++++++++++++++++++ .../components/src/atoms/zl-passkey.spec.ts | 24 +- packages/components/src/atoms/zl-passkey.ts | 17 +- packages/components/src/manifests.spec.ts | 15 + packages/components/src/manifests.ts | 2 + .../components/src/orchestrator/locales/de.ts | 2 + .../components/src/orchestrator/locales/en.ts | 1 + .../components/src/orchestrator/locales/it.ts | 1 + .../src/orchestrator/mandatory-gates.spec.ts | 54 ++ .../src/orchestrator/mandatory-gates.ts | 30 + .../src/orchestrator/zitadel-login.spec.ts | 63 ++ .../src/orchestrator/zitadel-login.ts | 51 ++ packages/components/vitest.config.ts | 3 + packages/components/vitest.setup.unit.ts | 18 + 25 files changed, 1381 insertions(+), 13 deletions(-) create mode 100644 .changeset/frontend-captcha-gate.md create mode 100644 packages/api-mock/src/altcha.spec.ts create mode 100644 packages/api-mock/src/altcha.ts create mode 100644 packages/components/src/atoms/zl-captcha.spec.ts create mode 100644 packages/components/src/atoms/zl-captcha.ts create mode 100644 packages/components/vitest.setup.unit.ts diff --git a/.changeset/frontend-captcha-gate.md b/.changeset/frontend-captcha-gate.md new file mode 100644 index 000000000..f2cf64d91 --- /dev/null +++ b/.changeset/frontend-captcha-gate.md @@ -0,0 +1,23 @@ +--- +"@zitadel/components": minor +--- + +Implement the frontend captcha gate (ADR 019): a new invisible `` +atom solves a step's `captcha` gate — built-in Altcha proof-of-work (with a +Web Worker fast path) or a third-party widget (Turnstile, hCaptcha, +reCAPTCHA) mounted in light DOM — and emits the proof as an opaque string. +The `mandatory-gates` patcher now actually injects the gate consumer it +always documented: any `step.gates` entry without a matching `` +in the template gets one, so templates need no gate markup. The orchestrator +collects proofs and sends them as `gate_proofs` on submit, and surfaces +`error.gate_failed` (new locale key in en/de/it) when solving fails. + +Invisible atoms are now uniformly null-safe: `` and +`` idle silently when mounted without challenge data and start +automatically when it arrives; an explicit `startCeremony()`/`startSolve()` +without data still reports an error event. + +The api-mock issues a real Altcha challenge on the identifier step +(`bot_check`) and, when `setupMockHandlers({ verifyGates: true })` is set +(the standalone dev server does), verifies submitted proofs and re-renders +the step with a fresh challenge on failure. diff --git a/packages/api-mock/src/altcha.spec.ts b/packages/api-mock/src/altcha.spec.ts new file mode 100644 index 000000000..d3e64cf3b --- /dev/null +++ b/packages/api-mock/src/altcha.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { generateAltchaChallenge, verifyAltchaProof } from "./altcha.js"; + +/** Build the proof string the way `` does. */ +function proofFor(challenge: { algorithm: string; challenge: string; salt: string }, number: number): string { + return btoa( + JSON.stringify({ + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number, + salt: challenge.salt, + }), + ); +} + +describe("altcha challenge mint + verify", () => { + it("accepts the proof for the minted solution", async () => { + const challenge = await generateAltchaChallenge(42, 1000); + expect(challenge.max_number).toBe(1000); + await expect(verifyAltchaProof(challenge, proofFor(challenge, 42))).resolves.toBe(true); + }); + + it("rejects a proof with the wrong number", async () => { + const challenge = await generateAltchaChallenge(42, 1000); + await expect(verifyAltchaProof(challenge, proofFor(challenge, 41))).resolves.toBe(false); + }); + + it("rejects a proof whose salt does not match the issued challenge", async () => { + const challenge = await generateAltchaChallenge(7, 1000); + const foreign = { ...challenge, salt: "someone-elses-salt" }; + await expect(verifyAltchaProof(challenge, proofFor(foreign, 7))).resolves.toBe(false); + }); + + it("rejects malformed proof strings instead of throwing", async () => { + const challenge = await generateAltchaChallenge(); + await expect(verifyAltchaProof(challenge, "not-base64-json")).resolves.toBe(false); + await expect(verifyAltchaProof(challenge, btoa("[1,2,3]"))).resolves.toBe(false); + }); +}); diff --git a/packages/api-mock/src/altcha.ts b/packages/api-mock/src/altcha.ts new file mode 100644 index 000000000..78f548cb8 --- /dev/null +++ b/packages/api-mock/src/altcha.ts @@ -0,0 +1,88 @@ +/** + * Mock Altcha proof-of-work challenge generator and verifier. + * + * Generates a valid Altcha challenge with a known solution so `` + * can solve it trivially in development: `max_number` stays low (1000) and + * the solution is a small number, so the brute-force loop completes in + * milliseconds on any device. + * + * Proofs travel as strings per `flow-submit-request.yaml` — for Altcha + * that's the base64-encoded JSON solution payload the widget standard uses: + * `btoa(JSON.stringify({ algorithm, challenge, number, salt }))`. + * + * Uses the Web Crypto API (`globalThis.crypto.subtle`) — works in Node.js + * ≥ 18 and browsers without polyfills. + */ + +function bufferToHex(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export type AltchaChallenge = { + algorithm: string; + challenge: string; + salt: string; + max_number: number; +}; + +/** + * Generate a fresh Altcha PoW challenge. The solution is always within + * `[0, maxNumber]` so it can be brute-forced quickly in dev. + * + * @param solutionNumber - The number that solves the challenge (default: 42). + * Keep this low for fast dev-mode solving. + * @param maxNumber - Upper bound advertised to the solver (default: 1000). + */ +export async function generateAltchaChallenge( + solutionNumber = 42, + maxNumber = 1000, +): Promise { + const salt = bufferToHex(globalThis.crypto.getRandomValues(new Uint8Array(16))); + const algorithm = "SHA-256"; + + // challenge = hex(SHA-256(salt + solutionNumber)) + const encoder = new TextEncoder(); + const data = encoder.encode(salt + solutionNumber); + const hashBuffer = await globalThis.crypto.subtle.digest(algorithm, data); + const challenge = bufferToHex(hashBuffer); + + return { + algorithm, + challenge, + salt, + max_number: maxNumber, + }; +} + +/** + * Verify an Altcha proof string against the originally issued challenge. + * + * Decodes the base64 JSON payload, checks the salt matches the issued + * challenge, and recomputes `SHA-256(salt + number)`. Returns `false` for + * malformed payloads rather than throwing — a garbled proof is just an + * invalid one. + */ +export async function verifyAltchaProof( + original: AltchaChallenge, + proof: string, +): Promise { + let payload: { number?: unknown; salt?: unknown }; + try { + payload = JSON.parse(globalThis.atob(proof)) as { number?: unknown; salt?: unknown }; + } catch { + return false; + } + + const { number, salt } = payload; + if (typeof number !== "number" || typeof salt !== "string") return false; + if (salt !== original.salt) return false; + + const encoder = new TextEncoder(); + const data = encoder.encode(salt + number); + const hashBuffer = await globalThis.crypto.subtle.digest(original.algorithm, data); + + return bufferToHex(hashBuffer) === original.challenge; +} diff --git a/packages/api-mock/src/fixtures/login.spec.ts b/packages/api-mock/src/fixtures/login.spec.ts index eba5046ff..d96e083bf 100644 --- a/packages/api-mock/src/fixtures/login.spec.ts +++ b/packages/api-mock/src/fixtures/login.spec.ts @@ -39,7 +39,6 @@ const input: StepFixtureInput = { }; const syncBuilders = { - identifierStep, registerStep, registerPasswordStep, passwordStep, @@ -61,4 +60,20 @@ describe("runtime login-flow fixtures match the generated flow-step schema", () const resolved = await doneStep(input); expect(() => GetFlowStepResponse.parse(resolved)).not.toThrow(); }); + + // `identifierStep` is async too (it mints an Altcha challenge for the + // `bot_check` gate on every render). + test("identifierStep is spec-conformant and carries the bot_check gate", async () => { + const resolved = await identifierStep(input); + expect(() => GetFlowStepResponse.parse(resolved)).not.toThrow(); + const gate = resolved.step.gates?.["bot_check"]; + expect(gate?.kind).toBe("captcha"); + expect(gate?.provider).toBe("altcha"); + expect(gate?.config).toMatchObject({ + algorithm: "SHA-256", + max_number: expect.any(Number), + challenge: expect.any(String), + salt: expect.any(String), + }); + }); }); diff --git a/packages/api-mock/src/fixtures/login.ts b/packages/api-mock/src/fixtures/login.ts index 077398191..35477e622 100644 --- a/packages/api-mock/src/fixtures/login.ts +++ b/packages/api-mock/src/fixtures/login.ts @@ -12,6 +12,7 @@ */ import type { CreateFlow201, CreateFlow201Step } from "@zitadel/api/generated/model"; +import { generateAltchaChallenge } from "../altcha.js"; import { signHandoffToken } from "../crypto.js"; import type { StoredCredential } from "../lib/authn/index.js"; @@ -76,7 +77,10 @@ function wrap(input: StepFixtureInput, step: CreateFlow201Step, extras?: Partial * Matches the Flow API shape in `docs/design/flowengine/flow-engine.md` * (single `login` step with `fields: [email, password]`). */ -export function identifierStep(input: StepFixtureInput): CreateFlow201 { +export async function identifierStep(input: StepFixtureInput): Promise { + // Async on purpose: every render mints a fresh Altcha challenge for the + // `bot_check` gate, the way the real engine will (ADR 019). + const altcha = await generateAltchaChallenge(); return wrap(input, { name: "identifier", texts: { title_key: "identifier.title" }, @@ -100,7 +104,9 @@ export function identifierStep(input: StepFixtureInput): CreateFlow201 { { name: "register", kind: "navigate", text_key: "identifier.action.register.link" }, { name: "recover", kind: "navigate", text_key: "action.forgot_password" }, ], - gates: {}, + gates: { + bot_check: { kind: "captcha", provider: "altcha", config: altcha }, + }, }); } diff --git a/packages/api-mock/src/handlers.ts b/packages/api-mock/src/handlers.ts index ab7ea8bd2..d0a7f3ecc 100644 --- a/packages/api-mock/src/handlers.ts +++ b/packages/api-mock/src/handlers.ts @@ -29,6 +29,7 @@ import type { } from "@zitadel/api/generated/model"; import type { RequestHandler } from "msw"; +import { verifyAltchaProof, type AltchaChallenge } from "./altcha.js"; import { withBranding } from "./branding.js"; import { startFlowActor, type FlowActor, type FlowStepName } from "./flow-machine.js"; import { AuthnStore, type PasskeyProof } from "./lib/authn/index.js"; @@ -77,17 +78,41 @@ const FLOW_ID = "flow_mock"; * @param options.iss - Issuer URL embedded in the handoff token (default: * `"http://localhost:4000"`). Pass the server's own origin so that * `verifyHandoffToken` can enforce issuer consistency. + * @param options.verifyGates - When true, submits on gated steps are + * rejected (re-rendered with `error.gate_failed` and a fresh challenge) + * unless `gate_proofs` carries a valid solution for every issued + * challenge. Off by default so unit tests that drive steps directly + * don't have to solve captchas; the standalone dev server opts in. */ -export function setupMockHandlers(options: { iss?: string } = {}): MockHandle { +export function setupMockHandlers(options: { iss?: string; verifyGates?: boolean } = {}): MockHandle { const iss = options.iss ?? "http://localhost:8080"; + const verifyGates = options.verifyGates ?? false; let actor: FlowActor = startFlowActor(); let captured: CapturedRequest[] = []; const authn = new AuthnStore(); + /** Altcha challenges minted by the last rendered step, keyed by gate name. */ + const issuedChallenges = new Map(); function reset(): void { actor = startFlowActor(); captured = []; authn.clear(); + issuedChallenges.clear(); + } + + /** + * Record the Altcha challenges a response hands out so the next submit + * can be verified against exactly what was issued (a re-render replaces + * the tracked challenge, mirroring the engine's fresh-challenge-per-render + * behaviour in ADR 019). + */ + function trackChallenges(response: CreateFlow201): CreateFlow201 { + for (const [name, gate] of Object.entries(response.step.gates ?? {})) { + if (gate.kind === "captcha" && gate.provider === "altcha" && gate.config) { + issuedChallenges.set(name, gate.config as AltchaChallenge); + } + } + return response; } function registerCredential(userHandle: string, credentialId: string): void { @@ -136,7 +161,7 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle { case "done": return withBranding(await doneStep(input)); default: - return withBranding(identifierStep(input)); + return trackChallenges(withBranding(await identifierStep(input))); } } @@ -163,6 +188,24 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle { iss, }; + // Gate verification (ADR 019): a gated step's submit must carry a + // valid proof for every challenge issued with that step's render. + // Only the identifier step carries a gate in these fixtures; failed + // verification re-renders it with a fresh challenge, like the engine. + if (verifyGates && before === "identifier" && body.action === "submit" && issuedChallenges.size > 0) { + for (const [name, challenge] of issuedChallenges) { + const proof = body.gate_proofs?.[name]; + const valid = proof ? await verifyAltchaProof(challenge, proof) : false; + if (!valid) { + console.warn(`❌ [api-mock] gate "${name}" proof ${proof ? "INVALID" : "MISSING"}`); + const base = withBranding(await identifierStep(fixtureInput)); + return trackChallenges({ ...base, step: { ...base.step, error: "error.gate_failed" } }); + } + console.info(`✅ [api-mock] gate "${name}" proof VALID`); + } + issuedChallenges.clear(); + } + const registrationErrorKey = before === "register" && body.action === "submit" && email ? authn.registrationError(email) : null; @@ -175,8 +218,8 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle { ? authn.loginError(email) : null; if (loginErrorKey) { - const base = withBranding(identifierStep(fixtureInput)); - return { ...base, step: { ...base.step, error: loginErrorKey } }; + const base = withBranding(await identifierStep(fixtureInput)); + return trackChallenges({ ...base, step: { ...base.step, error: loginErrorKey } }); } const contextEmail = snapshot.context.capturedFields.email; diff --git a/packages/api-mock/src/index.spec.ts b/packages/api-mock/src/index.spec.ts index bf4bbff3c..e8a213ada 100644 --- a/packages/api-mock/src/index.spec.ts +++ b/packages/api-mock/src/index.spec.ts @@ -276,3 +276,87 @@ describe("setupMockHandlers", () => { expect(next.branding).toBeUndefined(); }); }); + +/** Brute-force the Altcha solution the way `` does. */ +async function solveGate(config: Record): Promise { + const { algorithm, challenge, salt, max_number } = config as { + algorithm: string; + challenge: string; + salt: string; + max_number: number; + }; + const encoder = new TextEncoder(); + for (let n = 0; n <= max_number; n++) { + const digest = await crypto.subtle.digest(algorithm, encoder.encode(salt + n)); + const hex = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + if (hex === challenge) { + return btoa(JSON.stringify({ algorithm, challenge, number: n, salt })); + } + } + throw new Error("gate unsolvable within max_number"); +} + +describe("gate verification (verifyGates: true)", () => { + beforeEach(() => { + const gated = setupMockHandlers({ verifyGates: true }); + server.use(...gated.handlers); + }); + + test("issues a bot_check gate on the identifier step", async () => { + const start = await createFlow({ purpose: "login", project_id: PROJECT_ID }); + const gate = start.step.gates?.["bot_check"]; + expect(gate?.kind).toBe("captcha"); + expect(gate?.provider).toBe("altcha"); + expect(gate?.config?.challenge).toBeTruthy(); + }); + + test("rejects a submit without a proof and re-renders with a fresh challenge", async () => { + const start = await createFlow({ purpose: "login", project_id: PROJECT_ID }); + const firstChallenge = start.step.gates?.["bot_check"]?.config?.challenge; + + const rejected = await submitFlowStep(start.id, { + session_token: start.session_token, + action: "submit", + fields: { email: "alice@acme.com", password: "hunter2" }, + }); + expect(rejected.step.name).toBe("identifier"); + expect(rejected.step.error).toBe("error.gate_failed"); + const freshChallenge = rejected.step.gates?.["bot_check"]?.config?.challenge; + expect(freshChallenge).toBeTruthy(); + expect(freshChallenge).not.toBe(firstChallenge); + }); + + test("rejects a tampered proof", async () => { + const start = await createFlow({ purpose: "login", project_id: PROJECT_ID }); + const config = start.step.gates?.["bot_check"]?.config as Record; + const valid = await solveGate(config); + const tampered = btoa( + JSON.stringify({ ...(JSON.parse(atob(valid)) as Record), number: -1 }), + ); + + const rejected = await submitFlowStep(start.id, { + session_token: start.session_token, + action: "submit", + fields: { email: "alice@acme.com", password: "hunter2" }, + gate_proofs: { bot_check: tampered }, + }); + expect(rejected.step.error).toBe("error.gate_failed"); + }); + + test("accepts a valid proof and advances the flow", async () => { + const start = await createFlow({ purpose: "login", project_id: PROJECT_ID }); + const config = start.step.gates?.["bot_check"]?.config as Record; + const proof = await solveGate(config); + + const done = await submitFlowStep(start.id, { + session_token: start.session_token, + action: "submit", + fields: { email: "alice@acme.com", password: "hunter2" }, + gate_proofs: { bot_check: proof }, + }); + expect(done.step.name).toBe("done"); + expect(done.step.error).toBeUndefined(); + }); +}); diff --git a/packages/api-mock/src/server.ts b/packages/api-mock/src/server.ts index a8e67e499..1269675aa 100644 --- a/packages/api-mock/src/server.ts +++ b/packages/api-mock/src/server.ts @@ -331,7 +331,12 @@ export function createMockApp(options: { issuer: string }): express.Express { res.status(204).end(); }); - app.use(createMiddleware(...setupMockHandlers({ iss }).handlers, ...setupPlatformHandlers())); + // The standalone dev server verifies gate proofs so the browser-facing + // golden path exercises the full challenge → solve → verify loop; unit + // tests keep the default (off) and drive steps directly. + app.use( + createMiddleware(...setupMockHandlers({ iss, verifyGates: true }).handlers, ...setupPlatformHandlers()), + ); return app; } diff --git a/packages/components/src/atoms/event-contract.spec.ts b/packages/components/src/atoms/event-contract.spec.ts index 269d2a791..86c258271 100644 --- a/packages/components/src/atoms/event-contract.spec.ts +++ b/packages/components/src/atoms/event-contract.spec.ts @@ -12,6 +12,7 @@ describe("atom event contract", () => { const expectations: Record = { "zl-alert": ["zl-dismiss"], "zl-button": ["zl-submit"], + "zl-captcha": ["zl-captcha-result", "zl-captcha-error"], "zl-card": [], "zl-checkbox": ["zl-change"], "zl-field": ["zl-input"], diff --git a/packages/components/src/atoms/index.ts b/packages/components/src/atoms/index.ts index a5375fe93..578d5b929 100644 --- a/packages/components/src/atoms/index.ts +++ b/packages/components/src/atoms/index.ts @@ -1,5 +1,11 @@ export { ZlAlert, zlAlertManifest } from "./zl-alert.js"; export { ZlButton, zlButtonManifest } from "./zl-button.js"; +export { + ZlCaptcha, + zlCaptchaManifest, + type ZlCaptchaResultDetail, + type ZlCaptchaErrorDetail, +} from "./zl-captcha.js"; export { ZlCard, zlCardManifest } from "./zl-card.js"; export { ZlCheckbox, zlCheckboxManifest, type ZlCheckboxChangeDetail } from "./zl-checkbox.js"; export { ZlField, zlFieldManifest, type ZlFieldType } from "./zl-field.js"; diff --git a/packages/components/src/atoms/zl-captcha.spec.ts b/packages/components/src/atoms/zl-captcha.spec.ts new file mode 100644 index 000000000..b7c9b7d95 --- /dev/null +++ b/packages/components/src/atoms/zl-captcha.spec.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ZlCaptcha, solveAltchaChallenge, zlCaptchaManifest } from "./zl-captcha.js"; + +// `crypto.subtle` comes from `vitest.setup.unit.ts` (jsdom 29 lacks it). + +/** Mint an Altcha challenge the way the server would: hex(SHA-256(salt + n)). */ +async function mintChallenge(solution: number, salt: string): Promise { + const data = new TextEncoder().encode(salt + solution); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +describe("zlCaptchaManifest", () => { + it("declares the correct tag", () => { + expect(zlCaptchaManifest.tag).toBe("zl-captcha"); + }); + + it("satisfies the captcha gate kind", () => { + expect(zlCaptchaManifest.satisfies_gate).toBe("captcha"); + }); + + it("declares expected attributes", () => { + expect(zlCaptchaManifest.attrs).toContain("kind"); + expect(zlCaptchaManifest.attrs).toContain("provider"); + expect(zlCaptchaManifest.attrs).toContain("gate-name"); + expect(zlCaptchaManifest.attrs).toContain("config"); + expect(zlCaptchaManifest.attrs).toContain("manual"); + }); + + it("declares result and error events", () => { + expect(zlCaptchaManifest.events).toContain("zl-captcha-result"); + expect(zlCaptchaManifest.events).toContain("zl-captcha-error"); + }); + + it("has no parts or slots (invisible component)", () => { + expect(zlCaptchaManifest.parts).toEqual([]); + expect(zlCaptchaManifest.slots).toEqual([]); + }); +}); + +describe("solveAltchaChallenge", () => { + it("finds the solution number for a minted challenge", async () => { + const salt = "abc123"; + const challenge = await mintChallenge(7, salt); + const result = await solveAltchaChallenge("SHA-256", challenge, salt, 50); + expect(result).toEqual({ number: 7, salt }); + }); + + it("throws when no solution exists within max_number", async () => { + const salt = "abc123"; + const challenge = await mintChallenge(30, salt); + await expect(solveAltchaChallenge("SHA-256", challenge, salt, 10)).rejects.toThrow( + /no solution found/, + ); + }); + + it("rejects with AbortError when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + solveAltchaChallenge("SHA-256", "deadbeef", "salt", 10, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("", () => { + let host: HTMLDivElement; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + }); + + afterEach(() => { + host.remove(); + }); + + it("is a registered custom element", () => { + const ctor = customElements.get("zl-captcha"); + expect(ctor).toBe(ZlCaptcha); + }); + + it("defaults kind to captcha and provider to altcha", () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + expect(el.kind).toBe("captcha"); + expect(el.provider).toBe("altcha"); + }); + + it("parses config from a JSON attribute", () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.setAttribute("config", '{"challenge":"abc","salt":"xyz"}'); + expect(el.config).toEqual({ challenge: "abc", salt: "xyz" }); + }); + + it("returns null for invalid JSON config", () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.setAttribute("config", "not-json"); + expect(el.config).toBeNull(); + }); + + it("renders in light DOM (no shadow root)", () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + host.appendChild(el); + expect(el.shadowRoot).toBeNull(); + }); + + it("solves an altcha challenge on mount and emits the base64 proof", async () => { + const salt = "mount-salt"; + const challenge = await mintChallenge(5, salt); + + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.setAttribute("gate-name", "bot_check"); + el.setAttribute( + "config", + JSON.stringify({ algorithm: "SHA-256", challenge, salt, max_number: 20 }), + ); + + const resultPromise = new Promise((resolve) => { + el.addEventListener("zl-captcha-result", (e) => resolve(e as CustomEvent), { + once: true, + }); + }); + + host.appendChild(el); + const event = await resultPromise; + + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + expect(event.detail.gate_name).toBe("bot_check"); + expect(typeof event.detail.proof).toBe("string"); + expect(JSON.parse(atob(event.detail.proof))).toEqual({ + algorithm: "SHA-256", + challenge, + number: 5, + salt, + }); + }); + + it("starts solving when config arrives after mount", async () => { + const salt = "late-salt"; + const challenge = await mintChallenge(3, salt); + + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.setAttribute("gate-name", "bot_check"); + host.appendChild(el); + + const resultPromise = new Promise((resolve) => { + el.addEventListener("zl-captcha-result", (e) => resolve(e as CustomEvent), { + once: true, + }); + }); + + // Null-safe idle until data shows up, then the solve starts. + el.config = { algorithm: "SHA-256", challenge, salt, max_number: 20 }; + + const event = await resultPromise; + expect(JSON.parse(atob(event.detail.proof)).number).toBe(3); + }); + + it("does not auto-start when manual is set", async () => { + const salt = "manual-salt"; + const challenge = await mintChallenge(2, salt); + + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.manual = true; + el.setAttribute( + "config", + JSON.stringify({ algorithm: "SHA-256", challenge, salt, max_number: 20 }), + ); + + let emitted = false; + el.addEventListener("zl-captcha-result", () => { + emitted = true; + }); + + host.appendChild(el); + await el.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(emitted).toBe(false); + }); + + it("stays idle without config on mount, but errors on an explicit startSolve", async () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.gateName = "test_gate"; + + const errors: string[] = []; + el.addEventListener("zl-captcha-error", (e) => { + errors.push((e as CustomEvent).detail.error as string); + }); + + // Auto path: mounting without config is a silent no-op. + host.appendChild(el); + await el.updateComplete; + expect(errors).toEqual([]); + + // Explicit call without config is a consumer bug and fails loudly. + await el.startSolve(); + expect(errors).toEqual(["No gate config provided."]); + }); + + it("emits zl-captcha-error with detail shape for an unsupported kind", async () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.kind = "unknown"; + el.gateName = "test_gate"; + el.config = { challenge: "abc" }; + el.manual = true; + + const errorPromise = new Promise((resolve) => { + el.addEventListener("zl-captcha-error", (e) => resolve(e as CustomEvent), { + once: true, + }); + }); + + host.appendChild(el); + void el.startSolve(); + + const event = await errorPromise; + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + expect(event.detail.gate_name).toBe("test_gate"); + expect(event.detail.error).toContain("Unsupported gate kind"); + }); + + it("emits zl-captcha-error with detail shape for an unsupported provider", async () => { + const el = document.createElement("zl-captcha") as ZlCaptcha; + el.kind = "captcha"; + el.provider = "unknown_provider"; + el.gateName = "test_gate"; + el.config = { challenge: "abc" }; + el.manual = true; + + const errorPromise = new Promise((resolve) => { + el.addEventListener("zl-captcha-error", (e) => resolve(e as CustomEvent), { + once: true, + }); + }); + + host.appendChild(el); + void el.startSolve(); + + const event = await errorPromise; + expect(event.detail.gate_name).toBe("test_gate"); + expect(event.detail.error).toContain("Unsupported captcha provider"); + }); +}); diff --git a/packages/components/src/atoms/zl-captcha.ts b/packages/components/src/atoms/zl-captcha.ts new file mode 100644 index 000000000..78b6aa0dd --- /dev/null +++ b/packages/components/src/atoms/zl-captcha.ts @@ -0,0 +1,538 @@ +import { LitElement, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import type { AtomManifest } from "../manifest.js"; + +/** + * Detail shape emitted by the `zl-captcha-result` event. + * + * The orchestrator collects these and includes them in `gate_proofs` + * on the next submit body, keyed by `gate_name`. + * + * `proof` is an opaque string, per `flow-submit-request.yaml`: + * - Altcha: the base64-encoded JSON solution payload (the standard + * Altcha widget wire format). + * - Third-party vendors: the token their widget callback returns. + */ +export type ZlCaptchaResultDetail = { + gate_name: string; + proof: string; +}; + +/** + * Detail shape emitted by the `zl-captcha-error` event. + * + * The orchestrator surfaces this as a `step.error` so the user sees + * a `` banner. + */ +export type ZlCaptchaErrorDetail = { + gate_name: string; + error: string; +}; + +/** Third-party vendor script URLs, keyed by provider. */ +const VENDOR_SCRIPTS: Record = { + turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js", + hcaptcha: "https://js.hcaptcha.com/1/api.js", + recaptcha: "https://www.google.com/recaptcha/api.js", +}; + +/** Module-level dedup set — prevents loading the same vendor script twice. */ +const loadedScripts = new Set(); + +/** + * Solve an Altcha proof-of-work challenge: brute-force `hash(salt + n)` + * for `n` in `0..maxNumber` until the hex digest equals `challenge`. + * + * Exported for tests; not part of the public package API. + */ +export async function solveAltchaChallenge( + algorithm: string, + challenge: string, + salt: string, + maxNumber: number, + signal?: AbortSignal, +): Promise<{ number: number; salt: string }> { + const encoder = new TextEncoder(); + for (let n = 0; n <= maxNumber; n++) { + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + const data = encoder.encode(salt + n); + const hashBuffer = await crypto.subtle.digest(algorithm, data); + if (bufferToHex(hashBuffer) === challenge) { + return { number: n, salt }; + } + } + throw new Error("Altcha: no solution found within max_number range."); +} + +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Solve the Altcha PoW in a Web Worker when available (keeps the main + * thread free for large `max_number` budgets), falling back to the + * main-thread loop when Workers are unavailable or CSP blocks blob URLs. + */ +function solveAltchaWithWorkerFallback( + algorithm: string, + challenge: string, + salt: string, + maxNumber: number, + signal?: AbortSignal, +): Promise<{ number: number; salt: string }> { + if (typeof Worker !== "undefined") { + try { + return solveAltchaInWorker(algorithm, challenge, salt, maxNumber, signal); + } catch { + // Worker creation failed (CSP, etc.) — fall through to main thread. + } + } + return solveAltchaChallenge(algorithm, challenge, salt, maxNumber, signal); +} + +function solveAltchaInWorker( + algorithm: string, + challenge: string, + salt: string, + maxNumber: number, + signal?: AbortSignal, +): Promise<{ number: number; salt: string }> { + const workerCode = ` + self.onmessage = async function(e) { + const { algorithm, challenge, salt, maxNumber } = e.data; + const encoder = new TextEncoder(); + for (let n = 0; n <= maxNumber; n++) { + const data = encoder.encode(salt + n); + const hashBuffer = await crypto.subtle.digest(algorithm, data); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + if (hashHex === challenge) { + self.postMessage({ number: n, salt }); + return; + } + } + self.postMessage({ error: 'No solution found' }); + }; + `; + + const blob = new Blob([workerCode], { type: "application/javascript" }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + + return new Promise((resolve, reject) => { + const cleanup = () => { + worker.terminate(); + URL.revokeObjectURL(url); + }; + + if (signal) { + signal.addEventListener( + "abort", + () => { + cleanup(); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + } + + worker.onmessage = (e: MessageEvent) => { + cleanup(); + if (e.data.error) { + reject(new Error(`Altcha worker: ${e.data.error}`)); + } else { + resolve(e.data as { number: number; salt: string }); + } + }; + + worker.onerror = (e: ErrorEvent) => { + cleanup(); + reject(new Error(`Altcha worker error: ${e.message}`)); + }; + + worker.postMessage({ algorithm, challenge, salt, maxNumber }); + }); +} + +function loadVendorScript(provider: string): Promise { + const url = VENDOR_SCRIPTS[provider]; + if (!url) { + return Promise.reject(new Error(`Unknown captcha provider: ${provider}`)); + } + + if (loadedScripts.has(provider)) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = url; + script.async = true; + + script.onload = () => { + loadedScripts.add(provider); + resolve(); + }; + script.onerror = () => { + reject(new Error(`Failed to load ${provider} script from ${url}`)); + }; + + document.head.appendChild(script); + }); +} + +/** Number of automatic solve attempts before emitting `zl-captcha-error`. */ +const DEFAULT_MAX_RETRIES = 3; + +/** How long to wait for a third-party widget token before giving up. */ +const VENDOR_WIDGET_TIMEOUT_MS = 120_000; + +/** + * Atom: `` — invisible captcha gate solver. + * + * Mounted by the Liquid template, or injected by the `mandatory-gates` + * patcher when a step carries a gate with no consumer in the template. + * On mount it reads the gate configuration from attributes, dispatches on + * `kind` + `provider` to pick a solver, and emits the proof. + * + * The orchestrator listens for `zl-captcha-result` to collect proofs into + * `gate_proofs` on the next submit body. + * + * `captcha` is the only gate kind (ADR 013), with providers: + * - `altcha` — built-in proof-of-work, no vendor script + * - `turnstile` — Cloudflare Turnstile widget + * - `hcaptcha` — hCaptcha widget + * - `recaptcha` — Google reCAPTCHA widget + * + * Third-party widgets mount in light DOM: reCAPTCHA and hCaptcha break + * inside shadow roots (cross-origin iframe + document lookup friction), + * and light-DOM children don't participate in the orchestrator's + * `unsafeHTML` string comparison, so an in-flight solve isn't restarted + * by unrelated re-renders. + * + * A missing/null `config` is a silent no-op, not an error: the atom is + * always safe to place in a template even when the current step carries + * no gate data yet. + * + * Spec: ADR 019 — Captcha Gate Contract & Bot-Detection Signals + */ +@customElement("zl-captcha") +export class ZlCaptcha extends LitElement { + /** Gate category. Currently only `captcha` (ADR 013). */ + @property() accessor kind = "captcha"; + + /** Provider within the gate kind. */ + @property() accessor provider = "altcha"; + + /** The key in `step.gates` — echoed in the result event so the + * orchestrator can key `gate_proofs` correctly. */ + @property({ attribute: "gate-name" }) accessor gateName = ""; + + /** Provider-specific public config (JSON string via attribute, object + * via property). Invalid JSON parses to `null` — the atom then idles. */ + @property({ + attribute: "config", + converter: { + fromAttribute(value: string | null) { + if (!value) return null; + try { + return JSON.parse(value) as Record; + } catch { + return null; + } + }, + toAttribute(value: Record | null) { + return value ? JSON.stringify(value) : null; + }, + }, + }) + accessor config: Record | null = null; + + /** + * When true, the solve is not started automatically on mount. + * The consumer must call `startSolve()` manually. + */ + @property({ type: Boolean }) accessor manual = false; + + private abortController: AbortController | null = null; + private retryCount = 0; + private widgetContainer: HTMLDivElement | null = null; + private autoSolveStarted = false; + + /** + * Auto-solve from `updated()` rather than `connectedCallback`: initial + * attributes are only reflected to properties by the first update, and + * a config that arrives late (property set after mount) should start + * the solve too. `autoSolveStarted` keeps this one-shot per element. + */ + override updated(changed: PropertyValues): void { + if (changed.has("config")) { + this.maybeAutoSolve(); + } + } + + private maybeAutoSolve(): void { + if (!this.manual && this.config && !this.autoSolveStarted) { + this.autoSolveStarted = true; + void this.startSolve(); + } + } + + override disconnectedCallback(): void { + this.abort(); + this.cleanupWidget(); + super.disconnectedCallback(); + } + + /** Abort any in-flight solve. */ + abort(): void { + this.abortController?.abort(); + this.abortController = null; + } + + /** Trigger the gate solve. Dispatches on `kind` + `provider`. */ + async startSolve(): Promise { + if (!this.config) { + // The auto-solve path never gets here (maybeAutoSolve guards on + // config) — an explicit call without config is a consumer bug. + this.emitError("No gate config provided."); + return; + } + + this.abort(); + this.abortController = new AbortController(); + this.retryCount = 0; + + await this.solveWithRetry(); + } + + private async solveWithRetry(): Promise { + while (this.retryCount < DEFAULT_MAX_RETRIES) { + try { + const proof = await this.solve(); + this.emitResult(proof); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; // Component disconnected — don't retry or emit. + } + this.retryCount++; + if (this.retryCount >= DEFAULT_MAX_RETRIES) { + this.emitError( + error instanceof Error ? error.message : "Captcha solve failed.", + ); + } + } + } + } + + private async solve(): Promise { + if (this.kind !== "captcha") { + throw new Error(`Unsupported gate kind: ${this.kind}`); + } + + switch (this.provider) { + case "altcha": + return this.solveAltcha(); + case "turnstile": + case "hcaptcha": + case "recaptcha": + return this.solveVendor(); + default: + throw new Error(`Unsupported captcha provider: ${this.provider}`); + } + } + + private async solveAltcha(): Promise { + const cfg = this.config; + if (!cfg) throw new Error("Altcha config not provided."); + const algorithm = (cfg.algorithm as string) ?? "SHA-256"; + const challenge = cfg.challenge as string; + const salt = cfg.salt as string; + const maxNumber = (cfg.max_number as number) ?? 100_000; + + if (!challenge || !salt) { + throw new Error("Altcha config missing challenge or salt."); + } + + const { number } = await solveAltchaWithWorkerFallback( + algorithm, + challenge, + salt, + maxNumber, + this.abortController?.signal, + ); + + // The standard Altcha payload: base64-encoded JSON of the solution. + // `signature` is passed through when the server minted one. + return btoa( + JSON.stringify({ + algorithm, + challenge, + number, + salt, + ...(typeof cfg.signature === "string" ? { signature: cfg.signature } : {}), + }), + ); + } + + private async solveVendor(): Promise { + await loadVendorScript(this.provider); + return this.renderVendorWidget(); + } + + /** + * Render a vendor captcha widget in light DOM and wait for the token + * callback. Each vendor has a slightly different global API. + */ + private renderVendorWidget(): Promise { + const siteKey = this.config?.site_key as string | undefined; + if (!siteKey) { + return Promise.reject( + new Error(`${this.provider}: missing site_key in config.`), + ); + } + + if (!this.widgetContainer) { + this.widgetContainer = document.createElement("div"); + this.appendChild(this.widgetContainer); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`${this.provider}: widget timed out.`)); + }, VENDOR_WIDGET_TIMEOUT_MS); + + const callback = (token: string) => { + clearTimeout(timeout); + resolve(token); + }; + + const errorCallback = () => { + clearTimeout(timeout); + reject(new Error(`${this.provider}: widget error.`)); + }; + + try { + this.mountVendorWidget(siteKey, callback, errorCallback); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + } + + /** Mount the vendor widget through its global render API. */ + private mountVendorWidget( + siteKey: string, + callback: (token: string) => void, + errorCallback: () => void, + ): void { + const container = this.widgetContainer; + if (!container) throw new Error("Widget container not initialized."); + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const win = window as any; + + switch (this.provider) { + case "turnstile": + if (!win.turnstile) throw new Error("Turnstile API not loaded."); + win.turnstile.render(container, { + sitekey: siteKey, + callback, + "error-callback": errorCallback, + }); + break; + + case "hcaptcha": + if (!win.hcaptcha) throw new Error("hCaptcha API not loaded."); + win.hcaptcha.render(container, { + sitekey: siteKey, + callback, + "error-callback": errorCallback, + }); + break; + + case "recaptcha": + if (!win.grecaptcha) throw new Error("reCAPTCHA API not loaded."); + win.grecaptcha.ready(() => { + win.grecaptcha.render(container, { + sitekey: siteKey, + callback, + "error-callback": errorCallback, + }); + }); + break; + + default: + throw new Error(`No widget renderer for provider: ${this.provider}`); + } + /* eslint-enable @typescript-eslint/no-explicit-any */ + } + + private cleanupWidget(): void { + if (this.widgetContainer) { + this.widgetContainer.remove(); + this.widgetContainer = null; + } + } + + private emitResult(proof: string): void { + const detail: ZlCaptchaResultDetail = { + gate_name: this.gateName, + proof, + }; + this.dispatchEvent( + new CustomEvent("zl-captcha-result", { + bubbles: true, + composed: true, + detail, + }), + ); + } + + private emitError(message: string): void { + const detail: ZlCaptchaErrorDetail = { + gate_name: this.gateName, + error: message, + }; + this.dispatchEvent( + new CustomEvent("zl-captcha-error", { + bubbles: true, + composed: true, + detail, + }), + ); + } + + /** + * Light DOM on purpose: vendor widgets can't live in a shadow root, and + * runtime children don't participate in the orchestrator's `unsafeHTML` + * string comparison, so an in-flight solve survives unrelated re-renders. + */ + override createRenderRoot(): this { + return this; + } +} + +export const zlCaptchaManifest: AtomManifest = { + tag: "zl-captcha", + consumes: {}, + satisfies_gate: "captcha", + attrs: ["kind", "provider", "gate-name", "config", "manual"], + parts: [], + slots: [], + events: ["zl-captcha-result", "zl-captcha-error"], +} as const; + +declare global { + interface HTMLElementTagNameMap { + "zl-captcha": ZlCaptcha; + } +} diff --git a/packages/components/src/atoms/zl-passkey.spec.ts b/packages/components/src/atoms/zl-passkey.spec.ts index 0fc42351d..c472d0658 100644 --- a/packages/components/src/atoms/zl-passkey.spec.ts +++ b/packages/components/src/atoms/zl-passkey.spec.ts @@ -130,6 +130,18 @@ describe("", () => { expect(credentials.get).not.toHaveBeenCalled(); }); + it("stays idle when mounted without options, then auto-starts when they arrive", async () => { + const el = create(); + host.appendChild(el); + await el.updateComplete; + expect(credentials.get).not.toHaveBeenCalled(); + + const detail = nextEvent(el, "zl-passkey-result"); + el.options = { challenge: "AAAA" }; + await detail; + expect(credentials.get).toHaveBeenCalledTimes(1); + }); + it("auto-starts on connect and serialises the assertion into base64url proof", async () => { const el = create({ ceremony: "authenticate", @@ -276,6 +288,9 @@ describe("", () => { }); const done = nextEvent(el, "zl-passkey-result"); host.appendChild(el); + // Two cycles: the first update triggers the auto-start (which flips + // `pending`), the second renders the pending UI. + await el.updateComplete; await el.updateComplete; const pendingUi = el.querySelector('[data-testid="zitadel-passkey-pending"]'); @@ -312,6 +327,9 @@ describe("", () => { const el = create({ ceremony: "authenticate", options: { challenge: "AAAA" } }); const errored = nextEvent<{ aborted: boolean; timed_out: boolean }>(el, "zl-passkey-error"); host.appendChild(el); + // Two cycles: the first update triggers the auto-start (which flips + // `pending`), the second renders the pending UI. + await el.updateComplete; await el.updateComplete; const cancel = el.querySelector('[data-testid="zitadel-passkey-cancel"]'); @@ -339,6 +357,9 @@ describe("", () => { }); const errored = nextEvent<{ aborted: boolean; timed_out: boolean }>(el, "zl-passkey-error"); host.appendChild(el); + // The auto-start runs on the first update cycle (a microtask, so + // unaffected by the fake timers); await it so `rejectGet` is bound. + await el.updateComplete; vi.advanceTimersByTime(60_000); rejectGet(new DOMException("timed out", "NotAllowedError")); @@ -361,7 +382,7 @@ describe("", () => { }); const errored = nextEvent<{ aborted: boolean; timed_out: boolean }>(el, "zl-passkey-error"); host.appendChild(el); - await Promise.resolve(); + await el.updateComplete; rejectGet(new DOMException("dismissed", "NotAllowedError")); const result = await errored; expect(result.aborted).toBe(true); @@ -378,6 +399,7 @@ describe("", () => { const el = create({ ceremony: "authenticate", options: { challenge: "AAAA" } }); const errored = nextEvent<{ timed_out: boolean }>(el, "zl-passkey-error"); host.appendChild(el); + await el.updateComplete; vi.advanceTimersByTime(600_000); rejectGet(new DOMException("dismissed", "NotAllowedError")); const result = await errored; diff --git a/packages/components/src/atoms/zl-passkey.ts b/packages/components/src/atoms/zl-passkey.ts index 10d9ac6c9..1a84426cf 100644 --- a/packages/components/src/atoms/zl-passkey.ts +++ b/packages/components/src/atoms/zl-passkey.ts @@ -1,4 +1,4 @@ -import { html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { bufferToBase64Url, base64UrlToBuffer } from "../internal/base64url.js"; @@ -163,9 +163,18 @@ export class ZlPasskey extends LitElement { /** `Date.now()` at ceremony start, for timeout classification on reject. */ private startedAt = 0; - override connectedCallback(): void { - super.connectedCallback(); - if (!this.manual && this.options) { + private autoStartDone = false; + + /** + * Auto-start from `updated()` rather than `connectedCallback`: the atom + * is null-safe — it may be mounted before the step carries challenge + * data — and options that arrive late (property set after mount) should + * start the ceremony too. `autoStartDone` keeps this one-shot per + * element; a step re-render mounts a fresh element anyway. + */ + override updated(changed: PropertyValues): void { + if (changed.has("options") && !this.manual && this.options && !this.autoStartDone) { + this.autoStartDone = true; void this.startCeremony(); } } diff --git a/packages/components/src/manifests.spec.ts b/packages/components/src/manifests.spec.ts index d9d161bee..08c2d2e76 100644 --- a/packages/components/src/manifests.spec.ts +++ b/packages/components/src/manifests.spec.ts @@ -8,6 +8,7 @@ describe("manifest registry", () => { [ "zl-alert", "zl-button", + "zl-captcha", "zl-card", "zl-checkbox", "zl-field", @@ -92,6 +93,20 @@ describe("manifest registry", () => { ); }); + it("declares zl-captcha as the captcha gate consumer", () => { + // The mandatory-gates patcher injects for any step gate without a consumer; the manifest keeps the + // validator and the sanitiser's attribute allowlist honest. + const captcha = findManifest("zl-captcha"); + expect(captcha?.satisfies_gate).toBe("captcha"); + expect(captcha?.attrs).toEqual( + expect.arrayContaining(["kind", "provider", "gate-name", "config", "manual"]), + ); + expect(captcha?.events).toEqual( + expect.arrayContaining(["zl-captcha-result", "zl-captcha-error"]), + ); + }); + it("declares the default slot for atoms that project default content", () => { // Atoms rendering a bare expose the default ("") slot as a // tier-3 override surface; keep the manifest in step with the markup. diff --git a/packages/components/src/manifests.ts b/packages/components/src/manifests.ts index 628552fe0..de5c44334 100644 --- a/packages/components/src/manifests.ts +++ b/packages/components/src/manifests.ts @@ -9,6 +9,7 @@ import { zlAlertManifest, zlButtonManifest, + zlCaptchaManifest, zlCardManifest, zlCheckboxManifest, zlFieldManifest, @@ -23,6 +24,7 @@ import type { AtomManifest } from "./manifest.js"; export const manifestRegistry: readonly AtomManifest[] = [ zlAlertManifest, zlButtonManifest, + zlCaptchaManifest, zlCardManifest, zlCheckboxManifest, zlFieldManifest, diff --git a/packages/components/src/orchestrator/locales/de.ts b/packages/components/src/orchestrator/locales/de.ts index 6a28f3c63..f66a513b9 100644 --- a/packages/components/src/orchestrator/locales/de.ts +++ b/packages/components/src/orchestrator/locales/de.ts @@ -175,6 +175,8 @@ export const de: Locale = { "Passkey-Registrierung wurde nicht abgeschlossen. Bitte versuche es erneut.", "error.passkey_unsupported": "Dieses Gerät unterstützt keine Passkeys", "error.passkey_failed": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "error.gate_failed": + "Die Sicherheitsprüfung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "error.passkey_invalid": "Dieser Passkey konnte nicht bestätigt werden. Bitte versuche es erneut.", "error.passkey_registration_invalid": diff --git a/packages/components/src/orchestrator/locales/en.ts b/packages/components/src/orchestrator/locales/en.ts index dfec9a890..14e3b6c8f 100644 --- a/packages/components/src/orchestrator/locales/en.ts +++ b/packages/components/src/orchestrator/locales/en.ts @@ -180,6 +180,7 @@ export const en: Record = { "error.passkey_setup_failed": "Passkey registration did not complete. Please try again.", "error.passkey_unsupported": "This device does not support passkeys", "error.passkey_failed": "Something went wrong. Please try again.", + "error.gate_failed": "The security check could not be completed. Please try again.", "error.passkey_invalid": "This passkey could not be verified. Please try again.", "error.passkey_registration_invalid": "The new passkey could not be verified. Please try registering it again.", diff --git a/packages/components/src/orchestrator/locales/it.ts b/packages/components/src/orchestrator/locales/it.ts index f8389d282..8f33c3427 100644 --- a/packages/components/src/orchestrator/locales/it.ts +++ b/packages/components/src/orchestrator/locales/it.ts @@ -172,6 +172,7 @@ export const it: Locale = { "La registrazione della passkey non è stata completata. Riprova.", "error.passkey_unsupported": "Questo dispositivo non supporta le passkey", "error.passkey_failed": "Qualcosa è andato storto. Riprova.", + "error.gate_failed": "Impossibile completare la verifica di sicurezza. Riprova.", "error.passkey_invalid": "Non è stato possibile verificare questa passkey. Riprova.", "error.passkey_registration_invalid": "Non è stato possibile verificare la nuova passkey. Riprova a registrarla.", diff --git a/packages/components/src/orchestrator/mandatory-gates.spec.ts b/packages/components/src/orchestrator/mandatory-gates.spec.ts index 5a4bac69c..a5e4b5721 100644 --- a/packages/components/src/orchestrator/mandatory-gates.spec.ts +++ b/packages/components/src/orchestrator/mandatory-gates.spec.ts @@ -96,6 +96,60 @@ describe("patchMandatoryGates", () => { expect(out).toContain(' { + const gatedStep: CreateFlow201Step = { + ...step, + gates: { + bot_check: { + kind: "captcha", + provider: "altcha", + config: { algorithm: "SHA-256", challenge: "abc", salt: "xyz", max_number: 1000 }, + }, + }, + }; + const html = `${mandatoryGatesMarkerComment}`; + const out = patchMandatoryGates(html, gatedStep, locale); + + const parsed = new DOMParser().parseFromString(out, "text/html"); + const captcha = parsed.querySelector("zl-captcha"); + expect(captcha).not.toBeNull(); + expect(captcha?.getAttribute("gate-name")).toBe("bot_check"); + expect(captcha?.getAttribute("kind")).toBe("captcha"); + expect(captcha?.getAttribute("provider")).toBe("altcha"); + expect(JSON.parse(captcha?.getAttribute("config") ?? "null")).toEqual({ + algorithm: "SHA-256", + challenge: "abc", + salt: "xyz", + max_number: 1000, + }); + }); + + it("does not duplicate a zl-captcha the template already provides", () => { + const gatedStep: CreateFlow201Step = { + ...step, + gates: { bot_check: { kind: "captcha", provider: "altcha" } }, + }; + const html = + `` + + `` + + `` + + `${mandatoryGatesMarkerComment}`; + const out = patchMandatoryGates(html, gatedStep, locale); + expect(out.match(/ { + const gatedStep: CreateFlow201Step = { + ...step, + gates: { bot_check: { kind: "captcha", provider: "altcha" } }, + }; + const out = patchMandatoryGates(mandatoryGatesMarkerComment, gatedStep, locale); + const captchaIndex = out.indexOf(" { const malicious: CreateFlow201Step = { ...step, diff --git a/packages/components/src/orchestrator/mandatory-gates.ts b/packages/components/src/orchestrator/mandatory-gates.ts index fffbbee05..0d050c24b 100644 --- a/packages/components/src/orchestrator/mandatory-gates.ts +++ b/packages/components/src/orchestrator/mandatory-gates.ts @@ -62,6 +62,13 @@ function collectMissingAtoms( ): Element[] { const additions: Element[] = []; + // Gates first — invisible atoms should start solving as soon as the + // step mounts, before any visible field work. + for (const [name, gate] of Object.entries(step.gates ?? {})) { + if (hasGateFor(fragment, name)) continue; + additions.push(buildGate(name, gate)); + } + if (step.fields) { for (const field of step.fields) { if (!field.required) continue; @@ -86,6 +93,15 @@ function hasPrimaryButton(fragment: DocumentFragment): boolean { return Boolean(fragment.querySelector('zl-button[hierarchy="primary"]')); } +function hasGateFor(fragment: DocumentFragment, name: string): boolean { + // Same rationale as `hasFieldFor`: walk instead of a CSS attribute + // selector so arbitrary characters in the gate name need no escaping. + for (const gate of fragment.querySelectorAll("zl-captcha")) { + if (gate.getAttribute("gate-name") === name) return true; + } + return false; +} + function hasFieldFor(fragment: DocumentFragment, name: string): boolean { // A field renders as one of several form-participating atoms depending on // its type: (text/email/password), (enum), or @@ -118,6 +134,20 @@ function findMarkerComment(fragment: DocumentFragment): Comment | null { return null; } +function buildGate( + name: string, + gate: { kind: string; provider: string; config?: Record }, +): Element { + const el = document.createElement("zl-captcha"); + el.setAttribute("gate-name", name); + el.setAttribute("kind", gate.kind); + el.setAttribute("provider", gate.provider); + if (gate.config) { + el.setAttribute("config", JSON.stringify(gate.config)); + } + return el; +} + function buildField( name: string, textKey: string | undefined, diff --git a/packages/components/src/orchestrator/zitadel-login.spec.ts b/packages/components/src/orchestrator/zitadel-login.spec.ts index 05982bba9..dec6771c8 100644 --- a/packages/components/src/orchestrator/zitadel-login.spec.ts +++ b/packages/components/src/orchestrator/zitadel-login.spec.ts @@ -290,6 +290,69 @@ describe(" against the typed Flow API", () => { expect(typeof submits[0]?.body.session_token).toBe("string"); }); + it("injects , solves the gate, and submits a proof the mock verifies", async () => { + // Strict mode: the mock rejects any identifier submit whose gate proof + // is missing or invalid, so reaching "done" proves the whole loop — + // patcher injection → Altcha solve → gate_proofs on the wire → verify. + mock = setupMockHandlers({ verifyGates: true }); + server.resetHandlers(...mock.handlers); + + const element = attachLogin(host); + const proofEvents: CustomEvent[] = []; + element.addEventListener("zl-captcha-result", (event: Event) => + proofEvents.push(event as CustomEvent), + ); + + // The template never mentions gates — the mandatory-gates patcher must + // inject the atom for the fixture's `bot_check` gate. + await waitFor(() => element.shadowRoot?.querySelector('zl-captcha[gate-name="bot_check"]')); + await waitFor(() => (proofEvents.length > 0 ? proofEvents : null)); + + await advanceMockLoginFlow(element); + await waitFor(() => { + const title = element.shadowRoot?.querySelector(".zl-card-title"); + return title?.textContent?.includes("You're signed in") ? title : null; + }); + + const submit = mock + .getCaptured() + .find( + (req): req is Extract => + req.kind === "submitFlowStep", + ); + const proof = submit?.body.gate_proofs?.["bot_check"]; + expect(typeof proof).toBe("string"); + const payload = JSON.parse(atob(proof as string)) as { number: number; salt: string }; + expect(typeof payload.number).toBe("number"); + expect(typeof payload.salt).toBe("string"); + }); + + it("renders the gate-failed banner once on zl-captcha-error without looping", async () => { + const element = await mount(host); + + const dispatchError = () => + element.shadowRoot?.dispatchEvent( + new CustomEvent("zl-captcha-error", { + bubbles: true, + composed: true, + detail: { gate_name: "bot_check", error: "widget exploded" }, + }), + ); + + dispatchError(); + await waitFor(() => { + const alert = element.shadowRoot?.querySelector("zl-alert"); + return alert?.textContent?.includes("security check") ? alert : null; + }); + + // A second error for the same step must not trigger another update — + // the re-render remounts the atom, so without the guard this loops. + dispatchError(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const alerts = element.shadowRoot?.querySelectorAll("zl-alert") ?? []; + expect(alerts).toHaveLength(1); + }); + it("emits zitadel-flow-complete when the step ends with `complete: show`", async () => { const element = await mount(host); const completeEvents: CustomEvent[] = []; diff --git a/packages/components/src/orchestrator/zitadel-login.ts b/packages/components/src/orchestrator/zitadel-login.ts index 60947ad3e..79e1b88e7 100644 --- a/packages/components/src/orchestrator/zitadel-login.ts +++ b/packages/components/src/orchestrator/zitadel-login.ts @@ -237,6 +237,13 @@ export class ZitadelLogin extends LitElement { // emits `zl-passkey-error` when the ceremony fails or is // cancelled. Surface the error on the current step. root.addEventListener("zl-passkey-error", this.handlePasskeyError as EventListener); + // emits `zl-captcha-result` when a gate is solved. + // Collect the proof — it rides along as `gate_proofs` on the next + // submit; the user still drives the submission (ADR 019). + root.addEventListener("zl-captcha-result", this.handleCaptchaResult as EventListener); + // emits `zl-captcha-error` when solving fails for good. + // Surface the error on the current step. + root.addEventListener("zl-captcha-error", this.handleCaptchaError as EventListener); } return root; } @@ -451,6 +458,9 @@ export class ZitadelLogin extends LitElement { private applyResponse(wire: CreateFlow201): void { // A fresh response carries fresh (or no) errors — un-dismiss. this.stepErrorDismissed = false; + // Gate proofs are per-step: a fresh response carries fresh gates (or + // none), so proofs from the previous render must not leak forward. + this.gateProofs = {}; this.response = wire; const { branding, issues } = validateBranding(wire.branding); this.branding = branding; @@ -899,6 +909,46 @@ export class ZitadelLogin extends LitElement { ); }; + /** + * Gate proofs collected from `` for the current step, keyed + * by gate name. A plain field on purpose: proofs never affect rendering, + * they only ride along on the next submit body as `gate_proofs`. + */ + private gateProofs: Record = {}; + + /** Collect a solved gate proof for the next submit. */ + private handleCaptchaResult = ( + event: CustomEvent<{ gate_name: string; proof: string }>, + ): void => { + const { gate_name, proof } = event.detail; + if (!gate_name) return; + this.gateProofs = { ...this.gateProofs, [gate_name]: proof }; + }; + + /** + * Handle a captcha solve failure. Re-render the current step with an + * error banner. Same guard as {@link handlePasskeyError}: the re-render + * remounts a fresh `` that re-solves; if that fails again, + * the matching error key stops the second update and breaks the loop. + */ + private handleCaptchaError = ( + event: CustomEvent<{ gate_name: string; error: string }>, + ): void => { + if (!this.response) return; + const errorKey = "error.gate_failed"; + if (this.response.step.error === errorKey) return; + // This path replaces the response without going through applyResponse; + // the fresh error must not start life dismissed. + this.stepErrorDismissed = false; + this.response = { + ...this.response, + step: { ...this.response.step, error: errorKey }, + }; + console.warn( + `[zitadel-login] captcha gate "${event.detail.gate_name}" failed: ${event.detail.error}`, + ); + }; + private findPrimaryAction(): string | null { const root = this.shadowRoot; if (!root) return null; @@ -933,6 +983,7 @@ export class ZitadelLogin extends LitElement { session_token, action: action ?? "submit", fields, + ...(Object.keys(this.gateProofs).length > 0 ? { gate_proofs: this.gateProofs } : {}), ...(challengeResponse ? { challenge_response: challengeResponse } : {}), }; const { api } = resolveApi(this.project, this.projectAttrs, ""); diff --git a/packages/components/vitest.config.ts b/packages/components/vitest.config.ts index ece0cf124..a649ad5f1 100644 --- a/packages/components/vitest.config.ts +++ b/packages/components/vitest.config.ts @@ -38,6 +38,9 @@ export default defineConfig({ name: "unit", globals: true, environment: "jsdom", + // jsdom 29 lacks `crypto.subtle`; the setup file backs it with + // Node's WebCrypto for the captcha-gate solve/mint paths. + setupFiles: ["./vitest.setup.unit.ts"], include: ["src/**/*.spec.ts"], exclude: ["src/**/*.browser.spec.ts"], }, diff --git a/packages/components/vitest.setup.unit.ts b/packages/components/vitest.setup.unit.ts new file mode 100644 index 000000000..33f7c955f --- /dev/null +++ b/packages/components/vitest.setup.unit.ts @@ -0,0 +1,18 @@ +/** + * Unit-project (jsdom) test setup. + * + * jsdom 29 implements `crypto.getRandomValues` but not `crypto.subtle`. + * The captcha gate path needs `subtle.digest` on both sides — `` + * solves the Altcha proof-of-work, and the api-mock fixtures mint one per + * identifier-step render — so back the global with Node's WebCrypto, which + * is spec-identical. The browser project runs real Chromium and never loads + * this file. + */ +import { webcrypto } from "node:crypto"; + +if (!globalThis.crypto?.subtle) { + Object.defineProperty(globalThis, "crypto", { + value: webcrypto, + configurable: true, + }); +}