@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.
@@ -0,0 +1,35 @@
1
+ import type { BackoffPolicy } from './types.js';
2
+ export declare const DEFAULT_BACKOFF: BackoffPolicy;
3
+ /**
4
+ * Apply jitter on top of the base delay. `'full'` picks a uniform random
5
+ * value in `[0, base]` to smooth synchronized client retries; `'none'` is
6
+ * deterministic (useful for tests / benchmarks).
7
+ *
8
+ * @param { BackoffPolicy } policy The backoff policy parameters.
9
+ * @param { number } attempt Zero-indexed retry attempt number.
10
+ * @returns { number } The (optionally jittered) delay in milliseconds.
11
+ */
12
+ export declare const computeBackoffDelayMs: (policy: BackoffPolicy, attempt: number) => number;
13
+ /**
14
+ * Resolve `Retry-After` header per RFC 7231 §7.1.3 (delta-seconds OR HTTP-date).
15
+ * Returns `undefined` when absent or unparseable so callers fall back to the
16
+ * computed backoff. A past HTTP-date is floored to `policy.initialMs` so a
17
+ * malformed or already-elapsed `Retry-After` cannot collapse to a 0 ms
18
+ * immediate retry that hammers a broken upstream. The result is also clamped
19
+ * to `policy.maxMs` so a hostile or buggy large value cannot stall the caller
20
+ * beyond the configured backoff ceiling.
21
+ *
22
+ * @param { string | null | undefined } header The raw `Retry-After` header value.
23
+ * @param { BackoffPolicy } policy The backoff policy (floor `initialMs` and ceiling `maxMs` applied to the result).
24
+ * @returns { number | undefined } The delay in milliseconds, or `undefined` when the header is absent/unparseable.
25
+ */
26
+ export declare const parseRetryAfterMs: (header: string | null | undefined, policy: BackoffPolicy) => number | undefined;
27
+ /**
28
+ * Sleep promise that respects `unref` so it never holds the event loop open
29
+ * past intentional shutdown.
30
+ *
31
+ * @param { number } ms Delay in milliseconds (negative values are clamped to 0).
32
+ * @returns { Promise<void> } Resolves after the delay.
33
+ */
34
+ export declare const sleep: (ms: number) => Promise<void>;
35
+ //# sourceMappingURL=backoff.d.ts.map
@@ -0,0 +1,79 @@
1
+ import { isEmpty, isFunction } from './type-guards.js';
2
+ export const DEFAULT_BACKOFF = {
3
+ initialMs: 1_000,
4
+ maxMs: 30_000,
5
+ factor: 2,
6
+ jitter: 'full',
7
+ };
8
+ /**
9
+ * Compute the delay for the Nth retry attempt (zero-indexed). With `factor=2`
10
+ * and `initialMs=1000` this yields 1s, 2s, 4s, 8s, ..., capped at `maxMs`.
11
+ *
12
+ * @param { BackoffPolicy } policy The backoff policy parameters.
13
+ * @param { number } attempt Zero-indexed retry attempt number.
14
+ * @returns { number } The unjittered base delay in milliseconds.
15
+ */
16
+ const baseDelayMs = (policy, attempt) => {
17
+ const raw = policy.initialMs * policy.factor ** attempt;
18
+ return Math.min(raw, policy.maxMs);
19
+ };
20
+ /**
21
+ * Apply jitter on top of the base delay. `'full'` picks a uniform random
22
+ * value in `[0, base]` to smooth synchronized client retries; `'none'` is
23
+ * deterministic (useful for tests / benchmarks).
24
+ *
25
+ * @param { BackoffPolicy } policy The backoff policy parameters.
26
+ * @param { number } attempt Zero-indexed retry attempt number.
27
+ * @returns { number } The (optionally jittered) delay in milliseconds.
28
+ */
29
+ export const computeBackoffDelayMs = (policy, attempt) => {
30
+ const base = baseDelayMs(policy, attempt);
31
+ if (policy.jitter === 'none') {
32
+ return base;
33
+ }
34
+ return Math.floor(Math.random() * base);
35
+ };
36
+ /**
37
+ * Resolve `Retry-After` header per RFC 7231 §7.1.3 (delta-seconds OR HTTP-date).
38
+ * Returns `undefined` when absent or unparseable so callers fall back to the
39
+ * computed backoff. A past HTTP-date is floored to `policy.initialMs` so a
40
+ * malformed or already-elapsed `Retry-After` cannot collapse to a 0 ms
41
+ * immediate retry that hammers a broken upstream. The result is also clamped
42
+ * to `policy.maxMs` so a hostile or buggy large value cannot stall the caller
43
+ * beyond the configured backoff ceiling.
44
+ *
45
+ * @param { string | null | undefined } header The raw `Retry-After` header value.
46
+ * @param { BackoffPolicy } policy The backoff policy (floor `initialMs` and ceiling `maxMs` applied to the result).
47
+ * @returns { number | undefined } The delay in milliseconds, or `undefined` when the header is absent/unparseable.
48
+ */
49
+ export const parseRetryAfterMs = (header, policy) => {
50
+ if (isEmpty(header)) {
51
+ return undefined;
52
+ }
53
+ const trimmed = header.trim();
54
+ const seconds = Number(trimmed);
55
+ if (Number.isFinite(seconds) && seconds >= 0) {
56
+ return Math.min(Math.max(Math.floor(seconds * 1_000), policy.initialMs), policy.maxMs);
57
+ }
58
+ const dateMs = Date.parse(trimmed);
59
+ if (Number.isFinite(dateMs)) {
60
+ const deltaMs = dateMs - Date.now();
61
+ return Math.min(Math.max(deltaMs, policy.initialMs), policy.maxMs);
62
+ }
63
+ return undefined;
64
+ };
65
+ /**
66
+ * Sleep promise that respects `unref` so it never holds the event loop open
67
+ * past intentional shutdown.
68
+ *
69
+ * @param { number } ms Delay in milliseconds (negative values are clamped to 0).
70
+ * @returns { Promise<void> } Resolves after the delay.
71
+ */
72
+ export const sleep = (ms) => new Promise((resolve) => {
73
+ const timer = setTimeout(resolve, Math.max(0, ms));
74
+ /* v8 ignore next 3 -- defensive cross-runtime guard; setTimeout in Node always returns a Timeout with unref */
75
+ if (isFunction(timer.unref)) {
76
+ timer.unref();
77
+ }
78
+ });
79
+ //# sourceMappingURL=backoff.js.map
@@ -0,0 +1,21 @@
1
+ import type { ClaimGcpCredentialOptions, CredentialClaim } from './types.js';
2
+ /**
3
+ * Perform the Tier-1 GCP lazy-claim against `credentialEndpoint` and return the
4
+ * delivered credential.
5
+ *
6
+ * `idTokenProvider` is called with `credentialEndpoint` (the required `aud`)
7
+ * once per attempt and MUST return a fresh Google ID token each time — the
8
+ * issuer single-uses each token, so a cached one would be rejected on retry.
9
+ * Transient failures (429 / 5xx / network) are retried per the backoff policy,
10
+ * honouring a clamped `Retry-After`.
11
+ *
12
+ * @param { ClaimGcpCredentialOptions } opts Endpoint, ID-token provider, and transport/retry knobs.
13
+ * @returns { Promise<CredentialClaim> } The delivered `{ clientId, clientSecret, audience, tokenUrl }`.
14
+ * @throws { ProvisioningError } On 404 — no credential bound to the identity yet — and immediately on a 5xx carrying `degraded` (see `classifyServerError`).
15
+ * @throws { LazyClaimError } On 401/403 — the ID token was rejected, or the provider threw.
16
+ * @throws { ConfigurationError } On every other non-2xx — the request itself is wrong (malformed body, or a `retailerCode` that must be supplied or corrected).
17
+ * @throws { TransientError } When transient failures persist past the retry budget, or when `opts.deadlineMs` elapses before a retry.
18
+ * @throws { ResponseShapeError } When a 200 body fails the wire schema.
19
+ */
20
+ export declare const claimGcpCredential: (opts: ClaimGcpCredentialOptions) => Promise<CredentialClaim>;
21
+ //# sourceMappingURL=claim.d.ts.map
package/dist/claim.js ADDED
@@ -0,0 +1,169 @@
1
+ import { drainBody } from './drain-body.js';
2
+ import { isUndefined } from './type-guards.js';
3
+ import { resolveRequestId } from './request-id.js';
4
+ import { credentialMeResponseSchema } from './schemas.js';
5
+ import { readOAuthErrorCode, readOAuthErrorDetail } from './oauth-error.js';
6
+ import { computeBackoffDelayMs, DEFAULT_BACKOFF, parseRetryAfterMs } from './backoff.js';
7
+ import { deadlineSignal, sleepOrThrowIfDeadlineExceeded, startDeadline } from './deadline.js';
8
+ import { ConfigurationError, LazyClaimError, ProvisioningError, ResponseShapeError, TransientError } from './errors.js';
9
+ import { DEFAULT_REQUEST_ID_HEADER, MAX_RETRIES, NOOP_LOGGER, OAUTH_ERROR_CODE_DEGRADED } from './token-client.constants.js';
10
+ /**
11
+ * Parse a 200 `gcp_identity` delivery body into a `CredentialClaim`, reusing
12
+ * the shared `/credentials/me` delivery schema.
13
+ *
14
+ * @param { Response } response The successful fetch response.
15
+ * @returns { Promise<CredentialClaim> } The delivered credential.
16
+ * @throws { ResponseShapeError } If the body fails JSON parsing or schema validation.
17
+ */
18
+ const parseDelivery = async (response) => {
19
+ let json;
20
+ try {
21
+ json = await response.json();
22
+ }
23
+ catch (error) {
24
+ throw new ResponseShapeError(`Failed to parse /credentials/me JSON body: ${String(error)}`);
25
+ }
26
+ const parsed = credentialMeResponseSchema.safeParse(json);
27
+ if (!parsed.success) {
28
+ throw new ResponseShapeError(`Unexpected /credentials/me response shape: ${parsed.error.message}`);
29
+ }
30
+ return parsed.data;
31
+ };
32
+ /**
33
+ * Classify a 5xx, splitting the in-loop-retryable ones from `degraded`.
34
+ *
35
+ * `degraded` is raised by two unrelated issuer paths — a credential bound to
36
+ * this service account but carrying a non-`gcp` consumer type, and the per-IP
37
+ * limiter failing closed when Redis is unreachable. The first is permanent until
38
+ * an admin acts; the second clears on its own.
39
+ * The discriminating `error_subcode` is audit-only, so a consumer cannot tell
40
+ * them apart and must treat the code as terminal FOR THIS ATTEMPT: each in-loop
41
+ * retry mints a fresh single-use Google ID token (the issuer fingerprints and
42
+ * refuses reuse) and the backoff curve spends ~60s on the inbound request path,
43
+ * all against an answer that does not change. It raises `ProvisioningError`
44
+ * rather than `TransientError` because the wire spec forbids folding `degraded`
45
+ * into the retryable class: a caller that buckets it as transient retries a
46
+ * misconfigured row forever. The rare limiter-outage case is then reported as
47
+ * needing an operator too — accepted, since a supervised restart is its
48
+ * recovery path either way.
49
+ *
50
+ * @param { Response } response The 5xx fetch response; its body is consumed here.
51
+ * @param { BackoffPolicy } backoff Active backoff policy used to compute the retry delay.
52
+ * @param { number } attempt Zero-based attempt index.
53
+ * @returns { Promise<{ transient: TransientError; retryAfterMs: number }> } The retry signal for an ordinary 5xx.
54
+ * @throws { ProvisioningError } Immediately, without an in-loop retry, when the envelope carries `degraded`.
55
+ */
56
+ const classifyServerError = async (response, backoff, attempt) => {
57
+ const isDegraded = (await readOAuthErrorCode(response)) === OAUTH_ERROR_CODE_DEGRADED;
58
+ await drainBody(response);
59
+ if (isDegraded) {
60
+ throw new ProvisioningError(`/credentials/me (gcp_identity) returned ${String(response.status)} degraded — not retried in-loop, because every attempt would spend a freshly minted single-use ID token on an answer that cannot change within this call. An operator has to correct the credential row; retry the operation after that.`);
61
+ }
62
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'), backoff) ?? computeBackoffDelayMs(backoff, attempt);
63
+ return { transient: new TransientError(`/credentials/me (gcp_identity) returned ${String(response.status)}`), retryAfterMs };
64
+ };
65
+ /**
66
+ * Classify a non-2xx `/credentials/me` (gcp_identity) response: throw the
67
+ * terminal error for 404 / 401 / 403 / anything else, or return the transient
68
+ * retry signal (error + backoff delay) for a retryable 429 / 5xx.
69
+ *
70
+ * @param { Response } response The non-ok fetch response.
71
+ * @param { BackoffPolicy } backoff Active backoff policy used to compute the retry delay.
72
+ * @param { number } attempt Zero-based attempt index.
73
+ * @returns { Promise<{ transient: TransientError; retryAfterMs: number }> } The retry signal for a 429 / 5xx.
74
+ * @throws { ProvisioningError } On 404 — no credential bound to the identity yet — and on a 5xx carrying `degraded` (thrown without an in-loop retry).
75
+ * @throws { LazyClaimError } On 401 / 403 — the ID token was rejected.
76
+ * @throws { ConfigurationError } On every other status — a malformed body, or a `retailerCode` the calling identity must supply or correct. Permanent: retrying burns a freshly minted ID token against a request that will fail identically. Carries the envelope's `errorDescription`.
77
+ */
78
+ const classifyNonOkResponse = async (response, backoff, attempt) => {
79
+ if (response.status === 404) {
80
+ await drainBody(response);
81
+ throw new ProvisioningError('credential not provisioned (404 from /credentials/me gcp_identity)');
82
+ }
83
+ if (response.status === 401 || response.status === 403) {
84
+ throw new LazyClaimError(`GCP identity token rejected by /credentials/me: ${await readOAuthErrorCode(response)}`);
85
+ }
86
+ if (response.status >= 500) {
87
+ return classifyServerError(response, backoff, attempt);
88
+ }
89
+ if (response.status === 429) {
90
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'), backoff) ?? computeBackoffDelayMs(backoff, attempt);
91
+ await drainBody(response);
92
+ return { transient: new TransientError('/credentials/me (gcp_identity) returned 429'), retryAfterMs };
93
+ }
94
+ // Everything left is the caller's own request: a malformed body, or a
95
+ // retailerCode that must be supplied or corrected. Permanent — folding
96
+ // these into TransientError would have the retry loop mint a fresh ID
97
+ // token per attempt against a request that fails identically every time.
98
+ // The detail reader keeps the envelope's `errorDescription`, the only field
99
+ // that separates `retailer_required` from an ordinary malformed body.
100
+ throw new ConfigurationError(`/credentials/me (gcp_identity) rejected the request: ${String(response.status)} ${await readOAuthErrorDetail(response)}`);
101
+ };
102
+ /**
103
+ * Perform the Tier-1 GCP lazy-claim against `credentialEndpoint` and return the
104
+ * delivered credential.
105
+ *
106
+ * `idTokenProvider` is called with `credentialEndpoint` (the required `aud`)
107
+ * once per attempt and MUST return a fresh Google ID token each time — the
108
+ * issuer single-uses each token, so a cached one would be rejected on retry.
109
+ * Transient failures (429 / 5xx / network) are retried per the backoff policy,
110
+ * honouring a clamped `Retry-After`.
111
+ *
112
+ * @param { ClaimGcpCredentialOptions } opts Endpoint, ID-token provider, and transport/retry knobs.
113
+ * @returns { Promise<CredentialClaim> } The delivered `{ clientId, clientSecret, audience, tokenUrl }`.
114
+ * @throws { ProvisioningError } On 404 — no credential bound to the identity yet — and immediately on a 5xx carrying `degraded` (see `classifyServerError`).
115
+ * @throws { LazyClaimError } On 401/403 — the ID token was rejected, or the provider threw.
116
+ * @throws { ConfigurationError } On every other non-2xx — the request itself is wrong (malformed body, or a `retailerCode` that must be supplied or corrected).
117
+ * @throws { TransientError } When transient failures persist past the retry budget, or when `opts.deadlineMs` elapses before a retry.
118
+ * @throws { ResponseShapeError } When a 200 body fails the wire schema.
119
+ */
120
+ export const claimGcpCredential = async (opts) => {
121
+ const fetchImpl = opts.fetch ?? fetch;
122
+ const backoff = opts.backoff ?? DEFAULT_BACKOFF;
123
+ const log = opts.logger ?? NOOP_LOGGER;
124
+ const requestIdHeader = opts.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER;
125
+ const deadline = startDeadline(opts.deadlineMs);
126
+ let lastTransient;
127
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
128
+ let idToken;
129
+ try {
130
+ idToken = await opts.idTokenProvider(opts.credentialEndpoint);
131
+ }
132
+ catch (error) {
133
+ throw new LazyClaimError('idTokenProvider threw while minting a GCP identity token', { cause: error });
134
+ }
135
+ const signal = deadlineSignal(deadline);
136
+ let response;
137
+ try {
138
+ response = await fetchImpl(opts.credentialEndpoint, {
139
+ method: 'POST',
140
+ headers: {
141
+ accept: 'application/json',
142
+ 'content-type': 'application/json',
143
+ [requestIdHeader]: resolveRequestId(opts.requestId),
144
+ authorization: `Bearer ${idToken}`,
145
+ },
146
+ // The request schema is strict and rejects a null, so an unset
147
+ // retailer omits the key rather than sending one.
148
+ body: JSON.stringify({ grantType: 'gcp_identity', ...(isUndefined(opts.retailerCode) ? {} : { retailerCode: opts.retailerCode }) }),
149
+ ...(isUndefined(signal) ? {} : { signal }),
150
+ });
151
+ }
152
+ catch (error) {
153
+ lastTransient = new TransientError('Network failure calling /credentials/me (gcp_identity)', { cause: error });
154
+ log.warn({ attempt, err: String(error) }, 'token-client.claim_network_error');
155
+ await sleepOrThrowIfDeadlineExceeded(deadline, computeBackoffDelayMs(backoff, attempt), '/credentials/me (gcp_identity)');
156
+ continue;
157
+ }
158
+ if (response.ok) {
159
+ return parseDelivery(response);
160
+ }
161
+ const { transient, retryAfterMs } = await classifyNonOkResponse(response, backoff, attempt);
162
+ lastTransient = transient;
163
+ log.warn({ attempt, status: response.status, retryAfterMs }, 'token-client.claim_transient');
164
+ await sleepOrThrowIfDeadlineExceeded(deadline, retryAfterMs, '/credentials/me (gcp_identity)');
165
+ }
166
+ /* v8 ignore next -- defensive; the loop assigns lastTransient on every transient path before exhausting MAX_RETRIES */
167
+ throw lastTransient ?? new TransientError('exhausted retries calling /credentials/me (gcp_identity)');
168
+ };
169
+ //# sourceMappingURL=claim.js.map
@@ -0,0 +1,24 @@
1
+ import type { GcpTokenClientOptions, TokenClient, TokenClientOptions } from './types.js';
2
+ /**
3
+ * Build a token client bound to one consumer credential. The returned object
4
+ * is safe to share across the lifetime of the calling process — it manages
5
+ * its own in-memory cache, single-flight refresh, and rotation pickup.
6
+ *
7
+ * @param { TokenClientOptions } opts Client configuration (endpoints, credentials, lead time, retry policy, hooks).
8
+ * @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
9
+ */
10
+ export declare const createTokenClient: (opts: TokenClientOptions) => TokenClient;
11
+ /**
12
+ * Build a Tier-1 GCP token client that lazy-claims its credential on first use
13
+ * (via `POST /credentials/me` `grantType=gcp_identity`) and silently re-claims
14
+ * when a mint is rejected with 401. A thin wrapper over `createTokenClient` with
15
+ * `idTokenProvider` required and `clientId` / `clientSecret` omitted (both are
16
+ * bootstrapped by the first claim). `idTokenProvider` is called with the
17
+ * credential endpoint URL — the issuer-pinned `aud` — and must return a fresh
18
+ * Google ID token each call.
19
+ *
20
+ * @param { GcpTokenClientOptions } opts GCP client configuration (endpoints, audience, ID-token provider, retry policy).
21
+ * @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
22
+ */
23
+ export declare const createGcpTokenClient: (opts: GcpTokenClientOptions) => TokenClient;
24
+ //# sourceMappingURL=client.d.ts.map