@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/types.d.ts
ADDED
|
@@ -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
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
|
+
}
|