diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1d1384eecc5..95433c224dd 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -448,6 +448,16 @@ token counts may still be estimated, and the credit value does not replace USD c Completion fallback requests add their reported credits. An absent value means Kiro did not report credit usage; an explicit zero means it reported no spend. +### Kiro refresh attention + +If an AWS SSO refresh returns `400 invalid_request` and matching Kiro CLI recovery does not +succeed, the dashboard shows the affected account as needing re-authentication once its access +token expires. The active provider then reports `loggedIn: false` from `/api/oauth/status`. +A still-valid access token remains logged in. You can sign in again through the dashboard; +a later successful automatic refresh also clears the attention state. This status does not +classify the refresh grant as revoked or disable future refresh attempts. Network and server +errors do not trigger this status. + ### Kiro credential import The dashboard offers native Builder ID, Google, and GitHub device login without Kiro CLI. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5a6f6c0f21f..5d07b21409e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1060,7 +1060,7 @@ "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", - "kiro-oauth.test.ts": "providers/kiro", + "kiro-oauth.test.ts": "providers/kiro", "oauth-kiro-refresh-attention.test.ts": "oauth", "kiro-pool-load-settings.test.ts": "providers/kiro", "kiro-pool-rank.test.ts": "providers/kiro", "kiro-reasoning-roundtrip.test.ts": "providers/kiro", diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 44242165570..b054d1e04ad 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -10,7 +10,7 @@ import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability" import { maskAccountId } from "../lib/privacy"; import { findLiveProxy } from "../server/proxy-liveness"; import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; -import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; +import { accountNeedsReauthForStatus, loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; import type { ProviderAccount } from "./types"; export type OAuthAccountHealth = @@ -210,9 +210,10 @@ export function projectStoredOAuthAccountHealth( const anthropicSnap = provider === "anthropic" ? getAnthropicAccountHealthSnapshot(account.id, now) : null; + const needsReauth = accountNeedsReauthForStatus(provider, account, now); return projectOAuthAccountHealth({ - needsReauth: account.needsReauth === true, - reauthReason: account.needsReauth === true + needsReauth, + reauthReason: needsReauth ? (account.needsReauthReason ?? "refresh_failed") : undefined, cooldownUntilMs: anthropicSnap?.cooldownUntil, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 112caffe3d8..63e165ad897 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -16,7 +16,7 @@ import { getAccountCredentialWithStatus, getAccountSet, getCredential, - markAccountNeedsReauthIfGeneration, + markAccountNeedsReauthIfGeneration, accountNeedsReauthForStatus, markAccountRefreshAttentionIfGeneration, markOAuthRefreshIntentCleanupPending, markOAuthRefreshIntentStaleOwner, mergeAccountCredential, @@ -1116,6 +1116,10 @@ export async function refreshGenericAccountWithLock( return fresh.access; } catch (error) { if (error instanceof OAuthMutationBusyError) throw error; + if (provider === "kiro" && error instanceof KiroTokenRefreshError && error.refreshAttention) { + await markAccountRefreshAttentionIfGeneration(provider, accountId, generation, writerGeneration); + throw error; + } if (!terminal(error)) throw error; // Nous-specific failure-atomicity: a terminal refresh error that carries // an already-issued rotated refresh token (e.g. the access JWT lacked the @@ -1840,7 +1844,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: ...(a.alias ? { alias: a.alias } : {}), email: projectEmail(a.credential.email, maskEmails) ?? undefined, active: a.id === set.activeAccountId, - ...(a.needsReauth ? { needsReauth: true } : {}), + ...(accountNeedsReauthForStatus(provider, a) ? { needsReauth: true } : {}), ...(a.needsReauth && a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}), expiresAt: a.credential.expires, // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider @@ -1852,11 +1856,11 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: // A stored credential counts as "logged in" when it exists and is not marked for // re-authentication. An expired access token with a valid refresh token is still - // logged in: request resolution refreshes expired/near-expiry credentials lazily. + // logged in unless refresh-attention evidence applies; refresh eligibility stays internal. // Invalid/unknown local-import expiries are handled at parse/adoption time // (local-token-detect.ts), never by over-reporting login state here. - const activeNeedsReauth = set?.accounts - .find(a => a.id === set.activeAccountId)?.needsReauth === true; + const activeAccount = set?.accounts.find(a => a.id === set.activeAccountId); + const activeNeedsReauth = activeAccount && accountNeedsReauthForStatus(provider, activeAccount); return { loggedIn: !!cred && !activeNeedsReauth, email: projectEmail(cred?.email, maskEmails) ?? undefined, diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index aaef19c246d..16c3a89cd07 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -60,6 +60,7 @@ export class KiroTokenRefreshError extends Error { constructor( readonly httpStatus: number, readonly oauthError?: string, + readonly refreshAttention = false, ) { super(`Kiro token refresh failed: ${httpStatus}${oauthError ? ` (${oauthError})` : ""}`); this.name = "KiroTokenRefreshError"; @@ -530,17 +531,21 @@ export function resolveKiroRequestProfile( : { profileArn: undefined, builderIdFallback: false }; } -async function kiroTokenRefreshError(response: Response): Promise { +async function kiroTokenRefreshError(response: Response, awsSsoRefresh = false): Promise { let oauthError: string | undefined; + let refreshAttention = false; try { const payload = await response.json() as { error?: unknown }; + // This input-error code is attention evidence only for our AWS SSO refresh request, + // never a terminal grant verdict. Do not surface arbitrary upstream descriptions. + refreshAttention = awsSsoRefresh && response.status === 400 && payload.error === "invalid_request"; if (typeof payload.error === "string" && KIRO_TERMINAL_REFRESH_ERRORS.has(payload.error)) { oauthError = payload.error; } } catch { // Error bodies are untrusted and intentionally excluded from the surfaced message. } - return new KiroTokenRefreshError(response.status, oauthError); + return new KiroTokenRefreshError(response.status, oauthError, refreshAttention); } async function readTokenResponse(res: Response, oldRefresh: string): Promise { @@ -608,7 +613,7 @@ async function refreshAwsSsoOidcToken( signal: kiroRefreshSignal(signal), }); const res = await run(refresh); - if (!res.ok) throw await kiroTokenRefreshError(res); + if (!res.ok) throw await kiroTokenRefreshError(res, true); return readTokenResponse(res, refresh); } @@ -668,11 +673,19 @@ export async function refreshKiroToken( const local = matchingRotatedKiroCliCredential(refresh, credential); if (!local) throw error; const retryMetadata = metadataForRotatedKiroCliCredential(credential, local); - const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, { - ...credential, - refresh: local.refresh, - kiro: retryMetadata, - }); - return { ...fresh, kiro: retryMetadata }; + try { + const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, { + ...credential, + refresh: local.refresh, + kiro: retryMetadata, + }); + return { ...fresh, kiro: retryMetadata }; + } catch (recoveryError) { + // A failed CLI fallback may use the desktop endpoint. Retain the original AWS SSO + // attention evidence on another input rejection; transient recovery stays retryable. + if (error.refreshAttention && recoveryError instanceof KiroTokenRefreshError + && recoveryError.httpStatus === 400 && recoveryError.oauthError === undefined) throw error; + throw recoveryError; + } } } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 86d7c10242e..53de4364221 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -630,6 +630,9 @@ function normalizeAccount(value: unknown): ProviderAccount | null { const account: ProviderAccount = { id: candidate.id, credential }; if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim(); if (candidate.needsReauth === true) account.needsReauth = true; + if (candidate.refreshAttentionGeneration === credentialGeneration(credential)) { + account.refreshAttentionGeneration = candidate.refreshAttentionGeneration; + } if (candidate.paused === true) account.paused = true; const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride); if (threshold !== null) account.autoSwitchThresholdOverride = threshold; @@ -892,6 +895,7 @@ export async function saveCredentialWithReceipt( set.selectionRevision = previousSelectionRevision; delete existing.needsReauth; delete existing.needsReauthReason; + delete existing.refreshAttentionGeneration; accountId = existing.id; } else { const id = distinctAccountId(safe, set.accounts); @@ -905,6 +909,7 @@ export async function saveCredentialWithReceipt( existing.credential = safe; delete existing.needsReauth; delete existing.needsReauthReason; + delete existing.refreshAttentionGeneration; if (existing.paused !== true) set.activeAccountId = existing.id; accountId = existing.id; } else { @@ -917,6 +922,7 @@ export async function saveCredentialWithReceipt( active.credential = safe; delete active.needsReauth; delete active.needsReauthReason; + delete active.refreshAttentionGeneration; accountId = active.id; } else { const id = distinctAccountId(safe, set.accounts); @@ -937,6 +943,7 @@ export async function saveCredentialWithReceipt( active.credential = safe; delete active.needsReauth; delete active.needsReauthReason; + delete active.refreshAttentionGeneration; accountId = active.id; } else { const id = distinctAccountId(safe, set.accounts); @@ -1086,6 +1093,7 @@ export async function upsertCredentialByIdentity( existing.credential = safe; delete existing.needsReauth; delete existing.needsReauthReason; + delete existing.refreshAttentionGeneration; set.activeAccountId ??= existing.id; return "updated"; } @@ -1189,6 +1197,7 @@ export async function saveAccountCredential( if (opts.rotateLoginId) account.loginId = randomUUID(); delete account.needsReauth; delete account.needsReauthReason; + delete account.refreshAttentionGeneration; // A pause that found no usable fallback leaves the active id on a paused row. Once this // reauthentication makes an unpaused account usable, hand the selection to it. const active = set.accounts.find(a => a.id === set.activeAccountId); @@ -1403,6 +1412,7 @@ export async function replaceProviderAccountSet( credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, ...(account.alias ? { alias: account.alias } : {}), ...(account.needsReauth ? { needsReauth: true } : {}), + ...(account.refreshAttentionGeneration ? { refreshAttentionGeneration: account.refreshAttentionGeneration } : {}), ...(account.paused ? { paused: true } : {}), ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}), ...(account.needsReauthReason === "verify_account" ? { needsReauthReason: account.needsReauthReason } : {}), @@ -1468,7 +1478,28 @@ export async function markAccountNeedsReauth( }, [provider, accountId]); } -export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);} +export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);} // A late refresh failure must not change an operator-paused account's health. Check under // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten. export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account"):Promise{const key=oauthAccountKey(provider,accountId);if(writerGeneration{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration { + if (provider !== "kiro") return false; + const key = oauthAccountKey(provider, accountId); + return await mutateStore(store => { + const account = store[provider]?.accounts.find(a => a.id === accountId); + if (!account || account.paused || credentialGeneration(account.credential) !== generation) return false; + if (writerGeneration < lastReconciledGeneration && !liveOAuthAccountKeys.has(key)) return false; + account.refreshAttentionGeneration = generation; + return true; + }, [provider, accountId, generation]); +} diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 67d4cf8e79d..9f9f06852d4 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -90,6 +90,8 @@ export interface ProviderAccount { credential: OAuthCredentials; /** Terminal refresh failure (invalid_grant / reused / revoked) — re-login required. */ needsReauth?: boolean; + /** Kiro SSO refresh input-error evidence; status-only attention after this generation expires. */ + refreshAttentionGeneration?: string; /** Operator exclusion from generic OAuth account selection until explicitly resumed. */ paused?: boolean; /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */ diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3bd23a59d0a..b4ea40bd203 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -114,6 +114,19 @@ wire type for a known field or a varint longer than ten bytes, keep last-good; a with nothing measurable is authoritative-empty. Only Devin's credential host extends its quota cache identity; generic OAuth pause still suppresses per-account probes. +Kiro AWS SSO token refresh in `src/oauth/kiro.ts` retains HTTP 400 `invalid_request` as +refresh-attention evidence separate from terminal grant errors. Matching rotated CLI credentials +are tried before the failure reaches `src/oauth/index.ts`. `src/oauth/store.ts` persists the +failed credential generation under its mutation lock, respecting replacement, config reconciliation, +and operator pause. Once that generation's access token expires, account summaries project +`needsReauth: true` and the active login projects `loggedIn: false`. `src/oauth/health.ts` uses +the same generation/expiry predicate with its supplied observation time, so account-list health +and CLI diagnostics project `reauth_required` with `refresh_failed`; `ocx doctor` warns with a +login action. The internal terminal flag +stays clear so a later refresh remains eligible; successful refresh or credential replacement +clears the evidence. Desktop input errors, network failures, and 5xx responses create no evidence. +No upstream description or credential metadata enters the status response. + Kiro's account quota cache persists quota and an optional exhaustion verdict under one opaque account key and a non-secret login identity. Hydration admits only matching live accounts and bounds quota and verdict independently by reset and ten-minute TTL; a failed diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 700841acd0a..1a8c6da6d34 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -629,7 +629,7 @@ "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", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", - "kiro-oauth.test.ts": "providers/kiro", "kiro-pool-load-settings.test.ts": "providers/kiro", + "kiro-oauth.test.ts": "providers/kiro", "oauth-kiro-refresh-attention.test.ts": "oauth", "kiro-pool-load-settings.test.ts": "providers/kiro", "kiro-pool-rank.test.ts": "providers/kiro", "kiro-reasoning-roundtrip.test.ts": "providers/kiro", "kiro-refusal-failover.test.ts": "providers/kiro", "kiro-refusal.test.ts": "providers/kiro", "kiro-remote-image.test.ts": "providers/kiro", "kiro-retry.test.ts": "providers/kiro", diff --git a/tests/oauth/oauth-kiro-refresh-attention.test.ts b/tests/oauth/oauth-kiro-refresh-attention.test.ts new file mode 100644 index 00000000000..fa0e17d42d7 --- /dev/null +++ b/tests/oauth/oauth-kiro-refresh-attention.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getLoginStatus, getValidAccessTokenForAccount, OAUTH_PROVIDERS, refreshGenericAccountWithLock } from "../../src/oauth"; +import { credentialGeneration, getAccountSet, saveAccountCredential, saveCredential, setAccountPaused } from "../../src/oauth/store"; +import type { OAuthCredentials } from "../../src/oauth/types"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { collectOAuthHealthEntriesForCli, projectStoredOAuthAccountHealth } from "../../src/oauth/health"; +import { collectOAuthDoctorChecks } from "../../src/cli/doctor"; + +const envKeys = ["OPENCODEX_HOME", "HOME", "USERPROFILE", "LOCALAPPDATA", "KIROCLI_DB_PATH", "KIRO_CLI_DB_FILE", "KIROCLI_TOKEN_KEY", "KIRO_ACCESS_TOKEN", "KIRO_CREDS_FILE", "KIRO_CREDENTIALS_FILE", "KIRO_REGION", "KIRO_PROFILE_ARN"] as const; +const profileArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/test"; +const rejection = { error: "invalid_request", error_description: "Invalid request", location: null, reason: null }; +let savedEnv: Record; +let home: string; +let originalFetch: typeof fetch; +let stored: OAuthCredentials; +let accountId: string; + +beforeEach(async () => { + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + home = mkdtempSync(join(tmpdir(), "kiro-attention-")); + for (const key of envKeys) delete process.env[key]; + process.env.OPENCODEX_HOME = home; + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.LOCALAPPDATA = home; + process.env.KIROCLI_DB_PATH = join(home, "cli.sqlite3"); + originalFetch = globalThis.fetch; + stored = { access: "test-access", refresh: "test-refresh", expires: Date.now() - 60_000, accountId: profileArn, + kiro: { profileArn, ssoRegion: "us-east-1", clientId: "test-client", clientSecret: "test-secret", authType: "aws_sso_oidc" } }; + await saveCredential("kiro", stored); + accountId = getAccountSet("kiro")!.activeAccountId; +}); +afterEach(async () => { + globalThis.fetch = originalFetch; + await flushConfigDirHardeningForTests(); + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + removeTreeWithRetry(home); +}); + +function rejectRefresh(status = 400, payload: unknown = rejection): void { + globalThis.fetch = (async () => Response.json(payload, { status })) as typeof fetch; +} +function refresh(): Promise { + return refreshGenericAccountWithLock("kiro", accountId, OAUTH_PROVIDERS.kiro, stored); +} +function seedCli(identity = profileArn, registered = true): void { + const db = new Database(process.env.KIROCLI_DB_PATH!); + db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO auth_kv VALUES (?, ?)", ["kirocli:odic:token", JSON.stringify({ + access_token: "test-cli-access", refresh_token: "test-cli-refresh", profile_arn: identity, region: "us-east-1", expires_at: "2099-01-01T00:00:00Z", + })]); + if (registered) db.run("INSERT INTO auth_kv VALUES (?, ?)", ["kirocli:odic:device-registration", JSON.stringify({ clientId: "cli-client", clientSecret: "cli-secret", region: "us-east-1" })]); + db.close(); +} +function assertAttention(): void { + expect(getLoginStatus("kiro").loggedIn).toBe(false); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauthReason).toBeUndefined(); + // Status attention must never quarantine the credential from internal refresh. + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBeUndefined(); +} + +describe("Kiro AWS SSO refresh attention", () => { + test("exact refresh rejection projects needsReauth after expiry and persists across restart", async () => { + const requests: unknown[] = []; + globalThis.fetch = (async (input, init) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return Response.json(rejection, { status: 400 }); + }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(requests).toEqual([{ url: "https://oidc.us-east-1.amazonaws.com/token", body: { + grantType: "refresh_token", clientId: "test-client", clientSecret: "test-secret", refreshToken: "test-refresh", + } }]); + assertAttention(); + const auth = JSON.parse(readFileSync(join(home, "auth.json"), "utf8")); + expect(auth.kiro.accounts[0].refreshAttentionGeneration).toBe(credentialGeneration(stored)); + const restart = Bun.spawnSync([process.execPath, "-e", `import {getLoginStatus} from ${JSON.stringify(join(import.meta.dir, "../../src/oauth/index.ts"))}; console.log(JSON.stringify(getLoginStatus("kiro")));`], { env: process.env }); + expect(restart.exitCode).toBe(0); + const status = JSON.parse(restart.stdout.toString()); + expect(status.loggedIn).toBe(false); + expect(status.accounts[0].needsReauth).toBe(true); + }); + + test("still-valid access token stays logged in, then projects attention at expiry", async () => { + stored = { ...stored, expires: Date.now() + 60_000 }; + await saveAccountCredential("kiro", accountId, stored); + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + const originalNow = Date.now; + Date.now = () => stored.expires + 1; + try { assertAttention(); } finally { Date.now = originalNow; } + }); + + test("desktop malformed-request rejection does not set attention or quarantine", async () => { + stored = { ...stored, kiro: { profileArn, authType: "kiro_desktop" } }; + await saveAccountCredential("kiro", accountId, stored); + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBeUndefined(); + }); + + for (const [label, status, payload] of [ + ["5xx", 503, rejection], ["different input error", 400, { error: "invalid_client" }], + ["unallowlisted error text", 400, { error: "invalid_request arbitrary-secret-text" }], + ] as const) { + test(`${label} does not flag attention`, async () => { + rejectRefresh(status, payload); + await expect(refresh()).rejects.toThrow(); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + }); + } + test("network failure does not flag attention", async () => { + globalThis.fetch = (async () => { throw new Error("network unavailable"); }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + }); + + test("matching rotated CLI recovery succeeds before recording attention", async () => { + seedCli(); + const tokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = JSON.parse(String(init?.body)).refreshToken; + tokens.push(token); + return token === stored.refresh ? Response.json(rejection, { status: 400 }) + : Response.json({ accessToken: "recovered-access", refreshToken: "recovered-refresh", expiresIn: 3600 }); + }) as typeof fetch; + expect(await refresh()).toBe("recovered-access"); + expect(tokens).toEqual(["test-refresh", "test-cli-refresh"]); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + }); + + test("failed matching CLI recovery records attention only after both attempts", async () => { + seedCli(); + let calls = 0; + globalThis.fetch = (async () => { calls++; return Response.json(rejection, { status: 400 }); }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(calls).toBe(2); + assertAttention(); + }); + test("failed matching desktop CLI recovery preserves the original SSO evidence", async () => { + seedCli(profileArn, false); + const urls: string[] = []; + globalThis.fetch = (async input => { urls.push(String(input)); return Response.json(rejection, { status: 400 }); }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(urls).toEqual(["https://oidc.us-east-1.amazonaws.com/token", "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"]); + assertAttention(); + }); + test("transient matching CLI recovery failure does not flag attention", async () => { + seedCli(); + let calls = 0; + globalThis.fetch = (async () => Response.json(rejection, { status: ++calls === 1 ? 400 : 503 })) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(calls).toBe(2); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + }); + test("terminal grant rejection retains the existing internal quarantine", async () => { + rejectRefresh(400, { error: "invalid_grant" }); + await expect(refresh()).rejects.toThrow(); + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBe(true); + expect(getLoginStatus("kiro").loggedIn).toBe(false); + }); + + test("unrelated CLI account is never adopted", async () => { + seedCli("arn:aws:codewhisperer:us-east-1:123456789012:profile/other"); + let calls = 0; + globalThis.fetch = (async () => { calls++; return Response.json(rejection, { status: 400 }); }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(calls).toBe(1); + assertAttention(); + }); + + test("late rejection cannot mark a replacement credential", async () => { + let dispatch!: () => void; + const dispatched = new Promise(resolve => { dispatch = resolve; }); + let reject!: (response: Response) => void; + globalThis.fetch = (() => { dispatch(); return new Promise(resolve => { reject = resolve; }); }) as typeof fetch; + const attempt = refresh(); + await dispatched; + await saveAccountCredential("kiro", accountId, { ...stored, access: "replacement-access", refresh: "replacement-refresh", expires: Date.now() + 3600_000 }); + reject(Response.json(rejection, { status: 400 })); + await expect(attempt).rejects.toThrow(); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).kiro.accounts[0].refreshAttentionGeneration).toBeUndefined(); + }); + + test("successful subsequent refresh stays eligible and clears durable evidence", async () => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + assertAttention(); + globalThis.fetch = (async () => Response.json({ accessToken: "recovered-access", refreshToken: "recovered-refresh", expiresIn: 3600 })) as typeof fetch; + expect(await getValidAccessTokenForAccount("kiro", accountId)).toBe("recovered-access"); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).kiro.accounts[0].refreshAttentionGeneration).toBeUndefined(); + }); + test("explicit replacement clears evidence even for identical tokens", async () => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + assertAttention(); + await saveCredential("kiro", stored); + expect(getLoginStatus("kiro").loggedIn).toBe(true); + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).kiro.accounts[0].refreshAttentionGeneration).toBeUndefined(); + }); + test("operator pause committed during refresh is preserved", async () => { + globalThis.fetch = (async () => { + await setAccountPaused("kiro", accountId, true); + return Response.json(rejection, { status: 400 }); + }) as typeof fetch; + await expect(refresh()).rejects.toThrow(); + expect(getAccountSet("kiro")!.accounts[0]!.paused).toBe(true); + expect(getLoginStatus("kiro").accounts?.[0]?.needsReauth).toBeUndefined(); + }); + + test("canonical health honors the supplied time at the attention expiry boundary", async () => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + const account = getAccountSet("kiro")!.accounts[0]!; + expect(projectStoredOAuthAccountHealth("kiro", account, stored.expires - 1, { observeOnly: true })) + .toEqual({ status: "healthy" }); + expect(projectStoredOAuthAccountHealth("kiro", account, stored.expires, { observeOnly: true })) + .toEqual({ status: "reauth_required", reason: "refresh_failed" }); + expect(account.needsReauth).toBeUndefined(); + }); + + test("canonical health ignores stale attention generations and other providers", async () => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + const account = getAccountSet("kiro")!.accounts[0]!; + expect(projectStoredOAuthAccountHealth("kiro", { + ...account, credential: { ...account.credential, access: "replacement-access" }, + }, stored.expires, { observeOnly: true })).toEqual({ status: "healthy" }); + expect(projectStoredOAuthAccountHealth("xai", account, stored.expires, { observeOnly: true })) + .toEqual({ status: "healthy" }); + }); + + test("collected CLI health and doctor warn at attention expiry without mutating quarantine", async () => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + const authPath = join(home, "auth.json"); + const before = readFileSync(authPath, "utf8"); + const deps = { findLiveProxyImpl: async () => null }; + const valid = await collectOAuthHealthEntriesForCli(stored.expires - 1, deps); + expect(valid.entries.find(entry => entry.provider === "kiro")?.health).toEqual({ status: "healthy" }); + const validChecks = await collectOAuthDoctorChecks(stored.expires - 1, deps); + expect(validChecks.some(check => check.level === "WARN" && check.message.includes("kiro"))).toBe(false); + const expired = await collectOAuthHealthEntriesForCli(stored.expires, deps); + expect(expired.entries.find(entry => entry.provider === "kiro")).toEqual({ + provider: "kiro", accountId, health: { status: "reauth_required", reason: "refresh_failed" }, + action: "run `ocx login kiro`", + }); + const checks = await collectOAuthDoctorChecks(stored.expires, deps); + const warning = checks.find(check => check.level === "WARN" && check.message.includes("kiro")); + expect(warning?.message).toContain("requires reauthentication"); + expect(warning?.message).toContain("ocx login kiro"); + expect(warning?.message).not.toContain(accountId); + for (const secret of ["test-access", "test-refresh", "test-secret", "refreshAttentionGeneration", credentialGeneration(stored)]) { + expect(JSON.stringify({ expired, checks })).not.toContain(secret); + } + expect(readFileSync(authPath, "utf8")).toBe(before); + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBeUndefined(); + }); + + test.each(["status", "accounts"])("management %s API exposes attention without internal evidence or secrets", async endpoint => { + rejectRefresh(); + await expect(refresh()).rejects.toThrow(); + const request = new ManagementRequest(`http://localhost/api/oauth/${endpoint}?provider=kiro`); + const config = { port: 0, defaultProvider: "kiro", providers: {} } as OcxConfig; + const response = await handleManagementAPI(request, new URL(request.url), config); + expect(response?.status).toBe(200); + const body = await response!.json(); + if (endpoint === "status") expect(body.loggedIn).toBe(false); + expect(body.accounts[0].needsReauth).toBe(true); + if (endpoint === "accounts") { + expect(body.accounts[0].health).toEqual({ status: "reauth_required", reason: "refresh_failed" }); + expect(body.accounts[0].healthLabel).toBe("Refresh failed"); + expect(body.accounts[0].healthAction).toBe("run `ocx login kiro`"); + expect(body.accounts[0].healthSummary).toContain("reauthentication required"); + } + for (const value of ["test-access", "test-refresh", "test-secret", "test-client", "refreshAttentionGeneration", credentialGeneration(stored)]) { + expect(JSON.stringify(body)).not.toContain(value); + } + }); +});