Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ a later successful automatic refresh also clears the attention state. This statu
classify the refresh grant as revoked or disable future refresh attempts. Network and server
errors do not trigger this status.

For desktop-auth accounts, an HTTP 401 with exactly `Bad credentials` requires a new login
and immediately marks the rejected credential generation as needing re-authentication.
Other messages, statuses, and AWS SSO responses do not trigger this desktop-specific rule.

### Kiro credential import

The dashboard offers native Builder ID, Google, and GitHub device login without Kiro CLI.
Expand Down Expand Up @@ -512,7 +516,7 @@ an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redir
from the live CLI store, or when an existing primary CLI database has no recognized token row.
Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import
selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected.
The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success.
The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success. Google and GitHub approval completes when Kiro reports `authorized` (or the legacy `approved` status) with valid tokens and a profile ARN.
The account list marks Kiro accounts excluded from automatic selection with a reason, when available.

## 3. API-key catalog
Expand Down
3 changes: 2 additions & 1 deletion src/oauth/kiro-device-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,10 @@ async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise<KiroDev
if (data.status === "authorization_pending") return publicView(flow);
if (data.status === "expired_token" || data.status === "expired") return terminal(flow, "expired");
}
// The social poll reports a completed grant as status "authorized"; "approved" is kept as an alias.
if (result.status !== 200 || Object.hasOwn(data, "error") || error || (builder && (data.status !== undefined || !positive(data.expiresIn) || !isStorableKiroClientPart(flow.clientId)
|| !isStorableKiroClientPart(flow.clientSecret)))
|| (!builder && (data.status !== undefined && data.status !== "approved"))) return terminal(flow, "failed");
|| (!builder && (data.status !== undefined && data.status !== "authorized" && 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 = {
Expand Down
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
2 changes: 1 addition & 1 deletion structure/providers/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ It uses fixed Kiro authorization hosts, guarded outbound POSTs, and a bounded pr
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
management responses. Social polling accepts `authorized` and the legacy `approved` alias, with the same token and profile checks. Native slots carry `loginOrigin: "kiro-device"`; kiro-cli reauth
refuses them before starting CLI work. Upstream verification URLs require HTTPS, no credentials
or control characters, and a 2048-character limit; user codes use 4–32 plain alphanumeric or
hyphen characters. Completed flow results are consumed once, terminal entries expire after
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
21 changes: 21 additions & 0 deletions tests/providers/kiro/kiro-device-social.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ describe("Kiro social device grant", () => {
expect(JSON.stringify(done)).not.toMatch(/private-access|private-refresh|private-device/);
});

test("accepts the authorized status the social poll returns after a pending reply", async () => {
fixture = kiroDeviceFixture();
const polls = [
{ status: "authorization_pending", accessToken: null, refreshToken: null, profileArn: null },
{ status: "authorized", identityProvider: "Google", accessToken: "private-access", refreshToken: "private-refresh", profileArn },
];
fixture.setPost(async url => url.endsWith("/authorization") ? response(socialAuthorization) : response(polls.shift()!));
const start = await startKiroDeviceLogin("google", "admin-token");
fixture.advance(5_000);
expect((await statusKiroDeviceLogin(start.flowId, "admin-token"))?.state).toBe("pending");
expect(getAccountSet("kiro")).toBeNull();
fixture.advance(5_000);
const done = await statusKiroDeviceLogin(start.flowId, "admin-token");
expect(done?.state).toBe("done");
expect(polls).toHaveLength(0);
const account = getAccountSet("kiro")?.accounts[0];
expect(account?.credential.refresh).toBe("private-refresh");
expect(account?.credential.kiro?.profileArn).toBe(profileArn);
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 },
Expand Down
Loading