mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
* 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>
202 lines
7.7 KiB
TypeScript
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)}`
|
|
);
|
|
});
|