diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index bc8df516..d40a7833 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -26,7 +26,11 @@ export class KiroExecutor extends BaseExecutor { // exactly like an OAuth access token, but with an extra `tokentype: API_KEY` // header so CodeWhisperer treats it as a long-lived API key rather than an // OIDC/social access token. Mirrors the Kiro IDE headless-auth behavior. - const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key"; + // Enterprise / Microsoft Entra (external_idp) tokens are OAuth access tokens, + // but CodeWhisperer requires TokenType=EXTERNAL_IDP to bind them to profiles. + const authMethod = credentials?.providerSpecificData?.authMethod; + const isApiKey = authMethod === "api_key"; + const isExternalIdp = authMethod === "external_idp"; const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); if (isApiKey && apiKey) { @@ -34,6 +38,9 @@ export class KiroExecutor extends BaseExecutor { headers["tokentype"] = "API_KEY"; } else if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; + if (isExternalIdp) { + headers["TokenType"] = "EXTERNAL_IDP"; + } } return headers; @@ -49,13 +56,16 @@ export class KiroExecutor extends BaseExecutor { * BaseExecutor.execute() returns immediately (only 429 / network errors fall * through to the next host). So for api-key auth we must try the *.amazonaws.com * CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never - * routes api-key traffic through kiro.dev. OAuth keeps the default order - * (kiro.dev first) since its token is what that gateway accepts. + * routes api-key traffic through kiro.dev. External IdP enterprise tokens also + * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. + * Other OAuth methods keep the default order (kiro.dev first) since their + * tokens are what that gateway accepts. */ getOrderedBaseUrls(credentials) { const baseUrls = this.getBaseUrls(); - const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key"; - if (!isApiKey) return baseUrls; + const authMethod = credentials?.providerSpecificData?.authMethod; + const isCodeWhispererSurface = authMethod === "api_key" || authMethod === "external_idp"; + if (!isCodeWhispererSurface) return baseUrls; const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); return amazon.length > 0 ? [...amazon, ...others] : baseUrls; diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index b8352156..54c2fa0b 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -2,6 +2,7 @@ import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js"; import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js"; import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { dedupRefresh } from "./dedup.js"; +import { buildExternalIdpRefreshParams } from "../../../src/lib/oauth/kiroExternalIdp.js"; let _xaiServiceSingleton = null; export async function refreshXaiToken(refreshToken, log) { @@ -309,6 +310,49 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, const clientSecret = providerSpecificData?.clientSecret; const region = providerSpecificData?.region; + if (authMethod === "external_idp") { + let refreshRequest; + try { + refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + } catch (error) { + log?.warn?.("TOKEN_REFRESH", `Invalid Kiro external_idp refresh config: ${error.message}`); + return null; + } + + const response = await proxyAwareFetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }, proxyOptions); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", { + status: response.status, + error: errorText, + }); + 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, + providerSpecificData: refreshRequest.providerSpecificData, + }; + } + if (clientId && clientSecret) { const isIDC = authMethod === "idc"; const endpoint = isIDC && region diff --git a/open-sse/services/usage/kiro.js b/open-sse/services/usage/kiro.js index fb221565..f517d0cd 100644 --- a/open-sse/services/usage/kiro.js +++ b/open-sse/services/usage/kiro.js @@ -55,7 +55,9 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio // CodeWhisperer treats it as a long-lived API key rather than an OIDC token. // Without this header the GetUsageLimits call is rejected (401/403). const isApiKey = authMethod === "api_key"; + const isExternalIdp = authMethod === "external_idp"; const apiKeyHeaders = isApiKey ? { tokentype: "API_KEY" } : {}; + const externalIdpHeaders = isExternalIdp ? { TokenType: "EXTERNAL_IDP" } : {}; // For api-key auth, never inject the shared default placeholder profileArn — // CodeWhisperer 403s a request whose profileArn isn't owned by the key's @@ -84,6 +86,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", "user-agent": "aws-sdk-js/1.0.0 KiroIDE", ...apiKeyHeaders, + ...externalIdpHeaders, }, }, proxyOptions @@ -99,6 +102,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", "Accept": "application/json", ...apiKeyHeaders, + ...externalIdpHeaders, }, body: JSON.stringify({ origin: "AI_EDITOR", @@ -121,6 +125,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio "Authorization": `Bearer ${accessToken}`, "Accept": "application/json", ...apiKeyHeaders, + ...externalIdpHeaders, }, }, proxyOptions); }, diff --git a/src/app/api/oauth/kiro/import-cli-proxy/route.js b/src/app/api/oauth/kiro/import-cli-proxy/route.js new file mode 100644 index 00000000..d71d6a7c --- /dev/null +++ b/src/app/api/oauth/kiro/import-cli-proxy/route.js @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { normalizeKiroExternalIdpAuth } from "@/lib/oauth/kiroExternalIdp"; + +/** + * POST /api/oauth/kiro/import-cli-proxy + * Import Kiro CLIProxyAPI auth JSON for Microsoft external_idp accounts. + */ +export async function POST(request) { + try { + const body = await request.json(); + const rawAuth = body?.cliProxyAuth ?? body?.auth ?? body?.json ?? body; + const tokenData = normalizeKiroExternalIdpAuth(rawAuth); + + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + email: tokenData.email || null, + providerSpecificData: tokenData.providerSpecificData, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + return NextResponse.json( + { error: error?.message || "CLIProxyAPI import failed" }, + { status: 400 } + ); + } +} diff --git a/src/lib/oauth/kiroExternalIdp.js b/src/lib/oauth/kiroExternalIdp.js new file mode 100644 index 00000000..d07fb46f --- /dev/null +++ b/src/lib/oauth/kiroExternalIdp.js @@ -0,0 +1,155 @@ +const MICROSOFT_TOKEN_ENDPOINT_HOSTS = new Set([ + "login.microsoftonline.com", + "login.microsoft.com", + "login.windows.net", +]); + +const DEFAULT_REGION = "us-east-1"; +const DEFAULT_EXPIRES_IN = 3600; + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function validateMicrosoftTokenEndpoint(rawEndpoint) { + const tokenEndpoint = normalizeString(rawEndpoint); + if (!tokenEndpoint) throw new Error("token_endpoint is required"); + + let parsed; + try { + parsed = new URL(tokenEndpoint); + } catch { + throw new Error("token_endpoint must be a valid URL"); + } + + if (parsed.protocol !== "https:") { + throw new Error("token_endpoint must use https"); + } + + const host = parsed.hostname.toLowerCase(); + if (!MICROSOFT_TOKEN_ENDPOINT_HOSTS.has(host)) { + throw new Error("token_endpoint must be a Microsoft login endpoint"); + } + + return parsed.toString(); +} + +export function normalizeScope(scopes) { + if (Array.isArray(scopes)) { + return scopes.map(normalizeString).filter(Boolean).join(" "); + } + return normalizeString(scopes); +} + +export function decodeJwtPayload(jwt) { + try { + if (!jwt || 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; + return JSON.parse(Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8")); + } catch { + return null; + } +} + +function resolveExpiresAt(input) { + const explicit = input.expired || input.expires_at || input.expiresAt; + if (explicit) { + const ms = new Date(explicit).getTime(); + if (Number.isFinite(ms)) return new Date(ms).toISOString(); + } + + const expiresIn = Number(input.expires_in || input.expiresIn || 0); + if (Number.isFinite(expiresIn) && expiresIn > 0) { + return new Date(Date.now() + expiresIn * 1000).toISOString(); + } + + const payload = decodeJwtPayload(input.access_token || input.accessToken); + if (payload?.exp) { + return new Date(payload.exp * 1000).toISOString(); + } + + return new Date(Date.now() + DEFAULT_EXPIRES_IN * 1000).toISOString(); +} + +export function normalizeKiroExternalIdpAuth(rawAuth) { + let input = rawAuth; + if (typeof input === "string") { + try { + input = JSON.parse(input); + } catch { + throw new Error("CLIProxyAPI auth JSON is invalid"); + } + } + + if (!input || typeof input !== "object") { + throw new Error("CLIProxyAPI auth JSON is required"); + } + + const authMethod = normalizeString(input.auth_method || input.authMethod); + if (authMethod && authMethod !== "external_idp") { + throw new Error("Only external_idp Kiro auth is supported by this importer"); + } + + const accessToken = normalizeString(input.access_token || input.accessToken); + const refreshToken = normalizeString(input.refresh_token || input.refreshToken); + const clientId = normalizeString(input.client_id || input.clientId); + const tokenEndpoint = validateMicrosoftTokenEndpoint(input.token_endpoint || input.tokenEndpoint); + const profileArn = normalizeString(input.profile_arn || input.profileArn); + const region = normalizeString(input.region) || DEFAULT_REGION; + const scope = normalizeScope(input.scopes || input.scope); + + if (!accessToken) throw new Error("access_token is required"); + if (!refreshToken) throw new Error("refresh_token is required"); + if (!clientId) throw new Error("client_id is required"); + if (!scope) throw new Error("scopes is required"); + if (!profileArn) throw new Error("profile_arn is required"); + + const payload = decodeJwtPayload(accessToken); + const email = input.email || payload?.email || payload?.preferred_username || payload?.upn || payload?.sub || null; + + return { + accessToken, + refreshToken, + expiresAt: resolveExpiresAt(input), + email, + providerSpecificData: { + profileArn, + region, + authMethod: "external_idp", + provider: "CLIProxyAPI", + clientId, + tokenEndpoint, + scope, + }, + }; +} + +export function buildExternalIdpRefreshParams(refreshToken, providerSpecificData = {}) { + const clientId = normalizeString(providerSpecificData.clientId || providerSpecificData.client_id); + const tokenEndpoint = validateMicrosoftTokenEndpoint(providerSpecificData.tokenEndpoint || providerSpecificData.token_endpoint); + const scope = normalizeScope(providerSpecificData.scope || providerSpecificData.scopes); + + if (!refreshToken) throw new Error("refresh token is required"); + if (!clientId) throw new Error("clientId is required for external_idp refresh"); + if (!scope) throw new Error("scope is required for external_idp refresh"); + + return { + tokenEndpoint, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + scope, + }), + providerSpecificData: { + ...providerSpecificData, + authMethod: "external_idp", + clientId, + tokenEndpoint, + scope, + }, + }; +} diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js index d5325f95..dcc56cc4 100644 --- a/src/shared/components/KiroAuthModal.js +++ b/src/shared/components/KiroAuthModal.js @@ -13,6 +13,7 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { const [idcStartUrl, setIdcStartUrl] = useState(""); const [idcRegion, setIdcRegion] = useState("us-east-1"); const [refreshToken, setRefreshToken] = useState(""); + const [cliProxyJson, setCliProxyJson] = useState(""); const [apiKey, setApiKey] = useState(""); const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1"); const [error, setError] = useState(null); @@ -105,6 +106,36 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { } }; + const handleImportCliProxyJson = async () => { + if (!cliProxyJson.trim()) { + setError("Please paste CLIProxyAPI auth JSON"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/kiro/import-cli-proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ json: cliProxyJson.trim() }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "CLIProxyAPI import failed"); + } + + onMethodSelect("import-cli-proxy"); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + const handleIdcContinue = () => { if (!idcStartUrl.trim()) { setError("Please enter your IDC start URL"); @@ -256,6 +287,22 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { + + {/* Import CLIProxyAPI JSON */} + )} @@ -495,6 +542,47 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { )} )} + + {/* Import CLIProxyAPI JSON */} + {selectedMethod === "import-cli-proxy" && ( +
+ Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted. +
+{error}
+