mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
175
open-sse/services/kiroExternalIdp.ts
Normal file
175
open-sse/services/kiroExternalIdp.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
138
tests/unit/kiro-external-idp.test.ts
Normal file
138
tests/unit/kiro-external-idp.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user