feat(kiro): support enterprise External IdP (Your organization) logins (#6363)

* 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>
This commit is contained in:
NOXX - Commiter
2026-07-10 03:39:09 +03:00
committed by GitHub
parent d3331f8bca
commit ece4bf7b53
12 changed files with 680 additions and 47 deletions

View File

@@ -15,6 +15,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen)
- **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333)
- **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `<alias>/<modelId>` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok)
- **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc)
### 🐛 Bug Fixes

View File

@@ -183,7 +183,8 @@
"open-sse/services/rateLimitManager.ts": 1035,
"_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.",
"_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.",
"open-sse/services/tokenRefresh.ts": 2182,
"_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.",
"open-sse/services/tokenRefresh.ts": 2249,
"open-sse/services/usage.ts": 3454,
"open-sse/translator/request/openai-to-gemini.ts": 906,
"open-sse/translator/request/openai-to-kiro.ts": 890,

View File

@@ -8,6 +8,11 @@ import {
import { PROVIDERS } from "../config/constants.ts";
import { v4 as uuidv4 } from "uuid";
import { refreshKiroToken } from "../services/tokenRefresh.ts";
import {
isExternalIdpAuthMethod,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE,
} from "../services/kiroExternalIdp.ts";
import {
splitInlineThinking,
flushPendingThinking,
@@ -198,6 +203,16 @@ export class KiroExecutor extends BaseExecutor {
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
// Enterprise / Microsoft Entra "Your organization" (external_idp) logins send an
// org-IdP-issued access token. CodeWhisperer only binds it to the Amazon Q Developer
// profile when the request carries `TokenType: EXTERNAL_IDP`; without it every call
// returns `ValidationException: Invalid ARN <clientId>` (the service falls back to the
// token's client id as the resource ARN). AWS SSO (Builder ID / IDC) and social tokens
// must NOT send this header, so it is gated on the persisted authMethod.
if (isExternalIdpAuthMethod(credentials.providerSpecificData?.authMethod)) {
headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE;
}
}
return headers;

View File

@@ -0,0 +1,175 @@
/**
* kiroExternalIdp.ts — shared helpers for Kiro / Amazon Q **External IdP**
* (enterprise "Your organization" SSO) accounts.
*
* Unlike AWS Builder ID / IAM Identity Center (which mint AWS SSO-OIDC tokens
* refreshed at `oidc.{region}.amazonaws.com` and whose refresh token starts with
* `aorAAAAAG`) or the Google/GitHub social flow (refreshed at the Kiro auth
* service), an **External IdP** login federates through the organization's own
* identity provider (most commonly Microsoft Entra ID). Its Kiro token file
* (`~/.aws/sso/cache/kiro-auth-token.json`) looks like:
*
* {
* "accessToken": "<JWT issued by the org IdP, scp: codewhisperer:*>",
* "refreshToken": "<IdP refresh token, NOT aorAAAAAG…>",
* "authMethod": "external_idp",
* "provider": "ExternalIdp",
* "clientId": "<IdP application (client) id — a public client, no secret>",
* "tokenEndpoint":"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
* "issuerUrl": "https://login.microsoftonline.com/{tenant}/v2.0",
* "scopes": "api://{clientId}/codewhisperer:conversations … offline_access"
* }
*
* Two consequences this module encodes (both verified against a live org token):
* 1. The token is refreshed with a **standard public-client OAuth2
* `refresh_token` grant against `tokenEndpoint`** (form-encoded
* client_id + refresh_token + scope, NO client_secret) — see
* {@link buildExternalIdpRefreshParams}.
* 2. At runtime the access token is sent to CodeWhisperer as a normal bearer
* but MUST carry the header `TokenType: EXTERNAL_IDP` so the service binds
* it to the Amazon Q Developer profile (without it every call returns
* `ValidationException: Invalid ARN <clientId>`). The profileArn itself is
* NOT discoverable via `ListAvailableProfiles` (it returns an empty list
* for these tokens); it is read from the Kiro IDE `profile.json` at import.
*/
/** authMethod marker persisted on External IdP connections. */
export const KIRO_EXTERNAL_IDP_AUTH_METHOD = "external_idp";
/** Header CodeWhisperer requires to bind an External IdP bearer to its profile. */
export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER = "TokenType";
export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE = "EXTERNAL_IDP";
/**
* Allowlist of enterprise IdP token-endpoint host suffixes. The refresh token is
* POSTed to this endpoint, so we constrain it to well-known identity providers
* (SSRF guard — the value ultimately originates from an on-disk token file).
* Microsoft Entra is by far the most common Kiro org IdP; the others cover the
* major enterprise SSO vendors an org might federate Kiro through.
*/
const ALLOWED_IDP_HOST_SUFFIXES: readonly string[] = [
"login.microsoftonline.com",
"login.microsoftonline.us",
"login.partner.microsoftonline.cn",
"login.microsoft.com",
"login.windows.net",
"sts.windows.net",
".okta.com",
".oktapreview.com",
".okta-emea.com",
".auth0.com",
".onelogin.com",
".pingidentity.com",
".pingone.com",
"accounts.google.com",
"oauth2.googleapis.com",
".amazoncognito.com",
];
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** True when a connection's providerSpecificData marks it as an External IdP login. */
export function isExternalIdpAuthMethod(authMethod: unknown): boolean {
return normalizeString(authMethod).toLowerCase() === KIRO_EXTERNAL_IDP_AUTH_METHOD;
}
/**
* Validate the IdP token endpoint before it is used as a fetch target. Requires
* https and a host on {@link ALLOWED_IDP_HOST_SUFFIXES}. Returns the normalized
* URL string; throws on anything unexpected.
*/
export function validateExternalIdpTokenEndpoint(rawEndpoint: unknown): string {
const tokenEndpoint = normalizeString(rawEndpoint);
if (!tokenEndpoint) throw new Error("tokenEndpoint is required for external_idp");
let parsed: URL;
try {
parsed = new URL(tokenEndpoint);
} catch {
throw new Error("tokenEndpoint must be a valid URL");
}
if (parsed.protocol !== "https:") {
throw new Error("tokenEndpoint must use https");
}
const host = parsed.hostname.toLowerCase();
const allowed = ALLOWED_IDP_HOST_SUFFIXES.some((suffix) =>
suffix.startsWith(".") ? host.endsWith(suffix) : host === suffix
);
if (!allowed) {
throw new Error(`tokenEndpoint host is not an allowed identity provider: ${host}`);
}
return parsed.toString();
}
/** Collapse an array-or-space-delimited scope value into a single space-delimited string. */
export function normalizeScope(scopes: unknown): string {
if (Array.isArray(scopes)) {
return scopes.map(normalizeString).filter(Boolean).join(" ");
}
return normalizeString(scopes);
}
/** Best-effort base64url JWT payload decode (no signature verification). */
export function decodeJwtPayload(jwt: unknown): Record<string, unknown> | null {
try {
if (typeof jwt !== "string") return null;
const parts = jwt.split(".");
if (parts.length !== 3) return null;
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padding = (4 - (base64.length % 4)) % 4;
const json = Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8");
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Extract the login identity (email) from an External IdP access token. Org IdP
* tokens carry it as `preferred_username`/`upn`/`email` rather than the AWS
* `email` claim — otherwise the connection surfaces as the opaque "ExternalIdp".
*/
export function emailFromExternalIdpToken(accessToken: unknown): string | null {
const claims = decodeJwtPayload(accessToken);
if (!claims) return null;
const pick = (k: string): string | undefined =>
typeof claims[k] === "string" ? (claims[k] as string) : undefined;
return pick("email") || pick("preferred_username") || pick("upn") || null;
}
export interface ExternalIdpRefreshRequest {
tokenEndpoint: string;
body: URLSearchParams;
}
/**
* Build the public-client `refresh_token` grant for an External IdP token. The
* IdP application is a PUBLIC client (no secret), so the body is exactly
* `grant_type=refresh_token&client_id&refresh_token&scope`. Throws when any
* required field is missing/invalid so callers can fail closed.
*/
export function buildExternalIdpRefreshParams(
refreshToken: string,
providerSpecificData: Record<string, unknown> | null | undefined
): ExternalIdpRefreshRequest {
const psd = providerSpecificData || {};
const clientId = normalizeString(psd.clientId ?? (psd as Record<string, unknown>).client_id);
const tokenEndpoint = validateExternalIdpTokenEndpoint(
psd.tokenEndpoint ?? (psd as Record<string, unknown>).token_endpoint
);
const scope = normalizeScope(psd.scope ?? psd.scopes);
if (!refreshToken) throw new Error("refresh token is required for external_idp refresh");
if (!clientId) throw new Error("clientId is required for external_idp refresh");
if (!scope) throw new Error("scope is required for external_idp refresh");
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
scope,
});
return { tokenEndpoint, body };
}

View File

@@ -5,6 +5,10 @@ import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles
import { pbkdf2Sync } from "node:crypto";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts";
import {
buildExternalIdpRefreshParams,
isExternalIdpAuthMethod,
} from "./kiroExternalIdp.ts";
import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth";
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
@@ -1209,6 +1213,69 @@ export async function refreshKiroToken(
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
// Enterprise / Microsoft Entra "Your organization" (external_idp) logins refresh with a
// standard PUBLIC-client OAuth2 refresh_token grant against the org IdP's own tokenEndpoint
// (form-encoded client_id + refresh_token + scope, no client_secret) — NOT the AWS SSO OIDC
// or Kiro social endpoints. The rotated refresh_token is persisted by the caller.
if (isExternalIdpAuthMethod(authMethod)) {
let refreshRequest;
try {
refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData);
} catch (cfgErr) {
log?.error?.(
"TOKEN_REFRESH",
`Invalid Kiro external_idp refresh config: ${cfgErr instanceof Error ? cfgErr.message : String(cfgErr)}`
);
return null;
}
const response = await runWithProxyContext(proxyConfig, () =>
fetch(refreshRequest.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: refreshRequest.body,
})
);
if (!response.ok) {
const errorText = await response.text();
let oauthErr: string | undefined;
try {
oauthErr = JSON.parse(errorText)?.error;
} catch {
/* not JSON */
}
if (oauthErr === "invalid_grant" || oauthErr === "invalid_client") {
log?.error?.(
"TOKEN_REFRESH",
"Kiro external_idp refresh token expired/invalid. Re-authentication required.",
{ oauthErr }
);
return { error: "unrecoverable_refresh_error", code: oauthErr };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", {
status: response.status,
error: errorText.slice(0, 200),
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro external_idp token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in || 3600,
};
}
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified).
// Exception: imported social tokens (authMethod === "imported") carry a freshly-registered

View File

@@ -13,6 +13,11 @@
import { toRecord, toNumber } from "./scalars.ts";
import { type UsageQuota, parseResetTime } from "./quota.ts";
import {
isExternalIdpAuthMethod,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE,
} from "../kiroExternalIdp.ts";
type JsonRecord = Record<string, unknown>;
@@ -188,14 +193,22 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?:
resourceType: "AGENTIC_REQUEST",
};
// Enterprise / Microsoft Entra (external_idp) org accounts require the
// `TokenType: EXTERNAL_IDP` header for CodeWhisperer to bind the bearer to the
// profile; without it GetUsageLimits returns `ValidationException: Invalid ARN`.
const usageHeaders: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
Accept: "application/json",
};
if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) {
usageHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE;
}
const response = await fetch(usageBaseUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
Accept: "application/json",
},
headers: usageHeaders,
body: JSON.stringify(payload),
});

View File

@@ -13,6 +13,11 @@ import { syncToCloud } from "@/lib/cloudSync";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { KiroService } from "@/lib/oauth/services/kiro";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import {
emailFromExternalIdpToken,
isExternalIdpAuthMethod,
normalizeScope,
} from "@omniroute/open-sse/services/kiroExternalIdp.ts";
/**
* GET /api/oauth/kiro/auto-import
@@ -202,16 +207,69 @@ async function tryKiroCliSqlite(): Promise<{
// ── ~/.aws/sso/cache fallback ─────────────────────────────────────────────────
/**
* Read the Amazon Q Developer profileArn the Kiro IDE persists in its
* `profile.json`. This is the authoritative source for the profileArn of AWS
* IAM Identity Center AND External IdP (organization) logins, since neither can
* enumerate it via ListAvailableProfiles (org tokens get an empty list).
*
* The ARN's region segment is preserved verbatim (#2314). #2059 originally
* forced every ARN's region to us-east-1, which 403s the runtime gateway for
* IDC accounts that live in a non-us-east-1 region. The OAuth device-code
* path (src/lib/oauth/providers/kiro.ts) already discovers the correct
* region-matched ARN, so this fallback now mirrors that behavior instead of
* rewriting it.
*/
async function readKiroIdeProfileArn(): Promise<string | null> {
const { readFile } = await import("fs/promises");
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"),
join(
homedir(),
"Library",
"Application Support",
"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) {
return profileData.arn;
}
} catch {
continue;
}
}
return null;
}
async function tryAwsSsoCache(targetProvider: string): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
accessToken?: string | null;
source?: string;
clientId?: string | null;
clientSecret?: string | null;
region?: string | null;
authMethod?: string | null;
profileArn?: string | null;
tokenEndpoint?: string | null;
scopes?: string | string[] | null;
}> {
const { readFile, readdir } = await import("fs/promises");
const cachePath = join(homedir(), ".aws/sso/cache");
@@ -235,6 +293,34 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
// Enterprise / Microsoft Entra "Your organization" (external_idp) tokens are NOT AWS SSO
// tokens — their refresh token does not start with `aorAAAAAG`. Detect them by authMethod/
// provider and take the dedicated external_idp branch (org IdP tokenEndpoint refresh +
// profileArn read from the Kiro IDE profile.json).
const isExternalIdp =
!!data.refreshToken &&
(isExternalIdpAuthMethod(data.authMethod) ||
String(data.provider || "").toLowerCase() === "externalidp");
if (isExternalIdp) {
const region: string | null = data.region || null;
const profileArn = await readKiroIdeProfileArn();
return {
found: true,
source: file,
refreshToken: data.refreshToken,
accessToken: data.accessToken || null,
clientId: data.clientId || null,
clientSecret: null,
region,
authMethod: "external_idp",
profileArn,
tokenEndpoint: data.tokenEndpoint || null,
scopes: data.scopes || null,
};
}
if (data.refreshToken?.startsWith("aorAAAAAG")) {
const region: string | null = data.region || null;
const authMethod: string | null = data.authMethod || null;
@@ -257,46 +343,9 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
}
}
// Read profileArn from Kiro IDE's profile.json.
// Kiro IDC (Identity Center) accounts can live in regions other than
// us-east-1. #2059 forced every ARN's region segment to us-east-1,
// which 403s the runtime gateway for non-us-east-1 IDC accounts. The
// OAuth device-code path (src/lib/oauth/providers/kiro.ts) already
// discovers the correct region-matched ARN; mirror that here by
// preserving the profile's ARN region verbatim instead of rewriting
// it.
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) {
profileArn = profileData.arn;
break;
}
} catch {
continue;
}
}
// Read profileArn from Kiro IDE's profile.json. The region is preserved
// verbatim by readKiroIdeProfileArn() (#2314) — see its docstring for why.
const profileArn: string | null = await readKiroIdeProfileArn();
return {
found: true,
@@ -375,6 +424,9 @@ export function findKiroConnectionByProfileArn(
type SaveAndRespondResult = Awaited<ReturnType<typeof tryKiroCliSqlite>> & {
// Fields added by tryAwsSsoCache for IDC tokens (#2059)
authMethod?: string | null;
// Fields added by tryAwsSsoCache for External IdP (organization) tokens
tokenEndpoint?: string | null;
scopes?: string | string[] | null;
};
async function saveAndRespond(
@@ -386,6 +438,81 @@ async function saveAndRespond(
const kiroService = new KiroService();
const proxy = await resolveProxyForProvider(targetProvider);
// Enterprise / Microsoft Entra "Your organization" (external_idp) tokens: refresh via the
// org IdP tokenEndpoint (public-client OAuth2), persist the Kiro IDE profileArn, and mark
// the connection so the runtime executor sends `TokenType: EXTERNAL_IDP` and the quota
// fetch works. These tokens can't refresh via AWS OIDC / Kiro social and have no client
// secret, so they get their own path.
if (isExternalIdpAuthMethod(result.authMethod)) {
const region = result.region || "us-east-1";
const scope = normalizeScope(result.scopes);
const externalIdpPsd = {
authMethod: "external_idp",
clientId: result.clientId || undefined,
tokenEndpoint: result.tokenEndpoint || undefined,
scope,
region,
};
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(result.refreshToken!, externalIdpPsd)
);
const email =
emailFromExternalIdpToken(refreshed.accessToken) ||
kiroService.extractEmailFromJWT(refreshed.accessToken);
const profileArn = result.profileArn || null;
const connectionName = deriveKiroConnectionName({
email,
profileArn: profileArn || undefined,
region,
targetProvider,
});
const providerSpecificData: Record<string, any> = {
authMethod: "external_idp",
provider: "ExternalIdp",
clientId: result.clientId || null,
tokenEndpoint: result.tokenEndpoint || null,
scope,
region,
};
if (profileArn) providerSpecificData.profileArn = profileArn;
const existingConnections = await getProviderConnections({ provider: targetProvider });
const existingByArn = findKiroConnectionByProfileArn(
existingConnections,
profileArn || undefined
);
const record = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || result.refreshToken!,
expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(),
email: email || null,
name: connectionName,
providerSpecificData,
testStatus: "active",
};
if (existingByArn && typeof existingByArn.id === "string") {
await updateProviderConnection(existingByArn.id, record);
} else {
await createProviderConnection({
provider: targetProvider,
authType: "oauth",
...record,
} as any);
}
if (isCloudEnabled()) {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId).catch(() => {});
}
return NextResponse.json({
found: true,
source: result.source,
email: email || null,
profileArn: profileArn || null,
region,
message: "Kiro credentials imported successfully.",
});
}
// If we have a refresh token but no valid access token, refresh now
let accessToken = result.accessToken;
let refreshToken = result.refreshToken!;

View File

@@ -8,6 +8,11 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
emailFromExternalIdpToken,
isExternalIdpAuthMethod,
normalizeScope,
} from "@omniroute/open-sse/services/kiroExternalIdp.ts";
/**
* Build the user-facing error message for a failed Kiro/Amazon-Q token import.
@@ -63,12 +68,58 @@ export async function POST(request: Request) {
}
const { refreshToken, region, clientId, clientSecret, authMethod, profileArn } =
validation.data;
const { tokenEndpoint, scopes } = validation.data;
const kiroService = new KiroService();
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(targetProvider);
// Enterprise / Microsoft Entra "Your organization" (external_idp) import. These tokens are
// NOT AWS SSO tokens (their refresh token does not start with `aorAAAAAG`), so the Builder
// ID / IDC path (validateImportToken) rejects them. Refresh via the org IdP's tokenEndpoint,
// persist the org profileArn (read from the Kiro IDE profile.json by the caller), and mark
// the connection so the runtime executor sends `TokenType: EXTERNAL_IDP`.
if (isExternalIdpAuthMethod(authMethod)) {
const scope = normalizeScope(scopes);
const externalIdpPsd = {
authMethod: "external_idp",
clientId,
tokenEndpoint,
scope,
region: region || "us-east-1",
};
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken.trim(), externalIdpPsd)
);
const email =
emailFromExternalIdpToken(refreshed.accessToken) ||
kiroService.extractEmailFromJWT(refreshed.accessToken);
const connection: any = await createProviderConnection({
provider: targetProvider,
authType: "oauth",
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || refreshToken.trim(),
expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(),
email: email || null,
providerSpecificData: {
profileArn: profileArn || null,
authMethod: "external_idp",
provider: "ExternalIdp",
clientId,
tokenEndpoint,
scope,
region: region || "us-east-1",
},
testStatus: "active",
} as any);
await syncToCloudIfEnabled();
return NextResponse.json({
success: true,
connection: { id: connection.id, provider: connection.provider, email: connection.email },
});
}
// 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 /

View File

@@ -1,4 +1,8 @@
import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth";
import {
buildExternalIdpRefreshParams,
isExternalIdpAuthMethod,
} from "@omniroute/open-sse/services/kiroExternalIdp.ts";
/**
* Kiro OAuth Service
@@ -187,6 +191,32 @@ export class KiroService {
async refreshToken(refreshToken: string, providerSpecificData: any = {}) {
const { authMethod, clientId, clientSecret, region } = providerSpecificData;
// Enterprise / Microsoft Entra "Your organization" (external_idp) login: refresh with a
// standard public-client OAuth2 refresh_token grant against the org IdP's tokenEndpoint
// (form-encoded client_id + refresh_token + scope, no client_secret). The AWS SSO OIDC and
// Kiro social endpoints cannot refresh these tokens.
if (isExternalIdpAuthMethod(authMethod)) {
const refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData);
const response = await fetch(refreshRequest.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: refreshRequest.body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token refresh failed: ${error}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresIn: data.expires_in || 3600,
};
}
// AWS SSO OIDC refresh (Builder ID or IDC).
// Imported social tokens (authMethod === "imported") have a registered clientId/clientSecret
// but a Kiro-social refresh token the OIDC client can't refresh — use the social path (#2467).

View File

@@ -193,6 +193,11 @@ export const kiroImportSchema = z.object({
clientSecret: z.string().optional(),
authMethod: z.string().optional(),
profileArn: z.string().optional(),
// External IdP ("Your organization" / Microsoft Entra) token fields — present
// when authMethod === "external_idp". The token is refreshed via a public-client
// OAuth2 grant against `tokenEndpoint` using `clientId` + `scopes` (no secret).
tokenEndpoint: z.string().optional(),
scopes: z.union([z.string(), z.array(z.string())]).optional(),
});
export const zedImportSchema = z.object({

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
// Coverage for enterprise / Microsoft Entra "Your organization" (external_idp) Kiro accounts:
// • public-client refresh_token grant against the org IdP tokenEndpoint (no client secret),
// • the runtime `TokenType: EXTERNAL_IDP` header the CodeWhisperer service requires to bind
// the bearer to the Amazon Q Developer profile (without it every call is
// `ValidationException: Invalid ARN <clientId>`),
// • tokenEndpoint SSRF allowlist, scope normalization, and JWT identity extraction.
import {
buildExternalIdpRefreshParams,
validateExternalIdpTokenEndpoint,
normalizeScope,
isExternalIdpAuthMethod,
emailFromExternalIdpToken,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER,
KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE,
} from "../../open-sse/services/kiroExternalIdp.ts";
import { KiroExecutor } from "../../open-sse/executors/kiro.ts";
const MS_ENDPOINT = "https://login.microsoftonline.com/9d769d6d-e03a-442a-8ab1-a7da2037a5d4/oauth2/v2.0/token";
function makeJwt(payload: Record<string, unknown>): string {
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
return `${b64({ alg: "none", typ: "JWT" })}.${b64(payload)}.sig`;
}
test("validateExternalIdpTokenEndpoint accepts Microsoft/Okta https, rejects others", () => {
assert.equal(validateExternalIdpTokenEndpoint(MS_ENDPOINT), MS_ENDPOINT);
assert.ok(validateExternalIdpTokenEndpoint("https://dev-123.okta.com/oauth2/v1/token"));
assert.throws(() => validateExternalIdpTokenEndpoint("http://login.microsoftonline.com/x/token"));
assert.throws(() => validateExternalIdpTokenEndpoint("https://evil.example.com/token"));
assert.throws(() => validateExternalIdpTokenEndpoint(""));
});
test("normalizeScope handles array and space-delimited string", () => {
assert.equal(normalizeScope(["a", "b", "offline_access"]), "a b offline_access");
assert.equal(normalizeScope("a b offline_access"), "a b offline_access");
assert.equal(normalizeScope([" x ", "", "y"]), "x y");
assert.equal(normalizeScope(undefined), "");
});
test("isExternalIdpAuthMethod recognizes external_idp (case-insensitive)", () => {
assert.equal(isExternalIdpAuthMethod("external_idp"), true);
assert.equal(isExternalIdpAuthMethod("EXTERNAL_IDP"), true);
assert.equal(isExternalIdpAuthMethod("idc"), false);
assert.equal(isExternalIdpAuthMethod(undefined), false);
});
test("emailFromExternalIdpToken reads preferred_username / upn / email", () => {
assert.equal(
emailFromExternalIdpToken(makeJwt({ preferred_username: "finbar.heslin@mrdevvn.cyou" })),
"finbar.heslin@mrdevvn.cyou"
);
assert.equal(emailFromExternalIdpToken(makeJwt({ upn: "a@b.com" })), "a@b.com");
assert.equal(emailFromExternalIdpToken(makeJwt({ email: "c@d.com" })), "c@d.com");
assert.equal(emailFromExternalIdpToken("not-a-jwt"), null);
});
test("buildExternalIdpRefreshParams builds a public-client form body", () => {
const req = buildExternalIdpRefreshParams("RT-123", {
clientId: "app-guid",
tokenEndpoint: MS_ENDPOINT,
scopes: ["api://app-guid/codewhisperer:conversations", "offline_access"],
});
assert.equal(req.tokenEndpoint, MS_ENDPOINT);
assert.equal(req.body.get("grant_type"), "refresh_token");
assert.equal(req.body.get("client_id"), "app-guid");
assert.equal(req.body.get("refresh_token"), "RT-123");
assert.equal(
req.body.get("scope"),
"api://app-guid/codewhisperer:conversations offline_access"
);
// Public client: never a secret.
assert.equal(req.body.get("client_secret"), null);
});
test("buildExternalIdpRefreshParams fails closed on missing fields", () => {
assert.throws(() => buildExternalIdpRefreshParams("", { clientId: "x", tokenEndpoint: MS_ENDPOINT, scopes: "s" }));
assert.throws(() => buildExternalIdpRefreshParams("rt", { tokenEndpoint: MS_ENDPOINT, scopes: "s" }));
assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", scopes: "s" }));
assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", tokenEndpoint: MS_ENDPOINT }));
});
test("KiroService.refreshToken uses the org IdP tokenEndpoint for external_idp", async () => {
const { KiroService } = await import("../../src/lib/oauth/services/kiro.ts");
const ORIGINAL_FETCH = globalThis.fetch;
const calls: { url: string; body: string; contentType: string | null }[] = [];
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
const body = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
calls.push({ url: u, body, contentType: (init?.headers as Record<string, string>)?.["Content-Type"] ?? null });
return new Response(
JSON.stringify({ access_token: "new-at", refresh_token: "rotated-rt", expires_in: 4481 }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
try {
const svc = new KiroService();
const res = await svc.refreshToken("RT-old", {
authMethod: "external_idp",
clientId: "app-guid",
tokenEndpoint: MS_ENDPOINT,
scopes: "codewhisperer:conversations offline_access",
});
assert.equal(res.accessToken, "new-at");
assert.equal(res.refreshToken, "rotated-rt");
assert.equal(calls.length, 1);
assert.equal(calls[0].url, MS_ENDPOINT);
assert.ok(calls[0].url.includes("login.microsoftonline.com"));
// Must NOT hit AWS OIDC or the Kiro social endpoint.
assert.ok(!calls[0].url.includes("amazonaws.com"));
assert.ok(!calls[0].url.includes("desktop.kiro.dev"));
assert.ok(calls[0].body.includes("grant_type=refresh_token"));
assert.ok(calls[0].body.includes("client_id=app-guid"));
} finally {
globalThis.fetch = ORIGINAL_FETCH;
}
});
test("KiroExecutor.buildHeaders sends TokenType: EXTERNAL_IDP only for external_idp", () => {
const exec = new KiroExecutor();
const idpHeaders = exec.buildHeaders({
accessToken: "at",
providerSpecificData: { authMethod: "external_idp" },
} as never);
assert.equal(idpHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE);
const idcHeaders = exec.buildHeaders({
accessToken: "at",
providerSpecificData: { authMethod: "idc" },
} as never);
assert.equal(idcHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined);
const builderIdHeaders = exec.buildHeaders({ accessToken: "at" } as never);
assert.equal(builderIdHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined);
});

View File

@@ -24,6 +24,7 @@ const core = await import("../../src/lib/db/core.ts");
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;
@@ -37,12 +38,21 @@ test.beforeEach(() => {
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 {