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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/oauth/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion tests/oauth/oauth-kiro-refresh-attention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" }],
Expand Down
Loading