/** * AWS credential resolution for the Bedrock provider. * * Chain (first hit wins): * 1. Static credentials from the environment * (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]). * 2. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO): * - static `aws_access_key_id` / `aws_secret_access_key` / `aws_session_token` * - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`, * which we exchange for short-lived role credentials via * `https://portal.sso.{region}.amazonaws.com/federation/credentials`. * - `credential_process` — an external command emitting the AWS SDK * `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`, * in-house brokers, etc. * 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and * `169.254.169.254` is reachable within a 1 s timeout). * * Resolved credentials are cached process-wide per profile and refreshed * 60 s before `Expiration` to absorb clock skew. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils"; import * as AIError from "../error"; import type { FetchImpl } from "../types"; import { raceWithSignal } from "../utils/abort"; import type { AwsCredentials } from "./aws-sigv4"; export interface ResolvedCredentials extends AwsCredentials { /** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */ expiresAt?: number; } export interface CredentialResolveOptions { /** Named profile from `~/.aws/credentials` / `~/.aws/config`. */ profile?: string; /** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */ region?: string; signal?: AbortSignal; fetch?: FetchImpl; } const REFRESH_SKEW_MS = 60_000; /** * TTL for file-sourced credentials that carry a session token but no expiry. * Tools like aws-vault/saml2aws rewrite ~/.aws/credentials with short-lived STS * session keys; caching them forever serves stale creds after rotation. */ const FILE_SESSION_CREDS_TTL_MS = 5 * 60_000; /** * Bound for the detached (signal-free) shared resolution: a hung * credential_process/SSO/IMDS fetch must not pin the inflight slot forever. */ const SHARED_RESOLVE_TIMEOUT_MS = 30_000; interface CacheEntry { creds: ResolvedCredentials; expiresAt: number; } const cache: Map = new Map(); const inflight: Map> = new Map(); export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}): Promise { const profile = opts.profile || $env.AWS_PROFILE || "default"; const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; const cacheKey = `${profile}\x00${region}`; const hit = cache.get(cacheKey); if (hit && hit.expiresAt - REFRESH_SKEW_MS > Date.now()) return hit.creds; // Single-flight: N concurrent cold calls must not each spawn credential_process/SSO/IMDS fetches. // The shared resolution is deliberately detached from any caller's signal — aborting one // request must not fail every waiter — and bounded by its own timeout instead; each caller // races its own signal against the shared promise. const existing = inflight.get(cacheKey); if (existing) return raceWithSignal(existing, opts.signal); const fetchImpl = opts.fetch ?? (globalThis.fetch as FetchImpl); const promise = (async () => { try { const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), fetchImpl); cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY }); return creds; } finally { inflight.delete(cacheKey); } })(); inflight.set(cacheKey, promise); return raceWithSignal(promise, opts.signal); } async function resolveFresh( profile: string, region: string, signal?: AbortSignal, fetchImpl: FetchImpl = globalThis.fetch as FetchImpl, ): Promise { // 1. Environment first — matches the AWS SDK chain order. const envCreds = readEnvCredentials(); if (envCreds) return envCreds; // 2. Profile (static or SSO). const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl); if (profileCreds) return profileCreds; // 3. EC2 IMDSv2. if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") { const imdsCreds = await readImdsCredentials(signal, fetchImpl); if (imdsCreds) return imdsCreds; } throw new AIError.AwsCredentialsError( `Unable to resolve AWS credentials. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` + `or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`, "resolution", ); } function readEnvCredentials(): ResolvedCredentials | undefined { const ak = $env.AWS_ACCESS_KEY_ID; const sk = $env.AWS_SECRET_ACCESS_KEY; if (!ak || !sk) return undefined; const token = $env.AWS_SESSION_TOKEN; return token ? { accessKeyId: ak, secretAccessKey: sk, sessionToken: token } : { accessKeyId: ak, secretAccessKey: sk }; } // ---------- INI parsing ---------- /** Map of section name -> map of key -> value. Section names are stripped of * any leading `profile ` (so `~/.aws/config` aligns with `~/.aws/credentials`). */ type IniFile = Record>; function parseIni(text: string): IniFile { const out: IniFile = {}; let current: Record | null = null; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#") || line.startsWith(";")) continue; if (line.startsWith("[") && line.endsWith("]")) { let name = line.slice(1, -1).trim(); if (name.startsWith("profile ")) name = name.slice(8).trim(); if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`; let section = out[name]; if (!section) { section = {}; out[name] = section; } current = section; continue; } if (!current) continue; const eq = line.indexOf("="); if (eq === -1) continue; current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); } return out; } async function readIniFile(p: string): Promise { try { const text = await fs.promises.readFile(p, "utf8"); return parseIni(text); } catch (err) { if (isEnoent(err)) return undefined; throw err; } } // ---------- Profile / SSO ---------- async function readProfileCredentials( profile: string, region: string, signal: AbortSignal | undefined, fetchImpl: FetchImpl, ): Promise { const home = os.homedir(); const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, ".aws", "credentials"); const configPath = $env.AWS_CONFIG_FILE || path.join(home, ".aws", "config"); const credentialsIni = await readIniFile(credentialsPath); const configIni = await readIniFile(configPath); // Static credentials live in ~/.aws/credentials; SSO config lives in // ~/.aws/config under `[profile foo]`. Merge into a single view. const merged: Record = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) }; if (Object.keys(merged).length === 0) return undefined; if (merged.aws_access_key_id && merged.aws_secret_access_key) { const out: ResolvedCredentials = { accessKeyId: merged.aws_access_key_id, secretAccessKey: merged.aws_secret_access_key, }; if (merged.aws_session_token) { out.sessionToken = merged.aws_session_token; // Session-token creds in the credentials file are short-lived STS keys that // external tools rotate in place; cap the cache so rotations are picked up. out.expiresAt = Date.now() + FILE_SESSION_CREDS_TTL_MS; } return out; } if (merged.sso_account_id && merged.sso_role_name) { return readSsoCredentials(merged, configIni, region, signal, fetchImpl); } if (merged.credential_process) { return readCredentialProcess(profile, merged.credential_process, signal); } return undefined; } interface SsoCachedToken { accessToken?: string; expiresAt?: string; startUrl?: string; region?: string; } async function readSsoCredentials( profileCfg: Record, configIni: IniFile | undefined, defaultRegion: string, signal: AbortSignal | undefined, fetchImpl: FetchImpl, ): Promise { // Two SSO profile shapes: // - legacy: `sso_start_url` + `sso_region` directly on the profile // - sso-session: `sso_session = my-session` references a `[sso-session my-session]` block let startUrl = profileCfg.sso_start_url; let ssoRegion = profileCfg.sso_region; const sessionName = profileCfg.sso_session; if (sessionName && configIni) { const session = configIni[`sso-session:${sessionName}`]; if (session) { startUrl = startUrl || session.sso_start_url; ssoRegion = ssoRegion || session.sso_region; } } if (!startUrl || !ssoRegion) return undefined; const token = await loadSsoCachedToken(startUrl, sessionName); if (!token?.accessToken) { throw new AIError.AwsCredentialsError( `AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`, "sso-token-missing", ); } const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Number.POSITIVE_INFINITY; if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { throw new AIError.AwsCredentialsError( `AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`, "sso-token-expired", ); } const url = `https://portal.sso.${ssoRegion}.amazonaws.com/federation/credentials` + `?account_id=${encodeURIComponent(profileCfg.sso_account_id)}` + `&role_name=${encodeURIComponent(profileCfg.sso_role_name)}`; const response = await fetchImpl(url, { method: "GET", headers: { "x-amz-sso_bearer_token": token.accessToken }, signal, }); if (!response.ok) { const body = await response.text().catch(() => ""); throw new AIError.AwsCredentialsError( `AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`, "sso-role", ); } const json = (await response.json()) as { roleCredentials?: { accessKeyId: string; secretAccessKey: string; sessionToken: string; expiration: number }; }; const role = json.roleCredentials; if (!role) throw new AIError.AwsCredentialsError( "AWS SSO GetRoleCredentials: missing roleCredentials in response", "sso-role", ); // region is honored at the caller; we only consume defaultRegion to keep the // param wired for symmetry with other resolution paths. void defaultRegion; return { accessKeyId: role.accessKeyId, secretAccessKey: role.secretAccessKey, sessionToken: role.sessionToken, expiresAt: role.expiration, }; } async function loadSsoCachedToken( startUrl: string, sessionName: string | undefined, ): Promise { const cacheDir = path.join(os.homedir(), ".aws", "sso", "cache"); let entries: string[]; try { entries = await fs.promises.readdir(cacheDir); } catch (err) { if (isEnoent(err)) return undefined; throw err; } // Prefer the deterministic hash for legacy `sso_start_url` profiles or the // session name for the newer `sso-session` shape; otherwise scan. const candidates: string[] = []; const hash = await sha1Hex(sessionName || startUrl); candidates.push(`${hash}.json`); for (const entry of entries) { if (entry.endsWith(".json") && !candidates.includes(entry)) candidates.push(entry); } for (const file of candidates) { if (!entries.includes(file)) continue; try { const text = await fs.promises.readFile(path.join(cacheDir, file), "utf8"); const parsed = JSON.parse(text) as SsoCachedToken; if (parsed.startUrl === startUrl || (sessionName && file === `${hash}.json`)) { return parsed; } } catch (err) { logger.debug("aws-credentials: failed to read SSO cache", { file, err: String(err) }); } } return undefined; } async function sha1Hex(input: string): Promise { const digest = await globalThis.crypto.subtle.digest("SHA-1", new TextEncoder().encode(input)); const bytes = new Uint8Array(digest); let out = ""; for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0"); return out; } // ---------- credential_process ---------- /** JSON envelope emitted by an external credential process. Matches the * AWS CLI / SDK contract documented at * https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html */ interface CredentialProcessEnvelope { Version?: number; AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string; Expiration?: string; } async function readCredentialProcess( profile: string, command: string, signal: AbortSignal | undefined, ): Promise { const argv = buildCredentialProcessArgv(profile, command); const child = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true, signal, }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); if (exitCode !== 0) { const tail = stderr.trim().slice(-512) || stdout.trim().slice(-512) || "(no output)"; throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`, "credential-process", ); } let parsed: CredentialProcessEnvelope; try { parsed = JSON.parse(stdout) as CredentialProcessEnvelope; } catch (err) { throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`, "credential-process", { cause: err }, ); } if (parsed.Version !== 1) { throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' returned unsupported Version ${parsed.Version ?? ""}; expected 1.`, "credential-process", ); } if (!parsed.AccessKeyId || !parsed.SecretAccessKey) { throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' returned envelope without AccessKeyId/SecretAccessKey.`, "credential-process", ); } const out: ResolvedCredentials = { accessKeyId: parsed.AccessKeyId, secretAccessKey: parsed.SecretAccessKey, }; if (parsed.SessionToken) out.sessionToken = parsed.SessionToken; if (parsed.Expiration) { const exp = Date.parse(parsed.Expiration); if (!Number.isNaN(exp)) out.expiresAt = exp; } return out; } /** Resolve the argv for `Bun.spawn`. On Windows we route `.cmd`/`.bat` helpers * through `cmd.exe /c` because direct execution refuses batch files (mirrors * Node's `execFile` policy and avoids surprise no-ops). */ function buildCredentialProcessArgv(profile: string, command: string): string[] { const tokens = tokenizeCredentialProcessCommand(command); if (tokens.length === 0) { throw new AIError.AwsCredentialsError( `AWS credential_process for profile '${profile}' is empty.`, "credential-process", ); } if (process.platform === "win32" && isBatchScript(tokens[0])) { return ["cmd.exe", "/d", "/s", "/c", command]; } return tokens; } function isBatchScript(executable: string): boolean { const lower = executable.toLowerCase(); return lower.endsWith(".cmd") || lower.endsWith(".bat"); } /** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`. * * Outside quotes a backslash escapes the next character. Inside single quotes * everything is literal (no escapes, cannot contain `'`). Inside double quotes * a backslash only escapes `$`, `` ` ``, `"`, and `\` — every other backslash * is preserved verbatim, which is what makes Windows paths like * `"C:\Program Files\tool\auth.exe"` survive tokenization. */ export function tokenizeCredentialProcessCommand(cmd: string): string[] { const tokens: string[] = []; let current = ""; let hasToken = false; let mode: "normal" | "single" | "double" = "normal"; for (let i = 0; i < cmd.length; i++) { const ch = cmd[i]; if (mode === "normal") { if (ch === "'") { mode = "single"; hasToken = true; continue; } if (ch === '"') { mode = "double"; hasToken = true; continue; } if (ch === "\\" && i + 1 < cmd.length) { current += cmd[++i]; hasToken = true; continue; } if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { if (hasToken) { tokens.push(current); current = ""; hasToken = false; } continue; } current += ch; hasToken = true; continue; } if (mode === "single") { if (ch === "'") { mode = "normal"; continue; } current += ch; continue; } // double-quote if (ch === '"') { mode = "normal"; continue; } if (ch === "\\" && i + 1 < cmd.length) { const next = cmd[i + 1]; if (next === "$" || next === "`" || next === '"' || next === "\\") { current += next; i++; continue; } // Preserve literal backslash for Windows paths. current += ch; continue; } current += ch; } if (mode !== "normal") { throw new AIError.AwsCredentialsError( "AWS credential_process command has an unterminated quote.", "credential-process", ); } if (hasToken) tokens.push(current); return tokens; } // ---------- IMDSv2 ---------- const IMDS_HOST = "169.254.169.254"; const IMDS_TIMEOUT_MS = 1000; async function readImdsCredentials( parentSignal: AbortSignal | undefined, fetchImpl: FetchImpl, ): Promise { const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS); const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout; try { const tokenRes = await fetchImpl(`http://${IMDS_HOST}/latest/api/token`, { method: "PUT", headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" }, signal, }); if (!tokenRes.ok) return undefined; const token = await tokenRes.text(); const roleRes = await fetchImpl(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, { headers: { "x-aws-ec2-metadata-token": token }, signal, }); if (!roleRes.ok) return undefined; const role = (await roleRes.text()).trim(); if (!role) return undefined; const credsRes = await fetchImpl( `http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, { headers: { "x-aws-ec2-metadata-token": token }, signal, }, ); if (!credsRes.ok) return undefined; const body = (await credsRes.json()) as { AccessKeyId?: string; SecretAccessKey?: string; Token?: string; Expiration?: string; }; if (!body.AccessKeyId || !body.SecretAccessKey) return undefined; const out: ResolvedCredentials = { accessKeyId: body.AccessKeyId, secretAccessKey: body.SecretAccessKey, }; if (body.Token) out.sessionToken = body.Token; if (body.Expiration) out.expiresAt = Date.parse(body.Expiration); return out; } catch { return undefined; } } /** Test/diagnostic helper — drops cached credentials. */ export function clearAwsCredentialCache(): void { cache.clear(); } /** * Drop the cache entry for one profile/region. Called by the Bedrock provider on * 401/403 responses so stale credentials are re-resolved instead of served until restart. */ export function invalidateAwsCredentialCache(opts: { profile?: string; region?: string } = {}): void { const profile = opts.profile || $env.AWS_PROFILE || "default"; const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; cache.delete(`${profile}\x00${region}`); }