@mi9-identity/token-client 1.0.0
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/CHANGELOG.md +52 -0
- package/LICENSE +201 -0
- package/README.md +402 -0
- package/dist/backoff.d.ts +35 -0
- package/dist/backoff.js +79 -0
- package/dist/claim.d.ts +21 -0
- package/dist/claim.js +169 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +485 -0
- package/dist/deadline.d.ts +78 -0
- package/dist/deadline.js +75 -0
- package/dist/drain-body.d.ts +10 -0
- package/dist/drain-body.js +18 -0
- package/dist/endpoints.d.ts +16 -0
- package/dist/endpoints.js +19 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +63 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/oauth-error.d.ts +23 -0
- package/dist/oauth-error.js +42 -0
- package/dist/request-id.d.ts +9 -0
- package/dist/request-id.js +19 -0
- package/dist/schemas.d.ts +41 -0
- package/dist/schemas.js +44 -0
- package/dist/token-client.constants.d.ts +28 -0
- package/dist/token-client.constants.js +33 -0
- package/dist/type-guards.d.ts +93 -0
- package/dist/type-guards.js +102 -0
- package/dist/types.d.ts +241 -0
- package/dist/types.js +2 -0
- package/package.json +61 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { drainBody } from './drain-body.js';
|
|
2
|
+
import { claimGcpCredential } from './claim.js';
|
|
3
|
+
import { resolveRequestId } from './request-id.js';
|
|
4
|
+
import { isEmpty, isNull, isString, isUndefined } from './type-guards.js';
|
|
5
|
+
import { computeBackoffDelayMs, DEFAULT_BACKOFF, parseRetryAfterMs } from './backoff.js';
|
|
6
|
+
import { deadlineSignal, remainingMs, sleepOrThrowIfDeadlineExceeded, startDeadline } from './deadline.js';
|
|
7
|
+
import { DEFAULT_REFRESH_LEAD_TIME_MS, DEFAULT_REQUEST_ID_HEADER, MAX_RETRIES, NOOP_LOGGER } from './token-client.constants.js';
|
|
8
|
+
import { ConfigurationError, LazyClaimError, ProvisioningError, ResponseShapeError, RevocationError, TransientError } from './errors.js';
|
|
9
|
+
import { credentialMeResponseSchema, oauthErrorSchema, tokenResponseSchema } from './schemas.js';
|
|
10
|
+
/**
|
|
11
|
+
* Detect the rotation signal in a `/oauth/token` success response. ADR-7 fuses
|
|
12
|
+
* the body (`credentialRotated`) and header (`X-Mi9-Credential-Rotated`) signals
|
|
13
|
+
* — either one trips pickup.
|
|
14
|
+
*
|
|
15
|
+
* @param { TokenResponse } body The parsed token-response body.
|
|
16
|
+
* @param { Headers } headers The response headers.
|
|
17
|
+
* @returns { boolean } `true` when the issuer signalled rotation via either channel.
|
|
18
|
+
*/
|
|
19
|
+
const isRotationSignal = (body, headers) => {
|
|
20
|
+
if (body.credentialRotated === true) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
const headerValue = headers.get('x-mi9-credential-rotated');
|
|
24
|
+
return isString(headerValue) && headerValue.toLowerCase() === 'true';
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort extract of the `error` field from an OAuth error envelope; falls
|
|
28
|
+
* back to `'unknown'` when the body is missing or malformed.
|
|
29
|
+
*
|
|
30
|
+
* @param { Response } response The 4xx fetch response.
|
|
31
|
+
* @returns { Promise<string> } The `error` field value, or `'unknown'`.
|
|
32
|
+
*/
|
|
33
|
+
const readOAuthErrorMessage = async (response) => {
|
|
34
|
+
const oauthErr = oauthErrorSchema.safeParse(await response.json().catch(() => ({})));
|
|
35
|
+
return oauthErr.success ? (oauthErr.data.error ?? 'unknown') : 'unknown';
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Parse a successful `/oauth/token` 2xx body and bundle the rotation signal so
|
|
39
|
+
* the retry loop can return a single value.
|
|
40
|
+
*
|
|
41
|
+
* @param { Response } response The successful fetch response.
|
|
42
|
+
* @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
|
|
43
|
+
* @throws { ResponseShapeError } If the body fails JSON parsing or schema validation.
|
|
44
|
+
*/
|
|
45
|
+
const parseTokenSuccess = async (response) => {
|
|
46
|
+
let json;
|
|
47
|
+
try {
|
|
48
|
+
json = await response.json();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new ResponseShapeError(`Failed to parse /oauth/token JSON body: ${String(err)}`);
|
|
52
|
+
}
|
|
53
|
+
const parsed = tokenResponseSchema.safeParse(json);
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
throw new ResponseShapeError(`Unexpected /oauth/token response shape: ${parsed.error.message}`);
|
|
56
|
+
}
|
|
57
|
+
return { body: parsed.data, rotated: isRotationSignal(parsed.data, response.headers) };
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Build a token client bound to one consumer credential. The returned object
|
|
61
|
+
* is safe to share across the lifetime of the calling process — it manages
|
|
62
|
+
* its own in-memory cache, single-flight refresh, and rotation pickup.
|
|
63
|
+
*
|
|
64
|
+
* @param { TokenClientOptions } opts Client configuration (endpoints, credentials, lead time, retry policy, hooks).
|
|
65
|
+
* @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
|
|
66
|
+
*/
|
|
67
|
+
export const createTokenClient = (opts) => {
|
|
68
|
+
if (isEmpty(opts.audience)) {
|
|
69
|
+
throw new ConfigurationError('createTokenClient: `audience` must be a non-empty string or array of strings');
|
|
70
|
+
}
|
|
71
|
+
const gcpEnabled = !isUndefined(opts.idTokenProvider);
|
|
72
|
+
// Classic mode carries a static credential; GCP lazy-claim mode bootstraps
|
|
73
|
+
// it on first use, so the credential fields are required only without a provider.
|
|
74
|
+
// The discriminated union already rejects this at compile time — this guard
|
|
75
|
+
// catches a caller that reached the runtime through an `any` or a JS import.
|
|
76
|
+
if (!gcpEnabled && (isEmpty(opts.clientId) || isEmpty(opts.clientSecret))) {
|
|
77
|
+
throw new ConfigurationError('createTokenClient: `clientId` and `clientSecret` are required unless `idTokenProvider` is set');
|
|
78
|
+
}
|
|
79
|
+
const log = opts.logger ?? NOOP_LOGGER;
|
|
80
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
81
|
+
const refreshLeadTimeMs = opts.refreshLeadTimeMs ?? DEFAULT_REFRESH_LEAD_TIME_MS;
|
|
82
|
+
const requestIdHeader = opts.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER;
|
|
83
|
+
const backoff = opts.backoff ?? DEFAULT_BACKOFF;
|
|
84
|
+
const state = {
|
|
85
|
+
clientId: opts.clientId ?? '',
|
|
86
|
+
secret: opts.clientSecret ?? '',
|
|
87
|
+
cached: undefined,
|
|
88
|
+
inflightRefresh: undefined,
|
|
89
|
+
inflightPickup: undefined,
|
|
90
|
+
closed: false,
|
|
91
|
+
};
|
|
92
|
+
const ensureOpen = () => {
|
|
93
|
+
if (state.closed) {
|
|
94
|
+
throw new Error('TokenClient is closed');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const buildHeaders = (extra) => ({
|
|
98
|
+
accept: 'application/json',
|
|
99
|
+
'content-type': 'application/json',
|
|
100
|
+
[requestIdHeader]: resolveRequestId(opts.requestId),
|
|
101
|
+
...extra,
|
|
102
|
+
});
|
|
103
|
+
const postJson = async (url, body, headers, signal) => {
|
|
104
|
+
const init = {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: buildHeaders(headers),
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
};
|
|
109
|
+
if (!isUndefined(signal)) {
|
|
110
|
+
init.signal = signal;
|
|
111
|
+
}
|
|
112
|
+
return fetchImpl(url, init);
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Build the JSON payload for `/oauth/token`. `audience` is forwarded
|
|
116
|
+
* verbatim — strings, one-element arrays, and multi-element arrays all pass
|
|
117
|
+
* through unchanged (no collapse). Omits `scope` entirely when the caller
|
|
118
|
+
* did not configure one.
|
|
119
|
+
*
|
|
120
|
+
* @returns { TokenRequestBody } The request body to POST.
|
|
121
|
+
*/
|
|
122
|
+
const buildTokenRequestBody = () => {
|
|
123
|
+
const body = {
|
|
124
|
+
grantType: 'client_credentials',
|
|
125
|
+
clientId: state.clientId,
|
|
126
|
+
clientSecret: state.secret,
|
|
127
|
+
audience: opts.audience,
|
|
128
|
+
};
|
|
129
|
+
if (!isUndefined(opts.scope)) {
|
|
130
|
+
body.scope = opts.scope;
|
|
131
|
+
}
|
|
132
|
+
return body;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Map a non-2xx `/oauth/token` response to either a terminal throw (401
|
|
136
|
+
* revocation, 403 unauthorized audience, 404 not-provisioned, other) or a
|
|
137
|
+
* transient outcome the retry loop should sleep on.
|
|
138
|
+
*
|
|
139
|
+
* @param { Response } response The non-2xx fetch response.
|
|
140
|
+
* @param { number } attempt Zero-indexed retry attempt number.
|
|
141
|
+
* @returns { Promise<{ delayMs: number; transient: TransientError }> } Sleep delay and the transient error to remember.
|
|
142
|
+
* @throws { RevocationError } On 401.
|
|
143
|
+
* @throws { ConfigurationError } On 403 (`invalid_target` — audience not authorized for this credential).
|
|
144
|
+
* @throws { ProvisioningError } On 404.
|
|
145
|
+
* @throws { Error } On any other non-transient status.
|
|
146
|
+
*/
|
|
147
|
+
const handleTokenErrorResponse = async (response, attempt) => {
|
|
148
|
+
if (response.status === 401) {
|
|
149
|
+
throw new RevocationError(`credential rejected by /oauth/token: ${await readOAuthErrorMessage(response)}`);
|
|
150
|
+
}
|
|
151
|
+
if (response.status === 403) {
|
|
152
|
+
throw new ConfigurationError(`audience rejected by /oauth/token: ${await readOAuthErrorMessage(response)}`);
|
|
153
|
+
}
|
|
154
|
+
if (response.status === 404) {
|
|
155
|
+
await drainBody(response);
|
|
156
|
+
throw new ProvisioningError('credential not provisioned (404 from /oauth/token)');
|
|
157
|
+
}
|
|
158
|
+
if (response.status === 429 || response.status >= 500) {
|
|
159
|
+
const retryAfter = parseRetryAfterMs(response.headers.get('retry-after'), backoff) ?? computeBackoffDelayMs(backoff, attempt);
|
|
160
|
+
await drainBody(response);
|
|
161
|
+
const transient = new TransientError(`/oauth/token returned ${String(response.status)}`);
|
|
162
|
+
log.warn({ attempt, status: response.status, retryAfterMs: retryAfter }, 'token-client.transient');
|
|
163
|
+
return { delayMs: retryAfter, transient };
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`/oauth/token returned ${String(response.status)}: ${await readOAuthErrorMessage(response)}`);
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* One round-trip to `/oauth/token` for the configured consumer audience(s).
|
|
169
|
+
* Retries on transient failures per the backoff policy; 401/404 surface
|
|
170
|
+
* as terminal errors.
|
|
171
|
+
*
|
|
172
|
+
* @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset.
|
|
173
|
+
* @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
|
|
174
|
+
* @throws { TransientError } When retries are exhausted, or when `deadline` elapses before a retry.
|
|
175
|
+
*/
|
|
176
|
+
const requestToken = async (deadline) => {
|
|
177
|
+
let lastTransient;
|
|
178
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
|
179
|
+
ensureOpen();
|
|
180
|
+
let response;
|
|
181
|
+
try {
|
|
182
|
+
response = await postJson(opts.tokenEndpoint, buildTokenRequestBody(), undefined, deadlineSignal(deadline));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
lastTransient = new TransientError('Network failure calling /oauth/token', { cause: err });
|
|
186
|
+
log.warn({ attempt, err: String(err) }, 'token-client.network_error');
|
|
187
|
+
await sleepOrThrowIfDeadlineExceeded(deadline, computeBackoffDelayMs(backoff, attempt), '/oauth/token');
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (response.ok) {
|
|
191
|
+
return parseTokenSuccess(response);
|
|
192
|
+
}
|
|
193
|
+
const { delayMs, transient } = await handleTokenErrorResponse(response, attempt);
|
|
194
|
+
lastTransient = transient;
|
|
195
|
+
await sleepOrThrowIfDeadlineExceeded(deadline, delayMs, '/oauth/token');
|
|
196
|
+
}
|
|
197
|
+
/* v8 ignore next -- defensive; loop runs at least once with MAX_RETRIES > 0 so lastTransient is always assigned */
|
|
198
|
+
throw lastTransient ?? new TransientError('exhausted retries calling /oauth/token');
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Verify every configured audience is covered by the just-claimed
|
|
202
|
+
* credential's `authorizedAudiences`. Without this, a caller configured for
|
|
203
|
+
* an audience the credential does not authorize only learns about the gap
|
|
204
|
+
* from a 403 `invalid_target` on the very next mint — this fails fast,
|
|
205
|
+
* right after the claim, and names both sets so the operator can see the
|
|
206
|
+
* mismatch immediately.
|
|
207
|
+
*
|
|
208
|
+
* @param { readonly string[] } authorizedAudiences The claimed credential's `authorizedAudiences`.
|
|
209
|
+
* @returns { void } Nothing — throws on mismatch.
|
|
210
|
+
* @throws { LazyClaimError } When a configured audience is absent from `authorizedAudiences`.
|
|
211
|
+
*/
|
|
212
|
+
const assertAudienceAuthorized = (authorizedAudiences) => {
|
|
213
|
+
const configuredAudiences = isString(opts.audience) ? [opts.audience] : opts.audience;
|
|
214
|
+
const unauthorized = configuredAudiences.filter((audience) => !authorizedAudiences.includes(audience));
|
|
215
|
+
if (isEmpty(unauthorized)) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
throw new LazyClaimError(`configured audience(s) [${configuredAudiences.join(', ')}] not authorized for claimed credential (authorizedAudiences: [${authorizedAudiences.join(', ')}])`);
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* (Re)obtain this client's credential via the Tier-1 GCP lazy-claim
|
|
222
|
+
* (`POST /credentials/me`, `grantType: gcp_identity`) and swap the new
|
|
223
|
+
* `clientId` + secret into in-memory state. Runs inside the single-flight
|
|
224
|
+
* refresh promise (so it is coalesced with minting) and never fires
|
|
225
|
+
* `onSecretRotated` — Tier-1 credentials are held in memory and re-claimed on
|
|
226
|
+
* demand, not persisted.
|
|
227
|
+
*
|
|
228
|
+
* @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset. The claim receives whatever remains of it as its own `deadlineMs`.
|
|
229
|
+
* @returns { Promise<void> } Resolves once the freshly-claimed credential is in memory.
|
|
230
|
+
* @throws { LazyClaimError } The ID token was rejected (401/403), the provider threw, or the claimed credential's `authorizedAudiences` does not cover the configured `audience`.
|
|
231
|
+
* @throws { ProvisioningError } On 404 — the credential is not provisioned yet.
|
|
232
|
+
* @throws { TransientError } When `deadline` elapses before the claim completes.
|
|
233
|
+
*/
|
|
234
|
+
const lazyClaim = async (deadline) => {
|
|
235
|
+
/* v8 ignore next 3 -- defensive; lazyClaim is only reached when gcpEnabled, i.e. idTokenProvider is set */
|
|
236
|
+
if (isUndefined(opts.idTokenProvider)) {
|
|
237
|
+
throw new LazyClaimError('no idTokenProvider configured for GCP lazy-claim');
|
|
238
|
+
}
|
|
239
|
+
const claim = await claimGcpCredential({
|
|
240
|
+
credentialEndpoint: opts.credentialEndpoint,
|
|
241
|
+
idTokenProvider: opts.idTokenProvider,
|
|
242
|
+
fetch: fetchImpl,
|
|
243
|
+
backoff,
|
|
244
|
+
requestIdHeader,
|
|
245
|
+
logger: log,
|
|
246
|
+
...(isUndefined(opts.retailerCode) ? {} : { retailerCode: opts.retailerCode }),
|
|
247
|
+
...(isUndefined(opts.requestId) ? {} : { requestId: opts.requestId }),
|
|
248
|
+
...(isUndefined(deadline) ? {} : { deadlineMs: remainingMs(deadline) }),
|
|
249
|
+
});
|
|
250
|
+
assertAudienceAuthorized(claim.audience);
|
|
251
|
+
state.clientId = claim.clientId;
|
|
252
|
+
state.secret = claim.clientSecret;
|
|
253
|
+
log.info({ event: 'credential_claimed' }, 'token-client.credential_claimed');
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Mint via `/oauth/token`. In GCP mode a 401 (revoked or force-revoked
|
|
257
|
+
* secret) triggers exactly one lazy re-claim followed by one retry; a second
|
|
258
|
+
* 401 is terminal (`RevocationError`) so a permanently rejected credential
|
|
259
|
+
* cannot loop. In classic mode a 401 surfaces unchanged.
|
|
260
|
+
*
|
|
261
|
+
* @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset. The SAME budget is reused across the initial mint, the re-claim, and the retry mint — it is never reset mid-chain.
|
|
262
|
+
* @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
|
|
263
|
+
*/
|
|
264
|
+
const mintReclaiming = async (deadline) => {
|
|
265
|
+
try {
|
|
266
|
+
return await requestToken(deadline);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
if (!gcpEnabled || !(err instanceof RevocationError)) {
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
log.info({ event: 'reclaiming_after_401' }, 'token-client.reclaiming_after_401');
|
|
273
|
+
await lazyClaim(deadline);
|
|
274
|
+
return requestToken(deadline);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* Call `/credentials/me grantType=jwt` to fetch the new client secret.
|
|
279
|
+
* Reuses the just-acquired consumer-aud access token as the bearer — the
|
|
280
|
+
* issuer's verifier accepts any audience in the credential's
|
|
281
|
+
* `authorizedAudiences`, so a second `/oauth/token` mint is no longer
|
|
282
|
+
* required.
|
|
283
|
+
*
|
|
284
|
+
* Status branching: 204 → `null` (rotation already acknowledged — nothing
|
|
285
|
+
* to deliver), 2xx → the new secret, 401 → `RevocationError`, 429 / 5xx →
|
|
286
|
+
* `TransientError`. No 404 branch — issuer surfaces `not_found` / `revoked`
|
|
287
|
+
* as 401 `invalid_client` from `grantType=jwt`, not 404.
|
|
288
|
+
*
|
|
289
|
+
* @param { string } accessToken The just-acquired consumer-aud JWT (with `authClass: pending_secret`) carried by the rotated `/oauth/token` response.
|
|
290
|
+
* @returns { Promise<string | null> } The new client secret, or `null` when the issuer answered 204 (already acknowledged — no-op).
|
|
291
|
+
* @throws { ResponseShapeError } If a 2xx body fails JSON parsing or schema validation.
|
|
292
|
+
* @throws { RevocationError } On 401.
|
|
293
|
+
* @throws { TransientError } On 429 / 5xx.
|
|
294
|
+
* @throws { Error } On any other non-2xx status.
|
|
295
|
+
*/
|
|
296
|
+
const pickUpRotatedSecret = async (accessToken) => {
|
|
297
|
+
const response = await postJson(opts.credentialEndpoint, { grantType: 'jwt' }, { authorization: `Bearer ${accessToken}` });
|
|
298
|
+
// 204 No Content: another instance (or an earlier pickup) already
|
|
299
|
+
// acknowledged this rotation, so there is no new secret to deliver. A
|
|
300
|
+
// no-op, not a shape error — the 2xx branch below would try to parse an
|
|
301
|
+
// empty body and wrongly throw ResponseShapeError.
|
|
302
|
+
if (response.status === 204) {
|
|
303
|
+
await drainBody(response);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (response.ok) {
|
|
307
|
+
let json;
|
|
308
|
+
try {
|
|
309
|
+
json = await response.json();
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
throw new ResponseShapeError(`Failed to parse /credentials/me JSON body: ${String(err)}`);
|
|
313
|
+
}
|
|
314
|
+
const parsed = credentialMeResponseSchema.safeParse(json);
|
|
315
|
+
if (!parsed.success) {
|
|
316
|
+
throw new ResponseShapeError(`Unexpected /credentials/me response shape: ${parsed.error.message}`);
|
|
317
|
+
}
|
|
318
|
+
const next = parsed.data;
|
|
319
|
+
return next.clientSecret;
|
|
320
|
+
}
|
|
321
|
+
if (response.status === 401) {
|
|
322
|
+
const oauthErr = oauthErrorSchema.safeParse(await response.json().catch(() => ({})));
|
|
323
|
+
throw new RevocationError(`credential rejected by /credentials/me: ${oauthErr.success ? (oauthErr.data.error ?? 'unknown') : 'unknown'}`);
|
|
324
|
+
}
|
|
325
|
+
if (response.status === 429 || response.status >= 500) {
|
|
326
|
+
await drainBody(response);
|
|
327
|
+
throw new TransientError(`/credentials/me returned ${String(response.status)}`);
|
|
328
|
+
}
|
|
329
|
+
await drainBody(response);
|
|
330
|
+
throw new Error(`/credentials/me returned ${String(response.status)}`);
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Pick up a rotated secret in the BACKGROUND. Detached from `acquireToken`
|
|
334
|
+
* on purpose: the access token just minted by `/oauth/token` is a valid
|
|
335
|
+
* product token regardless of whether this secondary `/credentials/me`
|
|
336
|
+
* call succeeds, so a pickup failure must never reject the caller or
|
|
337
|
+
* discard that token. Single-flight via `state.inflightPickup`; the
|
|
338
|
+
* rotation signal keeps firing on subsequent mints until the issuer records
|
|
339
|
+
* the pickup, so a failed attempt is simply retried on the next refresh.
|
|
340
|
+
*
|
|
341
|
+
* Persist contract: the new secret is written to in-memory state BEFORE
|
|
342
|
+
* `onSecretRotated` runs, so a persist-callback throw cannot strand the
|
|
343
|
+
* client on the old secret (the issuer has recorded the pickup and will
|
|
344
|
+
* revoke the old secret after the grace window). The throw is logged and
|
|
345
|
+
* swallowed; the application reconciles its durable store on restart.
|
|
346
|
+
*
|
|
347
|
+
* @param { string } accessToken The just-minted consumer-aud JWT used as the pickup bearer.
|
|
348
|
+
* @returns { void } Nothing — the work runs detached.
|
|
349
|
+
*/
|
|
350
|
+
const startBackgroundPickup = (accessToken) => {
|
|
351
|
+
if (!isUndefined(state.inflightPickup)) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const task = (async () => {
|
|
355
|
+
const newSecret = await pickUpRotatedSecret(accessToken);
|
|
356
|
+
if (isNull(newSecret)) {
|
|
357
|
+
log.info({ event: 'rotation_already_acknowledged' }, 'token-client.rotation_already_acknowledged');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
state.secret = newSecret;
|
|
361
|
+
if (!isUndefined(opts.onSecretRotated)) {
|
|
362
|
+
try {
|
|
363
|
+
await opts.onSecretRotated(newSecret);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
log.error({ err: String(err) }, 'token-client.persist_failed');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
})()
|
|
370
|
+
.catch((err) => {
|
|
371
|
+
log.warn({ err: String(err) }, 'token-client.rotation_pickup_failed');
|
|
372
|
+
})
|
|
373
|
+
.finally(() => {
|
|
374
|
+
state.inflightPickup = undefined;
|
|
375
|
+
});
|
|
376
|
+
state.inflightPickup = task;
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* Internal refresh path. Single-flight via `state.inflightRefresh` so a
|
|
380
|
+
* burst of concurrent `acquireToken` calls funnels through one network
|
|
381
|
+
* round-trip. The minted token is cached and returned immediately; a
|
|
382
|
+
* rotation signal kicks off a detached background pickup
|
|
383
|
+
* (`startBackgroundPickup`) rather than blocking the caller on the
|
|
384
|
+
* secondary `/credentials/me` call.
|
|
385
|
+
*
|
|
386
|
+
* `opts.deadlineMs` is read fresh here, at the top of the chain this
|
|
387
|
+
* single-flight refresh drives — so the wall-clock budget spans claim +
|
|
388
|
+
* mint + re-claim + retry mint as one chain, and every refresh gets its
|
|
389
|
+
* own budget rather than sharing (or being starved by) a prior one.
|
|
390
|
+
*
|
|
391
|
+
* @returns { Promise<AccessToken> } The newly-minted (and cached) access token.
|
|
392
|
+
*/
|
|
393
|
+
const doRefresh = async () => {
|
|
394
|
+
const deadline = startDeadline(opts.deadlineMs);
|
|
395
|
+
if (gcpEnabled && state.secret === '') {
|
|
396
|
+
await lazyClaim(deadline);
|
|
397
|
+
}
|
|
398
|
+
const result = await mintReclaiming(deadline);
|
|
399
|
+
const expiresInMs = result.body.expiresIn * 1_000;
|
|
400
|
+
if (expiresInMs <= refreshLeadTimeMs) {
|
|
401
|
+
log.warn({ expiresInMs, refreshLeadTimeMs }, 'token-client.lead_time_exceeds_ttl');
|
|
402
|
+
}
|
|
403
|
+
const token = { token: result.body.accessToken, expiresAt: Date.now() + expiresInMs };
|
|
404
|
+
state.cached = token;
|
|
405
|
+
if (result.rotated) {
|
|
406
|
+
log.info({ event: 'credential_rotation_signal' }, 'token-client.rotation_signaled');
|
|
407
|
+
startBackgroundPickup(result.body.accessToken);
|
|
408
|
+
}
|
|
409
|
+
return token;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Refresh with single-flight. Concurrent callers wait on the same promise so
|
|
413
|
+
* only one round-trip to the issuer happens when the cache is stale. The
|
|
414
|
+
* first caller that notices the stale cache kicks off the refresh; others
|
|
415
|
+
* that arrive while it's in-flight wait for the result and get the fresh token
|
|
416
|
+
* when it resolves.
|
|
417
|
+
*
|
|
418
|
+
* @returns { Promise<AccessToken> } The newly-minted (and cached) access token.
|
|
419
|
+
*/
|
|
420
|
+
const refreshSingleFlight = () => {
|
|
421
|
+
if (!isUndefined(state.inflightRefresh)) {
|
|
422
|
+
return state.inflightRefresh;
|
|
423
|
+
}
|
|
424
|
+
const promise = doRefresh().finally(() => {
|
|
425
|
+
state.inflightRefresh = undefined;
|
|
426
|
+
});
|
|
427
|
+
state.inflightRefresh = promise;
|
|
428
|
+
return promise;
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Acquire the cached token if it's fresh, or refresh it if it's stale.
|
|
432
|
+
*
|
|
433
|
+
* @returns { Promise<AccessToken> } The cached or newly-minted access token.
|
|
434
|
+
*/
|
|
435
|
+
const acquireToken = async () => {
|
|
436
|
+
ensureOpen();
|
|
437
|
+
const cached = state.cached;
|
|
438
|
+
if (!isUndefined(cached) && Date.now() < cached.expiresAt - refreshLeadTimeMs) {
|
|
439
|
+
return cached;
|
|
440
|
+
}
|
|
441
|
+
return refreshSingleFlight();
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* Discard the cached token and mint a fresh one. Intentionally coalesces
|
|
445
|
+
* with an in-flight refresh: when one is already running it returns that
|
|
446
|
+
* same promise rather than starting a second concurrent mint, so a
|
|
447
|
+
* `forceRefresh` racing an automatic refresh still funnels through one
|
|
448
|
+
* `/oauth/token` round-trip (single-flight via `state.inflightRefresh`).
|
|
449
|
+
*
|
|
450
|
+
* @returns { Promise<AccessToken> } The freshly-minted (and cached) access token.
|
|
451
|
+
*/
|
|
452
|
+
const forceRefresh = async () => {
|
|
453
|
+
ensureOpen();
|
|
454
|
+
state.cached = undefined;
|
|
455
|
+
return refreshSingleFlight();
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* Close the client and clean up resources. After calling this, the client is
|
|
459
|
+
* permanently closed and all methods throw. This clears the cache and any
|
|
460
|
+
* in-flight refresh so that no future calls to `acquireToken` or `forceRefresh`
|
|
461
|
+
* can succeed. Call this on graceful shutdown to prevent a mid-refresh promise
|
|
462
|
+
* from outliving the surrounding service.
|
|
463
|
+
*/
|
|
464
|
+
const close = () => {
|
|
465
|
+
state.closed = true;
|
|
466
|
+
state.cached = undefined;
|
|
467
|
+
state.inflightRefresh = undefined;
|
|
468
|
+
state.inflightPickup = undefined;
|
|
469
|
+
};
|
|
470
|
+
return { acquireToken, forceRefresh, close };
|
|
471
|
+
};
|
|
472
|
+
/**
|
|
473
|
+
* Build a Tier-1 GCP token client that lazy-claims its credential on first use
|
|
474
|
+
* (via `POST /credentials/me` `grantType=gcp_identity`) and silently re-claims
|
|
475
|
+
* when a mint is rejected with 401. A thin wrapper over `createTokenClient` with
|
|
476
|
+
* `idTokenProvider` required and `clientId` / `clientSecret` omitted (both are
|
|
477
|
+
* bootstrapped by the first claim). `idTokenProvider` is called with the
|
|
478
|
+
* credential endpoint URL — the issuer-pinned `aud` — and must return a fresh
|
|
479
|
+
* Google ID token each call.
|
|
480
|
+
*
|
|
481
|
+
* @param { GcpTokenClientOptions } opts GCP client configuration (endpoints, audience, ID-token provider, retry policy).
|
|
482
|
+
* @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
|
|
483
|
+
*/
|
|
484
|
+
export const createGcpTokenClient = (opts) => createTokenClient(opts);
|
|
485
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { TransientError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Wall-clock budget for one claim+mint(+re-claim+mint) chain. `startDeadline`
|
|
4
|
+
* computes `expiresAt` once at the top of the public entry point so the
|
|
5
|
+
* budget spans every step of the chain instead of resetting at each retry,
|
|
6
|
+
* sleep, or re-claim.
|
|
7
|
+
*/
|
|
8
|
+
export interface Deadline {
|
|
9
|
+
readonly budgetMs: number;
|
|
10
|
+
readonly expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Start a wall-clock budget for one call, or return undefined when the
|
|
14
|
+
* caller configured none — undefined preserves today's unbounded-retry
|
|
15
|
+
* behaviour exactly, since every helper below is a no-op for it.
|
|
16
|
+
*
|
|
17
|
+
* @param { number | undefined } deadlineMs Configured wall-clock budget in ms.
|
|
18
|
+
* @returns { Deadline | undefined } The started budget, or undefined when unset.
|
|
19
|
+
*/
|
|
20
|
+
export declare const startDeadline: (deadlineMs: number | undefined) => Deadline | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Milliseconds left before `deadline` expires, floored at 0.
|
|
23
|
+
*
|
|
24
|
+
* @param { Deadline } deadline The active budget.
|
|
25
|
+
* @returns { number } Remaining ms, floored at 0.
|
|
26
|
+
*/
|
|
27
|
+
export declare const remainingMs: (deadline: Deadline) => number;
|
|
28
|
+
/**
|
|
29
|
+
* Milliseconds elapsed since `deadline` started, for the deadline-exceeded
|
|
30
|
+
* error message.
|
|
31
|
+
*
|
|
32
|
+
* @param { Deadline } deadline The active budget.
|
|
33
|
+
* @returns { number } Elapsed ms since the budget started.
|
|
34
|
+
*/
|
|
35
|
+
export declare const elapsedMs: (deadline: Deadline) => number;
|
|
36
|
+
/**
|
|
37
|
+
* True when sleeping `delayMs` from now would meet or exceed `deadline` — the
|
|
38
|
+
* caller must throw instead of sleeping in that case rather than blocking
|
|
39
|
+
* past the configured budget.
|
|
40
|
+
*
|
|
41
|
+
* @param { Deadline } deadline The active budget.
|
|
42
|
+
* @param { number } delayMs The backoff delay about to be slept.
|
|
43
|
+
* @returns { boolean } True when the upcoming sleep would exceed the budget.
|
|
44
|
+
*/
|
|
45
|
+
export declare const sleepWouldExceedDeadline: (deadline: Deadline, delayMs: number) => boolean;
|
|
46
|
+
/**
|
|
47
|
+
* An `AbortSignal` that fires when `deadline` expires, or undefined when no
|
|
48
|
+
* deadline is configured — pass this straight to `fetch` so a single stalled
|
|
49
|
+
* socket cannot outlive the budget either.
|
|
50
|
+
*
|
|
51
|
+
* @param { Deadline | undefined } deadline The active budget, or undefined when none is configured.
|
|
52
|
+
* @returns { AbortSignal | undefined } The timeout signal, or undefined when unconfigured.
|
|
53
|
+
*/
|
|
54
|
+
export declare const deadlineSignal: (deadline: Deadline | undefined) => AbortSignal | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Build the deadline-exceeded `TransientError`, naming the configured budget
|
|
57
|
+
* and the elapsed time so a caller's logs show why the chain stopped short of
|
|
58
|
+
* the normal retry ceiling. The caller's documented "surface upstream as 503
|
|
59
|
+
* and retry later" action (see `TransientError`) still applies unchanged.
|
|
60
|
+
*
|
|
61
|
+
* @param { Deadline } deadline The exceeded budget.
|
|
62
|
+
* @param { string } context Short label for which call the deadline stopped (e.g. `'/oauth/token'`, `'/credentials/me (gcp_identity)'`).
|
|
63
|
+
* @returns { TransientError } The error to throw in place of sleeping.
|
|
64
|
+
*/
|
|
65
|
+
export declare const deadlineExceededError: (deadline: Deadline, context: string) => TransientError;
|
|
66
|
+
/**
|
|
67
|
+
* Sleep `delayMs` before the next retry, or throw the deadline-exceeded error
|
|
68
|
+
* when that sleep would cross `deadline` — stopping the retry loop rather
|
|
69
|
+
* than blocking past the caller's configured budget.
|
|
70
|
+
*
|
|
71
|
+
* @param { Deadline | undefined } deadline Active wall-clock budget, or undefined when none is configured (preserves unbounded retry).
|
|
72
|
+
* @param { number } delayMs The backoff delay computed for this retry.
|
|
73
|
+
* @param { string } context Short label naming which call the deadline stopped, forwarded to `deadlineExceededError`.
|
|
74
|
+
* @returns { Promise<void> } Resolves after sleeping `delayMs`.
|
|
75
|
+
* @throws { TransientError } When `deadline` is configured and sleeping `delayMs` would meet or exceed it.
|
|
76
|
+
*/
|
|
77
|
+
export declare const sleepOrThrowIfDeadlineExceeded: (deadline: Deadline | undefined, delayMs: number, context: string) => Promise<void>;
|
|
78
|
+
//# sourceMappingURL=deadline.d.ts.map
|
package/dist/deadline.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { sleep } from './backoff.js';
|
|
2
|
+
import { TransientError } from './errors.js';
|
|
3
|
+
import { isUndefined } from './type-guards.js';
|
|
4
|
+
/**
|
|
5
|
+
* Start a wall-clock budget for one call, or return undefined when the
|
|
6
|
+
* caller configured none — undefined preserves today's unbounded-retry
|
|
7
|
+
* behaviour exactly, since every helper below is a no-op for it.
|
|
8
|
+
*
|
|
9
|
+
* @param { number | undefined } deadlineMs Configured wall-clock budget in ms.
|
|
10
|
+
* @returns { Deadline | undefined } The started budget, or undefined when unset.
|
|
11
|
+
*/
|
|
12
|
+
export const startDeadline = (deadlineMs) => isUndefined(deadlineMs) ? undefined : { budgetMs: deadlineMs, expiresAt: Date.now() + deadlineMs };
|
|
13
|
+
/**
|
|
14
|
+
* Milliseconds left before `deadline` expires, floored at 0.
|
|
15
|
+
*
|
|
16
|
+
* @param { Deadline } deadline The active budget.
|
|
17
|
+
* @returns { number } Remaining ms, floored at 0.
|
|
18
|
+
*/
|
|
19
|
+
export const remainingMs = (deadline) => Math.max(0, deadline.expiresAt - Date.now());
|
|
20
|
+
/**
|
|
21
|
+
* Milliseconds elapsed since `deadline` started, for the deadline-exceeded
|
|
22
|
+
* error message.
|
|
23
|
+
*
|
|
24
|
+
* @param { Deadline } deadline The active budget.
|
|
25
|
+
* @returns { number } Elapsed ms since the budget started.
|
|
26
|
+
*/
|
|
27
|
+
export const elapsedMs = (deadline) => deadline.budgetMs - remainingMs(deadline);
|
|
28
|
+
/**
|
|
29
|
+
* True when sleeping `delayMs` from now would meet or exceed `deadline` — the
|
|
30
|
+
* caller must throw instead of sleeping in that case rather than blocking
|
|
31
|
+
* past the configured budget.
|
|
32
|
+
*
|
|
33
|
+
* @param { Deadline } deadline The active budget.
|
|
34
|
+
* @param { number } delayMs The backoff delay about to be slept.
|
|
35
|
+
* @returns { boolean } True when the upcoming sleep would exceed the budget.
|
|
36
|
+
*/
|
|
37
|
+
export const sleepWouldExceedDeadline = (deadline, delayMs) => delayMs >= remainingMs(deadline);
|
|
38
|
+
/**
|
|
39
|
+
* An `AbortSignal` that fires when `deadline` expires, or undefined when no
|
|
40
|
+
* deadline is configured — pass this straight to `fetch` so a single stalled
|
|
41
|
+
* socket cannot outlive the budget either.
|
|
42
|
+
*
|
|
43
|
+
* @param { Deadline | undefined } deadline The active budget, or undefined when none is configured.
|
|
44
|
+
* @returns { AbortSignal | undefined } The timeout signal, or undefined when unconfigured.
|
|
45
|
+
*/
|
|
46
|
+
export const deadlineSignal = (deadline) => (isUndefined(deadline) ? undefined : AbortSignal.timeout(remainingMs(deadline)));
|
|
47
|
+
/**
|
|
48
|
+
* Build the deadline-exceeded `TransientError`, naming the configured budget
|
|
49
|
+
* and the elapsed time so a caller's logs show why the chain stopped short of
|
|
50
|
+
* the normal retry ceiling. The caller's documented "surface upstream as 503
|
|
51
|
+
* and retry later" action (see `TransientError`) still applies unchanged.
|
|
52
|
+
*
|
|
53
|
+
* @param { Deadline } deadline The exceeded budget.
|
|
54
|
+
* @param { string } context Short label for which call the deadline stopped (e.g. `'/oauth/token'`, `'/credentials/me (gcp_identity)'`).
|
|
55
|
+
* @returns { TransientError } The error to throw in place of sleeping.
|
|
56
|
+
*/
|
|
57
|
+
export const deadlineExceededError = (deadline, context) => new TransientError(`${context}: deadlineMs budget of ${String(deadline.budgetMs)}ms exceeded after ${String(elapsedMs(deadline))}ms elapsed — stopping instead of retrying past the deadline`);
|
|
58
|
+
/**
|
|
59
|
+
* Sleep `delayMs` before the next retry, or throw the deadline-exceeded error
|
|
60
|
+
* when that sleep would cross `deadline` — stopping the retry loop rather
|
|
61
|
+
* than blocking past the caller's configured budget.
|
|
62
|
+
*
|
|
63
|
+
* @param { Deadline | undefined } deadline Active wall-clock budget, or undefined when none is configured (preserves unbounded retry).
|
|
64
|
+
* @param { number } delayMs The backoff delay computed for this retry.
|
|
65
|
+
* @param { string } context Short label naming which call the deadline stopped, forwarded to `deadlineExceededError`.
|
|
66
|
+
* @returns { Promise<void> } Resolves after sleeping `delayMs`.
|
|
67
|
+
* @throws { TransientError } When `deadline` is configured and sleeping `delayMs` would meet or exceed it.
|
|
68
|
+
*/
|
|
69
|
+
export const sleepOrThrowIfDeadlineExceeded = async (deadline, delayMs, context) => {
|
|
70
|
+
if (!isUndefined(deadline) && sleepWouldExceedDeadline(deadline, delayMs)) {
|
|
71
|
+
throw deadlineExceededError(deadline, context);
|
|
72
|
+
}
|
|
73
|
+
await sleep(delayMs);
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=deadline.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drain a fetch Response body the caller is about to discard. Skipping this
|
|
3
|
+
* leaks the underlying connection in the undici pool — long-running pods see
|
|
4
|
+
* degraded throughput on the next request cycle.
|
|
5
|
+
*
|
|
6
|
+
* @param { Response } response The fetch response whose body needs draining.
|
|
7
|
+
* @returns { Promise<void> } Resolves when the body has been cancelled.
|
|
8
|
+
*/
|
|
9
|
+
export declare const drainBody: (response: Response) => Promise<void>;
|
|
10
|
+
//# sourceMappingURL=drain-body.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drain a fetch Response body the caller is about to discard. Skipping this
|
|
3
|
+
* leaks the underlying connection in the undici pool — long-running pods see
|
|
4
|
+
* degraded throughput on the next request cycle.
|
|
5
|
+
*
|
|
6
|
+
* @param { Response } response The fetch response whose body needs draining.
|
|
7
|
+
* @returns { Promise<void> } Resolves when the body has been cancelled.
|
|
8
|
+
*/
|
|
9
|
+
export const drainBody = async (response) => {
|
|
10
|
+
try {
|
|
11
|
+
await response.body?.cancel();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Body already consumed or stream errored — either way nothing for
|
|
15
|
+
// the caller to recover from.
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=drain-body.js.map
|