@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,241 @@
1
+ /**
2
+ * Structural logger contract — any pino-compatible object satisfies this.
3
+ * Defined in this package (rather than imported from `@mi9-identity/jwt-verifier`) so
4
+ * the token client has zero dependency on the verifier package.
5
+ */
6
+ export interface Logger {
7
+ debug(obj: unknown, msg?: string): void;
8
+ info(obj: unknown, msg?: string): void;
9
+ warn(obj: unknown, msg?: string): void;
10
+ error(obj: unknown, msg?: string): void;
11
+ }
12
+ /**
13
+ * Exponential backoff policy applied to 429/503/timeout responses. `jitter:
14
+ * 'full'` is the recommended default — picks a uniform random delay between
15
+ * 0 and the computed `currentMs`, smoothing thundering-herd retries when
16
+ * many consumers see the same 503.
17
+ */
18
+ export interface BackoffPolicy {
19
+ initialMs: number;
20
+ maxMs: number;
21
+ factor: number;
22
+ jitter: 'full' | 'none';
23
+ }
24
+ /**
25
+ * Token returned by `acquireToken` / `forceRefresh`. `expiresAt` is a Unix
26
+ * epoch in milliseconds — compatible with `Date.now()` for cache-hit
27
+ * comparisons.
28
+ */
29
+ export interface AccessToken {
30
+ token: string;
31
+ expiresAt: number;
32
+ }
33
+ /**
34
+ * Endpoint, transport, and retry options shared by both credential modes of
35
+ * `createTokenClient`. The mode itself comes from the
36
+ * `StaticCredentialOptions | GcpLazyClaimOptions` union this is intersected
37
+ * with in `TokenClientOptions`.
38
+ */
39
+ export interface TokenClientBaseOptions {
40
+ tokenEndpoint: string;
41
+ credentialEndpoint: string;
42
+ /**
43
+ * Full-URL audience(s) the minted access tokens target — a single URL or a
44
+ * non-empty array of them. Required. Forwarded to `/oauth/token` verbatim;
45
+ * the issuer echoes the requested shape into `aud` (a one-element array
46
+ * stays a one-element array, not collapsed to a scalar).
47
+ */
48
+ audience: string | readonly string[];
49
+ scope?: string;
50
+ /**
51
+ * Default 5 * 60_000 ms (T-5 min for a 1 h TTL).
52
+ */
53
+ refreshLeadTimeMs?: number;
54
+ backoff?: BackoffPolicy;
55
+ /**
56
+ * Persist callback invoked when the issuer rotates the secret. The new
57
+ * secret is already written to the client's in-memory state before this
58
+ * runs — a thrown rejection is logged and swallowed, NOT propagated. The
59
+ * application is responsible for reconciling its durable store on
60
+ * restart. Rationale: the issuer sets `rotationAcknowledgedAt` on the
61
+ * first successful pickup; a retry with the prior (active) secret returns
62
+ * `stale_after_ack` and the consumer is locked out. Better to keep the
63
+ * new secret in memory and surface the persist failure as an
64
+ * application-level alert.
65
+ */
66
+ onSecretRotated?: (newSecret: string) => Promise<void>;
67
+ /**
68
+ * Default `'X-Request-ID'`. Propagated outbound on every fetch (HTTP/2
69
+ * normalizes to lowercase on the wire).
70
+ */
71
+ requestIdHeader?: string;
72
+ /**
73
+ * Inbound request_id source. Pass a string to pin one value, or a getter
74
+ * to thread the caller's current request_id through on each mint. Default
75
+ * mints a fresh `randomUUID()` per request.
76
+ */
77
+ requestId?: string | (() => string);
78
+ /**
79
+ * Transport override — supply a wrapped `fetch` to route through a proxy,
80
+ * add instrumentation, or pin a custom agent/dispatcher. Defaults to the
81
+ * global `fetch`.
82
+ */
83
+ fetch?: typeof fetch;
84
+ logger?: Logger;
85
+ /**
86
+ * Wall-clock budget in ms for one ENTIRE `acquireToken()` / `forceRefresh()`
87
+ * call — spanning the claim, mint, re-claim, and retry-mint steps as a
88
+ * single chain rather than resetting the budget at each step. Checked
89
+ * before every backoff sleep (throws instead of sleeping past the
90
+ * deadline) and applied to every fetch via `AbortSignal.timeout`, so a
91
+ * single stalled socket cannot outlive it either. Absent by default —
92
+ * omitting it preserves today's unbounded-retry behaviour exactly (each
93
+ * step still stops at its own retry ceiling, just with no wall-clock cap
94
+ * spanning them). Recommended for a GCP lazy-claim client invoked on the
95
+ * inbound request path, where an unbounded chain can hold a Cloud Run
96
+ * request open for minutes during an issuer brownout.
97
+ */
98
+ deadlineMs?: number;
99
+ }
100
+ /**
101
+ * Static-credential mode: the caller already holds a `clientId` + secret
102
+ * (Tier 2 `on_prem` / Tier 3 `device`). `idTokenProvider` is forbidden here so
103
+ * the compiler keeps the two modes apart.
104
+ */
105
+ export interface StaticCredentialOptions {
106
+ clientId: string;
107
+ clientSecret: string;
108
+ idTokenProvider?: never;
109
+ retailerCode?: never;
110
+ }
111
+ /**
112
+ * GCP Tier-1 lazy-claim mode: the client bootstraps its own credential on
113
+ * first use and silently re-claims when a mint is rejected with 401, so
114
+ * `clientId` / `clientSecret` are optional seeds rather than requirements.
115
+ */
116
+ export interface GcpLazyClaimOptions {
117
+ /**
118
+ * Returns a fresh Google ID token for the audience it is passed — the
119
+ * client always passes `credentialEndpoint`, which the issuer pins as the
120
+ * required `aud`. Must return a fresh token per call; the issuer
121
+ * single-uses each ID token.
122
+ */
123
+ idTokenProvider: (audience: string) => string | Promise<string>;
124
+ /**
125
+ * Credential identifier (UUIDv7), if one is already known. The first claim
126
+ * populates it otherwise.
127
+ */
128
+ clientId?: string;
129
+ /**
130
+ * Initial secret, if one is already known. The first claim populates it
131
+ * otherwise, and Tier-1 credentials are held in memory only (never
132
+ * persisted).
133
+ */
134
+ clientSecret?: string;
135
+ /**
136
+ * Retailer whose credential this client claims. Required only when the
137
+ * service account holds credentials for several retailers; see
138
+ * `ClaimGcpCredentialOptions.retailerCode`. One client instance claims one
139
+ * retailer — a consumer serving several builds one client per retailer so
140
+ * each keeps its own cached token.
141
+ */
142
+ retailerCode?: string;
143
+ }
144
+ /**
145
+ * Configuration for `createTokenClient`. The client mints + caches access
146
+ * tokens, refreshes them proactively at `expiresAt - refreshLeadTimeMs`, and
147
+ * handles rotation pickup transparently when the issuer signals
148
+ * `credentialRotated`. The union enforces the two credential modes: supply
149
+ * `clientId` + `clientSecret`, or supply `idTokenProvider` and let the client
150
+ * lazy-claim.
151
+ */
152
+ export type TokenClientOptions = TokenClientBaseOptions & (StaticCredentialOptions | GcpLazyClaimOptions);
153
+ /**
154
+ * Public token-client surface. `close()` clears the in-memory cache and
155
+ * makes subsequent calls reject — call it on graceful shutdown so a
156
+ * mid-refresh promise doesn't outlive the surrounding service.
157
+ */
158
+ export interface TokenClient {
159
+ acquireToken(): Promise<AccessToken>;
160
+ forceRefresh(): Promise<AccessToken>;
161
+ close(): void;
162
+ }
163
+ /**
164
+ * A credential delivered by the Tier-1 `gcp_identity` lazy-claim: identifier,
165
+ * raw secret, authorized audiences, and token endpoint. Feed `clientId` /
166
+ * `clientSecret` into `/oauth/token` to mint access tokens.
167
+ */
168
+ export interface CredentialClaim {
169
+ clientId: string;
170
+ clientSecret: string;
171
+ audience: readonly string[];
172
+ tokenUrl: string;
173
+ }
174
+ /**
175
+ * Configuration for `createGcpTokenClient` — a Tier-1 client that lazy-claims
176
+ * its credential on first use and silently re-claims on a 401. Derived from
177
+ * `TokenClientBaseOptions` so every transport/retry option stays in sync, with
178
+ * `idTokenProvider` required. `onSecretRotated` is absent by design — Tier-1
179
+ * credentials live in memory and are re-claimed on demand, never persisted.
180
+ */
181
+ export type GcpTokenClientOptions = Omit<TokenClientBaseOptions, 'onSecretRotated'> & {
182
+ idTokenProvider: (audience: string) => string | Promise<string>;
183
+ /** See `GcpLazyClaimOptions.retailerCode`. */
184
+ retailerCode?: string;
185
+ };
186
+ /**
187
+ * Options for the standalone Tier-1 GCP lazy-claim.
188
+ */
189
+ export interface ClaimGcpCredentialOptions {
190
+ /**
191
+ * Absolute `/credentials/me` URL. Also the required `aud` passed to
192
+ * `idTokenProvider`.
193
+ */
194
+ credentialEndpoint: string;
195
+ /**
196
+ * Returns a fresh Google ID token for the audience it is passed. Must mint
197
+ * a fresh token per call — the issuer single-uses each one.
198
+ */
199
+ idTokenProvider: (audience: string) => string | Promise<string>;
200
+ /**
201
+ * Retailer whose credential to claim. Required only when the service
202
+ * account holds credentials for more than one retailer — a platform
203
+ * consumer minting tokens on behalf of several tenants. Omit it and the
204
+ * issuer resolves on the service account alone, which succeeds while the
205
+ * account holds exactly one credential and is `invalid_request` once it
206
+ * holds several. Naming it is never a widening: the issuer still only
207
+ * returns a credential already bound to the calling identity.
208
+ */
209
+ retailerCode?: string;
210
+ /**
211
+ * Transport override — supply a wrapped `fetch` to route through a proxy,
212
+ * add instrumentation, or pin a custom agent/dispatcher. Defaults to the
213
+ * global `fetch`.
214
+ */
215
+ fetch?: typeof fetch;
216
+ /**
217
+ * Retry/backoff policy for transient (429 / 5xx / network) failures.
218
+ * Defaults to `DEFAULT_BACKOFF`.
219
+ */
220
+ backoff?: BackoffPolicy;
221
+ /**
222
+ * Outbound correlation-id source. A string pins one value; a getter
223
+ * threads a per-call id; default mints a fresh `randomUUID()`.
224
+ */
225
+ requestId?: string | (() => string);
226
+ /**
227
+ * Header name for the outbound correlation id. Defaults to
228
+ * `'X-Request-ID'`.
229
+ */
230
+ requestIdHeader?: string;
231
+ logger?: Logger;
232
+ /**
233
+ * Wall-clock budget in ms for this call — bounds the claim's own retry
234
+ * loop so it cannot block a caller (standalone, or one composing this
235
+ * into a larger chain) past a wall-clock ceiling. Checked before every
236
+ * backoff sleep and applied to every fetch via `AbortSignal.timeout`.
237
+ * Absent by default, preserving today's unbounded-retry behaviour exactly.
238
+ */
239
+ deadlineMs?: number;
240
+ }
241
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@mi9-identity/token-client",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "Mi9 token client — consumer-side acquireToken/forceRefresh state machine for the Mi9 Identity Service. Handles proactive refresh, 429/503 backoff, 401 revocation, and rotation pickup.",
6
+ "keywords": [
7
+ "mi9",
8
+ "identity",
9
+ "oauth2",
10
+ "jwt",
11
+ "rs256",
12
+ "jwks",
13
+ "m2m"
14
+ ],
15
+ "license": "Apache-2.0",
16
+ "author": "Mi9 Retail",
17
+ "homepage": "https://mi9retail.com",
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=24.0.0"
21
+ },
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "!dist/**/*.map",
34
+ "!dist/.tsbuildinfo",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "zod": "^4.4.3"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "24.13.3",
47
+ "@vitest/coverage-v8": "4.1.11",
48
+ "vitest": "4.1.11",
49
+ "@mi9-identity/tsconfig": "^1.0.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -b",
53
+ "typecheck": "tsc --noEmit",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "test:cov": "vitest run --coverage",
57
+ "lint": "biome lint .",
58
+ "lint:fix": "biome lint --write .",
59
+ "clean": "rimraf dist .turbo"
60
+ }
61
+ }