mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* 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>
139 lines
6.4 KiB
TypeScript
139 lines
6.4 KiB
TypeScript
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);
|
|
});
|