diff --git a/.changeset/human-intervention-step.md b/.changeset/human-intervention-step.md new file mode 100644 index 0000000..eb6d063 --- /dev/null +++ b/.changeset/human-intervention-step.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": minor +--- + +Add the `AWAITING_HUMAN_INTERVENTION` step. When a connection has `hitl` enabled and the login hits a CAPTCHA the browser cannot solve, the component embeds the login browser's live view so the person can complete it, then resumes the login when they confirm. If the site asks for another check after the person continues, the screen shows a short note. Sites that restrict embedded content with a Content-Security-Policy need to allow the live view origin in `frame-src`. The `captcha_blocked` error screen now shows the API's `error_message`, which says whether a person was asked to verify and didn't finish in time. diff --git a/bun.lock b/bun.lock index f7813c2..feae260 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "^2.27.0", - "@onkernel/sdk": "0.93.0", + "@onkernel/sdk": "0.138.0", "@types/bun": "1.2.21", "@types/node": "^20", "@types/react-test-renderer": "18.3.1", @@ -34,7 +34,7 @@ }, "packages/managed-auth-react": { "name": "@onkernel/managed-auth-react", - "version": "0.2.0", + "version": "0.5.5", "dependencies": { "clsx": "^2.1.1", "tldts": "7.4.15", @@ -210,7 +210,7 @@ "@onkernel/managed-auth-react-demo": ["@onkernel/managed-auth-react-demo@workspace:packages/demo"], - "@onkernel/sdk": ["@onkernel/sdk@0.93.0", "", {}, "sha512-Hb28fq/XGoDbJGOZXHX7MPdFbBs9OtVER44Xo1p6VT4123xpQMVHCGdR4AdW2KuEEwJpXd1yxuTn0GPvUG8dXw=="], + "@onkernel/sdk": ["@onkernel/sdk@0.138.0", "", {}, "sha512-K1H89zavs6kj7E8tIsG46UqKKwEeJ1bD0XAj4c/yFl2zMx6MB1k31uy9rhIddwTmV6qe+P0E43am8TpqUsjkDA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], diff --git a/package.json b/package.json index 405b379..40f92a6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "^2.27.0", - "@onkernel/sdk": "0.93.0", + "@onkernel/sdk": "0.138.0", "@types/bun": "1.2.21", "@types/node": "^20", "@types/react-test-renderer": "18.3.1", diff --git a/packages/demo/src/States.tsx b/packages/demo/src/States.tsx index 39fc0d1..1fca9c3 100644 --- a/packages/demo/src/States.tsx +++ b/packages/demo/src/States.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { AppearanceProvider, ExternalActionWaiting, + HumanInterventionStep, LoadingState, LocalizationProvider, Shell, @@ -79,6 +80,8 @@ type StateName = | "awaiting_input_mfa" | "awaiting_external_action" | "awaiting_external_action_multi" + | "awaiting_human_intervention" + | "awaiting_human_intervention_again" | "submitting" | "success" | "expired" @@ -93,6 +96,8 @@ const allStates: StateName[] = [ "awaiting_input_mfa", "awaiting_external_action", "awaiting_external_action_multi", + "awaiting_human_intervention", + "awaiting_human_intervention_again", "submitting", "success", "expired", @@ -197,6 +202,25 @@ export function States() { onMFASelect={() => {}} /> ); + case "awaiting_human_intervention": + return ( + + ); + case "awaiting_human_intervention_again": + return ( + + ); case "submitting": return ( diff --git a/packages/managed-auth-react/README.md b/packages/managed-auth-react/README.md index 58dd9ac..a350c25 100644 --- a/packages/managed-auth-react/README.md +++ b/packages/managed-auth-react/README.md @@ -260,6 +260,7 @@ import { StepExpired, LoadingState, ExternalActionWaiting, + HumanInterventionStep, UnifiedAuthForm, AppearanceProvider, LocalizationProvider, @@ -270,10 +271,14 @@ Wrap them in `` and `` to inherit the ### Element keys -`root`, `shell`, `card`, `poweredBy`, `poweredByLink`, `poweredByLogo`, `siteIcon`, `title`, `subtitle`, `description`, `securityCard`, `securityRow`, `securityIcon`, `securityText`, `label`, `inputWrapper`, `input`, `inputHint`, `passwordToggle`, `button`, `buttonPrimary`, `buttonSecondary`, `divider`, `dividerLine`, `dividerText`, `ssoButton`, `ssoButtonIcon`, `ssoButtonLabel`, `mfaOption`, `mfaOptionIcon`, `mfaOptionLabel`, `mfaOptionTarget`, `mfaOptionDescription`, `signInOption`, `signInOptionLabel`, `signInOptionDescription`, `signInOptionChevron`, `errorBanner`, `errorBannerText`, `errorIcon`, `errorTitle`, `errorDescription`, `errorCode`, `successIcon`, `successTitle`, `successDescription`, `expiredIcon`, `expiredCard`, `expiredTitle`, `expiredDescription`, `spinner`, `loadingText`, `externalActionIcon`, `externalActionMessage`, `form`, `formField`, `legalText`, `legalLink`. +`root`, `shell`, `card`, `poweredBy`, `poweredByLink`, `poweredByLogo`, `siteIcon`, `title`, `subtitle`, `description`, `securityCard`, `securityRow`, `securityIcon`, `securityText`, `label`, `inputWrapper`, `input`, `inputHint`, `passwordToggle`, `button`, `buttonPrimary`, `buttonSecondary`, `divider`, `dividerLine`, `dividerText`, `ssoButton`, `ssoButtonIcon`, `ssoButtonLabel`, `mfaOption`, `mfaOptionIcon`, `mfaOptionLabel`, `mfaOptionTarget`, `mfaOptionDescription`, `signInOption`, `signInOptionLabel`, `signInOptionDescription`, `signInOptionChevron`, `errorBanner`, `errorBannerText`, `errorIcon`, `errorTitle`, `errorDescription`, `errorCode`, `successIcon`, `successTitle`, `successDescription`, `expiredIcon`, `expiredCard`, `expiredTitle`, `expiredDescription`, `spinner`, `loadingText`, `externalActionIcon`, `externalActionMessage`, `humanInterventionMessage`, `humanInterventionNote`, `humanInterventionLiveView`, `form`, `formField`, `legalText`, `legalLink`. `ssoButton`, `mfaOption`, and `signInOption` compose `button` → `buttonSecondary` → the slot-specific key, so any styling applied at `buttonSecondary` (e.g. a shared hover state) flows through to all three. +## Human verification + +When a connection has `hitl` enabled and a login hits a CAPTCHA the browser can't solve, the flow pauses at `AWAITING_HUMAN_INTERVENTION` and the component embeds the login browser's live view so the person can complete the check, then continue. If your page sets a Content-Security-Policy, allow the live view's origin (the origin of `live_view_url`) in `frame-src`, or the browser frame is blocked. + ## License MIT diff --git a/packages/managed-auth-react/src/KernelManagedAuth.test.tsx b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx index 0a92e78..4b9b66a 100644 --- a/packages/managed-auth-react/src/KernelManagedAuth.test.tsx +++ b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx @@ -59,3 +59,72 @@ describe("KernelManagedAuth initialization", () => { expect(renderer!.root.findAllByType("button")).toHaveLength(0); }); }); + +describe("KernelManagedAuth human verification", () => { + const hadWindow = "window" in globalThis; + // Unmount first: the live view removes its window listener on the way out. + afterEach(() => { + act(() => renderer?.unmount()); + renderer = null; + if (!hadWindow) delete (globalThis as { window?: unknown }).window; + }); + + test("keeps Continue disabled after the submit is accepted, until the flow moves on", async () => { + if (!hadWindow) { + Object.assign(globalThis, { + window: { addEventListener() {}, removeEventListener() {} }, + }); + } + const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/exchange")) return json({ jwt: "jwt" }); + if (url.endsWith("/events")) return new Promise(() => {}); + if (url.endsWith("/submit")) return json({}); + return json({ + id: "session-id", + domain: "example.com", + profile_name: "profile", + flow_status: "IN_PROGRESS", + flow_step: "AWAITING_HUMAN_INTERVENTION", + interaction_id: "mai_captcha", + live_view_url: "https://live.example.com/browser/live/abc", + fields: [], + choices: [ + { + id: "human_intervention_done", + type: "human_intervention", + label: "I've completed the verification", + }, + ], + }); + }) as typeof fetch; + + await act(async () => { + renderer = create( + createElement(KernelManagedAuth, { + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: fetchImpl, + appearance: { layout: { skipPrimeStep: true } }, + }), + ); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + const continueButton = () => + renderer!.root.findAll( + (node) => node.type === "button" && node.children.includes("Continue"), + )[0]!; + expect(continueButton().props.disabled).toBe(false); + + await act(async () => { + continueButton().props.onClick(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + + expect(continueButton().props.disabled).toBe(true); + }); +}); diff --git a/packages/managed-auth-react/src/KernelManagedAuth.tsx b/packages/managed-auth-react/src/KernelManagedAuth.tsx index 7ba9df4..c2d8a04 100644 --- a/packages/managed-auth-react/src/KernelManagedAuth.tsx +++ b/packages/managed-auth-react/src/KernelManagedAuth.tsx @@ -10,6 +10,8 @@ import { StepError } from "./components/StepError"; import { StepExpired } from "./components/StepExpired"; import { LoadingState } from "./components/LoadingState"; import { ExternalActionWaiting } from "./components/ExternalActionWaiting"; +import { HumanInterventionStep } from "./components/HumanInterventionStep"; +import { humanInterventionChoice } from "./session/state"; import { useLocalization } from "./localization/context"; import type { Appearance } from "./appearance/types"; import type { Localization } from "./localization/types"; @@ -104,6 +106,24 @@ function KernelManagedAuthInner({ ); } + // Stays mounted while Continue is in flight so the live view isn't torn down. + const humanIntervention = humanInterventionChoice(state); + if ( + humanIntervention && + (uiState === "awaiting_human_intervention" || uiState === "submitting") + ) { + return ( + submitSignIn(humanIntervention.id)} + isLoading={isSubmitting || uiState === "submitting"} + errorMessage={submitError ?? undefined} + note={humanIntervention.description} + /> + ); + } + if (uiState === "submitting") { return ( diff --git a/packages/managed-auth-react/src/appearance/types.ts b/packages/managed-auth-react/src/appearance/types.ts index 4db0796..b02072f 100644 --- a/packages/managed-auth-react/src/appearance/types.ts +++ b/packages/managed-auth-react/src/appearance/types.ts @@ -123,6 +123,10 @@ export interface AppearanceElements { /** External action waiting screen. */ externalActionIcon?: ElementValue; externalActionMessage?: ElementValue; + /** Human intervention screen. */ + humanInterventionMessage?: ElementValue; + humanInterventionLiveView?: ElementValue; + humanInterventionNote?: ElementValue; /** Form wrapper. */ form?: ElementValue; formField?: ElementValue; diff --git a/packages/managed-auth-react/src/components/HumanInterventionStep.test.tsx b/packages/managed-auth-react/src/components/HumanInterventionStep.test.tsx new file mode 100644 index 0000000..f0cd18d --- /dev/null +++ b/packages/managed-auth-react/src/components/HumanInterventionStep.test.tsx @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { AppearanceProvider } from "../appearance/context"; +import { LocalizationProvider } from "../localization/context"; +import { HumanInterventionStep } from "./HumanInterventionStep"; + +const LIVE_VIEW = "https://live.example.com/browser/live/abc"; +let listener: ((event: MessageEvent) => void) | undefined; + +function step(note?: string, liveViewUrl = LIVE_VIEW) { + return createElement( + AppearanceProvider, + null, + createElement( + LocalizationProvider, + null, + createElement(HumanInterventionStep, { + targetDomain: "example.com", + liveViewUrl, + onDone: () => {}, + note, + }), + ), + ); +} + +function render(note?: string) { + let renderer!: ReturnType; + act(() => { + renderer = create(step(note)); + }); + return renderer; +} + +const statusText = (renderer: ReturnType) => + renderer.root + .findAll((node) => node.props.role === "status" && node.type === "p") + .map((node) => node.children.join("")); + +describe("HumanInterventionStep", () => { + // The step listens for live view connection failures on window. + const hadWindow = "window" in globalThis; + beforeAll(() => { + if (!hadWindow) { + Object.assign(globalThis, { + window: { + addEventListener: (_: string, fn: (event: MessageEvent) => void) => { + listener = fn; + }, + removeEventListener: () => { + listener = undefined; + }, + }, + }); + } + }); + afterAll(() => { + if (!hadWindow) delete (globalThis as { window?: unknown }).window; + }); + + test("shows the note when the site asks again", () => { + const note = "example.com is asking to verify again."; + expect(statusText(render(note))).toEqual([note]); + }); + + test("shows no note on the first pause", () => { + expect(statusText(render())).toEqual([]); + }); + + test("starts the live view fresh when its url changes", () => { + const renderer = render(); + act(() => + listener?.({ + origin: "https://live.example.com", + data: { type: "KERNEL_CONNECTION_FAILED", reason: "unsupported" }, + } as MessageEvent), + ); + const alerts = () => + renderer.root.findAll((node) => node.props.role === "alert"); + expect(alerts()).toHaveLength(1); + + const next = "https://live.example.com/browser/live/def"; + act(() => renderer.update(step(undefined, next))); + + expect(alerts()).toHaveLength(0); + expect(renderer.root.findByType("iframe" as never).props.src).toBe(next); + act(() => renderer.unmount()); + }); +}); diff --git a/packages/managed-auth-react/src/components/HumanInterventionStep.tsx b/packages/managed-auth-react/src/components/HumanInterventionStep.tsx new file mode 100644 index 0000000..70a8aad --- /dev/null +++ b/packages/managed-auth-react/src/components/HumanInterventionStep.tsx @@ -0,0 +1,56 @@ +import { useSlot } from "../appearance/context"; +import { useLocalization } from "../localization/context"; +import { extractDomainName } from "../lib/profile-name"; +import { Button } from "./primitives/Button"; +import { ErrorBanner } from "./ErrorBanner"; +import { LiveViewFrame } from "./LiveViewFrame"; + +interface HumanInterventionStepProps { + targetDomain: string; + liveViewUrl?: string | null; + onDone: () => void; + isLoading?: boolean; + errorMessage?: string; + /** Shown when the site asks for a check again after the person continued. */ + note?: string | null; +} + +export function HumanInterventionStep({ + targetDomain, + liveViewUrl, + onDone, + isLoading, + errorMessage, + note, +}: HumanInterventionStepProps) { + const slot = useSlot(); + const l = useLocalization(); + + return ( +
+
+

{l.humanInterventionTitle}

+
+ + {note && ( +

+ {note} +

+ )} + {errorMessage && } + + + +

+ {l.humanInterventionMessage(extractDomainName(targetDomain))} +

+ + +
+ ); +} diff --git a/packages/managed-auth-react/src/components/LiveViewFrame.test.tsx b/packages/managed-auth-react/src/components/LiveViewFrame.test.tsx new file mode 100644 index 0000000..728c9b5 --- /dev/null +++ b/packages/managed-auth-react/src/components/LiveViewFrame.test.tsx @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { AppearanceProvider } from "../appearance/context"; +import { LocalizationProvider } from "../localization/context"; +import { DEFAULT_LOCALIZATION } from "../localization"; +import { LiveViewFrame } from "./LiveViewFrame"; + +const URL_ = "https://live.example.com/browser/live/abc"; +const ORIGIN = "https://live.example.com"; +let listener: ((event: MessageEvent) => void) | undefined; + +// The frame listens for live view messages on window. +const hadWindow = "window" in globalThis; +beforeAll(() => { + if (!hadWindow) { + Object.assign(globalThis, { + window: { + addEventListener: (_: string, fn: (event: MessageEvent) => void) => { + listener = fn; + }, + removeEventListener: () => { + listener = undefined; + }, + }, + }); + } +}); +afterAll(() => { + if (!hadWindow) delete (globalThis as { window?: unknown }).window; +}); + +function render(startTimeoutMs = 60_000) { + let renderer!: ReactTestRenderer; + act(() => { + renderer = create( + createElement( + AppearanceProvider, + null, + createElement( + LocalizationProvider, + null, + createElement(LiveViewFrame, { url: URL_, startTimeoutMs }), + ), + ), + ); + }); + return renderer; +} + +function post(data: unknown, origin = ORIGIN) { + act(() => listener?.({ origin, data } as MessageEvent)); +} + +const alertText = (renderer: ReactTestRenderer) => + renderer.root + .findAll((node) => node.props.role === "alert" && node.type === "div") + .map((node) => + node + .findAll((child) => child.type === "p") + .map((p) => p.children.join("")) + .join(""), + ); +const buttons = (renderer: ReactTestRenderer) => + renderer.root.findAll((node) => node.type === "button"); + +describe("LiveViewFrame", () => { + test("offers one retry for a network failure, then stops offering it", () => { + const renderer = render(); + post({ type: "KERNEL_CONNECTION_FAILED", reason: "transport" }); + expect(alertText(renderer)).toEqual([ + DEFAULT_LOCALIZATION.humanInterventionLiveViewFailed!, + ]); + expect(buttons(renderer)).toHaveLength(1); + + act(() => buttons(renderer)[0]!.props.onClick()); + expect(alertText(renderer)).toEqual([]); + + post({ type: "KERNEL_CONNECTION_FAILED", reason: "transport" }); + expect(alertText(renderer)).toEqual([ + DEFAULT_LOCALIZATION.humanInterventionLiveViewGaveUp!, + ]); + expect(buttons(renderer)).toHaveLength(0); + act(() => renderer.unmount()); + }); + + test("does not offer a retry when the viewer's browser can't play the stream", () => { + const renderer = render(); + post({ type: "KERNEL_CONNECTION_FAILED", reason: "unsupported" }); + expect(alertText(renderer)).toEqual([ + DEFAULT_LOCALIZATION.humanInterventionLiveViewUnsupported!, + ]); + expect(buttons(renderer)).toHaveLength(0); + act(() => renderer.unmount()); + }); + + test("ignores messages from other origins", () => { + const renderer = render(); + post( + { type: "KERNEL_CONNECTION_FAILED", reason: "transport" }, + "https://evil.example", + ); + expect(alertText(renderer)).toEqual([]); + act(() => renderer.unmount()); + }); + + test("reloads a viewer that never plays, then reports it", async () => { + const renderer = render(20); + const iframes: unknown[] = []; + for (let i = 0; i < 3; i++) { + iframes.push(renderer.root.findByType("iframe" as never)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + } + expect(new Set(iframes).size).toBe(3); + expect(alertText(renderer)).toEqual([ + DEFAULT_LOCALIZATION.humanInterventionLiveViewGaveUp!, + ]); + expect(buttons(renderer)).toHaveLength(0); + act(() => renderer.unmount()); + }); + + test("a playing viewer is left alone", async () => { + const renderer = render(20); + post({ type: "KERNEL_PLAYING", playing: true }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + }); + expect(alertText(renderer)).toEqual([]); + act(() => renderer.unmount()); + }); +}); diff --git a/packages/managed-auth-react/src/components/LiveViewFrame.tsx b/packages/managed-auth-react/src/components/LiveViewFrame.tsx new file mode 100644 index 0000000..83ae09e --- /dev/null +++ b/packages/managed-auth-react/src/components/LiveViewFrame.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSlot } from "../appearance/context"; +import { useLocalization } from "../localization/context"; +import { Button } from "./primitives/Button"; + +// The live view's connect bounds sum to 48s; the watchdog has to outlast them. +const LIVE_VIEW_START_TIMEOUT_MS = 60_000; +// Reloads after a silent stall before telling the person. +const LIVE_VIEW_AUTO_RELOADS = 2; + +type Failure = "retryable" | "gave_up" | "unsupported"; + +interface LiveViewMessage { + type?: unknown; + reason?: unknown; + iceConnectionState?: unknown; +} + +// A network-path failure can clear on a retry; a property of the viewer's +// browser repeats on every reload. +function connectFailure(message: LiveViewMessage): Failure { + if (message.reason === "transport") return "retryable"; + if ( + message.reason === "peer" && + (message.iceConnectionState === "failed" || + message.iceConnectionState === "closed") + ) { + return "retryable"; + } + return message.reason === "unsupported" ? "unsupported" : "gave_up"; +} + +function liveViewOrigin(url: string | null | undefined): string | null { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +interface LiveViewFrameProps { + url?: string | null; + /** How long to wait for video before reloading; overridable for tests. */ + startTimeoutMs?: number; +} + +/** + * Embeds a browser's live view and recovers from a viewer that never starts, + * following the live view's postMessage protocol. + */ +export function LiveViewFrame({ + url, + startTimeoutMs = LIVE_VIEW_START_TIMEOUT_MS, +}: LiveViewFrameProps) { + const slot = useSlot(); + const l = useLocalization(); + const origin = useMemo(() => liveViewOrigin(url), [url]); + const [attempt, setAttempt] = useState(0); + const [failure, setFailure] = useState(null); + const autoReloads = useRef(0); + const retried = useRef(false); + + useEffect(() => { + if (!origin || failure) return; + let watchdog: ReturnType | undefined; + const arm = () => { + clearTimeout(watchdog); + watchdog = setTimeout(() => { + if (autoReloads.current < LIVE_VIEW_AUTO_RELOADS) { + autoReloads.current++; + setAttempt((n) => n + 1); + } else { + setFailure("gave_up"); + } + }, startTimeoutMs); + }; + const onMessage = (event: MessageEvent) => { + if (event.origin !== origin) return; + const message = (event.data ?? {}) as LiveViewMessage; + if (message.type === "KERNEL_PLAYING") clearTimeout(watchdog); + else if (message.type === "KERNEL_PAUSED") arm(); + else if ( + message.type === "KERNEL_CONNECTION_FAILED" || + message.type === "KERNEL_CONNECTION_TIMEOUT" + ) { + clearTimeout(watchdog); + const next = connectFailure(message); + setFailure(next === "retryable" && retried.current ? "gave_up" : next); + } + }; + window.addEventListener("message", onMessage); + arm(); + return () => { + clearTimeout(watchdog); + window.removeEventListener("message", onMessage); + }; + }, [origin, attempt, failure, startTimeoutMs]); + + const retry = () => { + retried.current = true; + setFailure(null); + setAttempt((n) => n + 1); + }; + + return ( +
+ {origin ? ( +