@vizamodo/aws-sts-core 0.4.32 → 0.4.34

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.
@@ -1,4 +1,4 @@
1
- export declare function buildFederationLoginUrl(input: {
1
+ export interface BuildFederationLoginUrlInput {
2
2
  accessKeyId: string;
3
3
  secretAccessKey: string;
4
4
  sessionToken: string;
@@ -6,9 +6,11 @@ export declare function buildFederationLoginUrl(input: {
6
6
  expiration: string;
7
7
  intent?: "console" | "billing" | "dynamodb" | "ssm";
8
8
  forceRefresh?: boolean;
9
- }): Promise<{
9
+ }
10
+ export interface FederationLoginUrl {
10
11
  ok: true;
11
12
  loginUrl: string;
12
13
  shortUrl: string;
13
14
  region: string;
14
- }>;
15
+ }
16
+ export declare function buildFederationLoginUrl(input: BuildFederationLoginUrlInput): Promise<FederationLoginUrl>;
@@ -1,82 +1,76 @@
1
1
  import { sha256Hex } from "../crypto/sha256";
2
2
  import { getCachedOrFetch, wrapResult } from "@vizamodo/edge-cache-core";
3
+ // ── Constants ──────────────────────────────────────────────────────────────
4
+ /** AWS SigninToken TTL is ~15 minutes regardless of credential lifetime. */
5
+ const SIGNIN_TOKEN_TTL_SEC = 15 * 60;
6
+ // ── Main export ────────────────────────────────────────────────────────────
3
7
  export async function buildFederationLoginUrl(input) {
4
- const session = {
5
- sessionId: input.accessKeyId,
6
- sessionKey: input.secretAccessKey,
7
- sessionToken: input.sessionToken
8
- };
9
- const expiresAtMs = Date.parse(input.expiration);
8
+ const { accessKeyId, secretAccessKey, sessionToken, region, expiration, forceRefresh } = input;
9
+ const expiresAtMs = Date.parse(expiration);
10
10
  if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
11
11
  throw new Error("[federation] invalid or expired credentials");
12
12
  }
13
- const tokenHash = await sha256Hex(input.sessionToken);
14
- // Use region and tokenHash for cache key
15
- const cacheKey = `aws-signin:${input.region}:${tokenHash}`;
16
- const sessionJson = JSON.stringify(session);
17
- const encoded = encodeURIComponent(sessionJson);
18
- const SigninToken = await getCachedOrFetch(cacheKey, async () => {
19
- const tokenResp = await fetch(`https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=${encoded}`);
20
- if (!tokenResp.ok) {
21
- // best-effort: do not cache failures
22
- throw new Error("[signin] failed to fetch SigninToken");
23
- }
24
- const json = await tokenResp.json();
25
- const token = json?.SigninToken;
26
- if (!token) {
27
- // do not cache invalid response
28
- throw new Error("[signin] empty SigninToken");
29
- }
30
- // SigninToken TTL ~15 minutes (AWS behavior)
31
- const SIGNIN_TOKEN_TTL_SEC = 15 * 60;
32
- // derive effective TTL = min(tokenTTL, credentialTTL)
33
- const credRemainingSec = Math.floor((Date.parse(input.expiration) - Date.now()) / 1000);
34
- const effectiveTtlSec = Math.min(SIGNIN_TOKEN_TTL_SEC, credRemainingSec);
35
- // if credential too close to expiry → skip caching
36
- if (effectiveTtlSec <= 0) {
37
- return token;
38
- }
39
- const expiryIso = new Date(Date.now() + effectiveTtlSec * 1000).toISOString();
40
- // restore primitive storage (cache layer now handles primitive safely)
41
- return wrapResult(token, expiryIso);
42
- }, { ttlSec: 60, ...(input.forceRefresh !== undefined ? { forceRefresh: input.forceRefresh } : {}) } // allow caller-controlled retry
43
- );
44
- if (!SigninToken) {
13
+ // Hash token for cache key — avoids exposing raw token in logs/storage.
14
+ const tokenHash = await sha256Hex(sessionToken);
15
+ const cacheKey = `aws-signin:${region}:${accessKeyId}:${tokenHash}`;
16
+ const signinToken = await getCachedOrFetch(cacheKey, () => fetchSigninToken({ accessKeyId, secretAccessKey, sessionToken, expiration }), { ttlSec: 60, forceRefresh });
17
+ if (!signinToken)
45
18
  throw new Error("[federation] unable to obtain SigninToken");
19
+ const loginUrl = buildLoginUrl(signinToken, region, input.intent ?? "console");
20
+ return {
21
+ ok: true,
22
+ loginUrl,
23
+ shortUrl: loginUrl.slice(0, 40) + "..." + loginUrl.slice(-60),
24
+ region,
25
+ };
26
+ }
27
+ async function fetchSigninToken(input) {
28
+ const { accessKeyId, secretAccessKey, sessionToken, expiration } = input;
29
+ const session = JSON.stringify({
30
+ sessionId: accessKeyId,
31
+ sessionKey: secretAccessKey,
32
+ sessionToken,
33
+ });
34
+ let res;
35
+ try {
36
+ res = await fetch(`https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=${encodeURIComponent(session)}`);
37
+ }
38
+ catch {
39
+ throw new Error("[signin] unreachable");
46
40
  }
47
- const rawToken = SigninToken;
48
- const baseLogin = `https://signin.aws.amazon.com/federation?Action=login` +
41
+ if (!res.ok)
42
+ throw new Error("[signin] failed to fetch SigninToken");
43
+ const json = await res.json();
44
+ const token = json?.SigninToken;
45
+ if (!token)
46
+ throw new Error("[signin] empty SigninToken");
47
+ // Cache for min(SIGNIN_TOKEN_TTL, remaining credential lifetime).
48
+ const now = Date.now();
49
+ const credRemainingSec = Math.floor((Date.parse(expiration) - now) / 1000);
50
+ const cacheTtlSec = Math.min(SIGNIN_TOKEN_TTL_SEC, credRemainingSec);
51
+ if (cacheTtlSec <= 0)
52
+ return token; // credential too close to expiry — skip cache
53
+ const cacheExpiry = new Date(now + cacheTtlSec * 1000).toISOString();
54
+ return wrapResult(token, cacheExpiry);
55
+ }
56
+ // ── URL builder ────────────────────────────────────────────────────────────
57
+ function buildLoginUrl(signinToken, region, intent) {
58
+ const destination = getDestinationUrl(region, intent);
59
+ return (`https://signin.aws.amazon.com/federation` +
60
+ `?Action=login` +
49
61
  `&Issuer=viza` +
50
- `&SigninToken=${rawToken}`;
51
- const intent = input.intent ?? "console";
52
- let destinationUrl;
62
+ `&SigninToken=${signinToken}` +
63
+ `&Destination=${encodeURIComponent(destination)}`);
64
+ }
65
+ function getDestinationUrl(region, intent) {
53
66
  switch (intent) {
54
67
  case "billing":
55
- destinationUrl =
56
- `https://us-east-1.console.aws.amazon.com/costmanagement/home?region=${input.region}#/home`;
57
- break;
68
+ return `https://us-east-1.console.aws.amazon.com/costmanagement/home?region=${region}#/home`;
58
69
  case "dynamodb":
59
- destinationUrl =
60
- `https://${input.region}.console.aws.amazon.com/dynamodbv2/home?region=${input.region}#dashboard`;
61
- break;
70
+ return `https://${region}.console.aws.amazon.com/dynamodbv2/home?region=${region}#dashboard`;
62
71
  case "ssm":
63
- destinationUrl =
64
- `https://${input.region}.console.aws.amazon.com/systems-manager/parameters/?region=${input.region}&tab=Table`;
65
- break;
72
+ return `https://${region}.console.aws.amazon.com/systems-manager/parameters/?region=${region}&tab=Table`;
66
73
  case "console":
67
- default:
68
- destinationUrl =
69
- `https://console.aws.amazon.com/?region=${input.region}`;
70
- break;
74
+ return `https://console.aws.amazon.com/?region=${region}`;
71
75
  }
72
- const encodedDestination = encodeURIComponent(destinationUrl);
73
- const loginUrl = `${baseLogin}&Destination=${encodedDestination}`;
74
- const shortUrl = loginUrl.slice(0, 40) + "..." +
75
- loginUrl.slice(-60);
76
- return {
77
- ok: true,
78
- loginUrl,
79
- shortUrl,
80
- region: input.region
81
- };
82
76
  }
package/dist/sts/issue.js CHANGED
@@ -26,19 +26,12 @@ let signingKeyPromise = null;
26
26
  let cachedSigningKey = null;
27
27
  let cachedCertBase64 = null;
28
28
  let cachedPrivateKeyBase64 = null;
29
- // ---- single-flight hard refresh guard + cooldown ----
30
- let hardRefreshPromise = null;
31
- let lastHardRefreshAt = 0;
32
- const HARD_REFRESH_COOLDOWN_MS = 2000;
33
29
  export function resetIsolateCache() {
34
30
  signingKeyPromise = null;
35
31
  cachedSigningKey = null;
36
32
  cachedCertBase64 = null;
37
33
  cachedPrivateKeyBase64 = null;
38
34
  }
39
- // ---- certificate serial cache (DER walk is CPU-bound, cert rarely rotates) ----
40
- let cachedCertSerialDec = null;
41
- let cachedCertSerialSource = null;
42
35
  // ---------------------------------------------------------------------------
43
36
  // Signing material
44
37
  // ---------------------------------------------------------------------------
@@ -99,55 +92,46 @@ export async function issueAwsCredentials(input) {
99
92
  const certSerialDec = parseCertSerialDec(normalizedCert);
100
93
  const cacheKey = `${region}|${roleArn}|${profileArn}|${trustAnchorArn}|${certSerialDec}`;
101
94
  return getCachedOrFetch(cacheKey, async () => {
102
- async function buildSignedHeaders() {
103
- const { signingKey } = await getSigningMaterial({
104
- certBase64: normalizedCert,
105
- privateKeyPkcs8Base64,
106
- });
107
- const host = `${SERVICE}.${region}.amazonaws.com`;
108
- const iso = new Date().toISOString();
109
- const amzDate = isoToAmzDate(iso);
110
- const dateStamp = iso.slice(0, 4) + iso.slice(5, 7) + iso.slice(8, 10);
111
- const body = JSON.stringify({ trustAnchorArn, profileArn, roleArn, durationSeconds: sessionTtl });
112
- const payloadHash = await sha256Hex(body);
113
- const baseHeaders = {
114
- "content-type": "application/json",
115
- "host": host,
116
- "x-amz-date": amzDate,
117
- "x-amz-x509": normalizedCert,
118
- };
119
- const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
120
- const credentialScope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
121
- const canonicalRequest = buildCanonicalRequest({
122
- method: "POST",
123
- canonicalUri: PATH,
124
- query: "",
125
- canonicalHeaders,
126
- signedHeaders,
127
- payloadHash,
128
- });
129
- const canonicalRequestHash = await sha256Hex(canonicalRequest);
130
- const stringToSign = buildStringToSign({
131
- algorithm: ALGORITHM,
132
- amzDate,
133
- credentialScope,
134
- canonicalRequestHash,
135
- });
136
- const signatureHex = await signStringToSign(stringToSign, signingKey);
137
- const headers = 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
- return {
144
- headers,
145
- body,
146
- host,
147
- };
148
- }
149
- const { headers: finalHeaders, body, host } = await buildSignedHeaders();
150
- const issuedAt = Date.now();
95
+ const { signingKey } = await getSigningMaterial({
96
+ certBase64: normalizedCert,
97
+ privateKeyPkcs8Base64,
98
+ });
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 finalHeaders = new Headers({
130
+ "Content-Type": "application/json",
131
+ "X-Amz-Date": amzDate,
132
+ "X-Amz-X509": normalizedCert,
133
+ "Authorization": `${ALGORITHM} Credential=${certSerialDec}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signatureHex}`,
134
+ });
151
135
  let res;
152
136
  try {
153
137
  res = await fetch(`https://${host}${PATH}`, {
@@ -162,53 +146,12 @@ export async function issueAwsCredentials(input) {
162
146
  }
163
147
  if (!res.ok) {
164
148
  const status = res.status;
165
- // Safe internal hard reset trigger:
166
- // Only reset isolate cache on explicit forceRefresh AND first failure
167
- if (forceRefresh && status === 403) {
168
- const now = Date.now();
169
- // Cooldown guard to avoid rapid consecutive hard resets
170
- if (now - lastHardRefreshAt < HARD_REFRESH_COOLDOWN_MS) {
171
- console.warn("[aws-rejected][cooldown-skip-hard-reset]", { status, region });
172
- throw new InternalError("aws_rejected");
173
- }
174
- // Single-flight: only one request performs hard refresh
175
- if (!hardRefreshPromise) {
176
- hardRefreshPromise = (async () => {
177
- console.warn("[aws-rejected][hard-reset-trigger]", { status, region });
178
- lastHardRefreshAt = Date.now();
179
- resetIsolateCache();
180
- const { headers: retryHeaders, body: retryBody, host: retryHost } = await buildSignedHeaders();
181
- const retryRes = await fetch(`https://${retryHost}${PATH}`, {
182
- method: "POST",
183
- headers: retryHeaders,
184
- body: retryBody,
185
- });
186
- if (!retryRes.ok) {
187
- console.warn("[aws-rejected][after-hard-reset]", { status: retryRes.status, region });
188
- throw new InternalError("aws_rejected");
189
- }
190
- const retryJson = await retryRes.json();
191
- const retryCreds = retryJson?.credentialSet?.[0]?.credentials;
192
- if (!retryCreds?.accessKeyId || !retryCreds?.secretAccessKey || !retryCreds?.sessionToken) {
193
- throw new InternalError("aws_malformed_credentials");
194
- }
195
- return {
196
- accessKeyId: retryCreds.accessKeyId,
197
- secretAccessKey: retryCreds.secretAccessKey,
198
- sessionToken: retryCreds.sessionToken,
199
- expiration: retryCreds.expiration,
200
- };
201
- })().finally(() => {
202
- hardRefreshPromise = null;
203
- });
204
- }
205
- return await hardRefreshPromise;
206
- }
207
149
  console.warn("[aws-rejected]", { status, region, profile });
208
150
  throw new InternalError("aws_rejected");
209
151
  }
210
152
  const json = await res.json();
211
153
  const creds = json?.credentialSet?.[0]?.credentials;
154
+ const receivedAt = Date.now();
212
155
  if (!creds?.accessKeyId || !creds?.secretAccessKey || !creds?.sessionToken) {
213
156
  console.warn("[issueAwsCredentials] malformed AWS credential response");
214
157
  throw new InternalError("aws_malformed_credentials");
@@ -221,16 +164,16 @@ export async function issueAwsCredentials(input) {
221
164
  };
222
165
  // derive TTL = 1/3 lifetime
223
166
  const expiresAtMs = Date.parse(creds.expiration);
224
- const credLifetimeSec = Math.floor((expiresAtMs - issuedAt) / 1000);
167
+ const credLifetimeSec = Math.floor((expiresAtMs - receivedAt) / 1000);
225
168
  if (Number.isFinite(expiresAtMs) && credLifetimeSec > 0) {
226
169
  const edgeCacheTtlSec = Math.floor(credLifetimeSec / 3);
227
- const edgeCacheExpiry = new Date(issuedAt + edgeCacheTtlSec * 1000).toISOString();
170
+ const edgeCacheExpiry = new Date(receivedAt + edgeCacheTtlSec * 1000).toISOString();
228
171
  return wrapResult(value, edgeCacheExpiry);
229
172
  }
230
173
  return value;
231
174
  }, {
232
175
  ttlSec: 60,
233
- forceRefresh: !!forceRefresh
176
+ forceRefresh
234
177
  });
235
178
  }
236
179
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizamodo/aws-sts-core",
3
- "version": "0.4.32",
3
+ "version": "0.4.34",
4
4
  "description": "Pure AWS STS + SigV4 (X509 Roles Anywhere) core logic",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",