mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(oauth): support Kiro IDC (organization) token import (#4944)
Integrated into release/v3.8.38 (leva 5)
This commit is contained in:
committed by
GitHub
parent
abcf593213
commit
ac8bd72e91
@@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr)
|
||||
- **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628)
|
||||
- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19)
|
||||
- **oauth(kiro):** support Kiro IDC (organization) token import — when the `~/.aws/sso/cache` token carries a `clientIdHash`, auto-import now reads the linked client registration file to obtain `clientId`/`clientSecret`, probes the Kiro IDE `profile.json` for `profileArn` (ARN region normalized to `us-east-1` for the runtime gateway), and refreshes via the regional AWS OIDC endpoint instead of the social path; the import schema and modal forward these credentials so manual imports also work for IDC tokens. (thanks @enjoyer-hub)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -207,6 +207,11 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
|
||||
triedPath?: string;
|
||||
refreshToken?: string;
|
||||
source?: string;
|
||||
clientId?: string | null;
|
||||
clientSecret?: string | null;
|
||||
region?: string | null;
|
||||
authMethod?: string | null;
|
||||
profileArn?: string | null;
|
||||
}> {
|
||||
const { readFile, readdir } = await import("fs/promises");
|
||||
const cachePath = join(homedir(), ".aws/sso/cache");
|
||||
@@ -231,7 +236,77 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{
|
||||
const content = await readFile(join(cachePath, file), "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
if (data.refreshToken?.startsWith("aorAAAAAG")) {
|
||||
return { found: true, refreshToken: data.refreshToken, source: file };
|
||||
const region: string | null = data.region || null;
|
||||
const authMethod: string | null = data.authMethod || null;
|
||||
|
||||
// For IDC/organization tokens, resolve clientId and clientSecret from
|
||||
// the linked client registration file (referenced by clientIdHash).
|
||||
let clientId: string | null = null;
|
||||
let clientSecret: string | null = null;
|
||||
if (data.clientIdHash) {
|
||||
const clientFile = `${data.clientIdHash}.json`;
|
||||
try {
|
||||
const clientContent = await readFile(join(cachePath, clientFile), "utf-8");
|
||||
const clientData = JSON.parse(clientContent);
|
||||
if (clientData.clientId && clientData.clientSecret) {
|
||||
clientId = clientData.clientId;
|
||||
clientSecret = clientData.clientSecret;
|
||||
}
|
||||
} catch {
|
||||
// Client registration file not found — continue without it
|
||||
}
|
||||
}
|
||||
|
||||
// Read profileArn from Kiro IDE's profile.json.
|
||||
// The runtime gateway requires us-east-1 in the ARN regardless of the IDC
|
||||
// region, so we normalize the ARN region to us-east-1 (#2059).
|
||||
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) {
|
||||
// Normalize region to us-east-1 for the runtime gateway
|
||||
profileArn = profileData.arn.replace(
|
||||
/arn:aws:codewhisperer:[^:]+:/,
|
||||
"arn:aws:codewhisperer:us-east-1:"
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
refreshToken: data.refreshToken,
|
||||
source: file,
|
||||
clientId,
|
||||
clientSecret,
|
||||
region,
|
||||
authMethod,
|
||||
profileArn,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
@@ -296,8 +371,13 @@ export function findKiroConnectionByProfileArn(
|
||||
|
||||
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
|
||||
|
||||
type SaveAndRespondResult = Awaited<ReturnType<typeof tryKiroCliSqlite>> & {
|
||||
// Fields added by tryAwsSsoCache for IDC tokens (#2059)
|
||||
authMethod?: string | null;
|
||||
};
|
||||
|
||||
async function saveAndRespond(
|
||||
result: Awaited<ReturnType<typeof tryKiroCliSqlite>>,
|
||||
result: SaveAndRespondResult,
|
||||
targetProvider: string,
|
||||
request: Request
|
||||
) {
|
||||
@@ -311,8 +391,19 @@ async function saveAndRespond(
|
||||
let expiresAt = result.expiresAt;
|
||||
let profileArn = result.profileArn;
|
||||
|
||||
// Determine authMethod: prefer the value from the SSO cache token (e.g. "idc")
|
||||
// so that kiroService.refreshToken() takes the correct OIDC path for IDC tokens
|
||||
// (#2059). Fall back to "kiro-cli" for the SQLite path and "imported" for plain
|
||||
// social SSO cache tokens (no clientIdHash → no IDC client creds).
|
||||
const resolvedAuthMethod =
|
||||
result.source === "kiro-cli-sqlite"
|
||||
? "kiro-cli"
|
||||
: result.clientId
|
||||
? result.authMethod || "idc"
|
||||
: "imported";
|
||||
|
||||
const providerSpecificData: Record<string, any> = {
|
||||
authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported",
|
||||
authMethod: resolvedAuthMethod,
|
||||
provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache",
|
||||
};
|
||||
|
||||
|
||||
@@ -61,40 +61,72 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { refreshToken, region } = validation.data;
|
||||
const { refreshToken, region, clientId, clientSecret, authMethod, profileArn } =
|
||||
validation.data;
|
||||
|
||||
const kiroService = new KiroService();
|
||||
|
||||
// Resolve proxy for this provider (provider-level → global → direct)
|
||||
const proxy = await resolveProxyForProvider(targetProvider);
|
||||
|
||||
// Validate and refresh token (through proxy if configured).
|
||||
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
|
||||
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
|
||||
const tokenData = await runWithProxyContext(proxy, () =>
|
||||
kiroService.validateImportToken(refreshToken.trim(), region)
|
||||
);
|
||||
// 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 /
|
||||
// Builder-ID tokens (no clientId) use validateImportToken() which handles
|
||||
// registerClient() internally to obtain an isolated refresh session (#2328).
|
||||
const isIdc = !!(clientId && clientSecret);
|
||||
let tokenData: Awaited<ReturnType<typeof kiroService.validateImportToken>>;
|
||||
if (isIdc) {
|
||||
const providerSpecificData = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
region: region || "us-east-1",
|
||||
authMethod: "idc",
|
||||
};
|
||||
const refreshed = await runWithProxyContext(proxy, () =>
|
||||
kiroService.refreshToken(refreshToken.trim(), providerSpecificData)
|
||||
);
|
||||
tokenData = {
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: refreshed.refreshToken || refreshToken.trim(),
|
||||
expiresIn: refreshed.expiresIn || 3600,
|
||||
profileArn: profileArn || null,
|
||||
authMethod: "idc",
|
||||
clientId,
|
||||
clientSecret,
|
||||
} as any;
|
||||
} else {
|
||||
// Validate and refresh token (through proxy if configured).
|
||||
// validateImportToken also calls registerClient() to obtain a per-connection OIDC
|
||||
// client pair so multiple Kiro accounts do not share a single backend session (#2328).
|
||||
tokenData = await runWithProxyContext(proxy, () =>
|
||||
kiroService.validateImportToken(refreshToken.trim(), region)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract email from JWT if available
|
||||
const email = kiroService.extractEmailFromJWT(tokenData.accessToken);
|
||||
|
||||
const resolvedAuthMethod = isIdc ? "idc" : (tokenData as any).authMethod || "imported";
|
||||
const resolvedProfileArn = (tokenData as any).profileArn || null;
|
||||
|
||||
// Save to database
|
||||
const connection: any = await createProviderConnection({
|
||||
provider: targetProvider,
|
||||
authType: "oauth",
|
||||
accessToken: tokenData.accessToken,
|
||||
refreshToken: tokenData.refreshToken,
|
||||
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
|
||||
refreshToken: tokenData.refreshToken || refreshToken.trim(),
|
||||
expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(),
|
||||
email: email || null,
|
||||
providerSpecificData: {
|
||||
profileArn: tokenData.profileArn,
|
||||
authMethod: tokenData.authMethod || "imported",
|
||||
provider: "Imported",
|
||||
profileArn: resolvedProfileArn,
|
||||
authMethod: resolvedAuthMethod,
|
||||
provider: isIdc ? "Enterprise" : "Imported",
|
||||
...(tokenData.clientId
|
||||
? {
|
||||
clientId: tokenData.clientId,
|
||||
clientSecret: tokenData.clientSecret,
|
||||
region,
|
||||
region: region || "us-east-1",
|
||||
...(tokenData.clientSecretExpiresAt
|
||||
? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt }
|
||||
: {}),
|
||||
|
||||
@@ -32,6 +32,10 @@ export default function KiroAuthModal({
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [autoDetecting, setAutoDetecting] = useState(false);
|
||||
const [autoDetected, setAutoDetected] = useState(false);
|
||||
// IDC/organization credentials returned by auto-import when the SSO cache token
|
||||
// has a clientIdHash. Spread into the import POST body so the regional OIDC
|
||||
// endpoint is used for token refresh instead of the social path (#2059).
|
||||
const [idcCredentials, setIdcCredentials] = useState<Record<string, string> | null>(null);
|
||||
|
||||
// Auto-detect token when import method is selected
|
||||
useEffect(() => {
|
||||
@@ -41,6 +45,7 @@ export default function KiroAuthModal({
|
||||
setAutoDetecting(true);
|
||||
setError(null);
|
||||
setAutoDetected(false);
|
||||
setIdcCredentials(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
@@ -51,6 +56,16 @@ export default function KiroAuthModal({
|
||||
if (data.found) {
|
||||
setRefreshToken(data.refreshToken);
|
||||
setAutoDetected(true);
|
||||
// Store IDC/organization credentials if present in the auto-detect response
|
||||
if (data.clientId && data.clientSecret) {
|
||||
setIdcCredentials({
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
...(data.region ? { region: data.region } : {}),
|
||||
...(data.authMethod ? { authMethod: data.authMethod } : {}),
|
||||
...(data.profileArn ? { profileArn: data.profileArn } : {}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setError(data.error || "Could not auto-detect token");
|
||||
}
|
||||
@@ -89,7 +104,10 @@ export default function KiroAuthModal({
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken: refreshToken.trim() }),
|
||||
body: JSON.stringify({
|
||||
refreshToken: refreshToken.trim(),
|
||||
...(idcCredentials || {}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -203,6 +203,12 @@ export const traeImportSchema = z.object({
|
||||
export const kiroImportSchema = z.object({
|
||||
refreshToken: z.string().trim().min(1, "Refresh token is required"),
|
||||
region: z.string().trim().default("us-east-1"),
|
||||
// IDC (organization) token fields — present when auto-detected from an IDC SSO
|
||||
// cache token with a clientIdHash (#2059). Optional for backward compatibility.
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
authMethod: z.string().optional(),
|
||||
profileArn: z.string().optional(),
|
||||
});
|
||||
|
||||
export const kiroSocialExchangeSchema = z.object({
|
||||
@@ -213,4 +219,4 @@ export const kiroSocialExchangeSchema = z.object({
|
||||
|
||||
export const zedImportSchema = z.object({
|
||||
confirmedAccounts: z.array(confirmedAccountSchema),
|
||||
});
|
||||
});
|
||||
|
||||
355
tests/unit/oauth/kiro-auto-import-idc-2059.test.ts
Normal file
355
tests/unit/oauth/kiro-auto-import-idc-2059.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* TDD for PR #2059 — Kiro IDC (organization) token import support.
|
||||
*
|
||||
* When the ~/.aws/sso/cache token file includes a `clientIdHash` field the
|
||||
* auto-import path should:
|
||||
* (a) read `${clientIdHash}.json` from the same cache dir to obtain
|
||||
* `clientId` / `clientSecret`;
|
||||
* (b) probe Kiro IDE's `profile.json` (Windows + Linux paths) for `arn` and
|
||||
* normalize the ARN region to `us-east-1`;
|
||||
* (c) include all IDC fields in the returned JSON so the import UI can pass
|
||||
* them along to /api/oauth/kiro/import.
|
||||
*
|
||||
* When `clientIdHash` is absent the fallback path must still work (backward
|
||||
* compat).
|
||||
*
|
||||
* The import schema (`kiroImportSchema`) must accept the new optional IDC
|
||||
* fields and reject invalid types.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ── Hermetic DATA_DIR so DB setup / requireLogin does not hit real disk ──────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-idc-2059-data-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret-idc-2059";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-api-key-secret-idc-2059";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
// Import route module once (DB is initialized on first import).
|
||||
const { GET } = await import("../../../src/app/api/oauth/kiro/auto-import/route.ts");
|
||||
|
||||
const ORIGINAL_HOME = process.env.HOME;
|
||||
const ORIGINAL_APPDATA = process.env.APPDATA;
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
test.beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-idc-2059-"));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
process.env.HOME = tmpHome;
|
||||
delete process.env.APPDATA;
|
||||
// Reset fetch so tests with mocks don't bleed into each other.
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env.HOME = ORIGINAL_HOME;
|
||||
if (ORIGINAL_APPDATA !== undefined) {
|
||||
process.env.APPDATA = ORIGINAL_APPDATA;
|
||||
} else {
|
||||
delete process.env.APPDATA;
|
||||
}
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Writes the standard AWS SSO cache layout under tmpHome. */
|
||||
function writeAwsSsoCache(opts: {
|
||||
tokenFile?: string;
|
||||
tokenData: Record<string, unknown>;
|
||||
clientIdHash?: string;
|
||||
clientData?: Record<string, unknown>;
|
||||
}) {
|
||||
const cacheDir = path.join(tmpHome, ".aws/sso/cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const tokenFile = opts.tokenFile ?? "kiro-auth-token.json";
|
||||
fs.writeFileSync(path.join(cacheDir, tokenFile), JSON.stringify(opts.tokenData));
|
||||
|
||||
if (opts.clientIdHash && opts.clientData) {
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, `${opts.clientIdHash}.json`),
|
||||
JSON.stringify(opts.clientData)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes profile.json at the Linux globalStorage path. */
|
||||
function writeKiroProfileJson(arn: string) {
|
||||
const profileDir = path.join(tmpHome, ".config/Kiro/User/globalStorage/kiro.kiroagent");
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(profileDir, "profile.json"), JSON.stringify({ arn }));
|
||||
}
|
||||
|
||||
/** Stubs globalThis.fetch so that no real network calls are made.
|
||||
* Returns a minimal Kiro OIDC refresh response for all Kiro endpoints. */
|
||||
function stubFetchForRefresh() {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const u = String(input);
|
||||
// OIDC client registration
|
||||
if (u.includes("oidc.") && u.endsWith("/client/register")) {
|
||||
return new Response(
|
||||
JSON.stringify({ clientId: "reg-cid", clientSecret: "reg-secret", expiresIn: 86400 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
// OIDC token refresh (IDC / Builder ID path)
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "access-refreshed",
|
||||
refreshToken: "aorAAAAAGrefreshed",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
// Social/Builder-ID token refresh (prod.*.auth.desktop.kiro.dev/refreshToken)
|
||||
if (u.includes("kiro.dev") && u.endsWith("/refreshToken")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "access-social-refreshed",
|
||||
refreshToken: "aorAAAAAGsocial-refreshed",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`[kiro-idc-2059 test] unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function callGet(): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const request = new Request("http://localhost/api/oauth/kiro/auto-import");
|
||||
const response = await GET(request);
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
// ── Tests: IDC path (clientIdHash present) ───────────────────────────────────
|
||||
|
||||
test("auto-import: when clientIdHash is present, reads client registration file and uses OIDC endpoint for refresh", async () => {
|
||||
const CLIENT_ID_HASH = "abc123def456";
|
||||
|
||||
writeAwsSsoCache({
|
||||
tokenData: {
|
||||
refreshToken: "aorAAAAAGidc-refresh-token",
|
||||
clientIdHash: CLIENT_ID_HASH,
|
||||
region: "us-east-1",
|
||||
authMethod: "idc",
|
||||
},
|
||||
clientIdHash: CLIENT_ID_HASH,
|
||||
clientData: {
|
||||
clientId: "idc-client-id-value",
|
||||
clientSecret: "idc-client-secret-value",
|
||||
},
|
||||
});
|
||||
|
||||
// Track which URLs were fetched to verify the OIDC path (not social path) was used
|
||||
const fetchedUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const u = String(input);
|
||||
fetchedUrls.push(u);
|
||||
if (u.includes("oidc.") && u.endsWith("/client/register")) {
|
||||
return new Response(
|
||||
JSON.stringify({ clientId: "reg-cid", clientSecret: "reg-secret", expiresIn: 86400 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "access-refreshed",
|
||||
refreshToken: "aorAAAAAGrefreshed",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (u.includes("kiro.dev") && u.endsWith("/refreshToken")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "social-access",
|
||||
refreshToken: "aorAAAAAGsocial",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`[kiro-idc-2059 test] unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { body } = await callGet();
|
||||
|
||||
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
|
||||
|
||||
// Verify that the OIDC /token endpoint was called (IDC path), not the social
|
||||
// kiro.dev/refreshToken endpoint. This proves the registration file was read
|
||||
// and the IDC credentials were used for token refresh.
|
||||
const usedOidcTokenEndpoint = fetchedUrls.some(
|
||||
(u) => u.includes("oidc.") && u.endsWith("/token")
|
||||
);
|
||||
assert.ok(
|
||||
usedOidcTokenEndpoint,
|
||||
`expected OIDC /token endpoint to be called for IDC refresh, fetched URLs: ${JSON.stringify(fetchedUrls)}`
|
||||
);
|
||||
const usedSocialEndpoint = fetchedUrls.some(
|
||||
(u) => u.includes("kiro.dev") && u.endsWith("/refreshToken")
|
||||
);
|
||||
assert.equal(
|
||||
usedSocialEndpoint,
|
||||
false,
|
||||
`social kiro.dev/refreshToken must NOT be called for IDC tokens, fetched URLs: ${JSON.stringify(fetchedUrls)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-import: when clientIdHash is present and profile.json exists, returns normalized ARN with us-east-1", async () => {
|
||||
const CLIENT_ID_HASH = "hash999";
|
||||
|
||||
writeAwsSsoCache({
|
||||
tokenData: {
|
||||
refreshToken: "aorAAAAAGidc-arn-test",
|
||||
clientIdHash: CLIENT_ID_HASH,
|
||||
region: "ap-southeast-1",
|
||||
authMethod: "idc",
|
||||
},
|
||||
clientIdHash: CLIENT_ID_HASH,
|
||||
clientData: { clientId: "cid", clientSecret: "csec" },
|
||||
});
|
||||
|
||||
// Write profile.json with a non-us-east-1 region in the ARN.
|
||||
writeKiroProfileJson("arn:aws:codewhisperer:ap-southeast-1:123456789012:profile/MyProfile");
|
||||
|
||||
stubFetchForRefresh();
|
||||
|
||||
const { body } = await callGet();
|
||||
|
||||
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
|
||||
assert.ok(
|
||||
typeof body.profileArn === "string" && body.profileArn.includes("us-east-1"),
|
||||
`profileArn should be normalized to us-east-1, got: ${body.profileArn}`
|
||||
);
|
||||
assert.ok(
|
||||
!(body.profileArn as string).includes("ap-southeast-1"),
|
||||
`normalized profileArn must not contain original region ap-southeast-1, got: ${body.profileArn}`
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-import: without clientIdHash, fallback still works and clientId/clientSecret are absent or null", async () => {
|
||||
// No clientIdHash — standard non-IDC token
|
||||
writeAwsSsoCache({
|
||||
tokenData: {
|
||||
refreshToken: "aorAAAAAGstandard-token",
|
||||
},
|
||||
});
|
||||
|
||||
stubFetchForRefresh();
|
||||
|
||||
const { body } = await callGet();
|
||||
|
||||
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
|
||||
// clientId/clientSecret should be null or undefined (not set from non-IDC cache)
|
||||
assert.ok(
|
||||
body.clientId === null || body.clientId === undefined,
|
||||
`clientId must be null/undefined when no clientIdHash, got: ${body.clientId}`
|
||||
);
|
||||
assert.ok(
|
||||
body.clientSecret === null || body.clientSecret === undefined,
|
||||
`clientSecret must be null/undefined when no clientIdHash, got: ${body.clientSecret}`
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-import: clientIdHash present but registration file missing — gracefully continues without clientId", async () => {
|
||||
// clientIdHash in token but NO corresponding file on disk
|
||||
writeAwsSsoCache({
|
||||
tokenData: {
|
||||
refreshToken: "aorAAAAAGidc-no-reg-file",
|
||||
clientIdHash: "nonexistent-hash",
|
||||
region: "us-east-1",
|
||||
},
|
||||
// No clientData written — file will not exist
|
||||
});
|
||||
|
||||
stubFetchForRefresh();
|
||||
|
||||
const { body } = await callGet();
|
||||
|
||||
// Should still succeed (graceful degradation)
|
||||
assert.equal(body.found, true, `expected found:true, got: ${JSON.stringify(body)}`);
|
||||
// clientId must be null — file not found
|
||||
assert.ok(
|
||||
body.clientId === null || body.clientId === undefined,
|
||||
`clientId must be null when registration file is missing, got: ${body.clientId}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Tests: schema ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("kiroImportSchema: accepts optional IDC fields (clientId, clientSecret, authMethod, profileArn)", async () => {
|
||||
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
|
||||
|
||||
const result = kiroImportSchema.safeParse({
|
||||
refreshToken: "aorAAAAAGsome-token",
|
||||
region: "us-east-1",
|
||||
clientId: "idc-client-id",
|
||||
clientSecret: "idc-client-secret",
|
||||
authMethod: "idc",
|
||||
profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/MyProfile",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.success,
|
||||
true,
|
||||
`schema must accept IDC fields, errors: ${JSON.stringify(result.error?.errors)}`
|
||||
);
|
||||
if (result.success) {
|
||||
assert.equal(result.data.clientId, "idc-client-id");
|
||||
assert.equal(result.data.clientSecret, "idc-client-secret");
|
||||
assert.equal(result.data.authMethod, "idc");
|
||||
assert.equal(
|
||||
result.data.profileArn,
|
||||
"arn:aws:codewhisperer:us-east-1:123456789012:profile/MyProfile"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("kiroImportSchema: still valid without IDC fields (backward compat)", async () => {
|
||||
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
|
||||
|
||||
const result = kiroImportSchema.safeParse({
|
||||
refreshToken: "aorAAAAAGsome-token",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.success,
|
||||
true,
|
||||
`schema must be valid without IDC fields, errors: ${JSON.stringify(result.error?.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("kiroImportSchema: rejects non-string clientId", async () => {
|
||||
const { kiroImportSchema } = await import("../../../src/shared/validation/schemas/auth.ts");
|
||||
|
||||
const result = kiroImportSchema.safeParse({
|
||||
refreshToken: "aorAAAAAGsome-token",
|
||||
clientId: 12345, // bad type
|
||||
});
|
||||
|
||||
assert.equal(result.success, false, "schema must reject numeric clientId");
|
||||
});
|
||||
Reference in New Issue
Block a user