Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/human-intervention-step.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions packages/demo/src/States.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import {
AppearanceProvider,
ExternalActionWaiting,
HumanInterventionStep,
LoadingState,
LocalizationProvider,
Shell,
Expand Down Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -197,6 +202,25 @@ export function States() {
onMFASelect={() => {}}
/>
);
case "awaiting_human_intervention":
return (
<HumanInterventionStep
targetDomain={TARGET_DOMAIN}
liveViewUrl={null}
onDone={handleSubmit}
isLoading={isLoading}
/>
);
case "awaiting_human_intervention_again":
return (
<HumanInterventionStep
targetDomain={TARGET_DOMAIN}
liveViewUrl={null}
onDone={handleSubmit}
isLoading={isLoading}
note="kernel.sh is asking to verify again."
/>
);
case "submitting":
return (
<LoadingState message="Logging you in..." variant="authenticating" />
Expand Down
7 changes: 6 additions & 1 deletion packages/managed-auth-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ import {
StepExpired,
LoadingState,
ExternalActionWaiting,
HumanInterventionStep,
UnifiedAuthForm,
AppearanceProvider,
LocalizationProvider,
Expand All @@ -270,10 +271,14 @@ Wrap them in `<AppearanceProvider>` and `<LocalizationProvider>` 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
69 changes: 69 additions & 0 deletions packages/managed-auth-react/src/KernelManagedAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>(() => {});
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);
});
});
20 changes: 20 additions & 0 deletions packages/managed-auth-react/src/KernelManagedAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<HumanInterventionStep
targetDomain={targetDomain}
liveViewUrl={state?.live_view_url}
onDone={() => submitSignIn(humanIntervention.id)}
isLoading={isSubmitting || uiState === "submitting"}
errorMessage={submitError ?? undefined}
note={humanIntervention.description}
/>
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if (uiState === "submitting") {
return (
<LoadingState message={l.submittingMessage} variant="authenticating" />
Expand Down
4 changes: 4 additions & 0 deletions packages/managed-auth-react/src/appearance/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof create>;
act(() => {
renderer = create(step(note));
});
return renderer;
}

const statusText = (renderer: ReturnType<typeof create>) =>
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());
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div className="kma-step kma-human-intervention">
<div className="kma-step__header kma-human-intervention__header">
<h1 {...slot("title", "kma-title")}>{l.humanInterventionTitle}</h1>
</div>

{note && (
<p
{...slot("humanInterventionNote", "kma-human-intervention__note")}
role="status"
>
{note}
</p>
)}
{errorMessage && <ErrorBanner message={errorMessage} />}

<LiveViewFrame key={liveViewUrl ?? ""} url={liveViewUrl} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: the reload/retry counters in LiveViewFrame live in refs and don't reset if live_view_url changes, and a failure that's already set keeps the effect inactive for a new url. <LiveViewFrame key={liveViewUrl ?? ""} url={liveViewUrl} /> would start the frame fresh on a new url.

<p {...slot("humanInterventionMessage", "kma-human-intervention__hint")}>
{l.humanInterventionMessage(extractDomainName(targetDomain))}
</p>

<Button onClick={onDone} disabled={isLoading}>
{l.humanInterventionDone}
</Button>
</div>
);
}
Loading
Loading