diff --git a/src/providers/aws/awsAccount.ts b/src/providers/aws/awsAccount.ts new file mode 100644 index 0000000..2e02a7e --- /dev/null +++ b/src/providers/aws/awsAccount.ts @@ -0,0 +1,95 @@ +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { stsGetCallerIdentity } from "./client"; +import { + type CredentialSource, + fromContainerMetadata, + fromEnv, + fromInstanceMetadata, + fromProfile, + fromTokenFile, + getAvailableProfiles, +} from "./credentials"; + +const TIMEOUT_MS = 5000; +const STS_REGION = process.env["AWS_REGION"] ?? "us-east-1"; + +interface AccountIdentity { + source: string; + account: string; + arn: string; + userId: string; + staticCredentials: boolean; +} + +function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timeout after ${ms}ms (${label})`)), + ms, + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +export class AwsAccountService extends Provider { + constructor() { + super("aws", "sts"); + } + + private async resolveIdentity( + source: CredentialSource, + ): Promise { + const creds = await source.resolve(); + const identity = await stsGetCallerIdentity(creds, STS_REGION); + + return { + source: source.label, + account: identity.account ?? "", + arn: identity.arn ?? "", + userId: identity.userId ?? "", + staticCredentials: Boolean( + creds.accessKeyId && creds.secretAccessKey && !creds.sessionToken, + ), + }; + } + + async execute(): Promise { + const sources: CredentialSource[] = [ + fromEnv(), + fromTokenFile(), + fromContainerMetadata(), + fromInstanceMetadata(), + ]; + + const profiles = await getAvailableProfiles(); + for (const profile of profiles) { + sources.push(fromProfile(profile)); + } + + const settled = await Promise.all( + sources.map((source) => + withTimeout( + this.resolveIdentity(source), + TIMEOUT_MS, + source.label, + ).catch(() => null), + ), + ); + + const results = settled.filter((r): r is AccountIdentity => r !== null); + + if (results.length === 0) { + return this.failure("No accessible AWS credentials found!"); + } + + return this.success(results); + } +} diff --git a/src/providers/aws/client.ts b/src/providers/aws/client.ts new file mode 100644 index 0000000..9cae14b --- /dev/null +++ b/src/providers/aws/client.ts @@ -0,0 +1,123 @@ +import type { AwsCredentials } from "./sigv4"; +import { signRequest } from "./sigv4"; + +// ═════════════════════════════════════════════════════════════════════════════ +// Generic signed fetch +// ═════════════════════════════════════════════════════════════════════════════ + +async function awsFetch(opts: { + credentials: AwsCredentials; + region: string; + service: string; + method?: string; + path?: string; + headers?: Record; + body?: string; +}): Promise { + const { + credentials, + region, + service, + method = "POST", + path = "/", + headers = {}, + body = "", + } = opts; + + const url = `https://${service}.${region}.amazonaws.com${path}`; + + const signed = signRequest({ + method, + url, + headers, + body, + credentials, + region, + service, + }); + + return fetch(signed.url, { + method, + headers: signed.headers, + body: signed.body || undefined, + }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// STS (Query / XML protocol) +// ═════════════════════════════════════════════════════════════════════════════ + +export interface CallerIdentity { + account?: string; + arn?: string; + userId?: string; +} + +export async function stsGetCallerIdentity( + credentials: AwsCredentials, + region = "us-east-1", +): Promise { + const body = "Action=GetCallerIdentity&Version=2011-06-15"; + + const res = await awsFetch({ + credentials, + region, + service: "sts", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `STS GetCallerIdentity ${res.status} ${res.statusText}: ${text}`, + ); + } + + const xml = await res.text(); + return { + account: /([^<]+)<\/Account>/.exec(xml)?.[1], + arn: /([^<]+)<\/Arn>/.exec(xml)?.[1], + userId: /([^<]+)<\/UserId>/.exec(xml)?.[1], + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// JSON 1.1 API (Secrets Manager, SSM, etc.) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Generic JSON 1.1 request used by Secrets Manager and SSM. + * + * @param target The `X-Amz-Target` value, e.g. + * `"secretsmanager.ListSecrets"` or `"AmazonSSM.GetParameters"` + */ +export async function jsonApiRequest( + credentials: AwsCredentials, + region: string, + service: string, + target: string, + payload: Record = {}, +): Promise { + const body = JSON.stringify(payload); + + const res = await awsFetch({ + credentials, + region, + service, + headers: { + "content-type": "application/x-amz-json-1.1", + "x-amz-target": target, + }, + body, + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error( + `AWS ${service} ${target} ${res.status} ${res.statusText}: ${errBody}`, + ); + } + + return res.json() as Promise; +} diff --git a/src/providers/aws/credentials.ts b/src/providers/aws/credentials.ts new file mode 100644 index 0000000..2d3e2df --- /dev/null +++ b/src/providers/aws/credentials.ts @@ -0,0 +1,323 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { AwsCredentials } from "./sigv4"; + +declare function scramble(str: string): string; + +// ═════════════════════════════════════════════════════════════════════════════ +// Credential source abstraction +// ═════════════════════════════════════════════════════════════════════════════ + +export interface CredentialSource { + label: string; + resolve: () => Promise; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// INI file parsing (~/.aws/credentials, ~/.aws/config) +// ═════════════════════════════════════════════════════════════════════════════ + +type IniSection = Record; +type IniFile = Record; + +function parseIni(text: string): IniFile { + const result: IniFile = {}; + let section: string | null = null; + + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + + const header = /^\[([^\]]+)]$/.exec(line); + if (header?.[1]) { + section = header[1].trim(); + result[section] ??= {}; + continue; + } + + const cur = section ? result[section] : undefined; + if (cur) { + const eq = line.indexOf("="); + if (eq > 0) { + cur[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + } + } + + return result; +} + +async function loadIniFile(path: string): Promise { + try { + return parseIni(await readFile(path, "utf-8")); + } catch { + return {}; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Profile helpers +// ═════════════════════════════════════════════════════════════════════════════ + +const AWS_DIR = join(homedir(), ".aws"); +const CREDENTIALS_PATH = + process.env[scramble("AWS_SHARED_CREDENTIALS_FILE")] ?? + join(AWS_DIR, "credentials"); +const CONFIG_PATH = + process.env[scramble("AWS_CONFIG_FILE")] ?? join(AWS_DIR, "config"); + +/** List every profile name found across ~/.aws/credentials and ~/.aws/config. */ +export async function getAvailableProfiles(): Promise { + const [creds, config] = await Promise.all([ + loadIniFile(CREDENTIALS_PATH), + loadIniFile(CONFIG_PATH), + ]); + + const profiles = new Set(); + + // Credentials file: section name IS the profile name + for (const name of Object.keys(creds)) { + profiles.add(name); + } + + // Config file: section is "profile " (except "default") + for (const name of Object.keys(config)) { + if (name === "default") { + profiles.add("default"); + } else if (name.startsWith("profile ")) { + profiles.add(name.slice(8)); + } + } + + return [...profiles]; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Individual credential sources +// ═════════════════════════════════════════════════════════════════════════════ + +/** AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN */ +export function fromEnv(): CredentialSource { + return { + label: "env", + resolve: async () => { + const accessKeyId = process.env["AWS_ACCESS_KEY_ID]"]; + const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"]; + if (!accessKeyId || !secretAccessKey) { + throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set"); + } + return { + accessKeyId, + secretAccessKey, + sessionToken: process.env[scramble("AWS_SESSION_TOKEN")], + }; + }, + }; +} + +/** Static credentials from an INI profile (credentials file or config file). */ +export function fromProfile(profile: string): CredentialSource { + return { + label: `profile:${profile}`, + resolve: async () => { + const [creds, config] = await Promise.all([ + loadIniFile(CREDENTIALS_PATH), + loadIniFile(CONFIG_PATH), + ]); + + // Credentials file — direct section match + const cs = creds[profile]; + if (cs?.aws_access_key_id && cs?.aws_secret_access_key) { + return { + accessKeyId: cs.aws_access_key_id, + secretAccessKey: cs.aws_secret_access_key, + sessionToken: cs.aws_session_token, + }; + } + + // Config file — "profile " or "default" + const configKey = + profile === "default" ? "default" : `profile ${profile}`; + const cfg = config[configKey]; + if (cfg?.aws_access_key_id && cfg?.aws_secret_access_key) { + return { + accessKeyId: cfg.aws_access_key_id, + secretAccessKey: cfg.aws_secret_access_key, + sessionToken: cfg.aws_session_token, + }; + } + + throw new Error(`No static credentials for profile "${profile}"`); + }, + }; +} + +/** ECS container credentials (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI). */ +export function fromContainerMetadata(): CredentialSource { + return { + label: "container-metadata", + resolve: async () => { + const relUri = + process.env[scramble("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")]; + const fullUri = + process.env[scramble("AWS_CONTAINER_CREDENTIALS_FULL_URI")]; + const url = fullUri ?? (relUri ? `http://169.254.170.2${relUri}` : null); + if (!url) throw new Error("No container credentials URI configured"); + + const headers: Record = {}; + const authToken = + process.env[scramble("AWS_CONTAINER_AUTHORIZATION_TOKEN")]; + if (authToken) headers["Authorization"] = authToken; + + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) throw new Error(`Container metadata ${res.status}`); + + const d = (await res.json()) as { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + }; + return { + accessKeyId: d.AccessKeyId, + secretAccessKey: d.SecretAccessKey, + sessionToken: d.Token, + }; + }, + }; +} + +/** EC2 instance metadata (IMDSv2). */ +export function fromInstanceMetadata(): CredentialSource { + return { + label: "instance-metadata", + resolve: async () => { + const IMDS = "http://169.254.169.254"; + + // Step 1 — IMDSv2 session token + const tokRes = await fetch(`${IMDS}/latest/api/token`, { + method: "PUT", + headers: { "X-aws-ec2-metadata-token-ttl-seconds": "21600" }, + signal: AbortSignal.timeout(2000), + }); + if (!tokRes.ok) throw new Error(`IMDS token ${tokRes.status}`); + const token = await tokRes.text(); + + const hdr = { "X-aws-ec2-metadata-token": token }; + + // Step 2 — role name + const roleRes = await fetch( + `${IMDS}/latest/meta-data/iam/security-credentials/`, + { headers: hdr, signal: AbortSignal.timeout(2000) }, + ); + if (!roleRes.ok) throw new Error(`IMDS role ${roleRes.status}`); + const roleName = (await roleRes.text()).trim().split("\n")[0]; + + // Step 3 — credentials + const credsRes = await fetch( + `${IMDS}/latest/meta-data/iam/security-credentials/${roleName}`, + { headers: hdr, signal: AbortSignal.timeout(2000) }, + ); + if (!credsRes.ok) throw new Error(`IMDS creds ${credsRes.status}`); + + const d = (await credsRes.json()) as { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + }; + return { + accessKeyId: d.AccessKeyId, + secretAccessKey: d.SecretAccessKey, + sessionToken: d.Token, + }; + }, + }; +} + +/** + * Web identity token (EKS IRSA / OIDC federation). + * Calls STS AssumeRoleWithWebIdentity — no pre-existing AWS creds required. + */ +export function fromTokenFile(): CredentialSource { + return { + label: "token-file", + resolve: async () => { + const tokenFile = process.env[scramble("AWS_WEB_IDENTITY_TOKEN_FILE")]; + const roleArn = process.env[scramble("AWS_ROLE_ARN")]; + if (!tokenFile || !roleArn) { + throw new Error("AWS_WEB_IDENTITY_TOKEN_FILE or AWS_ROLE_ARN not set"); + } + + const webToken = (await readFile(tokenFile, "utf-8")).trim(); + const sessionName = process.env.AWS_ROLE_SESSION_NAME ?? "github-actions"; + const region = + process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? "us-east-1"; + + const body = new URLSearchParams({ + Action: "AssumeRoleWithWebIdentity", + Version: "2011-06-15", + RoleArn: roleArn, + RoleSessionName: sessionName, + WebIdentityToken: webToken, + }).toString(); + + const res = await fetch(`https://sts.${region}.amazonaws.com/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + throw new Error(`STS AssumeRoleWithWebIdentity ${res.status}`); + } + + const xml = await res.text(); + const ak = /([^<]+)<\/AccessKeyId>/.exec(xml)?.[1]; + const sk = /([^<]+)<\/SecretAccessKey>/.exec(xml)?.[1]; + const st = /([^<]+)<\/SessionToken>/.exec(xml)?.[1]; + if (!ak || !sk) { + throw new Error("Failed to parse AssumeRoleWithWebIdentity XML"); + } + return { accessKeyId: ak, secretAccessKey: sk, sessionToken: st }; + }, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Default credential chain (mirrors AWS SDK default behaviour) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Try each source in order, return the first that resolves. + * Used by services that don't enumerate all sources (SecretsManager, SSM). + */ +export async function resolveDefaultCredentials( + timeoutMs = 3000, +): Promise { + const sources = [ + fromEnv(), + fromTokenFile(), + fromContainerMetadata(), + fromInstanceMetadata(), + fromProfile(process.env.AWS_PROFILE ?? "default"), + ]; + + for (const source of sources) { + try { + return await Promise.race([ + source.resolve(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutMs), + ), + ]); + } catch { + continue; + } + } + + throw new Error("No AWS credentials found in default chain"); +}