@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.
- package/dist/federation/login.d.ts +5 -3
- package/dist/federation/login.js +60 -66
- package/dist/sts/issue.js +44 -101
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
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
|
-
}
|
|
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>;
|
package/dist/federation/login.js
CHANGED
|
@@ -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
|
|
5
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
const cacheKey = `aws-signin:${
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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=${
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
`&SigninToken=${signinToken}` +
|
|
63
|
+
`&Destination=${encodeURIComponent(destination)}`);
|
|
64
|
+
}
|
|
65
|
+
function getDestinationUrl(region, intent) {
|
|
53
66
|
switch (intent) {
|
|
54
67
|
case "billing":
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 -
|
|
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(
|
|
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
|
|
176
|
+
forceRefresh
|
|
234
177
|
});
|
|
235
178
|
}
|
|
236
179
|
// ---------------------------------------------------------------------------
|