Files
OmniRoute/open-sse/services/kiroExternalIdp.ts
NOXX - Commiter ece4bf7b53 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>
2026-07-09 21:39:09 -03:00

176 lines
7.2 KiB
TypeScript

/**
* 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 };
}