diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index 42ecff1340f..9d1ea73237a 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -45,6 +45,10 @@ const KIRO_TERMINAL_REFRESH_ERRORS = new Set([ "access_denied", "expired_token", ]); +// The desktop refresh endpoint rejects a dead refresh token with HTTP 401 and a bare +// `{"message":"Bad credentials"}` body, without an OAuth `error` code. Only that exact +// pair is a terminal verdict; it is surfaced under this fixed code, never the raw text. +const KIRO_DESKTOP_BAD_CREDENTIALS = "bad_credentials"; interface ImportedKiroToken { access: string; @@ -545,12 +549,14 @@ async function kiroTokenRefreshError(response: Response, awsSsoRefresh = false): let oauthError: string | undefined; let refreshAttention = false; try { - const payload = await response.json() as { error?: unknown }; + const payload = await response.json() as { error?: unknown; message?: 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; + } else if (!awsSsoRefresh && response.status === 401 && payload.message === "Bad credentials") { + oauthError = KIRO_DESKTOP_BAD_CREDENTIALS; } } catch { // Error bodies are untrusted and intentionally excluded from the surfaced message. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7d3aa123b26..c7c9665547a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -140,7 +140,7 @@ are tried before the failure reaches `src/oauth/index.ts`. `src/oauth/store.ts` 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. +clears the evidence. Desktop input errors, network failures, and 5xx responses create no evidence. No upstream description or credential metadata enters the status response. The desktop refresh endpoint answers a dead refresh token with HTTP 401 and a bare `{"message":"Bad credentials"}` body; only that exact pair on the desktop endpoint is a terminal grant refusal, surfaced as the fixed `bad_credentials` code and quarantined like an allowlisted OAuth error. 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 probe keeps the same-login last-good display bar. The protected OAuth store rotates diff --git a/tests/oauth/oauth-kiro-refresh-attention.test.ts b/tests/oauth/oauth-kiro-refresh-attention.test.ts index fa0e17d42d7..c76dfb9cfb0 100644 --- a/tests/oauth/oauth-kiro-refresh-attention.test.ts +++ b/tests/oauth/oauth-kiro-refresh-attention.test.ts @@ -3,7 +3,7 @@ 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 { getLoginStatus, getValidAccessTokenForAccount, OAuthLoginRequiredError, 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"; @@ -114,6 +114,55 @@ describe("Kiro AWS SSO refresh attention", () => { expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBeUndefined(); }); + test("desktop 401 Bad credentials quarantines the rejected generation", async () => { + stored = { ...stored, kiro: { profileArn, authType: "kiro_desktop" } }; + await saveAccountCredential("kiro", accountId, stored); + const urls: string[] = []; + globalThis.fetch = (async input => { + urls.push(String(input)); + return Response.json({ message: "Bad credentials" }, { status: 401 }); + }) as typeof fetch; + const error = await refresh().then(() => undefined, reason => reason); + expect(error).toBeInstanceOf(OAuthLoginRequiredError); + expect(urls).toEqual(["https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"]); + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBe(true); + expect(getLoginStatus("kiro").loggedIn).toBe(false); + }); + + test("desktop Bad credentials during matching CLI recovery quarantines the account", async () => { + seedCli(profileArn, false); + const urls: string[] = []; + globalThis.fetch = (async input => { + const url = String(input); + urls.push(url); + return url.endsWith("/refreshToken") + ? Response.json({ message: "Bad credentials" }, { status: 401 }) + : Response.json(rejection, { status: 400 }); + }) as typeof fetch; + const error = await refresh().then(() => undefined, reason => reason); + expect(urls).toEqual(["https://oidc.us-east-1.amazonaws.com/token", "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"]); + expect(error).toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountSet("kiro")!.accounts[0]!.needsReauth).toBe(true); + }); + + for (const [label, status, payload, authType] of [ + ["desktop 401 with another message", 401, { message: "Unauthorized" }, "kiro_desktop"], + ["desktop 400 Bad credentials", 400, { message: "Bad credentials" }, "kiro_desktop"], + ["AWS SSO 401 Bad credentials", 401, { message: "Bad credentials" }, "aws_sso_oidc"], + ] as const) { + test(`${label} does not quarantine`, async () => { + if (authType === "kiro_desktop") { + stored = { ...stored, kiro: { profileArn, authType } }; + await saveAccountCredential("kiro", accountId, stored); + } + rejectRefresh(status, payload); + const error = await refresh().then(() => undefined, reason => reason); + expect(error).toBeDefined(); + expect(error).not.toBeInstanceOf(OAuthLoginRequiredError); + 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" }],