SysDeck/klanker-gate/packages/providers/src/aws_credentials_test.ts

289 lines
9.9 KiB
TypeScript
Executable File

import { assert, assertEquals, assertRejects } from "@std/assert";
import {
assumeRole,
AwsCredentialProvider,
type AwsCredentials,
parseIni,
resolveAwsCredentials,
} from "./aws_credentials.ts";
// MOCKED-vs-LIVE: every IMDS/ECS/STS assertion below drives a MOCKED fetch and
// mocked env/files. They prove request CONSTRUCTION + response PARSING + source
// SELECTION ORDER. They do NOT prove live reachability of 169.254.169.254,
// 169.254.170.2, or sts.<region>.amazonaws.com — those are unverifiable offline
// (owner-accepted). The env + profile sources ARE fully real (no network).
const IMDS = "169.254.169.254";
const ECS = "169.254.170.2";
/** Routes a mocked fetch by URL substring; unmatched hosts "fail" (unreachable). */
function routedFetch(
routes: Array<{ match: string; respond: (init?: RequestInit) => Response }>,
): { fetchImpl: typeof fetch; calls: string[] } {
const calls: string[] = [];
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push(url);
for (const r of routes) {
if (url.includes(r.match)) {
return Promise.resolve(r.respond(init));
}
}
return Promise.reject(new Error(`unreachable: ${url}`));
}) as typeof fetch;
return { fetchImpl, calls };
}
function imdsCredsResponse() {
return new Response(
JSON.stringify({
Code: "Success",
AccessKeyId: "ASIAIMDS",
SecretAccessKey: "imdssecret",
Token: "imdstoken",
Expiration: "2030-01-01T00:00:00Z",
}),
{ headers: { "Content-Type": "application/json" } },
);
}
/** IMDSv2 routes, MOST-SPECIFIC FIRST (routedFetch takes the first substring
* match, and the per-role creds URL contains the role-listing prefix). */
function imdsRoutes() {
return [
{
match: `${IMDS}/latest/api/token`,
respond: () => new Response("imds-session-token"),
},
{
match: `${IMDS}/latest/meta-data/iam/security-credentials/frosty-role`,
respond: () => imdsCredsResponse(),
},
{
match: `${IMDS}/latest/meta-data/iam/security-credentials/`,
respond: () => new Response("frosty-role"),
},
];
}
Deno.test("parseIni reads sections, key=value, and skips comments", () => {
const ini = parseIni(
"# comment\n[default]\naws_access_key_id = AK\n; c\n[profile dev]\naws_secret_access_key=SK\n",
);
assertEquals(ini["default"].aws_access_key_id, "AK");
assertEquals(ini["profile dev"].aws_secret_access_key, "SK");
});
Deno.test("chain source 1: environment variables win", async () => {
const env: Record<string, string> = {
AWS_ACCESS_KEY_ID: "AKIAENV",
AWS_SECRET_ACCESS_KEY: "envsecret",
AWS_SESSION_TOKEN: "envsession",
};
const creds = await resolveAwsCredentials({
env: (n) => env[n],
// If either of these is touched, env did not short-circuit.
readTextFile: () => Promise.reject(new Error("profile must not be read")),
fetchImpl: (() => Promise.reject(new Error("no network"))) as typeof fetch,
});
assertEquals(creds.source, "env");
assertEquals(creds.accessKeyId, "AKIAENV");
assertEquals(creds.sessionToken, "envsession");
});
Deno.test("chain source 2: shared-config profile (env absent)", async () => {
const files: Record<string, string> = {
"/home/test/.aws/credentials":
"[default]\naws_access_key_id = AKIAPROFILE\naws_secret_access_key = profilesecret\naws_session_token = profilesession\n",
};
const creds = await resolveAwsCredentials({
env: () => undefined,
homeDir: "/home/test",
readTextFile: (p) =>
p in files
? Promise.resolve(files[p])
: Promise.reject(new Error("no file")),
fetchImpl: (() => Promise.reject(new Error("no network"))) as typeof fetch,
});
assertEquals(creds.source, "profile");
assertEquals(creds.accessKeyId, "AKIAPROFILE");
assertEquals(creds.sessionToken, "profilesession");
});
Deno.test("chain source 3: IMDSv2 (env + profile absent)", async () => {
const { fetchImpl, calls } = routedFetch(imdsRoutes());
const creds = await resolveAwsCredentials({
env: () => undefined,
readTextFile: () => Promise.reject(new Error("no file")),
fetchImpl,
});
assertEquals(creds.source, "imds");
assertEquals(creds.accessKeyId, "ASIAIMDS");
assertEquals(creds.sessionToken, "imdstoken");
assertEquals(creds.expiration, Date.parse("2030-01-01T00:00:00Z"));
// IMDSv2 handshake: PUT token, GET role, GET role creds.
assert(calls.some((u) => u.includes("/latest/api/token")));
assert(calls.some((u) => u.endsWith("/frosty-role")));
});
Deno.test("chain source 4: ECS relative URI when IMDS is unreachable", async () => {
const env: Record<string, string> = {
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/abc",
};
const { fetchImpl, calls } = routedFetch([
// IMDS omitted -> unreachable -> chain falls through to ECS.
{
match: `${ECS}/v2/credentials/abc`,
respond: () =>
new Response(
JSON.stringify({
AccessKeyId: "ASIAECS",
SecretAccessKey: "ecssecret",
Token: "ecstoken",
Expiration: "2030-01-01T00:00:00Z",
}),
{ headers: { "Content-Type": "application/json" } },
),
},
]);
const creds = await resolveAwsCredentials({
env: (n) => env[n],
readTextFile: () => Promise.reject(new Error("no file")),
fetchImpl,
});
assertEquals(creds.source, "ecs");
assertEquals(creds.accessKeyId, "ASIAECS");
assertEquals(creds.expiration, Date.parse("2030-01-01T00:00:00Z"));
// IMDS was attempted first (and failed) before ECS succeeded.
assert(calls.some((u) => u.includes(IMDS)));
assert(calls.some((u) => u.includes(`${ECS}/v2/credentials/abc`)));
});
Deno.test("chain source 5: STS AssumeRoleWithWebIdentity (last resort)", async () => {
const env: Record<string, string> = {
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/token",
AWS_ROLE_ARN: "arn:aws:iam::111122223333:role/web",
AWS_ROLE_SESSION_NAME: "frosty-session",
};
let stsBody = "";
const { fetchImpl } = routedFetch([
{
match: "sts.us-east-1.amazonaws.com",
respond: (init) => {
stsBody = String(init?.body);
return new Response(
"<Result><Credentials>" +
"<AccessKeyId>ASIAWEBID</AccessKeyId>" +
"<SecretAccessKey>websecret</SecretAccessKey>" +
"<SessionToken>webtoken</SessionToken>" +
"<Expiration>2030-01-01T00:00:00Z</Expiration>" +
"</Credentials></Result>",
);
},
},
]);
const creds = await resolveAwsCredentials({
region: "us-east-1",
env: (n) => env[n],
readTextFile: (p) =>
p === "/var/run/token"
? Promise.resolve("the.web.identity.jwt")
: Promise.reject(new Error("no file")),
fetchImpl,
});
assertEquals(creds.source, "sts-web-identity");
assertEquals(creds.accessKeyId, "ASIAWEBID");
assertEquals(creds.sessionToken, "webtoken");
// AssumeRoleWithWebIdentity is unsigned; the JWT + role travel in the body.
assert(stsBody.includes("Action=AssumeRoleWithWebIdentity"));
assert(stsBody.includes("WebIdentityToken=the.web.identity.jwt"));
assert(stsBody.includes("RoleSessionName=frosty-session"));
});
Deno.test("chain throws when every source is exhausted", async () => {
await assertRejects(
() =>
resolveAwsCredentials({
env: () => undefined,
readTextFile: () => Promise.reject(new Error("no file")),
fetchImpl: (() =>
Promise.reject(new Error("unreachable"))) as typeof fetch,
}),
Error,
"credential chain exhausted",
);
});
Deno.test("AwsCredentialProvider caches expiry-bearing creds and refreshes past expiry", async () => {
let tokenFetches = 0;
const routes = [
{
match: `${IMDS}/latest/api/token`,
respond: () => {
tokenFetches++;
return new Response("imds-session-token");
},
},
{
match: `${IMDS}/latest/meta-data/iam/security-credentials/frosty-role`,
respond: () => imdsCredsResponse(),
},
{
match: `${IMDS}/latest/meta-data/iam/security-credentials/`,
respond: () => new Response("frosty-role"),
},
];
const { fetchImpl } = routedFetch(routes);
const provider = new AwsCredentialProvider({
env: () => undefined,
readTextFile: () => Promise.reject(new Error("no file")),
fetchImpl,
});
const first = await provider.resolve();
assertEquals(first.source, "imds");
await provider.resolve(); // within cache window -> no new IMDS handshake
assertEquals(tokenFetches, 1);
assertEquals(provider.cachedSource(), "imds");
});
Deno.test("assumeRole signs the STS call with the source credentials", async () => {
let auth: string | null = null;
let body = "";
const fetchImpl = ((_input: RequestInfo | URL, init?: RequestInit) => {
auth = new Headers(init?.headers).get("Authorization");
body = String(init?.body);
return Promise.resolve(
new Response(
"<Result><Credentials>" +
"<AccessKeyId>ASIAASSUME</AccessKeyId>" +
"<SecretAccessKey>assumesecret</SecretAccessKey>" +
"<SessionToken>assumetoken</SessionToken>" +
"<Expiration>2030-01-01T00:00:00Z</Expiration>" +
"</Credentials></Result>",
),
);
}) as typeof fetch;
const source: AwsCredentials = {
accessKeyId: "AKIASOURCE",
secretAccessKey: "sourcesecret",
source: "static",
};
const creds = await assumeRole({
roleArn: "arn:aws:iam::111122223333:role/target",
roleSessionName: "frosty",
source,
region: "us-east-1",
fetchImpl,
});
assertEquals(creds?.source, "sts-assume-role");
assertEquals(creds?.accessKeyId, "ASIAASSUME");
assert(body.includes("Action=AssumeRole"));
// Signed with the SOURCE credentials against the sts service.
assert(auth, "expected a signed Authorization header");
assert((auth as string).includes("Credential=AKIASOURCE/"));
assert((auth as string).includes("/sts/aws4_request"));
});