From baa37d4f9fd931d36cf34b42b9b16a2e2efd9ee7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 27 Sep 2026 05:43:48 +0900 Subject: [PATCH 1/3] feat(kiro): native device login for Builder ID, Google and GitHub ocx account login kiro --method builder-id|google|github (and the management API with an explicit method) runs the device authorization flow inside the proxy and adds the approved account without touching the kiro-cli session. Native login is add-only: flows are bound to the starting principal and an unguessable, unlisted flow id; starts reserve their slot before any upstream call; polling is server-paced; only an exact approved reply is persisted, through a store-owned normalizing append with a fresh loginId and an in-write cancel fence; client registration is validated like the store validates it. Native-origin accounts refuse kiro-cli re-login (remove and re-add), and a re-login write now rotates loginId. The method-less login and the dashboard keep the existing kiro-cli path. --- .../060_native_device_login.md | 146 ++++++++++ .../docs/reference/cli/providers-accounts.md | 6 + scripts/test-layout/layout.json | 4 + .../ocx/references/01_management_surface.md | 23 +- src/cli/account-auth.ts | 47 +++- src/cli/capabilities.ts | 17 ++ src/oauth/index.ts | 5 + src/oauth/kiro-device-login.ts | 257 ++++++++++++++++++ src/oauth/store.ts | 46 +++- src/oauth/types.ts | 2 + src/server/management/oauth-account-routes.ts | 33 ++- structure/gui-and-management-api.md | 7 + structure/providers/kiro.md | 10 + tests/cli/cli-account-kiro-device.test.ts | 46 ++++ tests/cli/cli-capabilities.test.ts | 2 - tests/fixtures/test-layout-expected.json | 4 + tests/helpers/kiro-device-fixture.ts | 37 +++ tests/oauth/oauth-public-surface.test.ts | 8 +- tests/oauth/oauth-reauth-bind.test.ts | 34 ++- .../kiro/kiro-device-builder.test.ts | 82 ++++++ .../providers/kiro/kiro-device-social.test.ts | 48 ++++ tests/server/server-kiro-device-login.test.ts | 151 ++++++++++ 22 files changed, 995 insertions(+), 20 deletions(-) create mode 100644 src/oauth/kiro-device-login.ts create mode 100644 tests/cli/cli-account-kiro-device.test.ts create mode 100644 tests/helpers/kiro-device-fixture.ts create mode 100644 tests/providers/kiro/kiro-device-builder.test.ts create mode 100644 tests/providers/kiro/kiro-device-social.test.ts create mode 100644 tests/server/server-kiro-device-login.test.ts 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..b909ceab0c3 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; } /** @@ -128,9 +135,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 +162,34 @@ 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 = [ + start.verificationUriComplete ?? start.verificationUri ?? "", + start.userCode ? `User code: ${start.userCode}` : "", + start.flowId ? `Flow: ${start.flowId}` : "", + ].filter(Boolean).join("\n"); + 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 +333,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..4fc4a6fbc7a --- /dev/null +++ b/src/oauth/kiro-device-login.ts @@ -0,0 +1,257 @@ +/** 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; +} +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_LIFETIME_MS = 15 * 60_000; +const PROFILE_ARN = /^arn:[a-z0-9-]+:codewhisperer:[a-z0-9-]+:\d{12}:profile\/[A-Za-z0-9-]+$/; +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 { flows.clear(); transport = defaultPost; setKiroDeviceClockForTests(); setKiroDevicePublishForTests(); } + +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 terminal(flow: Flow, state: State, warning?: KiroDeviceView["warning"]): KiroDeviceView { + flow.view.state = state; + if (warning) flow.view.warning = warning; + delete flow.deviceCode; + delete flow.clientId; + delete flow.clientSecret; + return publicView(flow); +} +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"); + flows.delete(flowId); + return undefined; + } + return flow; +} +function code(value: unknown): value is string { return typeof value === "string" && value.length > 0; } +function viewFromAuthorization(flow: Flow, data: Record, scale: number): void { + if (!code(data.deviceCode) || !code(data.userCode) || !code(data.verificationUri)) 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, + ...(code(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. + for (const [id, flow] of flows) if (clock() >= flow.deadline) { terminal(flow, "expired"); flows.delete(id); } + if ([...flows.values()].filter(flow => flow.view.state === "pending").length >= MAX_KIRO_DEVICE_FLOWS) { + 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"); flows.delete(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 || 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 { + const flow = owned(flowId, principal); + if (!flow) return null; + if (flow.view.state !== "pending" || clock() < flow.nextPollAt) return publicView(flow); + if (!flow.polling) flow.polling = poll(flow, principal).finally(() => { flow.polling = undefined; }); + return await flow.polling; +} + +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"); + flows.delete(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..30423a00998 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,15 @@ 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 status = await statusKiroDeviceLogin(url.searchParams.get("flowId") ?? "", principal ?? "admin-token"); + if (!status) return jsonResponse({ error: "unknown login flow" }, 404); + if (status.state === "done") { + reconcileLiveConfigFromDisk(config, kiroDeviceConfigBaseline(status.flowId, principal ?? "admin-token") ?? 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..bd8a8123257 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -10,6 +10,16 @@ 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. 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..3e5d8124319 --- /dev/null +++ b/tests/cli/cli-account-kiro-device.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { 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("--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..57bd12e33db --- /dev/null +++ b/tests/providers/kiro/kiro-device-builder.test.ts @@ -0,0 +1,82 @@ +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); + }); +}); 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..10228b96f3c --- /dev/null +++ b/tests/providers/kiro/kiro-device-social.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { startKiroDeviceLogin, statusKiroDeviceLogin } from "../../../src/oauth/kiro-device-login"; +import { getAccountSet } from "../../../src/oauth/store"; +import { kiroDeviceFixture, profileArn, response, socialAuthorization } from "../../helpers/kiro-device-fixture"; + +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" }, + ]) { + 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; + } + }); +}); 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(); + }); +}); From 46f62455a99c2edb7c2bb23514e79276feb6e4d3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 27 Sep 2026 05:51:40 +0900 Subject: [PATCH 2/3] fix(kiro): validate device-login display fields, bound the flow table, reject error approvals Verification URIs must be plain https without control characters or credentials and user codes a strict charset, or the flow fails; the CLI also strips terminal controls before printing. Terminal flows are consumed once, expire after 60 s and the table holds at most 16 entries. An approval reply carrying any error key persists nothing. --- src/cli/account-auth.ts | 14 ++-- src/oauth/kiro-device-login.ts | 74 ++++++++++++++++--- src/server/management/oauth-account-routes.ts | 6 +- structure/providers/kiro.md | 6 +- tests/cli/cli-account-kiro-device.test.ts | 10 ++- .../kiro/kiro-device-builder.test.ts | 30 ++++++++ .../providers/kiro/kiro-device-social.test.ts | 18 ++++- 7 files changed, 138 insertions(+), 20 deletions(-) diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index b909ceab0c3..fdfc9f25be7 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -101,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]/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."; @@ -167,11 +175,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { method: "POST", body: JSON.stringify({ provider: "kiro", method }), }, deps); if (!wantsJson) { - const block = [ - start.verificationUriComplete ?? start.verificationUri ?? "", - start.userCode ? `User code: ${start.userCode}` : "", - start.flowId ? `Flow: ${start.flowId}` : "", - ].filter(Boolean).join("\n"); + const block = formatKiroDeviceInstructions(start); if (block) writeStdoutFully(`${block}\n`); } if (noWait) { printData(start, wantsJson, []); return; } diff --git a/src/oauth/kiro-device-login.ts b/src/oauth/kiro-device-login.ts index 4fc4a6fbc7a..f69569c56b5 100644 --- a/src/oauth/kiro-device-login.ts +++ b/src/oauth/kiro-device-login.ts @@ -34,13 +34,19 @@ interface Flow { 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}$/; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/; const flows = new Map(); async function defaultPost(url: string, body: Record): Promise { @@ -69,7 +75,11 @@ export function setKiroDeviceClockForTests(now?: () => number): void { clock = n export function setKiroDevicePublishForTests(publish?: () => void | Promise): void { publishConfig = publish ?? defaultPublishConfig; } -export function clearKiroDeviceFlowsForTests(): void { flows.clear(); transport = defaultPost; setKiroDeviceClockForTests(); setKiroDevicePublishForTests(); } +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 : {}; @@ -88,27 +98,63 @@ async function reply(url: string, body: Record): Promise<{ stat } 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"); - flows.delete(flowId); + 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) || !code(data.userCode) || !code(data.verificationUri)) throw new Error("Kiro device authorization failed"); + 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; @@ -121,17 +167,18 @@ function viewFromAuthorization(flow: Flow, data: Record, scale: flow.view = { flowId: flow.view.flowId, method: flow.view.method, state: "pending", userCode: data.userCode, verificationUri: data.verificationUri, - ...(code(data.verificationUriComplete) ? { verificationUriComplete: data.verificationUriComplete } : {}), + ...(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. - for (const [id, flow] of flows) if (clock() >= flow.deadline) { terminal(flow, "expired"); flows.delete(id); } + 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, @@ -167,7 +214,7 @@ export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: } catch { throw new Error("Kiro device login could not start"); } finally { - if (!started) { terminal(flow, "failed"); flows.delete(flowId); } + if (!started) { terminal(flow, "failed"); forgetFlow(flowId); } } } @@ -202,7 +249,7 @@ async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise { + pruneFlows(); const flow = owned(flowId, principal); if (!flow) return null; - if (flow.view.state !== "pending" || clock() < flow.nextPollAt) return publicView(flow); + 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; }); - return await flow.polling; + const result = await flow.polling; + if (result.state !== "pending") forgetFlow(flowId); + return result; } export function cancelKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): KiroDeviceView | null { @@ -251,7 +305,7 @@ export function cancelKiroDeviceLogin(flowId: string, principal: KiroDevicePrinc if (flow.commitAccepted && flow.view.state === "pending") return { ...flow.view, state: "done" }; if (flow.view.state === "pending") { terminal(flow, "cancelled"); - flows.delete(flowId); + forgetFlow(flowId); } return publicView(flow); } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 30423a00998..c70f4cff2ef 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -336,10 +336,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< 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 status = await statusKiroDeviceLogin(url.searchParams.get("flowId") ?? "", principal ?? "admin-token"); + 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, kiroDeviceConfigBaseline(status.flowId, principal ?? "admin-token") ?? structuredClone(config)); + reconcileLiveConfigFromDisk(config, baseline ?? structuredClone(config)); reconcileLiveStateStores(); } return jsonResponse(status); diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index bd8a8123257..d5e6b2f1d35 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -16,7 +16,11 @@ flow table. A flow ID is returned only at start; status and cancellation require 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. Explicit reauth rotates login identity, while token +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. diff --git a/tests/cli/cli-account-kiro-device.test.ts b/tests/cli/cli-account-kiro-device.test.ts index 3e5d8124319..cb33d7e1d0f 100644 --- a/tests/cli/cli-account-kiro-device.test.ts +++ b/tests/cli/cli-account-kiro-device.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { handleAccountAuthCommand } from "../../src/cli/account-auth"; +import { formatKiroDeviceInstructions, handleAccountAuthCommand } from "../../src/cli/account-auth"; interface Sent { path: string; body?: Record } async function run(command: "login" | "cancel", args: string[]) { @@ -26,6 +26,14 @@ async function run(command: "login" | "cancel", args: string[]) { } 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); diff --git a/tests/providers/kiro/kiro-device-builder.test.ts b/tests/providers/kiro/kiro-device-builder.test.ts index 57bd12e33db..b90cabdf8e7 100644 --- a/tests/providers/kiro/kiro-device-builder.test.ts +++ b/tests/providers/kiro/kiro-device-builder.test.ts @@ -79,4 +79,34 @@ describe("Kiro Builder ID device grant", () => { } 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" }, + ]) { + 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 index 10228b96f3c..de76006066a 100644 --- a/tests/providers/kiro/kiro-device-social.test.ts +++ b/tests/providers/kiro/kiro-device-social.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { startKiroDeviceLogin, statusKiroDeviceLogin } from "../../../src/oauth/kiro-device-login"; +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; }); @@ -35,6 +36,7 @@ describe("Kiro social device grant", () => { 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)); @@ -45,4 +47,18 @@ describe("Kiro social device grant", () => { 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); + } + }); }); From c110892d7fc239709171a976088272bca56c1cfe Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 27 Sep 2026 05:53:05 +0900 Subject: [PATCH 3/3] fix(kiro): refuse bidi and zero-width marks in device-login display fields --- src/cli/account-auth.ts | 2 +- src/oauth/kiro-device-login.ts | 3 ++- tests/providers/kiro/kiro-device-builder.test.ts | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index fdfc9f25be7..0ba7cdd1c57 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -101,7 +101,7 @@ 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]/g, ""); +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 ?? ""), diff --git a/src/oauth/kiro-device-login.ts b/src/oauth/kiro-device-login.ts index f69569c56b5..d57fc1dfbbf 100644 --- a/src/oauth/kiro-device-login.ts +++ b/src/oauth/kiro-device-login.ts @@ -46,7 +46,8 @@ 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}$/; -const CONTROL = /[\u0000-\u001f\u007f-\u009f]/; +/** 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 { diff --git a/tests/providers/kiro/kiro-device-builder.test.ts b/tests/providers/kiro/kiro-device-builder.test.ts index b90cabdf8e7..edffef66e76 100644 --- a/tests/providers/kiro/kiro-device-builder.test.ts +++ b/tests/providers/kiro/kiro-device-builder.test.ts @@ -88,6 +88,8 @@ describe("Kiro Builder ID device grant", () => { { 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")