Files
OmniRoute/tests/unit/kiro-windows-auto-import-3363.test.ts
NOXX - Commiter ece4bf7b53 feat(kiro): support enterprise External IdP (Your organization) logins (#6363)
* feat(kiro): support enterprise External IdP ("Your organization") logins

Kiro's enterprise "Your organization" sign-in federates through the org's own
identity provider (e.g. Microsoft Entra ID) and produces an `external_idp`
token that is fundamentally different from AWS Builder ID / IAM Identity Center
(AWS SSO-OIDC, refresh token starts with `aorAAAAAG`) and the Google/GitHub
social flow. Its `~/.aws/sso/cache/kiro-auth-token.json` carries an org-IdP JWT
access token, an IdP refresh token, a per-tenant `tokenEndpoint`, a public
`clientId` (no secret) and `scopes` (`codewhisperer:conversations …`).

Before this change every import path rejected these tokens (the
`aorAAAAAG` format gate + no client secret), and the runtime/quota calls would
have failed even if imported, so organization accounts could not be used.

This adds full external_idp support:

- New `open-sse/services/kiroExternalIdp.ts`: public-client refresh_token grant
  builder (`buildExternalIdpRefreshParams`), a token-endpoint SSRF allowlist
  (`validateExternalIdpTokenEndpoint` — Microsoft/Okta/Auth0/OneLogin/Ping/
  Google/Cognito, https only), scope normalization, JWT identity extraction
  (`preferred_username`/`upn`/`email`), and the `TokenType: EXTERNAL_IDP`
  header constants.
- Runtime executor (`open-sse/executors/kiro.ts`): send
  `TokenType: EXTERNAL_IDP` for external_idp accounts. CodeWhisperer only binds
  the org-IdP bearer to the Amazon Q Developer profile with this header;
  without it every call returns `ValidationException: Invalid ARN <clientId>`.
- Runtime + import token refresh (`open-sse/services/tokenRefresh.ts`,
  `src/lib/oauth/services/kiro.ts`): refresh external_idp tokens with a
  form-encoded public-client `refresh_token` grant against the org IdP's
  `tokenEndpoint` instead of AWS OIDC / the Kiro social endpoint.
- Quota (`open-sse/services/usage/kiro.ts`): send the same header on
  `GetUsageLimits` so organization quota resolves.
- Import routes: `POST /api/oauth/kiro/import` gains an external_idp branch
  (skips the `aorAAAAAG` gate, refreshes via the org IdP, stores
  clientId/tokenEndpoint/scope/region/profileArn); `GET /auto-import` now
  recognizes external_idp tokens in `~/.aws/sso/cache`, reads the profile ARN
  from the Kiro IDE `profile.json` (org tokens can't enumerate it via
  `ListAvailableProfiles`), and persists the connection. The profile.json
  reader is factored into a shared `readKiroIdeProfileArn()` helper.
- Validation schema (`kiroImportSchema`): accept `tokenEndpoint` + `scopes`.

Tests: new `tests/unit/kiro-external-idp.test.ts` (endpoint allowlist, scope
normalization, identity extraction, public-client refresh body, the org IdP
refresh path, and the `TokenType: EXTERNAL_IDP` header gating). Also hardens
`kiro-windows-auto-import-3363.test.ts` to isolate `USERPROFILE` (Windows
`os.homedir()` reads it, not `HOME`) so the probe never reads a real on-host
Kiro login.

* fix(changelog): restore #6363 bullet after release resync (CHANGELOG-eat guard)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): re-restore #6363 bullet after release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(merge): restore #6126 clinepass files reverted by release auto-resolve + rebaseline own tokenRefresh growth

The release sync's merge auto-resolve silently reverted sibling PR #6126's
clinepass work (registry entry, catalog, oauth constants, clineAuth.ts, the
clinepass token-refresh case, and its tests) — all outside this PR's Kiro
external-IdP scope. Restored every affected file to the release version; the
remaining diff is Kiro-IdP-only. Rebaselined tokenRefresh.ts 2182->2249 (+67,
this PR's own external_idp refresh branch) with justification, and restored
the #6126 CHANGELOG bullet (re-inserting only this PR's own).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
2026-07-09 21:39:09 -03:00

202 lines
7.7 KiB
TypeScript

/**
* Regression guard for #3363 — Kiro auto-import failed on Windows because
* tryKiroCliSqlite() only probed the Linux/macOS path
* (~/.local/share/kiro-cli/data.sqlite3) and never checked the Kiro IDE
* path that Windows users have: %APPDATA%\kiro\storage.db
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// @ts-ignore — better-sqlite3 has no bundled types in this project
import Database from "better-sqlite3";
// Set DATA_DIR before importing any app modules so isAuthRequired() reads from
// a fresh, empty settings DB (no password → requireLogin defaults to false).
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3363-data-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
// Import the GET handler at the module level so the DB is initialised once
// before any test runs.
const { GET } = await import("../../src/app/api/oauth/kiro/auto-import/route.ts");
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
const ORIGINAL_APPDATA = process.env.APPDATA;
const ORIGINAL_FETCH = globalThis.fetch;
let tmpHome: string;
test.beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3363-"));
// Reset DB instance so each test gets a clean settings DB (no requireLogin).
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
// Override HOME so homedir() returns a temp dir where no kiro-cli DB exists.
process.env.HOME = tmpHome;
// On Windows os.homedir() reads USERPROFILE (not HOME), so isolate it too —
// otherwise the probe reads the real ~/.aws/sso/cache and can find an actual
// (e.g. external_idp organization) Kiro login on the test host.
process.env.USERPROFILE = tmpHome;
// Ensure APPDATA is unset by default; individual tests that need it set it.
delete process.env.APPDATA;
});
test.afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE !== undefined) {
process.env.USERPROFILE = ORIGINAL_USERPROFILE;
} else {
delete process.env.USERPROFILE;
}
if (ORIGINAL_APPDATA !== undefined) {
process.env.APPDATA = ORIGINAL_APPDATA;
} else {
delete process.env.APPDATA;
}
globalThis.fetch = ORIGINAL_FETCH;
if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true });
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// Helper to call the GET handler and parse the JSON body.
async function callGet(): Promise<{ status: number; body: Record<string, unknown> }> {
const request = new Request("http://localhost/api/oauth/kiro/auto-import");
const response = await GET(request);
const body = (await response.json()) as Record<string, unknown>;
return { status: response.status, body };
}
test("triedPaths includes the Windows APPDATA path when process.env.APPDATA is set", async () => {
// Simulate a Windows environment with %APPDATA% pointing to a temp dir.
// The storage.db file does not exist there, so the probe fails gracefully.
process.env.APPDATA = tmpHome;
const { body } = await callGet();
assert.equal(body.found, false, "credentials must not be found when both DB files are absent");
assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array");
const expectedWindowsPath = path.join(tmpHome, "kiro", "storage.db");
assert.ok(
(body.triedPaths as string[]).includes(expectedWindowsPath),
`triedPaths must include the Windows APPDATA path ${expectedWindowsPath}, got: ${JSON.stringify(body.triedPaths)}`
);
});
test("triedPaths does NOT include any Windows path when process.env.APPDATA is not set", async () => {
// APPDATA is already unset by beforeEach.
const { body } = await callGet();
assert.equal(body.found, false, "credentials must not be found when DB file is absent");
assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array");
const paths = body.triedPaths as string[];
// No path should reference "kiro/storage.db" (the Windows IDE storage path).
const hasWindowsPath = paths.some(
(p) => p.includes("storage.db") && p.includes("kiro")
);
assert.equal(
hasWindowsPath,
false,
`triedPaths must not include any Windows kiro/storage.db path when APPDATA is unset, got: ${JSON.stringify(paths)}`
);
});
test("triedPaths always includes the Linux/macOS kiro-cli path", async () => {
const { body } = await callGet();
assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array");
const expectedLinuxPath = path.join(tmpHome, ".local/share/kiro-cli/data.sqlite3");
assert.ok(
(body.triedPaths as string[]).includes(expectedLinuxPath),
`triedPaths must always include the Linux/macOS kiro-cli path ${expectedLinuxPath}, got: ${JSON.stringify(body.triedPaths)}`
);
});
// ── Synthetic SQLite test: exercises the actual Windows schema-reading path ──
/**
* Creates a minimal storage.db in the Windows Kiro IDE schema:
* - Table: ItemTable (key TEXT, value TEXT)
* - Row: key="kiro:auth:token", value=JSON with refresh_token starting with "aorAAAAAG"
*
* Then sets APPDATA to the directory containing kiro/storage.db, calls GET,
* and asserts found:true with the correct refreshToken.
*/
test("GET extracts refresh_token from a Windows storage.db with ItemTable schema", async () => {
// Build the Windows storage.db directory structure: <tmpHome>/kiro/storage.db
const kiroDir = path.join(tmpHome, "kiro");
fs.mkdirSync(kiroDir, { recursive: true });
const dbPath = path.join(kiroDir, "storage.db");
// Populate the SQLite file using the Kiro IDE Windows schema.
const db = new Database(dbPath);
db.exec(`
CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`);
const tokenValue = JSON.stringify({
refresh_token: "aorAAAAAGsynthetic-refresh-token",
access_token: "access-synthetic",
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
region: "us-east-1",
});
db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)").run(
"kiro:auth:token",
tokenValue
);
db.close();
// Point APPDATA at tmpHome so tryKiroCliSqlite() resolves
// join(process.env.APPDATA, "kiro", "storage.db") to dbPath.
process.env.APPDATA = tmpHome;
// Mock fetch so the saveAndRespond path (kiroService.refreshToken()) succeeds
// without hitting the real Kiro OIDC endpoint.
globalThis.fetch = (async (url: string | URL | Request) => {
const u = String(url);
// registerClient() call — returns a new OIDC client registration.
if (u.includes("oidc.") && u.endsWith("/client/register")) {
return new Response(
JSON.stringify({ clientId: "reg-cid", clientSecret: "reg-secret", expiresIn: 86400 }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
// refreshToken() call — simulates Kiro OIDC token refresh.
if (u.includes("oidc.") && u.endsWith("/token")) {
return new Response(
JSON.stringify({
accessToken: "access-refreshed",
refreshToken: "aorAAAAAGrefreshed-token",
expiresIn: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
throw new Error(`[kiro-3363 test] unexpected fetch to ${u}`);
}) as typeof fetch;
const { status, body } = await callGet();
assert.equal(
status,
200,
`expected HTTP 200, got ${status}: ${JSON.stringify(body)}`
);
assert.equal(
body.found,
true,
`expected found:true from Windows storage.db, got: ${JSON.stringify(body)}`
);
});