@vizamodo/aws-sts-core 0.3.47 → 0.3.48

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.
@@ -11,29 +11,17 @@ export async function buildFederationLoginUrl(input) {
11
11
  throw new Error("[federation] invalid or expired credentials");
12
12
  }
13
13
  const tokenHash = await sha256Hex(input.sessionToken);
14
- // TEMP DEBUG: force static key to verify cache behavior
15
- const cacheKey = `aws-signin:test`;
14
+ // Use region and tokenHash for cache key
15
+ const cacheKey = `aws-signin:${input.region}:${tokenHash}`;
16
16
  const sessionJson = JSON.stringify(session);
17
17
  const encoded = encodeURIComponent(sessionJson);
18
18
  const SigninToken = await getCachedOrFetch(cacheKey, async () => {
19
- console.debug("[signin] fetcher start", {
20
- cacheKey,
21
- expiration: input.expiration,
22
- now: Date.now()
23
- });
24
19
  const tokenResp = await fetch(`https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=${encoded}`);
25
- console.debug("[signin] fetch response", {
26
- ok: tokenResp.ok,
27
- status: tokenResp.status
28
- });
29
20
  if (!tokenResp.ok) {
30
21
  // best-effort: do not cache failures
31
22
  throw new Error("[signin] failed to fetch SigninToken");
32
23
  }
33
24
  const json = await tokenResp.json();
34
- console.debug("[signin] json parsed", {
35
- hasToken: !!json?.SigninToken
36
- });
37
25
  const token = json?.SigninToken;
38
26
  if (!token) {
39
27
  // do not cache invalid response
@@ -43,29 +31,12 @@ export async function buildFederationLoginUrl(input) {
43
31
  const SIGNIN_TOKEN_TTL_SEC = 15 * 60;
44
32
  // derive effective TTL = min(tokenTTL, credentialTTL)
45
33
  const credRemainingSec = Math.floor((Date.parse(input.expiration) - Date.now()) / 1000);
46
- console.debug("[signin] TTL compute", {
47
- credRemainingSec,
48
- expiration: input.expiration,
49
- now: Date.now()
50
- });
51
34
  const effectiveTtlSec = Math.min(SIGNIN_TOKEN_TTL_SEC, credRemainingSec);
52
- console.debug("[signin] effective TTL", {
53
- effectiveTtlSec,
54
- SIGNIN_TOKEN_TTL_SEC
55
- });
56
35
  // if credential too close to expiry → skip caching
57
36
  if (effectiveTtlSec <= 0) {
58
- console.debug("[signin] skip cache (ttl<=0)", {
59
- effectiveTtlSec
60
- });
61
37
  return token;
62
38
  }
63
39
  const expiryIso = new Date(Date.now() + effectiveTtlSec * 1000).toISOString();
64
- console.debug("[signin] wrapResult", {
65
- effectiveTtlSec,
66
- expiryIso,
67
- now: Date.now()
68
- });
69
40
  // restore primitive storage (cache layer now handles primitive safely)
70
41
  return wrapResult(token, expiryIso);
71
42
  }, { ttlSec: 60, ...(input.forceRefresh !== undefined ? { forceRefresh: input.forceRefresh } : {}) } // allow caller-controlled retry
@@ -1,5 +1,5 @@
1
1
  import type { AwsCredentialResult } from "../types";
2
- export declare function issueAwsCredentials(input: {
2
+ export interface IssueAwsCredentialsInput {
3
3
  roleArn: string;
4
4
  profileArn: string;
5
5
  trustAnchorArn: string;
@@ -8,4 +8,13 @@ export declare function issueAwsCredentials(input: {
8
8
  privateKeyPkcs8Base64: string;
9
9
  profile: string;
10
10
  forceRefresh?: boolean;
11
- }): Promise<AwsCredentialResult>;
11
+ }
12
+ /**
13
+ * Reset isolate-level signing material and cert serial caches.
14
+ * ONLY for use in tests — forces re-import of signing key on next call.
15
+ *
16
+ * L1 cache is bypassed in tests via `forceRefresh: true` on each call —
17
+ * there is no need to expose a cache-clear API from the core library.
18
+ */
19
+ export declare function resetIsolateCache(): void;
20
+ export declare function issueAwsCredentials(input: IssueAwsCredentialsInput): Promise<AwsCredentialResult>;
package/dist/sts/issue.js CHANGED
@@ -5,10 +5,14 @@ import { buildStringToSign } from "../sigv4/string-to-sign";
5
5
  import { signStringToSign } from "./signer";
6
6
  import { InternalError } from "./errors";
7
7
  import { sha256Hex } from "../crypto/sha256";
8
- // ---- constants ----
8
+ // ── Constants ──────────────────────────────────────────────────────────────
9
9
  const ALGORITHM = "AWS4-X509-ECDSA-SHA256";
10
10
  const SERVICE = "rolesanywhere";
11
11
  const PATH = "/sessions";
12
+ /**
13
+ * Per-profile AWS session duration (seconds).
14
+ * Clamped to [MIN_SESSION_TTL, MAX_SESSION_TTL] at resolution time.
15
+ */
12
16
  const PROFILE_TTL = {
13
17
  Runtime: 45 * 60,
14
18
  ConsoleReadOnly: 12 * 60 * 60,
@@ -17,178 +21,183 @@ const PROFILE_TTL = {
17
21
  BillingAdmin: 3 * 60 * 60,
18
22
  BillingAccountant: 3 * 60 * 60,
19
23
  };
20
- const DEFAULT_TTL = 2 * 60 * 60;
21
- const MIN_PROFILE_TTL = 45 * 60; // 45 min — lower guard
22
- const MAX_PROFILE_TTL = 12 * 60 * 60; // 12 h — upper guard
23
- // ---- isolate-level signing material cache ----
24
- // Single Promise-based slot avoids concurrent cold-start races.
24
+ const DEFAULT_SESSION_TTL = 2 * 60 * 60;
25
+ const MIN_SESSION_TTL = 45 * 60; // 45 min
26
+ const MAX_SESSION_TTL = 12 * 60 * 60; // 12 h
27
+ // ── Isolate-level signing-material cache ───────────────────────────────────
28
+ // Single Promise slot prevents concurrent cold-start import races.
25
29
  let signingKeyPromise = null;
26
30
  let cachedSigningKey = null;
27
31
  let cachedCertBase64 = null;
28
32
  let cachedPrivateKeyBase64 = null;
29
- // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
33
+ // ── Isolate-level cert-serial cache ───────────────────────────────────────
34
+ // DER walk is CPU-bound; the cert rarely rotates within an isolate lifetime.
30
35
  let cachedCertSerialDec = null;
31
36
  let cachedCertSerialSource = null;
32
- // ---------------------------------------------------------------------------
33
- // Signing material
34
- // ---------------------------------------------------------------------------
35
- async function getSigningMaterial(input) {
36
- // Fast path: same material already imported.
37
+ // ── Test utilities ─────────────────────────────────────────────────────────
38
+ /**
39
+ * Reset isolate-level signing material and cert serial caches.
40
+ * ONLY for use in tests — forces re-import of signing key on next call.
41
+ *
42
+ * L1 cache is bypassed in tests via `forceRefresh: true` on each call —
43
+ * there is no need to expose a cache-clear API from the core library.
44
+ */
45
+ export function resetIsolateCache() {
46
+ signingKeyPromise = null;
47
+ cachedSigningKey = null;
48
+ cachedCertBase64 = null;
49
+ cachedPrivateKeyBase64 = null;
50
+ cachedCertSerialDec = null;
51
+ cachedCertSerialSource = null;
52
+ }
53
+ // ── Signing material ───────────────────────────────────────────────────────
54
+ async function getSigningMaterial(certBase64, privateKeyPkcs8Base64) {
55
+ // Fast path: same material already imported in this isolate.
37
56
  if (cachedSigningKey &&
38
- cachedCertBase64 === input.certBase64 &&
39
- cachedPrivateKeyBase64 === input.privateKeyPkcs8Base64) {
40
- return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
57
+ cachedCertBase64 === certBase64 &&
58
+ cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
59
+ return cachedSigningKey;
41
60
  }
42
- // Material rotated discard the stale resolved key so we re-import.
43
- if (cachedSigningKey) {
44
- signingKeyPromise = null;
61
+ // If material changed reset cached key + promise (once)
62
+ if (cachedCertBase64 !== certBase64 ||
63
+ cachedPrivateKeyBase64 !== privateKeyPkcs8Base64) {
45
64
  cachedSigningKey = null;
65
+ signingKeyPromise = null;
46
66
  }
67
+ // Deduplicate concurrent imports
47
68
  if (!signingKeyPromise) {
48
- try {
49
- const keyBuffer = base64ToBytes(input.privateKeyPkcs8Base64);
50
- signingKeyPromise = crypto.subtle.importKey("pkcs8", keyBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
51
- }
52
- catch {
69
+ signingKeyPromise = crypto.subtle
70
+ .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
71
+ .then((key) => {
72
+ cachedSigningKey = key;
73
+ cachedCertBase64 = certBase64;
74
+ cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
75
+ return key;
76
+ })
77
+ .catch(() => {
78
+ signingKeyPromise = null; // allow retry
53
79
  throw new InternalError("invalid_signing_material");
54
- }
55
- }
56
- try {
57
- cachedSigningKey = await signingKeyPromise;
58
- cachedCertBase64 = input.certBase64;
59
- cachedPrivateKeyBase64 = input.privateKeyPkcs8Base64;
60
- return { signingKey: cachedSigningKey, certBase64: cachedCertBase64 };
61
- }
62
- catch {
63
- signingKeyPromise = null; // allow retry on next call
64
- throw new InternalError("invalid_signing_material");
80
+ });
65
81
  }
82
+ return signingKeyPromise;
66
83
  }
67
- // ---------------------------------------------------------------------------
68
- // Profile TTL resolution
69
- // ---------------------------------------------------------------------------
70
- function resolveSessionTtlByProfile(profile) {
71
- const ttl = PROFILE_TTL[profile] ?? DEFAULT_TTL;
72
- return Math.min(Math.max(ttl, MIN_PROFILE_TTL), MAX_PROFILE_TTL);
84
+ // ── Session TTL ────────────────────────────────────────────────────────────
85
+ function resolveSessionTtl(profile) {
86
+ const ttl = PROFILE_TTL[profile] ?? DEFAULT_SESSION_TTL;
87
+ return Math.min(Math.max(ttl, MIN_SESSION_TTL), MAX_SESSION_TTL);
73
88
  }
74
- // ---------------------------------------------------------------------------
75
- // Main export
76
- // ---------------------------------------------------------------------------
89
+ // ── Main export ────────────────────────────────────────────────────────────
77
90
  export async function issueAwsCredentials(input) {
78
91
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
79
- const sessionTtl = resolveSessionTtlByProfile(profile);
92
+ const sessionTtl = resolveSessionTtl(profile);
80
93
  const normalizedCert = normalizeCert(certBase64);
81
- // ---- DER serial extraction (with isolate-level cache) ----
82
- let certSerialDec;
83
- if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
84
- certSerialDec = cachedCertSerialDec;
85
- }
86
- else {
87
- certSerialDec = parseCertSerialDec(normalizedCert); // throws InternalError on bad DER
88
- cachedCertSerialDec = certSerialDec;
89
- cachedCertSerialSource = normalizedCert;
90
- }
94
+ // Cert serial use isolate cache to avoid redundant DER walks.
95
+ const certSerialDec = getCertSerialDec(normalizedCert);
91
96
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
92
- console.debug("[issueAwsCredentials] cacheKey", { cacheKey, forceRefresh });
93
- console.debug("[issueAwsCredentials] invoking cache layer", { cacheKey });
94
- return getCachedOrFetch(cacheKey, async () => {
95
- const issuedAt = Date.now();
96
- const { signingKey } = await getSigningMaterial({
97
- certBase64: normalizedCert,
98
- privateKeyPkcs8Base64,
99
- });
100
- const host = `${SERVICE}.${region}.amazonaws.com`;
101
- const iso = new Date().toISOString();
102
- const amzDate = isoToAmzDate(iso);
103
- const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
104
- const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
105
- const payloadHash = await sha256Hex(body);
106
- const baseHeaders = {
107
- "content-type": "application/json",
108
- "host": host,
109
- "x-amz-date": amzDate,
110
- "x-amz-x509": normalizedCert,
111
- };
112
- const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
113
- const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
114
- const canonicalRequest = buildCanonicalRequest({
115
- method: "POST",
116
- canonicalUri: PATH,
117
- query: "",
118
- canonicalHeaders,
119
- signedHeaders,
120
- payloadHash,
121
- });
122
- const canonicalRequestHash = await sha256Hex(canonicalRequest);
123
- const stringToSign = buildStringToSign({
124
- algorithm: ALGORITHM,
125
- amzDate,
126
- credentialScope,
127
- canonicalRequestHash,
128
- });
129
- const signatureHex = await signStringToSign(stringToSign, signingKey);
130
- const finalHeaders = new Headers({
131
- "Content-Type": "application/json",
132
- "X-Amz-Date": amzDate,
133
- "X-Amz-X509": normalizedCert,
134
- "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
135
- });
136
- const res = await fetch(`https://${host}${PATH}`, {
97
+ return getCachedOrFetch(cacheKey, () => fetchCredentials({
98
+ roleArn, profileArn, trustAnchorArn,
99
+ region, normalizedCert, privateKeyPkcs8Base64,
100
+ sessionTtl,
101
+ }), { ttlSec: 60, forceRefresh });
102
+ }
103
+ async function fetchCredentials(input) {
104
+ const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
105
+ const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
106
+ const host = `${SERVICE}.${region}.amazonaws.com`;
107
+ const iso = new Date().toISOString();
108
+ const amzDate = isoToAmzDate(iso);
109
+ const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
110
+ const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
111
+ const payloadHash = await sha256Hex(body);
112
+ const baseHeaders = {
113
+ "content-type": "application/json",
114
+ "host": host,
115
+ "x-amz-date": amzDate,
116
+ "x-amz-x509": normalizedCert,
117
+ };
118
+ const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
119
+ const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
120
+ const canonicalRequest = buildCanonicalRequest({
121
+ method: "POST",
122
+ canonicalUri: PATH,
123
+ query: "",
124
+ canonicalHeaders,
125
+ signedHeaders,
126
+ payloadHash,
127
+ });
128
+ const canonicalRequestHash = await sha256Hex(canonicalRequest);
129
+ const stringToSign = buildStringToSign({
130
+ algorithm: ALGORITHM,
131
+ amzDate,
132
+ credentialScope,
133
+ canonicalRequestHash,
134
+ });
135
+ const signatureHex = await signStringToSign(stringToSign, signingKey);
136
+ const certSerialDec = getCertSerialDec(normalizedCert);
137
+ const finalHeaders = new Headers({
138
+ "Content-Type": "application/json",
139
+ "X-Amz-Date": amzDate,
140
+ "X-Amz-X509": normalizedCert,
141
+ "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
142
+ });
143
+ let res;
144
+ try {
145
+ res = await fetch(`https://${host}${PATH}`, {
137
146
  method: "POST",
138
147
  headers: finalHeaders,
139
148
  body,
140
149
  });
141
- if (!res.ok) {
142
- console.warn("[aws-rejected]", { status: res.status, region, profile });
143
- throw new InternalError("aws_rejected");
144
- }
145
- const json = await res.json();
146
- const creds = json?.credentialSet?.[0]?.credentials;
147
- if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
148
- console.warn("[issueAwsCredentials] malformed AWS credential response");
149
- throw new InternalError("aws_malformed_credentials");
150
- }
151
- console.debug("[issueAwsCredentials] fetched new credentials", {
152
- accessKeyId: creds.accessKeyId,
153
- expiration: creds.expiration
154
- });
155
- const value = {
156
- accessKeyId: creds.accessKeyId,
157
- secretAccessKey: creds.secretAccessKey,
158
- sessionToken: creds.sessionToken,
159
- expiration: creds.expiration,
160
- };
161
- // derive TTL = 1/3 lifetime
162
- const expiresAtMs = Date.parse(creds.expiration);
163
- const credLifetimeSec = Math.floor((expiresAtMs - issuedAt) / 1000);
164
- if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
165
- const edgeCacheTtlSec = Math.floor(credLifetimeSec / 3);
166
- const edgeCacheExpiry = new Date(issuedAt + edgeCacheTtlSec * 1000).toISOString();
167
- console.debug("[issueAwsCredentials] computed TTL", {
168
- credLifetimeSec,
169
- edgeCacheTtlSec
170
- });
171
- return wrapResult(value, edgeCacheExpiry);
172
- }
173
- console.debug("[issueAwsCredentials] fallback return (no TTL)", {
174
- accessKeyId: value.accessKeyId
175
- });
176
- return value;
177
- }, {
178
- ttlSec: 60,
179
- ...(forceRefresh !== undefined ? { forceRefresh } : {})
180
- });
150
+ }
151
+ catch (err) {
152
+ console.warn("[aws-unreachable]", { region, err });
153
+ throw new InternalError("aws_unreachable");
154
+ }
155
+ if (!res.ok) {
156
+ console.warn("[aws-rejected]", { status: res.status, region });
157
+ throw new InternalError("aws_rejected");
158
+ }
159
+ const json = await res.json();
160
+ const creds = json?.credentialSet?.[0]?.credentials;
161
+ if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
162
+ console.warn("[issueAwsCredentials] malformed AWS credential response");
163
+ throw new InternalError("aws_malformed_credentials");
164
+ }
165
+ const value = {
166
+ accessKeyId: creds.accessKeyId,
167
+ secretAccessKey: creds.secretAccessKey,
168
+ sessionToken: creds.sessionToken,
169
+ expiration: creds.expiration,
170
+ };
171
+ // Cache for 1/3 of the credential lifetime so it's refreshed well before expiry.
172
+ // deriveEdgeTtlSec in cache.ts will further subtract EDGE_BUFFER_SEC (5 min).
173
+ const expiresAtMs = Date.parse(creds.expiration);
174
+ const receivedAt = Date.now();
175
+ const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
176
+ if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
177
+ const cacheTtlSec = Math.floor(credLifetimeSec / 3);
178
+ const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
179
+ return wrapResult(value, cacheExpiry);
180
+ }
181
+ return value;
181
182
  }
182
- // ---------------------------------------------------------------------------
183
- // Helpers
184
- // ---------------------------------------------------------------------------
185
- /** Strip whitespace and reject PEM-wrapped input. */
183
+ // ── Helpers ────────────────────────────────────────────────────────────────
184
+ /** Strip whitespace; reject PEM-wrapped input. */
186
185
  function normalizeCert(raw) {
187
186
  if (raw.includes("BEGIN CERTIFICATE")) {
188
187
  throw new InternalError("pem_not_allowed");
189
188
  }
190
189
  return raw.replace(/\s+/g, "");
191
190
  }
191
+ /** Return cert serial as decimal string, using isolate-level cache. */
192
+ function getCertSerialDec(normalizedCert) {
193
+ if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
194
+ return cachedCertSerialDec;
195
+ }
196
+ const serial = parseCertSerialDec(normalizedCert);
197
+ cachedCertSerialDec = serial;
198
+ cachedCertSerialSource = normalizedCert;
199
+ return serial;
200
+ }
192
201
  /**
193
202
  * Minimal DER walk to extract the certificate serial number as a decimal string.
194
203
  * Throws InternalError("invalid_cert_der") on any parse failure.
@@ -220,8 +229,7 @@ function parseCertSerialDec(normalizedCertBase64) {
220
229
  // Skip optional [0] EXPLICIT version field.
221
230
  if (der[offset] === 0xa0) {
222
231
  offset++;
223
- const vLen = readLen();
224
- offset += vLen;
232
+ offset += readLen();
225
233
  }
226
234
  if (der[offset++] !== 0x02)
227
235
  throw new Error("bad serial tag");
@@ -233,7 +241,6 @@ function parseCertSerialDec(normalizedCertBase64) {
233
241
  if (serial.length > 1 && serial[0] === 0x00) {
234
242
  serial = serial.slice(1);
235
243
  }
236
- // Accumulate directly to BigInt — avoids intermediate hex string allocation.
237
244
  let serialBig = 0n;
238
245
  for (let i = 0; i < serial.length; i++) {
239
246
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
@@ -246,7 +253,7 @@ function parseCertSerialDec(normalizedCertBase64) {
246
253
  }
247
254
  }
248
255
  /**
249
- * Convert an ISO-8601 timestamp to the compact AMZ date-time format.
256
+ * Convert ISO-8601 to compact AMZ date-time format.
250
257
  * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
251
258
  */
252
259
  function isoToAmzDate(iso) {
@@ -259,7 +266,7 @@ function isoToAmzDate(iso) {
259
266
  iso.slice(17, 19) +
260
267
  "Z");
261
268
  }
262
- /** Faster base64 → Uint8Array using V8's vectorized atob path. */
269
+ /** base64 → Uint8Array via V8's vectorized atob path. */
263
270
  function base64ToBytes(base64) {
264
271
  const binary = atob(base64);
265
272
  const bytes = new Uint8Array(binary.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.3.47",
3
+ "version": "0.3.48",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,6 +28,6 @@
28
28
  "vitest": "^4.1.0"
29
29
  },
30
30
  "dependencies": {
31
- "@vizamodo/edge-cache-core": "^0.3.38"
31
+ "@vizamodo/edge-cache-core": "^0.3.40"
32
32
  }
33
33
  }