mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
fix(kiro): route enterprise IAM Identity Center accounts to their regional endpoint (#3631)
Integrated into release/v3.8.22
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
123
tests/unit/kiro-iam-region.test.ts
Normal file
123
tests/unit/kiro-iam-region.test.ts
Normal file
@@ -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<string, string>)?.["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);
|
||||
});
|
||||
Reference in New Issue
Block a user