diff --git a/devlog/_plan/260926_kiro_lb_parity2/060_native_device_login.md b/devlog/_plan/260926_kiro_lb_parity2/060_native_device_login.md index e262347cfea..4da8d350736 100644 --- a/devlog/_plan/260926_kiro_lb_parity2/060_native_device_login.md +++ b/devlog/_plan/260926_kiro_lb_parity2/060_native_device_login.md @@ -560,3 +560,149 @@ The reference social and OIDC shapes have not been observed live from this proxy - `r3-R2-1` (High) → Native writer allocates a UUID inside the protected mutation and assigns it on both add paths and same-slot reauth (`:232-265`); receipt fences compensation on that UUID and restores the entire prior account, including its `loginId` (`:280-297`). Named Builder and social add, evidence-invalidation, compensation, and stale-compensation tests are at `:477-478`. **rebase-verify at this layer's P** against 010's `ProviderAccount.loginId` and store normalizer. - `r3-R2-3` (Medium, this 060 hunk) → Field chain uses `kiroEvidenceIdentity(account)` with exactly five fields, including `loginId` and no `authType`, and routes through `kiroAccountEvidence(account, now?)` (`:440`, `:451`); same-slot reauth test asserts the old evidence is unknown (`:466`, `:478`). **rebase-verify at this layer's P** against 010's `kiro-account-state-disk.ts` contract. The separate 020 hunk belongs to its own layer. + +## wp7 P re-verification (2026-09-27, branch `codex/kiro-lb2-060-device-login` on dev `e772bdb228`, which contains 010–050) + +Executable plan for the 060 build; **overrides** earlier sections where they conflict. + +| ID | Disposition | +|---|---| +| D060-S1–S7 | Accept anchors: `accountScopedProfileArn` `src/oauth/kiro.ts:188-191` (`loginKiro` 347, `oauthCredentialFromImported` 281, `resolveKiroRequestProfile` 520, social refresh 561, OIDC refresh 573, `refreshKiroToken` 657); `normalizeCredential` `src/oauth/store.ts:514`, `mutateStore` 778-832 (bookkeeping 803-810, `finalizeResult` 813), `saveCredentialWithReceipt` 844-925 (`loginId` write 909), `saveAccountCredential` 1077; `runLogin` 1604, `startLoginFlow` 1833; routes start 224-285, cancel 289-297, status 315-322 (`oauth-account-routes.ts`); CLI login 123-241 (generic branch 206-240), usage 35-52, cancel 280-296, reauth alias ~367 (`account-auth.ts`). | +| **GUI (decision: option a)** | No GUI change. The management route runs the native flow **only when the request carries `method`** (`builder-id`, `google`, `github`); status and cancel are native only when a `flowId` is present. A method-less start keeps `startLoginFlow` (kiro-cli), so the dashboard's existing hooks (`gui/src/pages/use-providers-oauth.ts:101,132`, `use-add-provider-oauth.ts:98,126`) and provider-keyed cancel barrier are untouched. The GUI hunks, `tests/gui/kiro-device-login-ui.test.ts` and the `structure/gui-and-management-api.md` "dashboard uses native login" sentence are dropped. A dashboard device-code dialog is a recorded follow-up. | +| **Identity (decision)** | Neither native method yields a verified identity (Builder ID approval carries no profile; social `profileArn` uniqueness is unverified live). Therefore **native login adds accounts only**: `reauth` with a `method` is refused (route 400, CLI usage error, state machine), and re-login stays on the existing kiro-cli path (`src/oauth/kiro.ts:615-676` identity match). Social adds always append a new slot; no de-duplication by ARN. The "identity check before reauth" gate is satisfied by not offering native reauth. The unbound-`--reauth` guard applies to every Kiro start, including the method-less path. | +| Store write | New store-owned `appendKiroAccountFromDeviceLogin(credential, opts)` in `src/oauth/store.ts`: runs `normalizeCredential`, appends inside `mutateStore` with a fresh `loginId`, never changes `activeAccountId` for an existing set (first account becomes active), returns a receipt usable for rollback through `finalizeResult`. The flow module never calls `mutateStore` directly. | +| Live config | When a poll returns `done`, the route calls `reconcileLiveConfigFromDisk` and `reconcileLiveStateStores`, as the generic path's `onSettled` does (`oauth-account-routes.ts:255-258`). | +| Endpoints | As listed from `kiro/device_login.py` (`bee73b3`): social host `prod.us-east-1.auth.desktop.kiro.dev` with `/oauth/device/authorization` and `/oauth/device/poll` (millisecond expiry/interval); Builder ID `oidc.us-east-1.amazonaws.com` with `/client/register`, `/device_authorization`, `/token` (seconds), `slow_down` adds 5 s, expired is terminal, errors from `error` or `x-amzn-errortype`. All shapes are unverified live and pinned by fixtures; an unrecognised reply fails the flow and persists nothing. `verificationUriComplete` is shown when present. | +| Tests / registry | `tests/providers/kiro/kiro-device-builder.test.ts` and `kiro-device-social.test.ts` between `kiro-calibration` and `kiro-fallback-error-body` (`scripts/test-layout/layout.json:1080/1081`, expected `:901/902`); `tests/server/server-kiro-device-login.test.ts` after `server-kiro-completion-e2e` (1666 / 1492); `tests/cli/cli-account-kiro-device.test.ts` between `cli-account-cancel-flow` and `cli-account-orca-import` (504/505; 330/331). `cli-account.test.ts` (2313 cap) untouched. Existing tests unaffected under option (a). | +| CLI surface | `--method` joins the account-login flags in `src/cli/capabilities.ts` (~410); `bun run skill:surface` regenerates, `skill:surface:check` in C. | + +Verifier set for C: `bun run typecheck`; the four new test files; `tests/oauth/oauth-public-surface.test.ts`, `tests/oauth/oauth-reauth-bind.test.ts`, `tests/oauth/oauth-store-multi.test.ts`, `tests/providers/kiro/`; `bun test $(rg -l "oauth/login|startLoginFlow|account login" tests)` in the clean `/tmp` worktree; `bun run skill:surface:check`; layout, ratchet, lab-boundary; privacy; structure. + + +### wp7 reflection fold (same architect: MISALIGNED → folded) + +1. **Re-login path corrected.** The kiro-cli reauth is `runLogin` with `reauthAccountId` + (`src/oauth/index.ts:1640-1656`), not the refresh retry at `kiro.ts:615-676`. Two changes: + - Native accounts are marked `loginOrigin: "kiro-device"` on their `ProviderAccount` (protected + store; normalized like `loginId`; never in management DTOs). `runLogin` refuses `reauthAccountId` + for a native-origin slot with a fixed message telling the operator to remove and re-add it; this + is the documented recovery for native accounts. + - The `reauthAccountId` write rotates `loginId` (a re-login is a login write), closing the 010 + contract gap where `saveAccountCredential` kept it. Tests: `kiro-cli reauth refuses a native-origin + slot`; `a reauth write rotates loginId and invalidates Kiro evidence`. +2. **Duplicate social adds.** Social adds still append; when the appended `profileArn` already exists in + the pool, the route response and CLI output carry `warning: "duplicate_profile_arn"` (non-blocking), + and the docs note that duplicate slots of one real account each get their own cap and load count. + Test: `adding a social account whose profileArn already exists appends with a warning`. +3. **Verifier set adds** `tests/cli/cli-account-cancel-flow.test.ts`. + + +## wp7 FINAL executable spec (supersedes every earlier section of this document) + +Everything above this heading is history. Where it disagrees with this section — including the +route hunk at ~316, the writer at ~239-260, and the flow-state recipe at ~114-162 — this section +wins, and the builder does not implement the superseded hunks or their tests. + +**Scope.** Native Kiro device login (Builder ID, Google, GitHub) through the CLI and management API, +**add-only**. GUI unchanged. + +**Routes** (`src/server/management/oauth-account-routes.ts`, start 224-285, cancel 289-297, status 315-322): +- `POST /api/oauth/login` with `provider: "kiro"` takes the native branch **only** when `method` is + exactly `"builder-id" | "google" | "github"`; any other present value is 400; an absent `method` + keeps `startLoginFlow` (kiro-cli) byte-for-byte. `reauth` with a `method` is 400 + `native_login_is_add_only`. Unbound `reauth` (no `accountId`) is 400 on every Kiro start. +- Status and cancel use the native table **only** when the request carries a `flowId`; otherwise + the existing provider-keyed behaviour runs unchanged. +- On `done`, the route calls `reconcileLiveConfigFromDisk` and `reconcileLiveStateStores`. + +**Flow table** (`src/oauth/kiro-device-login.ts`): +- `flowId` = 32 random bytes, base64url. Returned only in the start response; no endpoint lists flows. +- Each flow records `ownerPrincipal` (`ctx.principal ?? "admin-token"`); status and cancel from a + different principal return 404 (same as unknown). Rationale: every management caller is already + authenticated as the local user; the principal check stops a token holder from driving a + dashboard-started flow and vice versa, and the unguessable id stops blind access. +- **Cap reserved before network work:** `start` inserts a `pending` placeholder (counted toward + `MAX_KIRO_DEVICE_FLOWS` = 4) synchronously before any upstream call and removes it on every + failure path (`finally`). Test: `concurrent starts beyond the cap are refused without upstream calls`. +- Polling is server-paced: a status call performs at most one upstream poll, never earlier than the + flow's `nextPollAt` (interval, +5 s on `slow_down`); earlier calls return the cached state. Expired + flows are removed on the next touch; a 15-minute absolute lifetime bounds any flow. +- **Exact approved shape before persist:** Builder ID approval requires HTTP 200 with string + `accessToken`, `refreshToken`, positive finite `expiresIn`, and no `error`; social approval + requires HTTP 200, string `accessToken`/`refreshToken`, `profileArn` matching the Kiro profile + ARN shape, and **no** `status` field or `status === "approved"`-free body (any present `status` + other than an approval shape the fixture pins means not approved). Anything else: pending if it is + the recognised pending signal, expired if recognised as expired, otherwise the flow fails and + persists nothing. Test: `a 200 reply with an unknown status persists nothing`. +- Client registration (Builder ID `clientId`/`clientSecret`) is validated non-empty and ≤ 4096 chars + before authorization and again before commit; it lives only in the flow record until commit, then + only in the protected store; `terminal()` erases it from memory. +- Nothing from a flow (device code, client secret, tokens) appears in responses, logs, the debug ring + or errors; responses expose only `flowId`, `method`, `userCode`, `verificationUri`, + `verificationUriComplete` (when present), `expiresAt`, `state`, and `warning`. + +**Store** (`src/oauth/store.ts`): +- `ProviderAccount.loginOrigin?: "kiro-device"` in `src/oauth/types.ts`; `normalizeAccount` keeps it only + when exactly that string; management summaries are allowlists and never include it. +- `appendKiroAccountFromDeviceLogin(credential)`: `normalizeCredential`, append inside `mutateStore` + with a fresh `loginId` and `loginOrigin: "kiro-device"`, active account unchanged unless it is the + first account, returns a receipt for rollback through `finalizeResult`. Always appends (no identity + de-duplication); when a social `profileArn` already exists, the result carries + `warning: "duplicate_profile_arn"`. +- `saveAccountCredential` gains an option `{ rotateLoginId?: boolean }` (default false, so refresh + writers keep `loginId`); only the `runLogin` reauth branch passes `true`. + +**`runLogin`** (`src/oauth/index.ts`): when `provider === "kiro"` and `opts.reauthAccountId` names a +`loginOrigin: "kiro-device"` slot, throw a fixed "remove and re-add" error **before** `def.login` +(line 1629), so no kiro-cli session work begins. The reauth write passes `rotateLoginId: true`. + +**CLI** (`src/cli/account-auth.ts`): `ocx account login kiro --method builder-id|google|github` prints +the user code and verification URI (complete URI when present), polls via the flow, and prints the +duplicate warning; `--method` with `--reauth` is a usage error; `ocx account cancel kiro --flow ` +cancels a native flow. `--method` joins `src/cli/capabilities.ts` (~410); `bun run skill:surface`. + +**Tests** (named; each file registered in both registries at the positions in "wp7 P re-verification"): +`tests/providers/kiro/kiro-device-builder.test.ts` (register/authorize/token happy path; pending, +slow_down, expired; unknown 200 persists nothing; client-string validation; no service profile stored), +`tests/providers/kiro/kiro-device-social.test.ts` (ms units; approval shape; unknown status persists +nothing; duplicate ARN appends with warning), `tests/server/server-kiro-device-login.test.ts` +(method-less start keeps kiro-cli; native only with method; reauth+method 400; unbound reauth 400; +cross-principal status/cancel 404; concurrent starts beyond cap; server-paced polling; secrets absent +from every response; live config reconciled on done; first-account config-save failure rolls back), +`tests/cli/cli-account-kiro-device.test.ts` (method flag, reauth usage error, cancel by flow), +plus in `tests/oauth/oauth-reauth-bind.test.ts` or a sibling: `kiro-cli reauth refuses a native-origin +slot before any CLI work`, `a reauth write rotates loginId and invalidates Kiro evidence`, +`a refresh write keeps loginId`. + +**Verifiers:** as in "wp7 P re-verification" plus `tests/cli/cli-account-cancel-flow.test.ts`. + + +### wp7 FINAL spec amendments (re-audit round 2) + +1. **Session binding — claim narrowed (decision).** Management requests expose only a principal kind + (`"gui-session"` / `"admin-token"`, `src/server/management-auth.ts:578`) and no per-session identity + (`ManagementSessionControl` offers `revokeCurrent`/`isCurrent`/`isPaired`, 276-282). Adding a session + identity seam would change management auth, which is outside this layer. The guarantee is therefore + stated as: *a native flow can be polled or cancelled only by a caller of the same principal kind that + also holds its unguessable 256-bit `flowId`, which is returned only in the start response and never + listed.* Two dashboard sessions of the same local user are the same principal by design. This is + strictly stronger than today's kiro-cli login, whose status and cancel are keyed by provider alone. + Recorded as a follow-up: bind to an admitted-session identity if one is added to management auth. +2. **Cancel/commit fence.** `appendKiroAccountFromDeviceLogin` takes an `assertBeforePersist` callback + that runs **inside** `mutateStore` immediately before the write; the flow passes a check that the flow + is still present, not cancelled, not expired, and owned by the same principal. A poll already in + flight when a cancel lands therefore writes nothing. Test: + `a cancel that lands during an approving poll persists nothing`. +3. **Registration validation = store normalization.** Builder ID `clientId`/`clientSecret` must be + non-empty, at most 4096 characters, free of control characters, and unchanged by trimming — the same + predicate `normalizeCredential` applies (`src/oauth/store.ts:540`), exported from the store as + `isStorableKiroClientPart` so both checks share one definition. Checked after registration and again + before commit. Test: `a registration with a control character or surrounding whitespace is refused + before authorization`. + +## wp7 build notes + +- Implemented the add-only native Kiro device grants for Builder ID, Google, and GitHub behind an explicit management `method`; method-less Kiro login still uses the existing CLI flow. Status and cancellation require a flow ID and matching management principal kind. +- The protected store appends a new native-origin slot with a fresh login ID. Approval is shape-checked before persistence; the commit fence rejects cancellation and expiry under the store lock. Config-publication failure uses the existing ownership receipt for rollback. Explicit reauth rotates login ID; refresh retains it. +- Fixture transports cover approval, pending, slowdown, expiry, unknown replies, registration validation, duplicate ARN warnings, principal isolation, cap reservation, pacing, cancellation during a queued approving write, and config rollback. No provider hosts were contacted. The GUI was unchanged. +- Verification: `bun run typecheck`; combined Kiro, server, CLI, and OAuth tests (687 pass, 0 fail); skill surface generation/check; layout, size-ratchet, Lab-boundary, and skill tests (68 pass, 0 fail); `bun run privacy:scan`; `bun run structure:check`; docs-site build. The two pre-existing OAuth test fixtures were moved to temporary directories because this worktree is inside the protected Codex home. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index d5823c13982..5f0e0e689ec 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -385,6 +385,12 @@ confirmed suspension refusals can rotate before output. Monthly exhaustion exclu that login until reset or evidence expiry; completed service clears an older verdict. Reactive rotation remains available when proactive account preference is off. Accounts are added one at a time — `ocx account login kiro` hands off to the Kiro CLI and appends the new account to the pool. +To add an account without the Kiro CLI, use `ocx account login kiro --method builder-id`, +`--method google`, or `--method github`. Open the printed verification URL, enter the user +code, and wait for approval. `--no-wait` prints the flow ID; cancel it with +`ocx account cancel kiro --flow `. Native login only adds accounts. To reauthenticate +one, remove it and add it again. A repeated social profile ARN creates another slot and prints +`duplicate_profile_arn`; the slots each carry their own quota and load state. Kiro can opt into proactive `least-loaded` placement with `pool.kernel` and account preference enabled. Proactive model preference also requires that account preference be explicitly enabled globally or for Kiro; an unset or false setting leaves a healthy active account in place. Model lists are diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2d5beaed2e5..3f8c566751f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -502,6 +502,7 @@ "clearable-deadline.test.ts": "lib", "cli-account-alias-target.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", + "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", @@ -1078,6 +1079,8 @@ "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", + "kiro-device-builder.test.ts": "providers/kiro", + "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", @@ -1664,6 +1667,7 @@ "server-key-failover-e2e.test.ts": "server", "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", + "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-frame-log.test.ts": "server", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 0dbe43ed790..49f91a2ab0a 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -678,6 +678,25 @@ JSON mode: `payload`. - `show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults. - Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly. +### `ocx account login` + +Log in to an OAuth provider; Kiro can add a native device account. + +| Method | Route | +|---|---| +| POST | `/api/oauth/login` | +| GET | `/api/oauth/status` | + +| Flag | Value | Meaning | +|---|---|---| +| `--method` | string | For Kiro: builder-id, google, or github device login (add only). | +| `--reauth` | boolean | Reauthenticate a selected existing account. | +| `--id` | string | Account id for reauthentication. | +| `--no-wait` | boolean | Return after the login flow starts. | +| `--json` | boolean | Emit flow state as JSON. | + +JSON mode: `payload`. + ### `ocx account main reauth` Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring. @@ -1139,6 +1158,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 64 -- of those, state-changing: 34 +- declared capabilities: 65 +- of those, state-changing: 35 - head-resolved invocations: 2 diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 9a292f6dde4..0ba7cdd1c57 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -34,7 +34,7 @@ function writeStdoutFully(text: string): void { } const USAGE = `Usage: - ocx account login [--id ] [--reauth] [--device] [--code -] [--no-wait] [--json] + ocx account login [--id ] [--reauth] [--device] [--method builder-id|google|github] [--code -] [--no-wait] [--json] ocx account code [--flow ] [--json] (reads the code from stdin) ocx account cancel [--flow ] [--json] (--flow required for codex) ocx account reset-credits [--consume --yes [--operation-id ]] [--json] @@ -72,6 +72,13 @@ interface LoginStart { deviceCode?: string; /** Whether the host actually opened a browser. Absent from older proxies. */ browserLaunch?: "started" | "failed" | "skipped"; + method?: "builder-id" | "google" | "github"; + userCode?: string; + verificationUri?: string; + verificationUriComplete?: string; + expiresAt?: number; + state?: string; + warning?: string; } /** @@ -94,6 +101,14 @@ const STDIN_SENTINEL = "-"; /** Providers whose ONLY login is already a device flow; --device is redundant, not wrong. */ const DEVICE_NATIVE_PROVIDERS = new Set(["kimi", "nous", "github-copilot"]); +const stripTerminalControls = (value: string): string => value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, ""); +export function formatKiroDeviceInstructions(start: { verificationUriComplete?: string; verificationUri?: string; userCode?: string; flowId?: string }): string { + return [ + stripTerminalControls(start.verificationUriComplete ?? start.verificationUri ?? ""), + start.userCode ? `User code: ${stripTerminalControls(start.userCode)}` : "", + start.flowId ? `Flow: ${stripTerminalControls(start.flowId)}` : "", + ].filter(Boolean).join("\n"); +} const ARGV_WARNING = "warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin."; @@ -128,9 +143,17 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { const noWait = takeFlag(args, "--no-wait"); const reauth = takeFlag(args, "--reauth"); const device = takeFlag(args, "--device"); + const method = takeOption(args, "--method"); const id = takeOption(args, "--id"); const suppliedCode = takeOptionWithSyntax(args, "--code"); if (!provider) throw new CliUsageError("provider is required", USAGE); + if (method !== undefined) { + if (provider !== "kiro" || !["builder-id", "google", "github"].includes(method)) { + throw new CliUsageError("--method requires kiro and builder-id, google, or github", USAGE); + } + if (reauth || id) throw new CliUsageError("native Kiro device login only adds accounts; remove and re-add to reauthenticate", USAGE); + if (device || suppliedCode) throw new CliUsageError("--method cannot be combined with --device or --code", USAGE); + } // A bare leftover here is plausibly the authorization code itself: this flow takes one // through --code, and a user who pastes it as a positional would otherwise see it echoed // back in the usage error. `ocx login codex` reaches this parser too, so the paste lands @@ -147,6 +170,30 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { // opens the browser flow and polls, and must not block on stdin. const code = await resolveCode(suppliedCode, deps, false); + if (method) { + const start = await runtimeRequest("/api/oauth/login", { + method: "POST", body: JSON.stringify({ provider: "kiro", method }), + }, deps); + if (!wantsJson) { + const block = formatKiroDeviceInstructions(start); + if (block) writeStdoutFully(`${block}\n`); + } + if (noWait) { printData(start, wantsJson, []); return; } + if (!start.flowId) throw new CliUsageError("Kiro device login did not return a flow id"); + for (let attempt = 0; attempt < 450; attempt++) { + await Bun.sleep(2_000); + const state = await runtimeRequest(`/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(start.flowId)}`, {}, deps); + if (state.state === "done") { + printData(state, wantsJson, ["Logged in to kiro.", ...(state.warning ? [`Warning: ${state.warning}`] : [])]); + return; + } + if (state.state === "failed" || state.state === "expired" || state.state === "cancelled") { + throw new CliUsageError(`Kiro device login ${state.state}`); + } + } + throw new CliUsageError("Kiro device login timed out"); + } + if (CODEX_NAMES.has(provider)) { const start = await runtimeRequest("/api/codex-auth/login", { method: "POST", @@ -290,7 +337,7 @@ async function cancel(argv: string[], deps: RuntimeApiDeps): Promise { } const result = await runtimeRequest(codex ? "/api/codex-auth/login/cancel" : "/api/oauth/login/cancel", { method: "POST", - body: JSON.stringify(codex ? { flowId } : { provider }), + body: JSON.stringify(codex ? { flowId } : { provider, ...(provider === "kiro" && flowId ? { flowId } : {}) }), }, deps); printData(result, wantsJson, [`Cancelled ${provider} login.`]); } diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index a24de20c840..fd269ae8551 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -386,6 +386,23 @@ export const CAPABILITIES: readonly Capability[] = [ "Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.", ], }, + { + command: ["account", "login"], + summary: "Log in to an OAuth provider; Kiro can add a native device account.", + routes: [ + { method: "POST", path: "/api/oauth/login" }, + { method: "GET", path: "/api/oauth/status" }, + ], + flags: [ + { name: "--method", value: "string", summary: "For Kiro: builder-id, google, or github device login (add only)." }, + { name: "--reauth", value: "boolean", summary: "Reauthenticate a selected existing account." }, + { name: "--id", value: "string", summary: "Account id for reauthentication." }, + { name: "--no-wait", value: "boolean", summary: "Return after the login flow starts." }, + { name: "--json", value: "boolean", summary: "Emit flow state as JSON." }, + ], + mutates: true, + json: "payload", + }, { command: ["account", "history"], summary: "Cached quota observations for one stored Codex pool account.", diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3f79ecfa788..2d14dfd5421 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1626,6 +1626,10 @@ export async function runLogin( const loginProviderConfig = preflightConfig ? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig) : def.providerConfig; + if (provider === "kiro" && opts?.reauthAccountId + && getAccountSet("kiro")?.accounts.some(a => a.id === opts.reauthAccountId && a.loginOrigin === "kiro-device")) { + throw new Error("Native Kiro device accounts cannot be reauthenticated with kiro-cli; remove and re-add the account."); + } const rawCred = await def.login(ctrl, opts, loginProviderConfig); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; @@ -1653,6 +1657,7 @@ export async function runLogin( } await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, { assertBeforePersist: deps.assertCurrentOwner, + rotateLoginId: true, }); } else { const saveOptions = { diff --git a/src/oauth/kiro-device-login.ts b/src/oauth/kiro-device-login.ts new file mode 100644 index 00000000000..d57fc1dfbbf --- /dev/null +++ b/src/oauth/kiro-device-login.ts @@ -0,0 +1,312 @@ +/** Native Kiro device grants. All upstream bodies and credentials stay inside this module. */ +import { randomBytes } from "node:crypto"; +import { loadConfig, saveConfig } from "../config"; +import { providerOutboundPost } from "../lib/provider-outbound"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; +import { upsertOAuthProvider } from "./index"; +import { appendKiroAccountFromDeviceLogin, isStorableKiroClientPart, rollbackCredentialWriteIfMatch } from "./store"; +import type { OAuthCredentials } from "./types"; +import type { ManagementPrincipal } from "../server/management-auth"; +import type { OcxConfig } from "../types"; + +export type KiroDeviceMethod = "builder-id" | "google" | "github"; +export type KiroDevicePrincipal = ManagementPrincipal; +type State = "pending" | "done" | "failed" | "expired" | "cancelled"; +export interface KiroDeviceView { + flowId: string; + method: KiroDeviceMethod; + state: State; + userCode?: string; + verificationUri?: string; + verificationUriComplete?: string; + expiresAt?: number; + warning?: "duplicate_profile_arn" | "manual_review_required"; +} +interface Flow { + view: KiroDeviceView; + ownerPrincipal: KiroDevicePrincipal; + deviceCode?: string; + clientId?: string; + clientSecret?: string; + nextPollAt: number; + intervalMs: number; + deadline: number; + polling?: Promise; + configBaseline?: OcxConfig; + commitAccepted?: boolean; + terminalAt?: number; + retentionTimer?: ReturnType; +} +export type KiroDevicePost = (url: string, body: Record) => Promise; +const SOCIAL = "https://prod.us-east-1.auth.desktop.kiro.dev"; +const OIDC = "https://oidc.us-east-1.amazonaws.com"; +const MAX_KIRO_DEVICE_FLOWS = 4; +const MAX_KIRO_DEVICE_ENTRIES = 16; +const TERMINAL_RETENTION_MS = 60_000; +const MAX_LIFETIME_MS = 15 * 60_000; +const PROFILE_ARN = /^arn:[a-z0-9-]+:codewhisperer:[a-z0-9-]+:\d{12}:profile\/[A-Za-z0-9-]+$/; +const USER_CODE = /^[A-Za-z0-9-]{4,32}$/; +/** C0/C1 controls plus bidi and zero-width formatting marks that can disguise a URL. */ +const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/; +const flows = new Map(); + +async function defaultPost(url: string, body: Record): Promise { + const configured = loadConfig().providers.kiro; + return providerOutboundPost("kiro", { + baseUrl: new URL(url).origin, + ...(configured?.proxy ? { proxy: configured.proxy } : {}), + ...(configured?.noProxy ? { noProxy: configured.noProxy } : {}), + ...(configured?.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}), + }, url, { + headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), + }); +} + +let transport: KiroDevicePost = defaultPost; +let clock = () => Date.now(); +function defaultPublishConfig(): void { + const config = loadConfig(); + upsertOAuthProvider(config, "kiro"); + saveConfig(config); +} +let publishConfig: () => void | Promise = defaultPublishConfig; +/** Tests replace the transport; production always uses the guarded outbound path. */ +export function setKiroDevicePostForTests(post?: KiroDevicePost): void { transport = post ?? defaultPost; } +export function setKiroDeviceClockForTests(now?: () => number): void { clock = now ?? (() => Date.now()); } +export function setKiroDevicePublishForTests(publish?: () => void | Promise): void { + publishConfig = publish ?? defaultPublishConfig; +} +export function clearKiroDeviceFlowsForTests(): void { + for (const flow of flows.values()) clearTimeout(flow.retentionTimer); + flows.clear(); transport = defaultPost; setKiroDeviceClockForTests(); setKiroDevicePublishForTests(); +} +export function kiroDeviceFlowCountForTests(): number { return flows.size; } + +function object(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} +async function reply(url: string, body: Record): Promise<{ status: number; data: Record; errorType: string }> { + const response = await transport(url, body); + // Unknown upstream bodies are bounded and never echoed into a response or error. + const { bytes, oversized } = await readBoundedResponseBytes(response, { + maxBytes: BOUNDED_BODY_MAX_BYTES, signal: AbortSignal.timeout(20_000), + }); + if (oversized) throw new Error("Kiro device reply too large"); + let parsed: unknown = {}; + try { parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { /* malformed reply fails closed */ } + const data = object(parsed); + return { status: response.status, data, errorType: response.headers.get("x-amzn-errortype")?.split(":")[0] ?? "" }; +} +function positive(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } +function publicView(flow: Flow): KiroDeviceView { return { ...flow.view }; } +function forgetFlow(id: string): void { + const flow = flows.get(id); + if (flow?.retentionTimer) clearTimeout(flow.retentionTimer); + flows.delete(id); +} +function terminal(flow: Flow, state: State, warning?: KiroDeviceView["warning"]): KiroDeviceView { + flow.view.state = state; + if (flow.terminalAt === undefined) { + flow.terminalAt = clock(); + flow.retentionTimer = setTimeout(() => { + if (flows.get(flow.view.flowId) === flow) forgetFlow(flow.view.flowId); + }, TERMINAL_RETENTION_MS); + flow.retentionTimer.unref(); + } + if (warning) flow.view.warning = warning; + delete flow.deviceCode; + delete flow.clientId; + delete flow.clientSecret; + delete flow.configBaseline; + return publicView(flow); +} +function pruneFlows(): void { + const now = clock(); + for (const [id, flow] of flows) { + if (flow.view.state === "pending" && now >= flow.deadline) terminal(flow, "expired"); + if (flow.terminalAt !== undefined && now - flow.terminalAt >= TERMINAL_RETENTION_MS) forgetFlow(id); + } + while (flows.size >= MAX_KIRO_DEVICE_ENTRIES) { + const oldest = [...flows].find(([, flow]) => flow.view.state !== "pending"); + if (!oldest) break; + forgetFlow(oldest[0]); + } +} +function owned(flowId: string, principal: KiroDevicePrincipal): Flow | undefined { + const flow = flows.get(flowId); + if (!flow || flow.ownerPrincipal !== principal) return undefined; + if (clock() >= flow.deadline) { + terminal(flow, "expired"); + forgetFlow(flowId); + return undefined; + } + return flow; +} +function code(value: unknown): value is string { return typeof value === "string" && value.length > 0; } +function verificationUri(value: unknown): value is string { + if (typeof value !== "string" || value.length > 2048 || CONTROL.test(value)) return false; + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + } catch { return false; } +} +function viewFromAuthorization(flow: Flow, data: Record, scale: number): void { + if (!code(data.deviceCode) || typeof data.userCode !== "string" || !USER_CODE.test(data.userCode) + || !verificationUri(data.verificationUri) + || (data.verificationUriComplete !== undefined && !verificationUri(data.verificationUriComplete))) { + throw new Error("Kiro device authorization failed"); + } + const now = clock(); + const lifetime = positive(data.expiresInMilliseconds) && scale === 1 ? data.expiresInMilliseconds + : positive(data.expiresIn) && scale === 1000 ? data.expiresIn * 1000 : scale === 1 ? 300_000 : 600_000; + const interval = positive(data.intervalInMilliseconds) && scale === 1 ? data.intervalInMilliseconds + : positive(data.interval) && scale === 1000 ? data.interval * 1000 : 5_000; + flow.deviceCode = data.deviceCode; + flow.deadline = Math.min(flow.deadline, now + lifetime); + flow.intervalMs = Math.max(1000, interval); + flow.nextPollAt = now + flow.intervalMs; + flow.view = { + flowId: flow.view.flowId, method: flow.view.method, state: "pending", userCode: data.userCode, + verificationUri: data.verificationUri, + ...(data.verificationUriComplete ? { verificationUriComplete: data.verificationUriComplete } : {}), + expiresAt: flow.deadline, + }; +} + +export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: KiroDevicePrincipal, configBaseline?: OcxConfig): Promise { + // Reserve before the first await, so concurrent starts cannot evade the cap. + pruneFlows(); + if ([...flows.values()].filter(flow => flow.view.state === "pending").length >= MAX_KIRO_DEVICE_FLOWS) { + throw new Error("Too many Kiro device logins in progress"); + } + if (flows.size >= MAX_KIRO_DEVICE_ENTRIES) throw new Error("Too many Kiro device logins in progress"); + const flowId = randomBytes(32).toString("base64url"); + const flow: Flow = { + view: { flowId, method, state: "pending" }, ownerPrincipal: principal, + nextPollAt: 0, intervalMs: 5_000, deadline: clock() + MAX_LIFETIME_MS, + ...(configBaseline ? { configBaseline: structuredClone(configBaseline) } : {}), + }; + flows.set(flowId, flow); + let started = false; + try { + if (method === "builder-id") { + const registration = await reply(`${OIDC}/client/register`, { + clientName: "kiro-cli", clientType: "public", + scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"], + }); + if (registration.status < 200 || registration.status >= 300 || !isStorableKiroClientPart(registration.data.clientId) + || !isStorableKiroClientPart(registration.data.clientSecret)) throw new Error("Kiro registration failed"); + flow.clientId = registration.data.clientId; + flow.clientSecret = registration.data.clientSecret; + const authorization = await reply(`${OIDC}/device_authorization`, { + clientId: flow.clientId, clientSecret: flow.clientSecret, startUrl: "https://view.awsapps.com/start", + }); + if (authorization.status < 200 || authorization.status >= 300) throw new Error("Kiro device authorization failed"); + viewFromAuthorization(flow, authorization.data, 1000); + } else { + const authorization = await reply(`${SOCIAL}/oauth/device/authorization`, { + clientId: "kiro-cli", loginProvider: method === "google" ? "Google" : "Github", + }); + if (authorization.status < 200 || authorization.status >= 300) throw new Error("Kiro device authorization failed"); + viewFromAuthorization(flow, authorization.data, 1); + } + started = true; + return publicView(flow); + } catch { + throw new Error("Kiro device login could not start"); + } finally { + if (!started) { terminal(flow, "failed"); forgetFlow(flowId); } + } +} + +export function kiroDeviceConfigBaseline(flowId: string, principal: KiroDevicePrincipal): OcxConfig | undefined { + return owned(flowId, principal)?.configBaseline; +} + +async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise { + const now = clock(); + flow.nextPollAt = now + flow.intervalMs; + try { + const builder = flow.view.method === "builder-id"; + const result = builder + ? await reply(`${OIDC}/token`, { + clientId: flow.clientId, clientSecret: flow.clientSecret, deviceCode: flow.deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }) + : await reply(`${SOCIAL}/oauth/device/poll`, { clientId: "kiro-cli", deviceCode: flow.deviceCode }); + if (!owned(flow.view.flowId, principal) || flow.view.state !== "pending") return publicView(flow); + const data = result.data; + const error = typeof data.error === "string" ? data.error : result.errorType; + if (builder && result.status === 400) { + if (error === "authorization_pending" || error === "AuthorizationPendingException") return publicView(flow); + if (error === "slow_down" || error === "SlowDownException") { + flow.intervalMs += 5_000; + flow.nextPollAt = clock() + flow.intervalMs; + return publicView(flow); + } + if (error === "expired_token" || error === "ExpiredTokenException") return terminal(flow, "expired"); + } + if (!builder && result.status === 200) { + if (data.status === "authorization_pending") return publicView(flow); + if (data.status === "expired_token" || data.status === "expired") return terminal(flow, "expired"); + } + if (result.status !== 200 || Object.hasOwn(data, "error") || error || (builder && (data.status !== undefined || !positive(data.expiresIn) || !isStorableKiroClientPart(flow.clientId) + || !isStorableKiroClientPart(flow.clientSecret))) + || (!builder && (data.status !== undefined && data.status !== "approved"))) return terminal(flow, "failed"); + if (!code(data.accessToken) || !code(data.refreshToken) + || (!builder && (typeof data.profileArn !== "string" || !PROFILE_ARN.test(data.profileArn)))) return terminal(flow, "failed"); + const credential: OAuthCredentials = { + access: data.accessToken, refresh: data.refreshToken, + expires: clock() + (positive(data.expiresIn) ? data.expiresIn * 1000 : 3600_000), + source: "oauth", + kiro: builder + ? { clientId: flow.clientId, clientSecret: flow.clientSecret, ssoRegion: "us-east-1", apiRegion: "us-east-1" } + : { profileArn: data.profileArn as string, ssoRegion: "us-east-1", apiRegion: "us-east-1" }, + }; + const assertBeforePersist = () => { + if (flows.get(flow.view.flowId) !== flow || flow.ownerPrincipal !== principal + || flow.view.state !== "pending" || clock() >= flow.deadline) throw new Error("Kiro device flow cancelled"); + flow.commitAccepted = true; + }; + const added = await appendKiroAccountFromDeviceLogin(credential, { assertBeforePersist }); + try { + await publishConfig(); + } catch { + try { + if (await rollbackCredentialWriteIfMatch(added.receipt) === "stale") flow.view.warning = "manual_review_required"; + } catch { + flow.view.warning = "manual_review_required"; + } + throw new Error("Kiro account configuration failed"); + } + return terminal(flow, "done", added.warning); + } catch { + return flow.view.state === "cancelled" ? publicView(flow) : terminal(flow, "failed"); + } +} + +export async function statusKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): Promise { + pruneFlows(); + const flow = owned(flowId, principal); + if (!flow) return null; + if (flow.view.state !== "pending") { + forgetFlow(flowId); + return publicView(flow); + } + if (clock() < flow.nextPollAt) return publicView(flow); + if (!flow.polling) flow.polling = poll(flow, principal).finally(() => { flow.polling = undefined; }); + const result = await flow.polling; + if (result.state !== "pending") forgetFlow(flowId); + return result; +} + +export function cancelKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): KiroDeviceView | null { + const flow = owned(flowId, principal); + if (!flow) return null; + if (flow.commitAccepted && flow.view.state === "pending") return { ...flow.view, state: "done" }; + if (flow.view.state === "pending") { + terminal(flow, "cancelled"); + forgetFlow(flowId); + } + return publicView(flow); +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts index a21f8d84a45..1720867c33a 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -511,6 +511,12 @@ function isCredentialSource(value: unknown): value is OAuthCredentialSource { return value === "oauth" || value === "local-cli" || value === "credential-file" || value === "environment" || value === "manual"; } +/** Registration values must survive store normalization unchanged. */ +export function isStorableKiroClientPart(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 4096 + && value === value.trim() && !/[\x00-\x1f\x7f]/.test(value); +} + function normalizeCredential(cred: unknown): OAuthCredentials | null { if (!cred || typeof cred !== "object") return null; const candidate = cred as Partial; @@ -545,8 +551,8 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null { const profileArn = clean(kiro.profileArn, 1024); const ssoRegion = clean(kiro.ssoRegion, 64); const apiRegion = clean(kiro.apiRegion, 64); - const clientId = clean(kiro.clientId, 4096); - const clientSecret = clean(kiro.clientSecret, 4096); + const clientId = isStorableKiroClientPart(kiro.clientId) ? kiro.clientId : undefined; + const clientSecret = isStorableKiroClientPart(kiro.clientSecret) ? kiro.clientSecret : undefined; if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) { normalized.kiro = { ...(profileArn ? { profileArn } : {}), @@ -624,6 +630,7 @@ function normalizeAccount(value: unknown): ProviderAccount | null { && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) { account.loginId = candidate.loginId; } + if (candidate.loginOrigin === "kiro-device") account.loginOrigin = candidate.loginOrigin; return account; } @@ -932,6 +939,38 @@ export async function saveCredential( await saveCredentialWithReceipt(provider, cred, opts); } +/** Native device approval always gets a new slot; no unverified identity can replace one. */ +export async function appendKiroAccountFromDeviceLogin( + cred: OAuthCredentials, + opts: { assertBeforePersist?: () => void } = {}, +): Promise<{ receipt: OAuthCredentialWriteReceipt; warning?: "duplicate_profile_arn" }> { + const safe = normalizeCredential(cred); + if (!safe || !safe.access || !safe.refresh) throw new Error("Invalid Kiro device credential"); + if (safe.kiro?.clientId || safe.kiro?.clientSecret) { + if (!isStorableKiroClientPart(cred.kiro?.clientId) || !isStorableKiroClientPart(cred.kiro?.clientSecret)) { + throw new Error("Invalid Kiro client registration"); + } + } + return await mutateStore(store => { + const set = store.kiro; + const id = distinctAccountId(safe, set?.accounts ?? []); + const warning = safe.kiro?.profileArn && set?.accounts.some(a => a.credential.kiro?.profileArn === safe.kiro?.profileArn) + ? "duplicate_profile_arn" as const : undefined; + const account: ProviderAccount = { id, credential: safe, addedAt: Date.now(), loginId: randomUUID(), loginOrigin: "kiro-device" }; + if (set) set.accounts.push(account); + else store.kiro = { activeAccountId: id, accounts: [account] }; + const receipt: OAuthCredentialWriteReceipt = { + provider: "kiro", accountId: id, credentialGeneration: credentialGeneration(safe), + selectionRevision: store.kiro.selectionRevision, + previousActiveAccountId: set?.activeAccountId, previousAccount: undefined, + }; + return { receipt, ...(warning ? { warning } : {}) }; + }, [safe], { + assertBeforePersist: opts.assertBeforePersist, + finalizeResult: (result, store) => { result.receipt.selectionRevision = store.kiro?.selectionRevision; }, + }); +} + /** Compensate one owned login write without deleting or selecting over concurrent work. */ export async function rollbackCredentialWriteIfMatch( receipt: OAuthCredentialWriteReceipt, @@ -1078,7 +1117,7 @@ export async function saveAccountCredential( provider: string, accountId: string, cred: OAuthCredentials, - opts: { assertBeforePersist?: () => void } = {}, + opts: { assertBeforePersist?: () => void; rotateLoginId?: boolean } = {}, ): Promise { const safe = normalizeCredential(cred); if (!safe) return; @@ -1086,6 +1125,7 @@ export async function saveAccountCredential( const account = store[provider]?.accounts.find(a => a.id === accountId); if (!account) return; account.credential = safe; + if (opts.rotateLoginId) account.loginId = randomUUID(); delete account.needsReauth; }, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist }); } diff --git a/src/oauth/types.ts b/src/oauth/types.ts index d3369fe238a..15f0b84201d 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -81,6 +81,8 @@ export interface ProviderAccount { id: string; /** Rotated on each explicit login and retained across token refreshes. */ loginId?: string; + /** Native Kiro device accounts cannot use the kiro-cli reauth path. */ + loginOrigin?: "kiro-device"; /** User-owned display label; never participates in auth identity or routing. */ alias?: string; credential: OAuthCredentials; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index b3285c9f395..c70f4cff2ef 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -24,6 +24,7 @@ import { submitManualLoginCode, } from "../../oauth"; import { OAuthMutationBusyError, removeCredential } from "../../oauth/store"; +import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { emailMaskingEnabled } from "../../lib/privacy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; @@ -222,7 +223,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // the provider's loopback callback server (inside this process) captures the redirect in the // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status. if (url.pathname === "/api/oauth/login" && req.method === "POST") { - const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown }; + const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown; method?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); // Muse may import a local Keychain credential or start a device grant; add-account @@ -235,6 +236,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); const reauth = body.reauth === true || Boolean(accountId); + if (provider === "kiro") { + if (reauth && !accountId) return jsonResponse({ error: "Kiro reauth requires an accountId" }, 400); + if (body.method !== undefined) { + if (body.method !== "builder-id" && body.method !== "google" && body.method !== "github") { + return jsonResponse({ error: "invalid Kiro device method" }, 400); + } + if (reauth) return jsonResponse({ error: "native_login_is_add_only" }, 400); + try { + return jsonResponse(await startKiroDeviceLogin(body.method as KiroDeviceMethod, principal ?? "admin-token", readConfigDiagnostics().config)); + } catch { + return jsonResponse({ error: "Kiro device login could not start" }, 409); + } + } + } try { if (accountId) { const { getAccountSet } = await import("../../oauth/store"); @@ -287,9 +302,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by // the same public predicate as /api/oauth/login — only publicly startable flows are cancellable. if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") { - const body = await readManagementJsonBodyOr(req, {}) as { provider?: string }; + const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; flowId?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + if (provider === "kiro" && body.flowId !== undefined) { + if (typeof body.flowId !== "string") return jsonResponse({ error: "unknown login flow" }, 404); + const result = cancelKiroDeviceLogin(body.flowId, principal ?? "admin-token"); + return result ? jsonResponse(result) : jsonResponse({ error: "unknown login flow" }, 404); + } const { cancelLoginFlow } = await import("../../oauth"); const cancelled = cancelLoginFlow(provider); return jsonResponse({ ok: true, cancelled }); @@ -315,6 +335,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (url.pathname === "/api/oauth/status" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + if (provider === "kiro" && url.searchParams.has("flowId")) { + const flowId = url.searchParams.get("flowId") ?? ""; + const baseline = kiroDeviceConfigBaseline(flowId, principal ?? "admin-token"); + const status = await statusKiroDeviceLogin(flowId, principal ?? "admin-token"); + if (!status) return jsonResponse({ error: "unknown login flow" }, 404); + if (status.state === "done") { + reconcileLiveConfigFromDisk(config, baseline ?? structuredClone(config)); + reconcileLiveStateStores(); + } + return jsonResponse(status); + } // Resolved here, at the request boundary that already holds the config, and passed down. // getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it // consumes the already-projected status rather than redacting a second time. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5578cfe362c..22eda7916b1 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -54,6 +54,13 @@ unsupported; deployments that previously relied on such embedding must open it a ## Authentication boundaries +Kiro management login starts the native device flow only when `POST /api/oauth/login` +supplies `method: "builder-id"`, `"google"`, or `"github"`. A method-less request retains +the Kiro CLI flow used by the dashboard. Native status and cancellation require `flowId`; +provider-keyed status and cancellation retain their previous behavior. Device-flow responses +contain only the flow handle, method, public verification fields, state, expiry, and a +duplicate-profile warning when applicable. The dashboard has no native device dialog. + OpenCodex uses three mutually exclusive reusable admission credential classes: | Credential class | Sources | Allowed surface | diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index cf3e87099b4..d5e6b2f1d35 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -10,6 +10,20 @@ provider capabilities below; see [Responses compatibility](../transports/respons ## Kiro CLI executable resolution +Native device login is an add-only Kiro account path for Builder ID, Google, and GitHub. +It uses fixed Kiro authorization hosts, guarded outbound POSTs, and a bounded process-local +flow table. A flow ID is returned only at start; status and cancellation require that ID and +the same management principal kind. Polling follows the server's interval, and only an exact +approval shape reaches the protected OAuth store. Device codes and tokens never enter +management responses. Native slots carry `loginOrigin: "kiro-device"`; kiro-cli reauth +refuses them before starting CLI work. Upstream verification URLs require HTTPS, no credentials +or control characters, and a 2048-character limit; user codes use 4–32 plain alphanumeric or +hyphen characters. Completed flow results are consumed once, terminal entries expire after +60 seconds, and the table holds at most 16 entries. Terminal entries retain no config snapshot. +Explicit reauth rotates login identity, while token +refresh preserves it. A first-account config-publication failure compensates the new slot +through the existing receipt ownership check. + Forced and add-account login spawn the local CLI, so `resolveKiroCliExecutable` in `src/oauth/kiro-credentials.ts` decides which file runs with credential-flow arguments. The canonical `kiro-cli` name is tried on `PATH` and then in the platform install locations. Only diff --git a/tests/cli/cli-account-kiro-device.test.ts b/tests/cli/cli-account-kiro-device.test.ts new file mode 100644 index 00000000000..cb33d7e1d0f --- /dev/null +++ b/tests/cli/cli-account-kiro-device.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { formatKiroDeviceInstructions, handleAccountAuthCommand } from "../../src/cli/account-auth"; + +interface Sent { path: string; body?: Record } +async function run(command: "login" | "cancel", args: string[]) { + const sent: Sent[] = []; + const originalLog = console.log; + const originalError = console.error; + const logs: string[] = []; + const errors: string[] = []; + console.log = (...parts) => { logs.push(parts.join(" ")); }; + console.error = (...parts) => { errors.push(parts.join(" ")); }; + try { + const code = await handleAccountAuthCommand(command, args, { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + sent.push({ path: url.pathname, ...(init?.body ? { body: JSON.parse(String(init.body)) as Record } : {}) }); + return new Response(JSON.stringify({ flowId: "flow-1", method: "google", state: "pending", userCode: "ABCD", verificationUri: "https://example.test/verify" }), { + status: 200, headers: { "content-type": "application/json" }, + }); + }) as typeof fetch, + }); + return { code, sent, logs: logs.join("\n"), errors: errors.join("\n") }; + } finally { console.log = originalLog; console.error = originalError; } +} + +describe("Kiro CLI native device login", () => { + test("human-readable instructions contain no terminal control bytes", () => { + const block = formatKiroDeviceInstructions({ + verificationUriComplete: "https://example.test/verify\u001b]0;owned\u0007", + userCode: "ABCD\u001b[31m", flowId: "flow\u009b1m", + }); + expect(block).toContain("User code: ABCD[31m"); + expect(block).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/); + }); + test("--method starts native flow and prints only the public view", async () => { + const result = await run("login", ["kiro", "--method", "google", "--no-wait", "--json"]); + expect(result.code).toBe(0); + expect(result.sent).toEqual([{ path: "/api/oauth/login", body: { provider: "kiro", method: "google" } }]); + expect(result.logs).toContain("flow-1"); + expect(result.logs).not.toMatch(/private-|clientSecret|refreshToken|deviceCode/); + }); + test("--method with --reauth is a usage error without a request", async () => { + const result = await run("login", ["kiro", "--method", "google", "--reauth"]); + expect(result.code).toBe(2); + expect(result.sent).toHaveLength(0); + }); + test("cancel by flow targets native Kiro flow", async () => { + const result = await run("cancel", ["kiro", "--flow", "flow-1", "--json"]); + expect(result.code).toBe(0); + expect(result.sent).toEqual([{ path: "/api/oauth/login/cancel", body: { provider: "kiro", flowId: "flow-1" } }]); + }); +}); diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 5da54eac8af..bda8041f79d 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -249,7 +249,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "GET /api/native-main-profiles/doctor", "GET /api/oauth/accounts", "GET /api/oauth/providers", - "GET /api/oauth/status", "GET /api/provider-context-caps", "GET /api/provider-presets", "GET /api/providers", @@ -298,7 +297,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "POST /api/native-main-profiles/switch", "POST /api/oauth/accounts/clear-cooldown", "POST /api/oauth/accounts/import", - "POST /api/oauth/login", "POST /api/oauth/login/cancel", "POST /api/oauth/login/code", "POST /api/oauth/logout", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index aa22bbd6732..b486050b8ec 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -328,6 +328,7 @@ "clearable-deadline.test.ts": "lib", "cli-account-alias-target.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", + "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", @@ -899,6 +900,8 @@ "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", + "kiro-device-builder.test.ts": "providers/kiro", + "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", @@ -1490,6 +1493,7 @@ "server-key-failover-e2e.test.ts": "server", "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", + "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-frame-log.test.ts": "server", diff --git a/tests/helpers/kiro-device-fixture.ts b/tests/helpers/kiro-device-fixture.ts new file mode 100644 index 00000000000..f0fc594a6ea --- /dev/null +++ b/tests/helpers/kiro-device-fixture.ts @@ -0,0 +1,37 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearKiroDeviceFlowsForTests, setKiroDeviceClockForTests, setKiroDevicePostForTests } from "../../src/oauth/kiro-device-login"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { removeTreeWithRetry } from "./remove-tree"; + +export const profileArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/example"; +export const authorization = { + deviceCode: "private-device-code", userCode: "ABCD-1234", verificationUri: "https://example.test/verify", + expiresIn: 600, interval: 5, +}; +export const socialAuthorization = { + ...authorization, expiresInMilliseconds: 300_000, intervalInMilliseconds: 5_000, +}; +export function response(body: Record, status = 200, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { status, headers }); +} + +export function kiroDeviceFixture() { + const previousHome = process.env.OPENCODEX_HOME; + const dir = mkdtempSync(join(tmpdir(), "ocx-kiro-device-")); + process.env.OPENCODEX_HOME = dir; + let current = 1_000_000; + setKiroDeviceClockForTests(() => current); + return { + setPost(post: (url: string, body: Record) => Promise) { setKiroDevicePostForTests(post); }, + advance(ms: number) { current += ms; }, + async close() { + clearKiroDeviceFlowsForTests(); + await flushConfigDirHardeningForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(dir); + }, + }; +} diff --git a/tests/oauth/oauth-public-surface.test.ts b/tests/oauth/oauth-public-surface.test.ts index c7f1af8bfc9..f665007e3fb 100644 --- a/tests/oauth/oauth-public-surface.test.ts +++ b/tests/oauth/oauth-public-surface.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdirSync} from "node:fs"; +import { mkdtempSync } from "node:fs"; import { join } from "node:path"; +import { tmpdir } from "node:os"; import { cancelLoginFlow, clearLoginState, @@ -30,7 +31,7 @@ import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClau import { isApiAuthRequired, requireApiAuth } from "../../src/server/auth-cors"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); +let TEST_DIR: string; const PUBLIC_OAUTH_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; const previousHome = process.env.OPENCODEX_HOME; const canonical = { @@ -52,8 +53,7 @@ beforeEach(() => { setIcaclsRunnerForTests(() => ICACLS_OK); setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); clearLoginState("xai"); - removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-oauth-public-surface-")); process.env.OPENCODEX_HOME = TEST_DIR; }); diff --git a/tests/oauth/oauth-reauth-bind.test.ts b/tests/oauth/oauth-reauth-bind.test.ts index 27ae71607eb..0dca8a8891a 100644 --- a/tests/oauth/oauth-reauth-bind.test.ts +++ b/tests/oauth/oauth-reauth-bind.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync} from "node:fs"; +import { mkdtempSync } from "node:fs"; import { join } from "node:path"; +import { tmpdir } from "node:os"; import { OAUTH_PROVIDERS, runLogin } from "../../src/oauth"; -import { getAccountCredential, getAccountSet, saveCredential } from "../../src/oauth/store"; +import { appendKiroAccountFromDeviceLogin, getAccountCredential, getAccountSet, saveAccountCredential, saveCredential } from "../../src/oauth/store"; import type { OAuthController, OAuthCredentials } from "../../src/oauth/types"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; @@ -10,7 +11,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; -const TEST_DIR = join(import.meta.dir, ".tmp-oauth-reauth-bind"); +let TEST_DIR: string; const previousHome = process.env.OPENCODEX_HOME; function config(): OcxConfig { @@ -42,8 +43,7 @@ const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; beforeEach(() => { setIcaclsRunnerForTests(() => ICACLS_OK); setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-oauth-reauth-bind-")); process.env.OPENCODEX_HOME = TEST_DIR; }); @@ -105,6 +105,7 @@ describe("OAuth account-scoped reauth", () => { accountId: "acct-a", }); const slotId = getAccountSet("xai")!.activeAccountId; + const loginId = getAccountSet("xai")!.accounts[0]!.loginId; const original = OAUTH_PROVIDERS.xai.login; OAUTH_PROVIDERS.xai.login = async () => ({ access: "a2", @@ -120,6 +121,29 @@ describe("OAuth account-scoped reauth", () => { } expect(getAccountCredential("xai", slotId)?.access).toBe("a2"); expect(getAccountSet("xai")?.accounts).toHaveLength(1); + expect(getAccountSet("xai")?.accounts[0]?.loginId).not.toBe(loginId); + }); + + test("kiro-cli reauth refuses a native-origin slot before any CLI work", async () => { + const cred: OAuthCredentials = { access: "native-access", refresh: "native-refresh", expires: Date.now() + 60_000 }; + await appendKiroAccountFromDeviceLogin(cred); + const slotId = getAccountSet("kiro")!.activeAccountId; + const original = OAUTH_PROVIDERS.kiro.login; + let called = false; + OAUTH_PROVIDERS.kiro.login = async () => { called = true; throw new Error("CLI was started"); }; + try { + await expect(runLogin("kiro", {} as OAuthController, { reauthAccountId: slotId })).rejects.toThrow(/remove and re-add/); + } finally { OAUTH_PROVIDERS.kiro.login = original; } + expect(called).toBe(false); + expect(getAccountSet("kiro")?.accounts[0]?.loginOrigin).toBe("kiro-device"); + }); + + test("a refresh write keeps loginId", async () => { + const cred: OAuthCredentials = { access: "a", refresh: "r", expires: Date.now() + 60_000 }; + await appendKiroAccountFromDeviceLogin(cred); + const before = getAccountSet("kiro")!.accounts[0]!; + await saveAccountCredential("kiro", before.id, { ...cred, access: "refreshed" }); + expect(getAccountSet("kiro")?.accounts[0]?.loginId).toBe(before.loginId); }); test("forced Kiro add-account preserves a legacy identity-less account", async () => { diff --git a/tests/providers/kiro/kiro-device-builder.test.ts b/tests/providers/kiro/kiro-device-builder.test.ts new file mode 100644 index 00000000000..edffef66e76 --- /dev/null +++ b/tests/providers/kiro/kiro-device-builder.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { startKiroDeviceLogin, statusKiroDeviceLogin } from "../../../src/oauth/kiro-device-login"; +import { getAccountSet, isStorableKiroClientPart } from "../../../src/oauth/store"; +import { authorization, kiroDeviceFixture, response } from "../../helpers/kiro-device-fixture"; + +let fixture: ReturnType | undefined; +afterEach(async () => { await fixture?.close(); fixture = undefined; }); + +describe("Kiro Builder ID device grant", () => { + test("register, authorize, pending, slow_down and approve without persisting a service profile", async () => { + fixture = kiroDeviceFixture(); + const paths: string[] = []; + let polls = 0; + fixture.setPost(async (url, body) => { + paths.push(new URL(url).pathname); + if (url.endsWith("/client/register")) return response({ clientId: "client-id", clientSecret: "client-secret" }); + if (url.endsWith("/device_authorization")) { + expect(body.clientId).toBe("client-id"); + return response(authorization); + } + polls++; + if (polls === 1) return response({ error: "authorization_pending" }, 400); + if (polls === 2) return response({ error: "slow_down" }, 400); + return response({ accessToken: "private-access", refreshToken: "private-refresh", expiresIn: 3600 }); + }); + const start = await startKiroDeviceLogin("builder-id", "admin-token"); + expect(JSON.stringify(start)).not.toMatch(/private-device|client-secret/); + expect(start.userCode).toBe("ABCD-1234"); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("pending"); + expect(polls).toBe(0); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("pending"); + expect(polls).toBe(1); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("pending"); + fixture.advance(10_000); + const done = await statusKiroDeviceLogin(start.flowId, "admin-token"); + expect(done?.state).toBe("done"); + expect(JSON.stringify(done)).not.toMatch(/private-access|private-refresh|client-secret|private-device/); + expect(paths).toEqual(["/client/register", "/device_authorization", "/token", "/token", "/token"]); + const account = getAccountSet("kiro")!.accounts[0]!; + expect(account.loginOrigin).toBe("kiro-device"); + expect(account.credential.kiro?.profileArn).toBeUndefined(); + expect(account.credential.kiro?.clientSecret).toBe("client-secret"); + }); + + test("a 200 reply with an unknown status persists nothing", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/client/register") + ? response({ clientId: "id", clientSecret: "secret" }) + : url.endsWith("/device_authorization") ? response(authorization) + : response({ status: "mystery", accessToken: "token", refreshToken: "refresh", expiresIn: 3600 })); + const start = await startKiroDeviceLogin("builder-id", "admin-token"); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("failed"); + expect(getAccountSet("kiro")).toBeNull(); + }); + + test("an expired token error from the AWS header ends the flow", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/client/register") + ? response({ clientId: "id", clientSecret: "secret" }) + : url.endsWith("/device_authorization") ? response(authorization) + : response({}, 400, { "x-amzn-errortype": "ExpiredTokenException:upstream" })); + const start = await startKiroDeviceLogin("builder-id", "admin-token"); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("expired"); + expect(getAccountSet("kiro")).toBeNull(); + }); + + test("registration whitespace or controls are rejected before authorization", async () => { + for (const clientId of [" id", "id\n", "\u0001id"]) { + fixture = kiroDeviceFixture(); + let calls = 0; + fixture.setPost(async () => { calls++; return response({ clientId, clientSecret: "secret" }); }); + await expect(startKiroDeviceLogin("builder-id", "admin-token")).rejects.toThrow("could not start"); + expect(calls).toBe(1); + await fixture.close(); fixture = undefined; + } + expect(isStorableKiroClientPart(" valid ")).toBe(false); + }); + + test("hostile verification fields fail before reaching a public view", async () => { + for (const override of [ + { verificationUri: "https://example.test/verify\u001b]8;;https://evil.test\u0007" }, + { verificationUriComplete: "https://example.test/verify\u009b31m" }, + { verificationUri: "https://user:pass@example.test/verify" }, + { verificationUri: "http://example.test/verify" }, + { verificationUri: `https://example.test/${"a".repeat(2048)}` }, + { userCode: "ABCD\u001b[31m" }, + { verificationUri: "https://example.test/\u202egnp.exe" }, + { verificationUriComplete: "https://example.test/\u200bverify" }, + ]) { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/client/register") + ? response({ clientId: "id", clientSecret: "secret" }) : response({ ...authorization, ...override })); + await expect(startKiroDeviceLogin("builder-id", "admin-token")).rejects.toThrow("could not start"); + expect(getAccountSet("kiro")).toBeNull(); + await fixture.close(); fixture = undefined; + } + }); + + test("a 200 approval with a non-string error persists nothing", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/client/register") + ? response({ clientId: "id", clientSecret: "secret" }) + : url.endsWith("/device_authorization") ? response(authorization) + : response({ accessToken: "token", refreshToken: "refresh", expiresIn: 3600, error: { message: "denied" } })); + const start = await startKiroDeviceLogin("builder-id", "admin-token"); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("failed"); + expect(getAccountSet("kiro")).toBeNull(); + }); +}); diff --git a/tests/providers/kiro/kiro-device-social.test.ts b/tests/providers/kiro/kiro-device-social.test.ts new file mode 100644 index 00000000000..de76006066a --- /dev/null +++ b/tests/providers/kiro/kiro-device-social.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { kiroDeviceConfigBaseline, kiroDeviceFlowCountForTests, startKiroDeviceLogin, statusKiroDeviceLogin } from "../../../src/oauth/kiro-device-login"; +import { getAccountSet } from "../../../src/oauth/store"; +import { kiroDeviceFixture, profileArn, response, socialAuthorization } from "../../helpers/kiro-device-fixture"; +import type { OcxConfig } from "../../../src/types"; + +let fixture: ReturnType | undefined; +afterEach(async () => { await fixture?.close(); fixture = undefined; }); + +describe("Kiro social device grant", () => { + test("uses millisecond authorization timings and appends duplicate profile ARN with warning", async () => { + fixture = kiroDeviceFixture(); + const bodies: Record[] = []; + fixture.setPost(async (url, body) => { + bodies.push(body); + return url.endsWith("/authorization") ? response(socialAuthorization) + : response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn }); + }); + const first = await startKiroDeviceLogin("google", "admin-token"); + expect(first.expiresAt).toBe(1_300_000); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(first.flowId, "admin-token"))?.state).toBe("done"); + const selected = getAccountSet("kiro")!.activeAccountId; + const second = await startKiroDeviceLogin("github", "admin-token"); + fixture.advance(5_000); + const done = await statusKiroDeviceLogin(second.flowId, "admin-token"); + expect(done?.warning).toBe("duplicate_profile_arn"); + expect(getAccountSet("kiro")?.accounts).toHaveLength(2); + expect(getAccountSet("kiro")?.activeAccountId).toBe(selected); + expect(bodies[0]?.loginProvider).toBe("Google"); + expect(bodies[2]?.loginProvider).toBe("Github"); + expect(JSON.stringify(done)).not.toMatch(/private-access|private-refresh|private-device/); + }); + + test("unknown approval status or malformed profile persists nothing", async () => { + for (const approval of [ + { status: "strange", accessToken: "a", refreshToken: "r", profileArn }, + { status: "approved", accessToken: "a", refreshToken: "r", profileArn: "bad" }, + { status: "approved", accessToken: "a", refreshToken: "r", profileArn, error: { message: "denied" } }, + ]) { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) : response(approval)); + const start = await startKiroDeviceLogin("google", "admin-token"); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("failed"); + expect(getAccountSet("kiro")).toBeNull(); + await fixture.close(); fixture = undefined; + } + }); + + test("many completed flows release their entries and config snapshots", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) + : response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn })); + for (let i = 0; i < 40; i++) { + const started = await startKiroDeviceLogin("google", "admin-token", { port: 10100, providers: {} } as OcxConfig); + fixture.advance(5_000); + expect((await statusKiroDeviceLogin(started.flowId, "admin-token"))?.state).toBe("done"); + expect(kiroDeviceConfigBaseline(started.flowId, "admin-token")).toBeUndefined(); + expect(await statusKiroDeviceLogin(started.flowId, "admin-token")).toBeNull(); + expect(kiroDeviceFlowCountForTests()).toBeLessThanOrEqual(16); + } + }); +}); diff --git a/tests/server/server-kiro-device-login.test.ts b/tests/server/server-kiro-device-login.test.ts new file mode 100644 index 00000000000..60f9bd2cff3 --- /dev/null +++ b/tests/server/server-kiro-device-login.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { saveConfig } from "../../src/config"; +import { clearLoginState, OAUTH_PROVIDERS } from "../../src/oauth"; +import { cancelKiroDeviceLogin, setKiroDevicePublishForTests, startKiroDeviceLogin, statusKiroDeviceLogin } from "../../src/oauth/kiro-device-login"; +import { getAccountSet, mutateStore, oauthMutationTailSnapshot } from "../../src/oauth/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { kiroDeviceFixture, profileArn, response, socialAuthorization } from "../helpers/kiro-device-fixture"; + +let fixture: ReturnType | undefined; +afterEach(async () => { await fixture?.close(); fixture = undefined; }); + +function config(): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; +} +async function api(config: OcxConfig, path: string, body?: Record, principal: "gui-session" | "admin-token" = "admin-token") { + const req = new Request(`http://127.0.0.1:10100${path}`, body ? { + method: "POST", headers: { "content-type": "application/json", host: "127.0.0.1:10100" }, body: JSON.stringify(body), + } : { headers: { host: "127.0.0.1:10100" } }); + const result = await handleManagementAPI(req, new URL(req.url), config, {}, principal); + return { status: result?.status, data: await result?.json() as Record }; +} + +describe("native Kiro management login", () => { + test("native only with a valid method; reauth and invalid methods are rejected", async () => { + fixture = kiroDeviceFixture(); + const cfg = config(); saveConfig(cfg); + let calls = 0; + fixture.setPost(async url => { calls++; return response(socialAuthorization); }); + expect((await api(cfg, "/api/oauth/login", { provider: "kiro", method: "other" })).status).toBe(400); + expect((await api(cfg, "/api/oauth/login", { provider: "kiro", reauth: true })).status).toBe(400); + const addOnly = await api(cfg, "/api/oauth/login", { provider: "kiro", method: "google", reauth: true, accountId: "a" }); + expect(addOnly.status).toBe(400); + expect(addOnly.data.error).toBe("native_login_is_add_only"); + const started = await api(cfg, "/api/oauth/login", { provider: "kiro", method: "google" }); + expect(started.status).toBe(200); + expect(started.data.method).toBe("google"); + expect(calls).toBe(1); + }); + + test("a method-less start stays on the kiro-cli login flow", async () => { + fixture = kiroDeviceFixture(); + const cfg = config(); saveConfig(cfg); + fixture.setPost(async () => { throw new Error("native transport must not run"); }); + const original = OAUTH_PROVIDERS.kiro.login; + let invoked = false; + OAUTH_PROVIDERS.kiro.login = async ctrl => { + invoked = true; + ctrl.onAuth?.({ url: "https://example.test/kiro-cli" }); + throw new Error("synthetic CLI stop"); + }; + try { + const started = await api(cfg, "/api/oauth/login", { provider: "kiro", openBrowser: false }); + expect(started.status).toBe(200); + expect(started.data.url).toBe("https://example.test/kiro-cli"); + expect(started.data.flowId).toBeUndefined(); + expect(invoked).toBe(true); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + clearLoginState("kiro"); + } + }); + + test("different principal cannot poll or cancel; responses contain no upstream secrets", async () => { + fixture = kiroDeviceFixture(); + const cfg = config(); saveConfig(cfg); + fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) + : response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn })); + const start = await api(cfg, "/api/oauth/login", { provider: "kiro", method: "google" }, "gui-session"); + const id = String(start.data.flowId); + expect((await api(cfg, `/api/oauth/status?provider=kiro&flowId=${id}`, undefined, "admin-token")).status).toBe(404); + expect((await api(cfg, "/api/oauth/login/cancel", { provider: "kiro", flowId: id }, "admin-token")).status).toBe(404); + fixture.advance(5_000); + const done = await api(cfg, `/api/oauth/status?provider=kiro&flowId=${id}`, undefined, "gui-session"); + expect(done.data.state).toBe("done"); + expect(JSON.stringify(start.data) + JSON.stringify(done.data)).not.toMatch(/private-device|private-access|private-refresh/); + expect(cfg.providers.kiro).toBeDefined(); + const accounts = await api(cfg, "/api/oauth/accounts?provider=kiro", undefined, "gui-session"); + expect(JSON.stringify(accounts.data)).not.toMatch(/loginOrigin|private-access|private-refresh|private-device/); + }); + + test("concurrent starts beyond the cap are refused without upstream calls", async () => { + fixture = kiroDeviceFixture(); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let calls = 0; + fixture.setPost(async () => { calls++; await gate; return response(socialAuthorization); }); + const pending = Array.from({ length: 4 }, () => startKiroDeviceLogin("google", "admin-token")); + await expect(startKiroDeviceLogin("google", "admin-token")).rejects.toThrow("Too many"); + expect(calls).toBe(4); + release(); + await Promise.all(pending); + }); + + test("status is server paced and a cancel during an approving poll persists nothing", async () => { + fixture = kiroDeviceFixture(); + let polls = 0; + let release!: (value: Response) => void; + const gate = new Promise(resolve => { release = resolve; }); + fixture.setPost(async url => { + if (url.endsWith("/authorization")) return response(socialAuthorization); + polls++; + return gate; + }); + const started = await startKiroDeviceLogin("google", "admin-token"); + expect((await statusKiroDeviceLogin(started.flowId, "admin-token"))?.state).toBe("pending"); + expect(polls).toBe(0); + fixture.advance(5_000); + const pending = statusKiroDeviceLogin(started.flowId, "admin-token"); + expect(polls).toBe(1); + expect(cancelKiroDeviceLogin(started.flowId, "admin-token")?.state).toBe("cancelled"); + release(response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn })); + expect((await pending)?.state).toBe("cancelled"); + expect(getAccountSet("kiro")).toBeNull(); + }); + + test("first-account config-save failure rolls back the native slot", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) + : response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn })); + setKiroDevicePublishForTests(() => { throw new Error("private config detail"); }); + const started = await startKiroDeviceLogin("google", "admin-token"); + fixture.advance(5_000); + const status = await statusKiroDeviceLogin(started.flowId, "admin-token"); + expect(status?.state).toBe("failed"); + expect(JSON.stringify(status)).not.toContain("private config detail"); + expect(getAccountSet("kiro")).toBeNull(); + }); + + test("a cancel that lands during an approving poll persists nothing", async () => { + fixture = kiroDeviceFixture(); + fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) + : response({ status: "approved", accessToken: "private-access", refreshToken: "private-refresh", profileArn })); + const started = await startKiroDeviceLogin("google", "admin-token"); + fixture.advance(5_000); + let entered!: () => void; + const occupied = new Promise(resolve => { entered = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const hold = mutateStore(async () => { entered(); await gate; }); + await occupied; + const approving = statusKiroDeviceLogin(started.flowId, "admin-token"); + for (let i = 0; i < 100 && oauthMutationTailSnapshot().active < 2; i++) await Promise.resolve(); + expect(oauthMutationTailSnapshot().active).toBe(2); + expect(cancelKiroDeviceLogin(started.flowId, "admin-token")?.state).toBe("cancelled"); + release(); + await hold; + expect((await approving)?.state).toBe("cancelled"); + expect(getAccountSet("kiro")).toBeNull(); + }); +});