diff --git a/CHANGELOG.md b/CHANGELOG.md index 6001d8cb1d..deaea900cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/` 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 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9653ffa19a..0ec0532b4e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 7def296e39..9ac1a4f69b 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -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 ` (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; diff --git a/open-sse/services/kiroExternalIdp.ts b/open-sse/services/kiroExternalIdp.ts new file mode 100644 index 0000000000..3e27c8120d --- /dev/null +++ b/open-sse/services/kiroExternalIdp.ts @@ -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": "", + * "refreshToken": "", + * "authMethod": "external_idp", + * "provider": "ExternalIdp", + * "clientId": "", + * "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 `). 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 | 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; + } 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 | null | undefined +): ExternalIdpRefreshRequest { + const psd = providerSpecificData || {}; + const clientId = normalizeString(psd.clientId ?? (psd as Record).client_id); + const tokenEndpoint = validateExternalIdpTokenEndpoint( + psd.tokenEndpoint ?? (psd as Record).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 }; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 4d9841342d..b479daf278 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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 diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 9b85579103..4efe496de1 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -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; @@ -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 = { + 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), }); diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 1387f06302..3c3937cc59 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -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 { + 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> & { // 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 = { + 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!; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 7774908866..4e58bfcbd3 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -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 / diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index b1357533f3..86601b3f2c 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -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). diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 2e7e6bb1ac..1e251aa596 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -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({ diff --git a/tests/unit/kiro-external-idp.test.ts b/tests/unit/kiro-external-idp.test.ts new file mode 100644 index 0000000000..a8f5273b6e --- /dev/null +++ b/tests/unit/kiro-external-idp.test.ts @@ -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 `), +// • 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 { + 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)?.["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); +}); diff --git a/tests/unit/kiro-windows-auto-import-3363.test.ts b/tests/unit/kiro-windows-auto-import-3363.test.ts index b3bbf12cfd..184a25a30a 100644 --- a/tests/unit/kiro-windows-auto-import-3363.test.ts +++ b/tests/unit/kiro-windows-auto-import-3363.test.ts @@ -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 {