fix(oauth): support Kiro IDC (organization) token import (#4944)

Integrated into release/v3.8.38 (leva 5)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 10:58:50 -03:00
committed by GitHub
parent abcf593213
commit ac8bd72e91
6 changed files with 521 additions and 18 deletions

View File

@@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr)
- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628)
- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19)
- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub)
---

View File

@@ -207,6 +207,11 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
triedPath?: string;
refreshToken?: string;
source?: string;
clientId?: string | null;
clientSecret?: string | null;
region?: string | null;
authMethod?: string | null;
profileArn?: string | null;
}> {
const { readFile, readdir } = await import("fs/promises");
const cachePath = join(homedir(), ".aws/sso/cache");
@@ -231,7 +236,77 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken?.startsWith("aorAAAAAG")) {
return { found: true, refreshToken: data.refreshToken, source: file };
const region: string | null = data.region || null;
const authMethod: string | null = data.authMethod || null;
// For IDC/organization tokens, resolve clientId and clientSecret from
// the linked client registration file (referenced by clientIdHash).
let clientId: string | null = null;
let clientSecret: string | null = null;
if (data.clientIdHash) {
const clientFile = `${data.clientIdHash}.json`;
try {
const clientContent = await readFile(join(cachePath, clientFile), "utf-8");
const clientData = JSON.parse(clientContent);
if (clientData.clientId && clientData.clientSecret) {
clientId = clientData.clientId;
clientSecret = clientData.clientSecret;
}
} catch {
// Client registration file not found — continue without it
}
}
// Read profileArn from Kiro IDE's profile.json.
// The runtime gateway requires us-east-1 in the ARN regardless of the IDC
// region, so we normalize the ARN region to us-east-1 (#2059).
let profileArn: string | null = null;
const kiroProfilePaths = [
join(
process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
"Kiro",
"User",
"globalStorage",
"kiro.kiroagent",
"profile.json"
),
join(
homedir(),
".config",
"Kiro",
"User",
"globalStorage",
"kiro.kiroagent",
"profile.json"
),
];
for (const profilePath of kiroProfilePaths) {
try {
const profileContent = await readFile(profilePath, "utf-8");
const profileData = JSON.parse(profileContent);
if (profileData.arn) {
// Normalize region to us-east-1 for the runtime gateway
profileArn = profileData.arn.replace(
/arn:aws:codewhisperer:[^:]+:/,
"arn:aws:codewhisperer:us-east-1:"
);
break;
}
} catch {
continue;
}
}
return {
found: true,
refreshToken: data.refreshToken,
source: file,
clientId,
clientSecret,
region,
authMethod,
profileArn,
};
}
} catch {
// skip
@@ -296,8 +371,13 @@ export function findKiroConnectionByProfileArn(
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
type SaveAndRespondResult = Awaited<ReturnType<typeof tryKiroCliSqlite>> & {
// Fields added by tryAwsSsoCache for IDC tokens (#2059)
authMethod?: string | null;
};
async function saveAndRespond(
result: Awaited<ReturnType<typeof tryKiroCliSqlite>>,
result: SaveAndRespondResult,
targetProvider: string,
request: Request
) {
@@ -311,8 +391,19 @@ async function saveAndRespond(
let expiresAt = result.expiresAt;
let profileArn = result.profileArn;
// Determine authMethod: prefer the value from the SSO cache token (e.g. "idc")
// so that kiroService.refreshToken() takes the correct OIDC path for IDC tokens
// (#2059). Fall back to "kiro-cli" for the SQLite path and "imported" for plain
// social SSO cache tokens (no clientIdHash → no IDC client creds).
const resolvedAuthMethod =
result.source === "kiro-cli-sqlite"
? "kiro-cli"
: result.clientId
? result.authMethod || "idc"
: "imported";
const providerSpecificData: Record<string, any> = {
authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported",
authMethod: resolvedAuthMethod,
provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache",
};

View File

@@ -61,40 +61,72 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { refreshToken, region } = validation.data;
const { refreshToken, region, clientId, clientSecret, authMethod, profileArn } =
validation.data;
const kiroService = new KiroService();
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(targetProvider);
// Validate and refresh token (through proxy if configured).
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
const tokenData = await runWithProxyContext(proxy, () =>
kiroService.validateImportToken(refreshToken.trim(), region)
);
// For IDC tokens the client already has OIDC client credentials extracted from the
// SSO cache registration file by auto-import (#2059). Refresh directly via the
// regional OIDC endpoint without calling registerClient() again. For social /
// Builder-ID tokens (no clientId) use validateImportToken() which handles
// registerClient() internally to obtain an isolated refresh session (#2328).
const isIdc = !!(clientId && clientSecret);
let tokenData: Awaited<ReturnType<typeof kiroService.validateImportToken>>;
if (isIdc) {
const providerSpecificData = {
clientId,
clientSecret,
region: region || "us-east-1",
authMethod: "idc",
};
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken.trim(), providerSpecificData)
);
tokenData = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || refreshToken.trim(),
expiresIn: refreshed.expiresIn || 3600,
profileArn: profileArn || null,
authMethod: "idc",
clientId,
clientSecret,
} as any;
} else {
// Validate and refresh token (through proxy if configured).
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
tokenData = await runWithProxyContext(proxy, () =>
kiroService.validateImportToken(refreshToken.trim(), region)
);
}
// Extract email from JWT if available
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
const resolvedAuthMethod = isIdc ? "idc" : (tokenData as any).authMethod || "imported";
const resolvedProfileArn = (tokenData as any).profileArn || null;
// Save to database
const connection: any = await createProviderConnection({
provider: targetProvider,
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
refreshToken: tokenData.refreshToken || refreshToken.trim(),
expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(),
email: email || null,
providerSpecificData: {
profileArn: tokenData.profileArn,
authMethod: tokenData.authMethod || "imported",
provider: "Imported",
profileArn: resolvedProfileArn,
authMethod: resolvedAuthMethod,
provider: isIdc ? "Enterprise" : "Imported",
...(tokenData.clientId
? {
clientId: tokenData.clientId,
clientSecret: tokenData.clientSecret,
region,
region: region || "us-east-1",
...(tokenData.clientSecretExpiresAt
? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt }
: {}),

View File

@@ -32,6 +32,10 @@ export default function KiroAuthModal({
const [importing, setImporting] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
const [autoDetected, setAutoDetected] = useState(false);
// IDC/organization credentials returned by auto-import when the SSO cache token
// has a clientIdHash. Spread into the import POST body so the regional OIDC
// endpoint is used for token refresh instead of the social path (#2059).
const [idcCredentials, setIdcCredentials] = useState<Record<string, string> | null>(null);
// Auto-detect token when import method is selected
useEffect(() => {
@@ -41,6 +45,7 @@ export default function KiroAuthModal({
setAutoDetecting(true);
setError(null);
setAutoDetected(false);
setIdcCredentials(null);
try {
const res = await fetch(
@@ -51,6 +56,16 @@ export default function KiroAuthModal({
if (data.found) {
setRefreshToken(data.refreshToken);
setAutoDetected(true);
// Store IDC/organization credentials if present in the auto-detect response
if (data.clientId && data.clientSecret) {
setIdcCredentials({
clientId: data.clientId,
clientSecret: data.clientSecret,
...(data.region ? { region: data.region } : {}),
...(data.authMethod ? { authMethod: data.authMethod } : {}),
...(data.profileArn ? { profileArn: data.profileArn } : {}),
});
}
} else {
setError(data.error || "Could not auto-detect token");
}
@@ -89,7 +104,10 @@ export default function KiroAuthModal({
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: refreshToken.trim() }),
body: JSON.stringify({
refreshToken: refreshToken.trim(),
...(idcCredentials || {}),
}),
}
);

View File

@@ -203,6 +203,12 @@ export const traeImportSchema = z.object({
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),
// IDC (organization) token fields — present when auto-detected from an IDC SSO
// cache token with a clientIdHash (#2059). Optional for backward compatibility.
clientId: z.string().optional(),
clientSecret: z.string().optional(),
authMethod: z.string().optional(),
profileArn: z.string().optional(),
});
export const kiroSocialExchangeSchema = z.object({
@@ -213,4 +219,4 @@ export const kiroSocialExchangeSchema = z.object({
export const zedImportSchema = z.object({
confirmedAccounts: z.array(confirmedAccountSchema),
});
});

View File

@@ -0,0 +1,355 @@
/**
* TDD for PR #2059 — Kiro IDC (organization) token import support.
*
* When the ~/.aws/sso/cache token file includes a `clientIdHash` field the
* auto-import path should:
* (a) read `${clientIdHash}.json` from the same cache dir to obtain
* `clientId` / `clientSecret`;
* (b) probe Kiro IDE's `profile.json` (Windows + Linux paths) for `arn` and
* normalize the ARN region to `us-east-1`;
* (c) include all IDC fields in the returned JSON so the import UI can pass
* them along to /api/oauth/kiro/import.
*
* When `clientIdHash` is absent the fallback path must still work (backward
* compat).
*
* The import schema (`kiroImportSchema`) must accept the new optional IDC
* fields and reject invalid types.
*/
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";
// ── Hermetic DATA_DIR so DB setup / requireLogin does not hit real disk ──────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-idc-2059-data-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret-idc-2059";
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-api-key-secret-idc-2059";
const core = await import("../../../src/lib/db/core.ts");
// Import route module once (DB is initialized on first import).
const { GET } = await import("../../../src/app/api/oauth/kiro/auto-import/route.ts");
const ORIGINAL_HOME = process.env.HOME;
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-idc-2059-"));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
process.env.HOME = tmpHome;
delete process.env.APPDATA;
// Reset fetch so tests with mocks don't bleed into each other.
globalThis.fetch = ORIGINAL_FETCH;
});
test.afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
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 });
});
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Writes the standard AWS SSO cache layout under tmpHome. */
function writeAwsSsoCache(opts: {
tokenFile?: string;
tokenData: Record<string, unknown>;
clientIdHash?: string;
clientData?: Record<string, unknown>;
}) {
const cacheDir = path.join(tmpHome, ".aws/sso/cache");
fs.mkdirSync(cacheDir, { recursive: true });
const tokenFile = opts.tokenFile ?? "kiro-auth-token.json";
fs.writeFileSync(path.join(cacheDir, tokenFile), JSON.stringify(opts.tokenData));
if (opts.clientIdHash && opts.clientData) {
fs.writeFileSync(
path.join(cacheDir, `${opts.clientIdHash}.json`),
JSON.stringify(opts.clientData)
);
}
}
/** Writes profile.json at the Linux globalStorage path. */
function writeKiroProfileJson(arn: string) {
const profileDir = path.join(tmpHome, ".config/Kiro/User/globalStorage/kiro.kiroagent");
fs.mkdirSync(profileDir, { recursive: true });
fs.writeFileSync(path.join(profileDir, "profile.json"), JSON.stringify({ arn }));
}
/** Stubs globalThis.fetch so that no real network calls are made.
* Returns a minimal Kiro OIDC refresh response for all Kiro endpoints. */
function stubFetchForRefresh() {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const u = String(input);
// 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" } }
);
}
// OIDC token refresh (IDC / Builder ID path)
if (u.includes("oidc.") && u.endsWith("/token")) {
return new Response(
JSON.stringify({
accessToken: "access-refreshed",
refreshToken: "aorAAAAAGrefreshed",
expiresIn: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
// Social/Builder-ID token refresh (prod.*.auth.desktop.kiro.dev/refreshToken)
if (u.includes("kiro.dev") && u.endsWith("/refreshToken")) {
return new Response(
JSON.stringify({
accessToken: "access-social-refreshed",
refreshToken: "aorAAAAAGsocial-refreshed",
expiresIn: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
throw new Error(`[kiro-idc-2059 test] unexpected fetch to ${u}`);
}) as typeof fetch;
}
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 };
}
// ── Tests: IDC path (clientIdHash present) ───────────────────────────────────
test("auto-import: when clientIdHash is present, reads client registration file and uses OIDC endpoint for refresh", async () => {
const CLIENT_ID_HASH = "abc123def456";
writeAwsSsoCache({
tokenData: {
refreshToken: "aorAAAAAGidc-refresh-token",
clientIdHash: CLIENT_ID_HASH,
region: "us-east-1",
authMethod: "idc",
},
clientIdHash: CLIENT_ID_HASH,
clientData: {
clientId: "idc-client-id-value",
clientSecret: "idc-client-secret-value",
},
});
// Track which URLs were fetched to verify the OIDC path (not social path) was used
const fetchedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const u = String(input);
fetchedUrls.push(u);
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" } }
);
}
if (u.includes("oidc.") && u.endsWith("/token")) {
return new Response(
JSON.stringify({
accessToken: "access-refreshed",
refreshToken: "aorAAAAAGrefreshed",
expiresIn: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (u.includes("kiro.dev") && u.endsWith("/refreshToken")) {
return new Response(
JSON.stringify({
accessToken: "social-access",
refreshToken: "aorAAAAAGsocial",
expiresIn: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
throw new Error(`[kiro-idc-2059 test] unexpected fetch to ${u}`);
}) as typeof fetch;
const { body } = await callGet();
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
// Verify that the OIDC /token endpoint was called (IDC path), not the social
// kiro.dev/refreshToken endpoint. This proves the registration file was read
// and the IDC credentials were used for token refresh.
const usedOidcTokenEndpoint = fetchedUrls.some(
(u) => u.includes("oidc.") && u.endsWith("/token")
);
assert.ok(
usedOidcTokenEndpoint,
`expected OIDC /token endpoint to be called for IDC refresh, fetched URLs: ${JSON.stringify(fetchedUrls)}`
);
const usedSocialEndpoint = fetchedUrls.some(
(u) => u.includes("kiro.dev") && u.endsWith("/refreshToken")
);
assert.equal(
usedSocialEndpoint,
false,
`social kiro.dev/refreshToken must NOT be called for IDC tokens, fetched URLs: ${JSON.stringify(fetchedUrls)}`
);
});
test("auto-import: when clientIdHash is present and profile.json exists, returns normalized ARN with us-east-1", async () => {
const CLIENT_ID_HASH = "hash999";
writeAwsSsoCache({
tokenData: {
refreshToken: "aorAAAAAGidc-arn-test",
clientIdHash: CLIENT_ID_HASH,
region: "ap-southeast-1",
authMethod: "idc",
},
clientIdHash: CLIENT_ID_HASH,
clientData: { clientId: "cid", clientSecret: "csec" },
});
// Write profile.json with a non-us-east-1 region in the ARN.
writeKiroProfileJson("arn:aws:codewhisperer:ap-southeast-1:123456789012:profile/MyProfile");
stubFetchForRefresh();
const { body } = await callGet();
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
assert.ok(
typeof body.profileArn === "string" && body.profileArn.includes("us-east-1"),
`profileArn should be normalized to us-east-1, got: ${body.profileArn}`
);
assert.ok(
!(body.profileArn as string).includes("ap-southeast-1"),
`normalized profileArn must not contain original region ap-southeast-1, got: ${body.profileArn}`
);
});
test("auto-import: without clientIdHash, fallback still works and clientId/clientSecret are absent or null", async () => {
// No clientIdHash — standard non-IDC token
writeAwsSsoCache({
tokenData: {
refreshToken: "aorAAAAAGstandard-token",
},
});
stubFetchForRefresh();
const { body } = await callGet();
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
// clientId/clientSecret should be null or undefined (not set from non-IDC cache)
assert.ok(
body.clientId === null || body.clientId === undefined,
`clientId must be null/undefined when no clientIdHash, got: ${body.clientId}`
);
assert.ok(
body.clientSecret === null || body.clientSecret === undefined,
`clientSecret must be null/undefined when no clientIdHash, got: ${body.clientSecret}`
);
});
test("auto-import: clientIdHash present but registration file missing — gracefully continues without clientId", async () => {
// clientIdHash in token but NO corresponding file on disk
writeAwsSsoCache({
tokenData: {
refreshToken: "aorAAAAAGidc-no-reg-file",
clientIdHash: "nonexistent-hash",
region: "us-east-1",
},
// No clientData written — file will not exist
});
stubFetchForRefresh();
const { body } = await callGet();
// Should still succeed (graceful degradation)
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
// clientId must be null — file not found
assert.ok(
body.clientId === null || body.clientId === undefined,
`clientId must be null when registration file is missing, got: ${body.clientId}`
);
});
// ── Tests: schema ─────────────────────────────────────────────────────────────
test("kiroImportSchema: accepts optional IDC fields (clientId, clientSecret, authMethod, profileArn)", async () => {
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
const result = kiroImportSchema.safeParse({
refreshToken: "aorAAAAAGsome-token",
region: "us-east-1",
clientId: "idc-client-id",
clientSecret: "idc-client-secret",
authMethod: "idc",
profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/MyProfile",
});
assert.equal(
result.success,
true,
`schema must accept IDC fields, errors: ${JSON.stringify(result.error?.errors)}`
);
if (result.success) {
assert.equal(result.data.clientId, "idc-client-id");
assert.equal(result.data.clientSecret, "idc-client-secret");
assert.equal(result.data.authMethod, "idc");
assert.equal(
result.data.profileArn,
"arn:aws:codewhisperer:us-east-1:123456789012:profile/MyProfile"
);
}
});
test("kiroImportSchema: still valid without IDC fields (backward compat)", async () => {
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
const result = kiroImportSchema.safeParse({
refreshToken: "aorAAAAAGsome-token",
});
assert.equal(
result.success,
true,
`schema must be valid without IDC fields, errors: ${JSON.stringify(result.error?.errors)}`
);
});
test("kiroImportSchema: rejects non-string clientId", async () => {
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
const result = kiroImportSchema.safeParse({
refreshToken: "aorAAAAAGsome-token",
clientId: 12345, // bad type
});
assert.equal(result.success, false, "schema must reject numeric clientId");
});