diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index eaf93b6b53..87f7a28b3e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -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. @@ -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 diff --git a/src/oauth/kiro-device-login.ts b/src/oauth/kiro-device-login.ts index d57fc1dfbb..9db5f28c29 100644 --- a/src/oauth/kiro-device-login.ts +++ b/src/oauth/kiro-device-login.ts @@ -250,9 +250,10 @@ async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise { 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" }], diff --git a/tests/providers/kiro/kiro-device-social.test.ts b/tests/providers/kiro/kiro-device-social.test.ts index de76006066..b035ae92fb 100644 --- a/tests/providers/kiro/kiro-device-social.test.ts +++ b/tests/providers/kiro/kiro-device-social.test.ts @@ -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 },