From 8ecfe757eab139ee123576e055efbae26424b23e Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Thu, 11 Jun 2026 17:36:36 +0300 Subject: [PATCH] fix(kiro): route enterprise IAM Identity Center accounts to their regional endpoint (#3631) Integrated into release/v3.8.22 --- open-sse/executors/kiro.ts | 37 ++++++++- open-sse/services/usage.ts | 18 ++++- src/lib/oauth/providers/kiro.ts | 44 ++++++++++- tests/unit/kiro-iam-region.test.ts | 123 +++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 tests/unit/kiro-iam-region.test.ts diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 379933f0d7..bb26d5f7d6 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -168,6 +168,37 @@ function ensureKiroUsage(state: KiroStreamState) { }; } +/** + * Resolve the AWS region for a Kiro/CodeWhisperer connection. Enterprise AWS IAM Identity + * Center accounts are region-bound: the access token, the Q Developer profile ARN and the + * runtime endpoint must all match the region the IdC instance lives in (e.g. eu-central-1). + * A request signed for one region is rejected by another ("bearer token is invalid"), and a + * regional profileArn sent to us-east-1 fails with "Improperly formed request". Falls back to + * the region embedded in the profileArn, then us-east-1 (the AWS Builder ID default). + */ +export function resolveKiroRegion( + credentials: { providerSpecificData?: unknown } | null | undefined +): string { + const psd = (credentials?.providerSpecificData || {}) as Record; + const region = typeof psd.region === "string" ? psd.region.trim().toLowerCase() : ""; + if (region) return region; + const arn = typeof psd.profileArn === "string" ? psd.profileArn.toLowerCase() : ""; + const match = arn.match(/^arn:aws:codewhisperer:([a-z0-9-]+):/); + return match ? match[1] : "us-east-1"; +} + +/** + * CodeWhisperer/Amazon Q runtime host for a region. us-east-1 keeps the legacy + * codewhisperer.us-east-1 host (AWS Builder ID); other regions use the regional Amazon Q + * endpoint q.{region}.amazonaws.com — codewhisperer.{region}.amazonaws.com does not resolve + * for non-us-east-1 regions. + */ +export function kiroRuntimeHost(region: string): string { + return region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; +} + /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) * Uses AWS CodeWhisperer streaming API with AWS EventStream binary format @@ -228,7 +259,11 @@ export class KiroExecutor extends BaseExecutor { log, upstreamExtraHeaders, }: ExecuteInput) { - const url = this.buildUrl(model, stream, 0); + // Route to the region-specific CodeWhisperer/Amazon Q endpoint. Enterprise IAM Identity + // Center accounts (e.g. eu-central-1) are rejected by the default us-east-1 host; only the + // regional endpoint accepts the region-bound token + profileArn. + const region = resolveKiroRegion(credentials); + const url = `${kiroRuntimeHost(region)}/generateAssistantResponse`; const headers = this.buildHeaders(credentials, stream); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformedBody = await this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 38336b2947..d33cbcea4a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -3005,6 +3005,22 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } + // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + // endpoint must all match the region. Derive the region from the stored region (preferred) + // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + const regionFromArn = + typeof profileArn === "string" + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + // Kiro uses AWS CodeWhisperer GetUsageLimits API const payload = { origin: "AI_EDITOR", @@ -3012,7 +3028,7 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec resourceType: "AGENTIC_REQUEST", }; - const response = await fetch(CODEWHISPERER_BASE_URL, { + const response = await fetch(usageBaseUrl, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, diff --git a/src/lib/oauth/providers/kiro.ts b/src/lib/oauth/providers/kiro.ts index 80c03a7d81..63d9965114 100644 --- a/src/lib/oauth/providers/kiro.ts +++ b/src/lib/oauth/providers/kiro.ts @@ -76,7 +76,7 @@ export const kiro = { }; }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const tokenRegion = extraData?._region || "us-east-1"; + const tokenRegion = String(extraData?._region || "us-east-1").toLowerCase(); const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`; const response = await fetch(tokenUrl, { @@ -123,7 +123,46 @@ export const kiro = { }, }; }, - mapTokens: (tokens) => ({ + // Enterprise IAM Identity Center accounts require a region-bound Q Developer profileArn on every + // CodeWhisperer call; without it AWS returns 403 "User is not authorized to make this call". The + // device-code flow does not return one, so discover it here via ListAvailableProfiles against the + // same regional endpoint the token was issued for. Best-effort: AWS Builder ID accounts have no + // profile and this simply yields none; failures never block login. + postExchange: async (tokenData) => { + const accessToken = tokenData?.access_token; + if (!accessToken) return null; + const region = String(tokenData?._region || "us-east-1").toLowerCase(); + const runtimeHost = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; + try { + const profRes = await fetch(`${runtimeHost}/`, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.0", + Accept: "application/json", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ maxResults: 10 }), + signal: AbortSignal.timeout(10000), + }); + if (!profRes.ok) return null; + const profData = await profRes.json(); + const profiles = Array.isArray(profData?.profiles) ? profData.profiles : []; + const matched = + profiles.find( + (p: { arn?: string }) => typeof p?.arn === "string" && p.arn.includes(`:${region}:`) + ) || profiles[0]; + const arn = matched && typeof matched.arn === "string" ? matched.arn : undefined; + return arn ? { profileArn: arn } : null; + } catch { + // Best-effort profile discovery — never block login on it. + return null; + } + }, + mapTokens: (tokens, extra) => ({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in, @@ -131,6 +170,7 @@ export const kiro = { clientId: tokens._clientId, clientSecret: tokens._clientSecret, region: tokens._region, + ...(extra?.profileArn ? { profileArn: extra.profileArn } : {}), }, }), }; diff --git a/tests/unit/kiro-iam-region.test.ts b/tests/unit/kiro-iam-region.test.ts new file mode 100644 index 0000000000..a3e8bf8611 --- /dev/null +++ b/tests/unit/kiro-iam-region.test.ts @@ -0,0 +1,123 @@ +/** + * Kiro enterprise IAM Identity Center region routing. + * + * Kiro/CodeWhisperer access tokens and Q Developer profile ARNs are region-bound. Enterprise + * IAM Identity Center accounts whose profile lives outside us-east-1 (e.g. eu-central-1) must + * route to the regional Amazon Q endpoint (q.{region}.amazonaws.com) with the region-matched + * profileArn — sending them to the default us-east-1 host fails with 403/400. These tests cover + * the region resolver, the regional host mapping, and the profileArn discovery added to the + * device-code login. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveKiroRegion, kiroRuntimeHost } from "../../open-sse/executors/kiro.ts"; +import { kiro } from "@/lib/oauth/providers/kiro"; + +test("resolveKiroRegion prefers the stored region", () => { + assert.equal(resolveKiroRegion({ providerSpecificData: { region: "eu-central-1" } }), "eu-central-1"); +}); + +test("resolveKiroRegion falls back to the region in the profileArn", () => { + assert.equal( + resolveKiroRegion({ + providerSpecificData: { + profileArn: "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/ABC123", + }, + }), + "ap-southeast-2" + ); +}); + +test("resolveKiroRegion defaults to us-east-1 when nothing is set", () => { + assert.equal(resolveKiroRegion({ providerSpecificData: {} }), "us-east-1"); + assert.equal(resolveKiroRegion(null), "us-east-1"); + assert.equal(resolveKiroRegion(undefined), "us-east-1"); +}); + +test("resolveKiroRegion normalizes case and whitespace", () => { + assert.equal( + resolveKiroRegion({ providerSpecificData: { region: " EU-CENTRAL-1 " } }), + "eu-central-1" + ); +}); + +test("kiroRuntimeHost keeps the legacy host for us-east-1 and uses regional Q hosts otherwise", () => { + assert.equal(kiroRuntimeHost("us-east-1"), "https://codewhisperer.us-east-1.amazonaws.com"); + assert.equal(kiroRuntimeHost("eu-central-1"), "https://q.eu-central-1.amazonaws.com"); + assert.equal(kiroRuntimeHost("ap-southeast-2"), "https://q.ap-southeast-2.amazonaws.com"); +}); + +test("kiro.postExchange discovers the region-matched profileArn via ListAvailableProfiles", async () => { + const originalFetch = global.fetch; + let requestedUrl = ""; + let requestedTarget = ""; + + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedTarget = String((init?.headers as Record)?.["x-amz-target"] || ""); + return new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/USEAST" }, + { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, + ], + }), + { status: 200 } + ); + }) as typeof fetch; + + try { + const extra = await kiro.postExchange({ access_token: "token", _region: "eu-central-1" }); + assert.equal(requestedUrl, "https://q.eu-central-1.amazonaws.com/"); + assert.equal(requestedTarget, "AmazonCodeWhispererService.ListAvailableProfiles"); + assert.deepEqual(extra, { + profileArn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ", + }); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.postExchange returns null when no profile is available (AWS Builder ID)", async () => { + const originalFetch = global.fetch; + global.fetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; + try { + assert.equal(await kiro.postExchange({ access_token: "token", _region: "us-east-1" }), null); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.postExchange never throws on network failure", async () => { + const originalFetch = global.fetch; + global.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + try { + assert.equal(await kiro.postExchange({ access_token: "token", _region: "eu-central-1" }), null); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.mapTokens stores the discovered profileArn from postExchange extra", () => { + const mapped = kiro.mapTokens( + { access_token: "at", refresh_token: "rt", expires_in: 3600, _region: "eu-central-1" }, + { profileArn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" } + ); + assert.equal( + mapped.providerSpecificData.profileArn, + "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" + ); +}); + +test("kiro.mapTokens omits profileArn when postExchange found none", () => { + const mapped = kiro.mapTokens( + { access_token: "at", refresh_token: "rt", expires_in: 3600, _region: "us-east-1" }, + null + ); + assert.equal("profileArn" in mapped.providerSpecificData, false); +});