@vizamodo/aws-sts-core 0.3.47 → 0.3.49

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,176 @@ 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 };
41
- }
42
- // Material rotated — discard the stale resolved key so we re-import.
43
- if (cachedSigningKey) {
44
- signingKeyPromise = null;
45
- cachedSigningKey = null;
57
+ cachedCertBase64 === certBase64 &&
58
+ cachedPrivateKeyBase64 === privateKeyPkcs8Base64) {
59
+ return cachedSigningKey;
46
60
  }
61
+ // Material rotated — discard stale state and re-import.
62
+ signingKeyPromise = null;
63
+ cachedSigningKey = null;
47
64
  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 {
65
+ signingKeyPromise = crypto.subtle
66
+ .importKey("pkcs8", base64ToBytes(privateKeyPkcs8Base64), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"])
67
+ .catch(() => {
68
+ signingKeyPromise = null; // allow retry
53
69
  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");
70
+ });
65
71
  }
72
+ cachedSigningKey = await signingKeyPromise;
73
+ cachedCertBase64 = certBase64;
74
+ cachedPrivateKeyBase64 = privateKeyPkcs8Base64;
75
+ return cachedSigningKey;
66
76
  }
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);
77
+ // ── Session TTL ────────────────────────────────────────────────────────────
78
+ function resolveSessionTtl(profile) {
79
+ const ttl = PROFILE_TTL[profile] ?? DEFAULT_SESSION_TTL;
80
+ return Math.min(Math.max(ttl, MIN_SESSION_TTL), MAX_SESSION_TTL);
73
81
  }
74
- // ---------------------------------------------------------------------------
75
- // Main export
76
- // ---------------------------------------------------------------------------
82
+ // ── Main export ────────────────────────────────────────────────────────────
77
83
  export async function issueAwsCredentials(input) {
78
84
  const { roleArn, profileArn, trustAnchorArn, region, certBase64, privateKeyPkcs8Base64, profile, forceRefresh, } = input;
79
- const sessionTtl = resolveSessionTtlByProfile(profile);
85
+ const sessionTtl = resolveSessionTtl(profile);
80
86
  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
- }
87
+ // Cert serial use isolate cache to avoid redundant DER walks.
88
+ const certSerialDec = getCertSerialDec(normalizedCert);
91
89
  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}`, {
90
+ return getCachedOrFetch(cacheKey, () => fetchCredentials({
91
+ roleArn, profileArn, trustAnchorArn,
92
+ region, normalizedCert, privateKeyPkcs8Base64,
93
+ sessionTtl,
94
+ }), { ttlSec: 60, forceRefresh });
95
+ }
96
+ async function fetchCredentials(input) {
97
+ const { roleArn, profileArn, trustAnchorArn, region, normalizedCert, privateKeyPkcs8Base64, sessionTtl, } = input;
98
+ const signingKey = await getSigningMaterial(normalizedCert, privateKeyPkcs8Base64);
99
+ const host = `${SERVICE}.${region}.amazonaws.com`;
100
+ const iso = new Date().toISOString();
101
+ const amzDate = isoToAmzDate(iso);
102
+ const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
103
+ const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
104
+ const payloadHash = await sha256Hex(body);
105
+ const baseHeaders = {
106
+ "content-type": "application/json",
107
+ "host": host,
108
+ "x-amz-date": amzDate,
109
+ "x-amz-x509": normalizedCert,
110
+ };
111
+ const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
112
+ const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
113
+ const canonicalRequest = buildCanonicalRequest({
114
+ method: "POST",
115
+ canonicalUri: PATH,
116
+ query: "",
117
+ canonicalHeaders,
118
+ signedHeaders,
119
+ payloadHash,
120
+ });
121
+ const canonicalRequestHash = await sha256Hex(canonicalRequest);
122
+ const stringToSign = buildStringToSign({
123
+ algorithm: ALGORITHM,
124
+ amzDate,
125
+ credentialScope,
126
+ canonicalRequestHash,
127
+ });
128
+ const signatureHex = await signStringToSign(stringToSign, signingKey);
129
+ const certSerialDec = getCertSerialDec(normalizedCert);
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
+ let res;
137
+ try {
138
+ res = await fetch(`https://${host}${PATH}`, {
137
139
  method: "POST",
138
140
  headers: finalHeaders,
139
141
  body,
140
142
  });
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
- });
143
+ }
144
+ catch (err) {
145
+ console.warn("[aws-unreachable]", { region, err });
146
+ throw new InternalError("aws_unreachable");
147
+ }
148
+ if (!res.ok) {
149
+ console.warn("[aws-rejected]", { status: res.status, region });
150
+ throw new InternalError("aws_rejected");
151
+ }
152
+ const json = await res.json();
153
+ const creds = json?.credentialSet?.[0]?.credentials;
154
+ if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
155
+ console.warn("[issueAwsCredentials] malformed AWS credential response");
156
+ throw new InternalError("aws_malformed_credentials");
157
+ }
158
+ const value = {
159
+ accessKeyId: creds.accessKeyId,
160
+ secretAccessKey: creds.secretAccessKey,
161
+ sessionToken: creds.sessionToken,
162
+ expiration: creds.expiration,
163
+ };
164
+ // Cache for 1/3 of the credential lifetime so it's refreshed well before expiry.
165
+ // deriveEdgeTtlSec in cache.ts will further subtract EDGE_BUFFER_SEC (5 min).
166
+ const expiresAtMs = Date.parse(creds.expiration);
167
+ const receivedAt = Date.now();
168
+ const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
169
+ if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
170
+ const cacheTtlSec = Math.floor(credLifetimeSec / 3);
171
+ const cacheExpiry = new Date(receivedAt + cacheTtlSec * 1000).toISOString();
172
+ return wrapResult(value, cacheExpiry);
173
+ }
174
+ return value;
181
175
  }
182
- // ---------------------------------------------------------------------------
183
- // Helpers
184
- // ---------------------------------------------------------------------------
185
- /** Strip whitespace and reject PEM-wrapped input. */
176
+ // ── Helpers ────────────────────────────────────────────────────────────────
177
+ /** Strip whitespace; reject PEM-wrapped input. */
186
178
  function normalizeCert(raw) {
187
179
  if (raw.includes("BEGIN CERTIFICATE")) {
188
180
  throw new InternalError("pem_not_allowed");
189
181
  }
190
182
  return raw.replace(/\s+/g, "");
191
183
  }
184
+ /** Return cert serial as decimal string, using isolate-level cache. */
185
+ function getCertSerialDec(normalizedCert) {
186
+ if (cachedCertSerialDec && cachedCertSerialSource === normalizedCert) {
187
+ return cachedCertSerialDec;
188
+ }
189
+ const serial = parseCertSerialDec(normalizedCert);
190
+ cachedCertSerialDec = serial;
191
+ cachedCertSerialSource = normalizedCert;
192
+ return serial;
193
+ }
192
194
  /**
193
195
  * Minimal DER walk to extract the certificate serial number as a decimal string.
194
196
  * Throws InternalError("invalid_cert_der") on any parse failure.
@@ -220,8 +222,7 @@ function parseCertSerialDec(normalizedCertBase64) {
220
222
  // Skip optional [0] EXPLICIT version field.
221
223
  if (der[offset] === 0xa0) {
222
224
  offset++;
223
- const vLen = readLen();
224
- offset += vLen;
225
+ offset += readLen();
225
226
  }
226
227
  if (der[offset++] !== 0x02)
227
228
  throw new Error("bad serial tag");
@@ -233,7 +234,6 @@ function parseCertSerialDec(normalizedCertBase64) {
233
234
  if (serial.length > 1 && serial[0] === 0x00) {
234
235
  serial = serial.slice(1);
235
236
  }
236
- // Accumulate directly to BigInt — avoids intermediate hex string allocation.
237
237
  let serialBig = 0n;
238
238
  for (let i = 0; i < serial.length; i++) {
239
239
  serialBig = (serialBig << 8n) | BigInt(serial[i]);
@@ -246,7 +246,7 @@ function parseCertSerialDec(normalizedCertBase64) {
246
246
  }
247
247
  }
248
248
  /**
249
- * Convert an ISO-8601 timestamp to the compact AMZ date-time format.
249
+ * Convert ISO-8601 to compact AMZ date-time format.
250
250
  * e.g. "2026-03-07T12:00:00.000Z" → "20260307T120000Z"
251
251
  */
252
252
  function isoToAmzDate(iso) {
@@ -259,7 +259,7 @@ function isoToAmzDate(iso) {
259
259
  iso.slice(17, 19) +
260
260
  "Z");
261
261
  }
262
- /** Faster base64 → Uint8Array using V8's vectorized atob path. */
262
+ /** base64 → Uint8Array via V8's vectorized atob path. */
263
263
  function base64ToBytes(base64) {
264
264
  const binary = atob(base64);
265
265
  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.49",
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
  }