@intentius/chant-lexicon-aws 0.124.23 → 0.124.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * The AWS credential chain, as the AWS CLI walks it, for `chant lifecycle
3
+ * whoami` (#3954).
4
+ *
5
+ * `resolveCredentials` (./sigv4.ts) reads the three environment variables and
6
+ * nothing else. That is right for the read transport's synchronous signing
7
+ * seam, and it made whoami wrong for anyone whose shell holds a profile rather
8
+ * than keys: `AWS_PROFILE=dev` with an SSO session came back as "no identity
9
+ * is configured" while `aws sts get-caller-identity` answered. This module is
10
+ * the asynchronous half: it resolves what the CLI would sign with and hands
11
+ * back literal credentials plus a label for where they came from.
12
+ *
13
+ * There is still no `@aws-sdk/*` here, for the reason the read transport gives:
14
+ * a lexicon keeps that tree out. The chain is small enough to state.
15
+ *
16
+ * ## Order
17
+ *
18
+ * 1. `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (+ `AWS_SESSION_TOKEN`).
19
+ * They win over `AWS_PROFILE`, as they do in the AWS CLI, and as they do
20
+ * in chant's own read transport, which signs with nothing else. (The JS
21
+ * SDK v3 skips the environment when `AWS_PROFILE` is set; the CLI does
22
+ * not, and the CLI is what an operator checks whoami against.) When both
23
+ * are set, the label says the profile was not used.
24
+ * 2. The profile `AWS_PROFILE` names, else `default` when one exists, read
25
+ * from `AWS_CONFIG_FILE` (`~/.aws/config`) and
26
+ * `AWS_SHARED_CREDENTIALS_FILE` (`~/.aws/credentials`). Within a profile:
27
+ * `role_arn` (with `source_profile` or `credential_source = Environment`),
28
+ * then static keys, then SSO (`sso_session` or the legacy
29
+ * `sso_start_url`), then `credential_process`.
30
+ *
31
+ * Not resolved, and said so rather than skipped: `web_identity_token_file`,
32
+ * `mfa_serial`, `credential_source` other than `Environment`, and the
33
+ * instance-metadata and container endpoints.
34
+ *
35
+ * ## SSO
36
+ *
37
+ * The token `aws sso login` cached under `~/.aws/sso/cache/` is read, and when
38
+ * its access token has expired but it carries a refresh token, it is refreshed
39
+ * through SSO OIDC the way the CLI does. The refreshed token is used for this
40
+ * call and NOT written back; whoami is read-only. A missing, expired or
41
+ * refused session is a `no-credentials` answer naming
42
+ * `aws sso login --profile <p>`.
43
+ *
44
+ * ## Credential material
45
+ *
46
+ * Nothing this module returns in a label or an error carries a key, a secret,
47
+ * a session token or an SSO bearer token. `credential_process` output is
48
+ * credential JSON, so a malformed one is described, never echoed.
49
+ */
50
+
51
+ import { exec } from "node:child_process";
52
+ import { createHash } from "node:crypto";
53
+ import { readFileSync } from "node:fs";
54
+ import { homedir } from "node:os";
55
+ import { join } from "node:path";
56
+ import { AwsReadError, assumeRole, type AwsReadClientOptions } from "./read-client";
57
+ import type { AwsCredentials } from "./sigv4";
58
+
59
+ /** One HTTP exchange for the SSO portal and OIDC calls. Injected by tests. */
60
+ export type CredentialChainHttp = (
61
+ url: string,
62
+ init: { method: "GET" | "POST"; headers: Record<string, string>; body?: string },
63
+ ) => Promise<{ status: number; text: string }>;
64
+
65
+ /** Runs a `credential_process` command line. Injected by tests. */
66
+ export type CredentialProcessRunner = (
67
+ command: string,
68
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
69
+
70
+ export interface CredentialChainOptions {
71
+ /** Defaults to `process.env`. */
72
+ env?: Record<string, string | undefined>;
73
+ /** Reads a file, `undefined` when it does not exist. Defaults to the filesystem. */
74
+ readFile?: (path: string) => string | undefined;
75
+ /** The home directory `~/.aws` hangs off. Defaults to `HOME`, then `os.homedir()`. */
76
+ home?: string;
77
+ /** SSO portal and OIDC transport. Defaults to `fetch`. */
78
+ http?: CredentialChainHttp;
79
+ /** `credential_process` runner. Defaults to a shell `exec`. */
80
+ runProcess?: CredentialProcessRunner;
81
+ /** STS transport for `role_arn` profiles. The `credentials` field is set per call. */
82
+ sts?: AwsReadClientOptions;
83
+ /** Clock for token and credential expiry. */
84
+ now?: () => Date;
85
+ }
86
+
87
+ export interface ResolvedChainCredentials {
88
+ credentials: AwsCredentials;
89
+ /** Where they came from, e.g. `profile dev (sso session acme)`. Never a credential value. */
90
+ source: string;
91
+ /** The profile that resolved them, when one did. */
92
+ profile?: string;
93
+ }
94
+
95
+ export interface UnresolvedChainCredentials {
96
+ reason: "no-credentials" | "read-failed";
97
+ detail: string;
98
+ }
99
+
100
+ export type CredentialChainResult =
101
+ | { resolved: ResolvedChainCredentials }
102
+ | { unresolved: UnresolvedChainCredentials };
103
+
104
+ type Section = Record<string, string>;
105
+
106
+ /**
107
+ * The INI dialect of the AWS shared files: `[section]`, `key = value`, `#` and
108
+ * `;` comments on their own line (not inline: a `credential_process` command
109
+ * line may hold either character). An indented line under a key with an empty value is a nested
110
+ * setting (`s3 =` / ` max_concurrent_requests = 10`); nothing here reads
111
+ * those, so they are dropped rather than mistaken for top-level keys.
112
+ */
113
+ export function parseIni(text: string): Record<string, Section> {
114
+ const out: Record<string, Section> = {};
115
+ let current: Section | undefined;
116
+ let nestedUnder: string | undefined;
117
+ for (const raw of text.split(/\r?\n/)) {
118
+ if (/^\s*([#;]|$)/.test(raw)) continue;
119
+ const header = raw.match(/^\s*\[([^\]]+)\]\s*([#;].*)?$/);
120
+ if (header) {
121
+ const name = header[1]!.trim().replace(/\s+/g, " ");
122
+ current = out[name] ??= {};
123
+ nestedUnder = undefined;
124
+ continue;
125
+ }
126
+ if (!current) continue;
127
+ if (nestedUnder && /^\s/.test(raw)) continue;
128
+ const kv = raw.match(/^\s*([^=]+?)\s*=\s*(.*?)\s*$/);
129
+ if (!kv) continue;
130
+ const [, key, value] = kv;
131
+ nestedUnder = value === "" ? key : undefined;
132
+ if (value !== "") current[key!] = value!;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ interface SharedFiles {
138
+ configPath: string;
139
+ credentialsPath: string;
140
+ config: Record<string, Section>;
141
+ credentials: Record<string, Section>;
142
+ }
143
+
144
+ function loadSharedFiles(env: Record<string, string | undefined>, home: string, read: (p: string) => string | undefined): SharedFiles {
145
+ const configPath = env.AWS_CONFIG_FILE || join(home, ".aws", "config");
146
+ const credentialsPath = env.AWS_SHARED_CREDENTIALS_FILE || join(home, ".aws", "credentials");
147
+ return {
148
+ configPath,
149
+ credentialsPath,
150
+ config: parseIni(read(configPath) ?? ""),
151
+ credentials: parseIni(read(credentialsPath) ?? ""),
152
+ };
153
+ }
154
+
155
+ /** A profile's settings, the credentials file winning over the config file as in the CLI. */
156
+ function profileSettings(files: SharedFiles, name: string): Section | undefined {
157
+ const fromConfig = files.config[name === "default" ? "default" : `profile ${name}`] ??
158
+ (name === "default" ? files.config["profile default"] : undefined);
159
+ const fromCredentials = files.credentials[name];
160
+ if (!fromConfig && !fromCredentials) return undefined;
161
+ return { ...fromConfig, ...fromCredentials };
162
+ }
163
+
164
+ const defaultReadFile = (path: string): string | undefined => {
165
+ try {
166
+ return readFileSync(path, "utf-8");
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ };
171
+
172
+ const defaultHttp: CredentialChainHttp = async (url, init) => {
173
+ const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body });
174
+ return { status: res.status, text: await res.text() };
175
+ };
176
+
177
+ const defaultRunProcess: CredentialProcessRunner = (command) =>
178
+ new Promise((resolve) => {
179
+ exec(command, { timeout: 60_000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
180
+ const code = error ? (typeof error.code === "number" ? error.code : 1) : 0;
181
+ resolve({ exitCode: code, stdout: String(stdout), stderr: String(stderr) });
182
+ });
183
+ });
184
+
185
+ class ChainRefusal extends Error {
186
+ constructor(readonly reason: UnresolvedChainCredentials["reason"], detail: string) {
187
+ super(detail);
188
+ }
189
+ }
190
+
191
+ interface Context {
192
+ env: Record<string, string | undefined>;
193
+ files: SharedFiles;
194
+ home: string;
195
+ read: (path: string) => string | undefined;
196
+ http: CredentialChainHttp;
197
+ runProcess: CredentialProcessRunner;
198
+ sts: AwsReadClientOptions;
199
+ now: () => Date;
200
+ }
201
+
202
+ function envCredentials(env: Record<string, string | undefined>): AwsCredentials | undefined {
203
+ if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) return undefined;
204
+ return {
205
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
206
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
207
+ ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}),
208
+ };
209
+ }
210
+
211
+ function envLabel(env: Record<string, string | undefined>): string {
212
+ return env.AWS_SESSION_TOKEN ? "env AWS_ACCESS_KEY_ID + AWS_SESSION_TOKEN" : "env AWS_ACCESS_KEY_ID";
213
+ }
214
+
215
+ /**
216
+ * Resolve credentials the way the AWS CLI does. Returns literal credentials
217
+ * and their source, or a typed refusal that says what to do about it.
218
+ */
219
+ export async function resolveCredentialChain(options: CredentialChainOptions = {}): Promise<CredentialChainResult> {
220
+ const env = options.env ?? process.env;
221
+ const fromEnv = envCredentials(env);
222
+ if (fromEnv) {
223
+ const ignored = env.AWS_PROFILE ? `; AWS_PROFILE=${env.AWS_PROFILE} not used, as in the AWS CLI` : "";
224
+ return { resolved: { credentials: fromEnv, source: `${envLabel(env)}${ignored}` } };
225
+ }
226
+
227
+ const home = options.home ?? (env.HOME || env.USERPROFILE || homedir());
228
+ const read = options.readFile ?? defaultReadFile;
229
+ const ctx: Context = {
230
+ env,
231
+ home,
232
+ read,
233
+ files: loadSharedFiles(env, home, read),
234
+ http: options.http ?? defaultHttp,
235
+ runProcess: options.runProcess ?? defaultRunProcess,
236
+ sts: options.sts ?? {},
237
+ now: options.now ?? (() => new Date()),
238
+ };
239
+
240
+ const named = env.AWS_PROFILE;
241
+ const name = named ?? "default";
242
+ if (!profileSettings(ctx.files, name)) {
243
+ return {
244
+ unresolved: {
245
+ reason: "no-credentials",
246
+ detail: named
247
+ ? `AWS_PROFILE=${named}, but there is no profile ${named} in ${ctx.files.configPath} or ${ctx.files.credentialsPath}`
248
+ : `no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment, no AWS_PROFILE, and no default profile in ${ctx.files.configPath} or ${ctx.files.credentialsPath}`,
249
+ },
250
+ };
251
+ }
252
+
253
+ try {
254
+ const resolved = await resolveProfile(ctx, name, []);
255
+ return { resolved: { ...resolved, profile: name } };
256
+ } catch (err) {
257
+ if (err instanceof ChainRefusal) return { unresolved: { reason: err.reason, detail: err.message } };
258
+ throw err;
259
+ }
260
+ }
261
+
262
+ async function resolveProfile(
263
+ ctx: Context,
264
+ name: string,
265
+ visited: string[],
266
+ ): Promise<{ credentials: AwsCredentials; source: string }> {
267
+ if (visited.includes(name)) {
268
+ throw new ChainRefusal("no-credentials", `profile ${name}: source_profile loops (${[...visited, name].join(" -> ")})`);
269
+ }
270
+ const p = profileSettings(ctx.files, name);
271
+ if (!p) {
272
+ throw new ChainRefusal(
273
+ "no-credentials",
274
+ `profile ${visited.at(-1)} names source_profile ${name}, which is not in ${ctx.files.configPath} or ${ctx.files.credentialsPath}`,
275
+ );
276
+ }
277
+ // A source profile that has static keys signs with them even when it also
278
+ // has a role_arn, as in the CLI. Only the profile asked for, or a source
279
+ // profile without keys, assumes its role.
280
+ const isSource = visited.length > 0;
281
+
282
+ if (p.role_arn && !(isSource && p.aws_access_key_id && p.aws_secret_access_key)) {
283
+ return resolveRoleProfile(ctx, name, p, visited);
284
+ }
285
+ if (p.aws_access_key_id && p.aws_secret_access_key) {
286
+ return {
287
+ credentials: {
288
+ accessKeyId: p.aws_access_key_id,
289
+ secretAccessKey: p.aws_secret_access_key,
290
+ ...(p.aws_session_token ? { sessionToken: p.aws_session_token } : {}),
291
+ },
292
+ source: `profile ${name} (static keys)`,
293
+ };
294
+ }
295
+ if (p.sso_session || p.sso_start_url) return resolveSsoProfile(ctx, name, p);
296
+ if (p.credential_process) return resolveProcessProfile(ctx, name, p.credential_process);
297
+ if (p.web_identity_token_file) {
298
+ throw new ChainRefusal("no-credentials", `profile ${name} uses web_identity_token_file, which chant does not resolve`);
299
+ }
300
+ throw new ChainRefusal(
301
+ "no-credentials",
302
+ `profile ${name} has no aws_access_key_id, role_arn, sso_session, sso_start_url or credential_process`,
303
+ );
304
+ }
305
+
306
+ async function resolveRoleProfile(
307
+ ctx: Context,
308
+ name: string,
309
+ p: Section,
310
+ visited: string[],
311
+ ): Promise<{ credentials: AwsCredentials; source: string }> {
312
+ if (p.mfa_serial) {
313
+ throw new ChainRefusal("no-credentials", `profile ${name} sets mfa_serial; chant cannot prompt for an MFA code`);
314
+ }
315
+ let base: { credentials: AwsCredentials; source: string };
316
+ if (p.source_profile) {
317
+ base = await resolveProfile(ctx, p.source_profile, [...visited, name]);
318
+ } else if (p.credential_source === "Environment") {
319
+ const creds = envCredentials(ctx.env);
320
+ if (!creds) {
321
+ throw new ChainRefusal(
322
+ "no-credentials",
323
+ `profile ${name} has credential_source = Environment, but AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are not set`,
324
+ );
325
+ }
326
+ base = { credentials: creds, source: envLabel(ctx.env) };
327
+ } else if (p.credential_source) {
328
+ throw new ChainRefusal(
329
+ "no-credentials",
330
+ `profile ${name} has credential_source = ${p.credential_source}; chant resolves only Environment`,
331
+ );
332
+ } else {
333
+ throw new ChainRefusal("no-credentials", `profile ${name} has role_arn but no source_profile or credential_source`);
334
+ }
335
+
336
+ const duration = p.duration_seconds ? Number(p.duration_seconds) : undefined;
337
+ try {
338
+ const assumed = await assumeRole(
339
+ {
340
+ roleArn: p.role_arn!,
341
+ sessionName: p.role_session_name || `chant-${ctx.now().getTime()}`,
342
+ ...(p.external_id ? { externalId: p.external_id } : {}),
343
+ ...(duration && Number.isFinite(duration) ? { durationSeconds: duration } : {}),
344
+ },
345
+ { ...ctx.sts, region: ctx.sts.region ?? p.region ?? "us-east-1", credentials: base.credentials },
346
+ );
347
+ return {
348
+ credentials: {
349
+ accessKeyId: assumed.accessKeyId,
350
+ secretAccessKey: assumed.secretAccessKey,
351
+ sessionToken: assumed.sessionToken,
352
+ },
353
+ source: `profile ${name} (role ${p.role_arn} via ${base.source})`,
354
+ };
355
+ } catch (err) {
356
+ const what = err instanceof AwsReadError ? `${err.code ? `${err.code}: ` : ""}${err.message}` : String(err);
357
+ const refused = err instanceof AwsReadError && (err.status === 401 || err.status === 403);
358
+ throw new ChainRefusal(refused ? "no-credentials" : "read-failed", `profile ${name}: sts:AssumeRole ${p.role_arn} failed: ${what}`);
359
+ }
360
+ }
361
+
362
+ interface SsoToken {
363
+ accessToken?: string;
364
+ expiresAt?: string;
365
+ refreshToken?: string;
366
+ clientId?: string;
367
+ clientSecret?: string;
368
+ registrationExpiresAt?: string;
369
+ }
370
+
371
+ function loginHint(name: string): string {
372
+ return `run \`aws sso login --profile ${name}\``;
373
+ }
374
+
375
+ async function resolveSsoProfile(
376
+ ctx: Context,
377
+ name: string,
378
+ p: Section,
379
+ ): Promise<{ credentials: AwsCredentials; source: string }> {
380
+ let startUrl = p.sso_start_url;
381
+ let ssoRegion = p.sso_region;
382
+ let cacheKey = startUrl;
383
+ let label = `profile ${name} (sso ${startUrl})`;
384
+ if (p.sso_session) {
385
+ const session = ctx.files.config[`sso-session ${p.sso_session}`];
386
+ if (!session) {
387
+ throw new ChainRefusal(
388
+ "no-credentials",
389
+ `profile ${name} names sso_session ${p.sso_session}, which has no [sso-session ${p.sso_session}] section in ${ctx.files.configPath}`,
390
+ );
391
+ }
392
+ startUrl = session.sso_start_url;
393
+ ssoRegion = session.sso_region ?? ssoRegion;
394
+ cacheKey = p.sso_session;
395
+ label = `profile ${name} (sso session ${p.sso_session})`;
396
+ }
397
+ const accountId = p.sso_account_id;
398
+ const roleName = p.sso_role_name;
399
+ if (!startUrl || !ssoRegion || !accountId || !roleName) {
400
+ const missing = [
401
+ !startUrl && "sso_start_url",
402
+ !ssoRegion && "sso_region",
403
+ !accountId && "sso_account_id",
404
+ !roleName && "sso_role_name",
405
+ ].filter(Boolean);
406
+ throw new ChainRefusal("no-credentials", `profile ${name} is an SSO profile missing ${missing.join(", ")}`);
407
+ }
408
+
409
+ const cachePath = join(ctx.home, ".aws", "sso", "cache", `${createHash("sha1").update(cacheKey!).digest("hex")}.json`);
410
+ const raw = ctx.read(cachePath);
411
+ if (raw === undefined) {
412
+ throw new ChainRefusal("no-credentials", `profile ${name}: no cached SSO token at ${cachePath}; ${loginHint(name)}`);
413
+ }
414
+ let token: SsoToken;
415
+ try {
416
+ token = JSON.parse(raw) as SsoToken;
417
+ } catch {
418
+ throw new ChainRefusal("no-credentials", `profile ${name}: the cached SSO token at ${cachePath} is not JSON; ${loginHint(name)}`);
419
+ }
420
+
421
+ const now = ctx.now().getTime();
422
+ let accessToken = token.accessToken;
423
+ const expires = token.expiresAt ? Date.parse(token.expiresAt) : NaN;
424
+ if (!accessToken || !(expires > now)) {
425
+ accessToken = p.sso_session ? await refreshSsoToken(ctx, token, ssoRegion, now) : undefined;
426
+ if (!accessToken) {
427
+ throw new ChainRefusal("no-credentials", `profile ${name}: the SSO session has expired; ${loginHint(name)}`);
428
+ }
429
+ }
430
+
431
+ const url =
432
+ `https://portal.sso.${ssoRegion}.amazonaws.com/federation/credentials` +
433
+ `?account_id=${encodeURIComponent(accountId)}&role_name=${encodeURIComponent(roleName)}`;
434
+ let res: { status: number; text: string };
435
+ try {
436
+ res = await ctx.http(url, { method: "GET", headers: { "x-amz-sso_bearer_token": accessToken } });
437
+ } catch (err) {
438
+ throw new ChainRefusal("read-failed", `profile ${name}: sso:GetRoleCredentials failed: ${err instanceof Error ? err.message : String(err)}`);
439
+ }
440
+ if (res.status === 401 || res.status === 403) {
441
+ throw new ChainRefusal(
442
+ "no-credentials",
443
+ `profile ${name}: the SSO portal refused the cached session (HTTP ${res.status}); ${loginHint(name)}`,
444
+ );
445
+ }
446
+ if (res.status >= 400) {
447
+ throw new ChainRefusal("read-failed", `profile ${name}: sso:GetRoleCredentials for ${roleName} in ${accountId} failed with HTTP ${res.status}`);
448
+ }
449
+ let role: { accessKeyId?: string; secretAccessKey?: string; sessionToken?: string } | undefined;
450
+ try {
451
+ role = (JSON.parse(res.text) as { roleCredentials?: typeof role }).roleCredentials;
452
+ } catch {
453
+ role = undefined;
454
+ }
455
+ if (!role?.accessKeyId || !role.secretAccessKey) {
456
+ throw new ChainRefusal("read-failed", `profile ${name}: sso:GetRoleCredentials answered without role credentials`);
457
+ }
458
+ return {
459
+ credentials: {
460
+ accessKeyId: role.accessKeyId,
461
+ secretAccessKey: role.secretAccessKey,
462
+ ...(role.sessionToken ? { sessionToken: role.sessionToken } : {}),
463
+ },
464
+ source: `${label}, role ${roleName} in ${accountId}`,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Trade a cached refresh token for a new access token through SSO OIDC, the
470
+ * way the CLI keeps an `sso-session` alive without a browser. Returns
471
+ * `undefined` when there is nothing to refresh with or the refresh is
472
+ * refused; the caller turns that into the `aws sso login` hint.
473
+ */
474
+ async function refreshSsoToken(ctx: Context, token: SsoToken, region: string, now: number): Promise<string | undefined> {
475
+ if (!token.refreshToken || !token.clientId || !token.clientSecret) return undefined;
476
+ if (token.registrationExpiresAt && !(Date.parse(token.registrationExpiresAt) > now)) return undefined;
477
+ let res: { status: number; text: string };
478
+ try {
479
+ res = await ctx.http(`https://oidc.${region}.amazonaws.com/token`, {
480
+ method: "POST",
481
+ headers: { "content-type": "application/json" },
482
+ body: JSON.stringify({
483
+ grantType: "refresh_token",
484
+ clientId: token.clientId,
485
+ clientSecret: token.clientSecret,
486
+ refreshToken: token.refreshToken,
487
+ }),
488
+ });
489
+ } catch {
490
+ return undefined;
491
+ }
492
+ if (res.status >= 400) return undefined;
493
+ try {
494
+ return (JSON.parse(res.text) as { accessToken?: string }).accessToken || undefined;
495
+ } catch {
496
+ return undefined;
497
+ }
498
+ }
499
+
500
+ async function resolveProcessProfile(
501
+ ctx: Context,
502
+ name: string,
503
+ command: string,
504
+ ): Promise<{ credentials: AwsCredentials; source: string }> {
505
+ const out = await ctx.runProcess(command);
506
+ if (out.exitCode !== 0) {
507
+ const firstLine = out.stderr.trim().split(/\r?\n/)[0] ?? "";
508
+ throw new ChainRefusal(
509
+ "no-credentials",
510
+ `profile ${name}: credential_process exited ${out.exitCode}${firstLine ? `: ${firstLine}` : ""}`,
511
+ );
512
+ }
513
+ let parsed: { Version?: number; AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string; Expiration?: string };
514
+ try {
515
+ parsed = JSON.parse(out.stdout) as typeof parsed;
516
+ } catch {
517
+ throw new ChainRefusal("no-credentials", `profile ${name}: credential_process did not print JSON`);
518
+ }
519
+ if (parsed.Version !== 1 || !parsed.AccessKeyId || !parsed.SecretAccessKey) {
520
+ throw new ChainRefusal(
521
+ "no-credentials",
522
+ `profile ${name}: credential_process output is not Version 1 with AccessKeyId and SecretAccessKey`,
523
+ );
524
+ }
525
+ if (parsed.Expiration && !(Date.parse(parsed.Expiration) > ctx.now().getTime())) {
526
+ throw new ChainRefusal("no-credentials", `profile ${name}: credential_process returned credentials that expired at ${parsed.Expiration}`);
527
+ }
528
+ return {
529
+ credentials: {
530
+ accessKeyId: parsed.AccessKeyId,
531
+ secretAccessKey: parsed.SecretAccessKey,
532
+ ...(parsed.SessionToken ? { sessionToken: parsed.SessionToken } : {}),
533
+ },
534
+ source: `profile ${name} (credential_process)`,
535
+ };
536
+ }
@@ -412,6 +412,60 @@ export async function getCallerIdentity(options: AwsReadClientOptions = {}): Pro
412
412
  return { arn: leaves.Arn ?? "", account: leaves.Account ?? "" };
413
413
  }
414
414
 
415
+ /** Temporary credentials `sts:AssumeRole` hands back, with their expiry. */
416
+ export interface AssumedRoleCredentials {
417
+ accessKeyId: string;
418
+ secretAccessKey: string;
419
+ sessionToken: string;
420
+ expiration?: string;
421
+ }
422
+
423
+ /**
424
+ * `sts:AssumeRole` — the call behind a shared-config profile with `role_arn`
425
+ * (#3954). Signed with the source credentials the caller passes in
426
+ * `options.credentials`, on the same Query transport as
427
+ * {@link getCallerIdentity}. The response is credential material, so it goes
428
+ * back to the caller and never into an error message.
429
+ */
430
+ export async function assumeRole(
431
+ params: { roleArn: string; sessionName: string; externalId?: string; durationSeconds?: number },
432
+ options: AwsReadClientOptions = {},
433
+ ): Promise<AssumedRoleCredentials> {
434
+ options = withEndpointOverride("sts", options);
435
+ const http = options.http ?? defaultHttp;
436
+ const url = serviceUrl("sts", options.endpoint, options.region);
437
+ const body = new URLSearchParams({
438
+ Action: "AssumeRole",
439
+ Version: STS_API_VERSION,
440
+ RoleArn: params.roleArn,
441
+ RoleSessionName: params.sessionName,
442
+ ...(params.externalId ? { ExternalId: params.externalId } : {}),
443
+ ...(params.durationSeconds ? { DurationSeconds: String(params.durationSeconds) } : {}),
444
+ }).toString();
445
+ const res = await http(
446
+ url,
447
+ {
448
+ headers: requestHeaders("sts", url, body, { "content-type": "application/x-www-form-urlencoded" }, options),
449
+ body,
450
+ },
451
+ options.signal,
452
+ );
453
+ const err = xmlError(res.text);
454
+ if (err || res.status >= 400) {
455
+ throw new AwsReadError(err?.message ?? `AssumeRole failed with HTTP ${res.status}`, res.status, err?.code);
456
+ }
457
+ const leaves = xmlLeaves(res.text.match(/<Credentials>([\s\S]*?)<\/Credentials>/)?.[1] ?? "");
458
+ if (!leaves.AccessKeyId || !leaves.SecretAccessKey || !leaves.SessionToken) {
459
+ throw new AwsReadError("AssumeRole answered without credentials", res.status);
460
+ }
461
+ return {
462
+ accessKeyId: leaves.AccessKeyId,
463
+ secretAccessKey: leaves.SecretAccessKey,
464
+ sessionToken: leaves.SessionToken,
465
+ ...(leaves.Expiration ? { expiration: leaves.Expiration } : {}),
466
+ };
467
+ }
468
+
415
469
  /* ── Cloud Control ────────────────────────────────────────────────────────── */
416
470
 
417
471
  /** One live resource, as Cloud Control describes it. */