From ac8bd72e917ce5a20ab1b997b3bb083c49b937ac Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:58:50 -0300 Subject: [PATCH] fix(oauth): support Kiro IDC (organization) token import (#4944) Integrated into release/v3.8.38 (leva 5) --- CHANGELOG.md | 1 + src/app/api/oauth/kiro/auto-import/route.ts | 97 ++++- src/app/api/oauth/kiro/import/route.ts | 58 ++- src/shared/components/KiroAuthModal.tsx | 20 +- src/shared/validation/schemas/auth.ts | 8 +- .../oauth/kiro-auto-import-idc-2059.test.ts | 355 ++++++++++++++++++ 6 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 tests/unit/oauth/kiro-auto-import-idc-2059.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea6dcb7a5..ddb05060ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) --- diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index eb8c104520..13e027c262 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -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> & { + // Fields added by tryAwsSsoCache for IDC tokens (#2059) + authMethod?: string | null; +}; + async function saveAndRespond( - result: Awaited>, + 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 = { - authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported", + authMethod: resolvedAuthMethod, provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache", }; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index b6a8ab8d1b..7774908866 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -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>; + 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 } : {}), diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index 7d4b3e07ec..f17d5f49e4 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -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 | 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 || {}), + }), } ); diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index d0692781f1..69a83b2398 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -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), -}); \ No newline at end of file +}); diff --git a/tests/unit/oauth/kiro-auto-import-idc-2059.test.ts b/tests/unit/oauth/kiro-auto-import-idc-2059.test.ts new file mode 100644 index 0000000000..6a3316675e --- /dev/null +++ b/tests/unit/oauth/kiro-auto-import-idc-2059.test.ts @@ -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; + clientIdHash?: string; + clientData?: Record; +}) { + 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 }> { + const request = new Request("http://localhost/api/oauth/kiro/auto-import"); + const response = await GET(request); + const body = (await response.json()) as Record; + 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"); +});