@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 ADDED
@@ -0,0 +1,52 @@
1
+ # @mi9-identity/token-client
2
+
3
+ All notable changes to this package are documented here. **Manually maintained** —
4
+ versions are semver and the three publishable `@mi9-identity/*` packages are
5
+ released in lockstep at the same version. Releases up to `2026.8.21` used calver
6
+ (`YYYY.M.D`).
7
+
8
+ ## 1.0.0
9
+
10
+ - **First public release on npmjs.com**, under the **Apache-2.0** license. Earlier versions were published only to an internal Mi9 registry.
11
+ - **Versioning changes from calver (`YYYY.M.D`) to semver**, starting at `1.0.0`. The three `@mi9-identity/*` packages stay in lockstep at one version. Issuer compatibility is still signalled by the `/api/v{N}` URL, not by the package version.
12
+ - **No functional change compared with `2026.8.21`** — same public API, same wire shapes, same behaviour.
13
+
14
+ ## 2026.8.21
15
+
16
+ - **`mi9IdentityEndpoints()` is exported from this package again**, returning `{ tokenEndpoint, credentialEndpoint }` — the two endpoints this package actually talks to. It derives them by appending bare paths to the issuer URL, with no `/api/v{N}` literal (ADR-13) and no new dependency, so an outbound-only consumer no longer installs `@mi9-identity/jwt-verifier` (and `jose`) to build two URLs. The JWKS URI now comes from that package's `mi9IdentityJwksEndpoint()`, which is a **breaking** rename on its side — see its changelog.
17
+
18
+ Correcting the record: this package exported `mi9IdentityEndpoints` up to `2026.6.4`. It was dropped in `2026.7.24` when the duplicated helper was consolidated into `jwt-verifier`, and that removal was never recorded here — a consumer that reinstalled at `2026.7.24` or later hit `TS2305: Module '"@mi9-identity/token-client"' has no exported member 'mi9IdentityEndpoints'` with nothing in this file to explain it.
19
+
20
+ - **Multi-retailer GCP lazy-claim.** New optional `retailerCode` on `claimGcpCredential(...)` and on `createGcpTokenClient(...)` / `createTokenClient(...)` in lazy-claim mode, forwarded to `POST /credentials/me` (`grantType: gcp_identity`). One GCP service account may now hold one credential **per retailer**, so a platform consumer minting tokens on behalf of several tenants names the retailer at claim time. Additive and backward compatible: omit it and the issuer resolves on the service account alone, which behaves exactly as before while the account holds a single credential. Naming a retailer is never a widening — the issuer still returns only a credential already bound to the calling identity. One client instance claims one retailer; a consumer serving several builds one client per retailer so each keeps its own cached token.
21
+ - **A non-retryable status from the `gcp_identity` grant now raises `ConfigurationError`, not `TransientError`.** Previously any uncategorized non-2xx (a 400 among them) fell through to `TransientError`, which is misleading — the request itself is wrong — and made a caller's own retry layer re-send it. The retry loop no longer spends a freshly minted single-use ID token per attempt on a request that fails identically every time. The common case is the issuer's `retailer_required`: a service account holding credentials for several retailers claimed without naming one. 404, 401/403, 429 and 5xx classification is unchanged.
22
+ - **Claim rejections now carry the issuer's `errorDescription`.** The thrown `ConfigurationError` message is `"<status> <code>: <description>"`. The issuer's machine-readable sub-codes are audit-only and never on the wire, so the description is the only field that separates `retailer_required` ("specify retailerCode") from an ordinary malformed body — both report `invalid_request`.
23
+
24
+ ## 2026.7.24
25
+
26
+ - **GCP Tier-1 lazy-claim + automatic re-claim.** New `createGcpTokenClient(...)` factory (and an `idTokenProvider` option on `createTokenClient`) enable **GCP lazy-claim mode**: the client bootstraps its `clientId` + `clientSecret` on first use via `POST /credentials/me` (`grantType: gcp_identity`) — no secret supplied up front — and **auto-re-claims** once when a mint is rejected with 401 (rotation / force-revoke / re-create). A second consecutive 401 is terminal (`RevocationError`); a claim never loops.
27
+ - New standalone primitive `claimGcpCredential(...)` returning a `CredentialClaim` (`clientId`, `clientSecret`, `audience`, `tokenUrl`); new `LazyClaimError` (the Google ID token was rejected 401/403, or the provider threw). `ProvisioningError` now also covers a 404 from the `gcp_identity` grant, and a `503 degraded` from that grant — thrown immediately, never retried in-loop, and deliberately **not** folded into `TransientError` (a caller that buckets it as retryable would retry a misconfigured credential row forever).
28
+ - New `ConfigurationError` — client options that do not describe a usable credential mode, or a `403 invalid_target` from `/oauth/token` (the requested `audience` is outside the credential's `authorizedAudiences`). Terminal and outside the Transient / Revocation / Provisioning taxonomy: the fix is a config or credential change, not a later attempt.
29
+ - `idTokenProvider` is called with the credential endpoint URL (the issuer-pinned `aud`) and must return a **fresh** Google ID token per call — the issuer single-uses each token. No metadata-server helper and no new runtime dependency; obtaining the ID token (metadata server, google-auth, Workload Identity) stays with the caller.
30
+ - `clientId` / `clientSecret` on `TokenClientOptions` are now optional, required (runtime-guarded) only outside lazy-claim mode.
31
+ - **New `deadlineMs` option** bounds the ENTIRE `acquireToken()` / `forceRefresh()` call — claim, mint, re-claim, and retry mint together — to a single wall-clock budget in ms, instead of each step separately retrying up to `MAX_RETRIES + 1` times (a GCP lazy-claim chain could otherwise reach 24 round-trips with no ceiling on wall-clock time, dangerous on an inbound request path during an issuer brownout). Checked before every backoff sleep (stops rather than sleeping past the deadline) and applied to every fetch via `AbortSignal.timeout(remaining)`, so a single stalled socket cannot outlive the budget either. Exceeding it surfaces as a `TransientError` naming the configured budget and the elapsed time. Also added to the standalone `claimGcpCredential(...)`. Optional and unset by default — omitting it preserves today's unbounded-retry behaviour exactly.
32
+
33
+ ## 2026.6.4
34
+
35
+ - **Removed the `audiences` request option** (reverts the 2026.5.27 addition). `/oauth/token` takes a single `audience` — a string or a non-empty array of strings, forwarded verbatim; the issuer echoes a scalar `aud` for a string request and a JSON array for an array request. Pass `audience: ['…']` where you previously passed `audiences: ['…']`.
36
+
37
+ ## 2026.6.1
38
+
39
+ - **`Retry-After` header clamped to `policy.maxMs`.** `parseRetryAfterMs` now applies an upper bound of `policy.maxMs` to both the delta-seconds and HTTP-date branches. Previously a hostile or buggy `Retry-After: 3600` (or a far-future HTTP-date) could stall a caller far past the configured backoff ceiling; the value is now clamped to `[policy.initialMs, policy.maxMs]`.
40
+ - **Rotation pickup is now non-blocking / background.** A `credentialRotated` signal no longer blocks `acquireToken` on the secondary `POST /credentials/me` call: the freshly-minted access token is cached and returned immediately, and the secret pickup runs as a detached task. A failed pickup (401 already-acknowledged, 503, other 4xx, or a malformed body) **no longer rejects `acquireToken`** — it is logged (`token-client.rotation_pickup_failed`) and swallowed, and the rotation signal re-triggers the pickup on the next refresh while it remains pending. Single-flight is preserved and `close()` clears the pickup handle.
41
+ - **`204 No Content` from `/credentials/me`** is treated as a no-op (rotation already acknowledged — nothing to pick up), not a `ResponseShapeError`.
42
+ - **Error envelope parser reads camelCase** (ADR-12 amendment). `oauthErrorSchema` now reads `errorDescription` (was `error_description`) plus an optional `requestId`. The client still surfaces only the `error` code, so this is a wire-fidelity fix with no API change.
43
+
44
+ ## 2026.5.27
45
+
46
+ - **Multiple audiences per token** (ADR-6 amended). Adds an optional `audiences` request option forwarded alongside `audience`; at least one of the two is now required. The singular `audience` is **deprecated** in favor of `audiences` (still accepted and forwarded for backward compatibility). The issuer mints a scalar `aud` for one audience, a JSON array for several.
47
+ - **`/api/v1` cutover** (ADR-13). Consumers point the issuer URL at the versioned value; `mi9IdentityEndpoints()` derives the token + credential endpoints.
48
+
49
+ ## 0.1.0
50
+
51
+ - Version reset to pre-1.0 (`0.1.0`); the earlier `1.0.0` placeholder is superseded.
52
+ - **Breaking**: wire-naming aligned with ADR-12. `/oauth/token` and `/credentials/me` request/response bodies use camelCase: `clientId`, `clientSecret`, `grantType`, `accessToken`, `tokenType`, `expiresIn`, `credentialRotated`, `tokenUrl`. Snake_case stays for standard OIDC claims (`iss`, `sub`, `aud`, …), RFC 6749 error codes (`invalid_request`, …), and `grantType` literal values (`client_credentials`, `gcp_identity`, `provisioning_token`, `jwt`).
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Mi9 Retail
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,402 @@
1
+ # @mi9-identity/token-client
2
+
3
+ Consumer-side `acquireToken()` / `forceRefresh()` state machine for the Mi9
4
+ Identity Service. Encapsulates the retry, rotation-pickup, and refresh logic
5
+ so every consumer service does not have to re-implement (and ship five subtly
6
+ different bugs).
7
+
8
+ Apache-2.0-licensed and free to use; access to the Mi9 Identity Platform it
9
+ acquires tokens from is governed by your commercial agreement with Mi9 Retail.
10
+
11
+ ---
12
+
13
+ ## Which package do I need?
14
+
15
+ | Package | Use it when |
16
+ | --- | --- |
17
+ | **`@mi9-identity/token-client`** (this one) | Your service **calls** a Mi9 API and needs a token. |
18
+ | [`@mi9-identity/jwt-verifier`](https://www.npmjs.com/package/@mi9-identity/jwt-verifier) | Your service **receives** Mi9-issued tokens and must verify them. |
19
+ | [`@mi9-identity/auth-middleware-express`](https://www.npmjs.com/package/@mi9-identity/auth-middleware-express) | The same, and your service is an Express 5 app. |
20
+
21
+ A service that both calls Mi9 APIs and serves Mi9-authenticated requests
22
+ installs two of them.
23
+
24
+ ---
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install @mi9-identity/token-client@1.0.0
30
+ ```
31
+
32
+ Versions are **semver**, and the three `@mi9-identity/*` packages
33
+ (`token-client`, `jwt-verifier`, `auth-middleware-express`) are released in
34
+ lockstep at the same version — pin an **exact** version, no `^` or `~` range.
35
+ Breaking wire changes ship as a new API path version (`/api/v2`), not as a
36
+ package major bump.
37
+
38
+ > **ESM + Node 24+.** The package is `"type": "module"` and requires Node ≥ 24.
39
+ > Use `await import()` from CJS if needed.
40
+
41
+ ---
42
+
43
+ ## Getting credentials
44
+
45
+ A Mi9 administrator provisions the credential for your service and gives you
46
+ four things: the issuer URL, a `clientId`, a `clientSecret`, and the audience
47
+ URL or URLs your service is allowed to request. This package does not create,
48
+ register or renew credentials — it only uses them. Ask your Mi9 contact if you
49
+ do not have them.
50
+
51
+ Secrets rotate on a schedule. When the issuer signals a rotation, this client
52
+ fetches the new secret in the background and calls your `onSecretRotated`
53
+ callback with it; persist it there, or the old secret stops working at the end
54
+ of its grace period. The details are in [Behavior](#behavior) below.
55
+
56
+ ## Configuration
57
+
58
+ Configure a single issuer URL and derive the endpoints from it:
59
+
60
+ ```
61
+ MI9_IDENTITY_ISSUER # https://identity.mi9retail.com/api/v1 — matches the JWT `iss` claim
62
+ ```
63
+
64
+ The issuer URL is the **versioned** one: `/api/v1` (or a future `/api/v2`) is
65
+ part of the configured value, so a version cutover is a one-env-var change at
66
+ each consumer.
67
+
68
+ ```ts
69
+ import { mi9IdentityEndpoints } from '@mi9-identity/token-client';
70
+
71
+ const { tokenEndpoint, credentialEndpoint } = mi9IdentityEndpoints(process.env.MI9_IDENTITY_ISSUER!);
72
+ // → `${issuer}/oauth/token`, `${issuer}/credentials/me`
73
+ ```
74
+
75
+ Audience values are always full URLs of the form `https://<host>/<path>`, never
76
+ a bare service name — `https://lda.mi9retail.com/api/v1`, not `lda`. The issuer
77
+ echoes the requested shape into the token's `aud`: a scalar for a string
78
+ request, a JSON array for an array request.
79
+
80
+ The Mi9 Identity Service does **not** publish
81
+ `/.well-known/openid-configuration`, so a generic OAuth client library that
82
+ auto-discovers its endpoints will fail — configure them explicitly, as above.
83
+
84
+ ---
85
+
86
+ ## Usage
87
+
88
+ ```ts
89
+ import { createTokenClient } from '@mi9-identity/token-client';
90
+
91
+ // The versioned issuer URL, e.g. https://identity.mi9retail.com/api/v1
92
+ const issuer = process.env.MI9_IDENTITY_ISSUER!;
93
+
94
+ const client = createTokenClient({
95
+ tokenEndpoint: `${issuer}/oauth/token`,
96
+ credentialEndpoint: `${issuer}/credentials/me`,
97
+ clientId: process.env.MERCHANT_CLIENT_ID!,
98
+ clientSecret: process.env.MERCHANT_CLIENT_SECRET!,
99
+ audience: ['https://lda.mi9retail.com/api/v1'],
100
+ onSecretRotated: async (newSecret) => {
101
+ await secretStore.atomicReplace(newSecret);
102
+ },
103
+ logger: log,
104
+ });
105
+
106
+ const { token } = await client.acquireToken();
107
+ await fetch('https://lda.mi9retail.com/api/v1/...', {
108
+ headers: { authorization: 'Bearer ' + token },
109
+ });
110
+
111
+ // On graceful shutdown:
112
+ client.close();
113
+ ```
114
+
115
+ > **`audience` accepts one URL or many.** Pass a single full-URL string, or a
116
+ > non-empty array of them for a multi-audience token. The issuer echoes the
117
+ > requested shape into `aud` (scalar for a string, JSON array for an array).
118
+
119
+ ### Scopes
120
+
121
+ **Do not set `scope`.** Credentials are provisioned today with an empty
122
+ authorized-scope list, so there is no scope you are allowed to request.
123
+ Requesting one is rejected by the issuer with `invalid_scope`, and your token
124
+ request fails. With `scope` unset the issuer grants the credential's full
125
+ authorized set, which is empty, and the token's `scope` claim is an empty
126
+ string.
127
+
128
+ Authorization is carried by the audience, the retailer and the product in the
129
+ token, not by scopes. Set `scope` only if your Mi9 administrator tells you that
130
+ your credential has authorized scopes.
131
+
132
+ > **Deriving the endpoint URLs.** `mi9IdentityEndpoints(issuer)` returns
133
+ > `{ tokenEndpoint, credentialEndpoint }` — the two endpoints this package
134
+ > talks to — from the single issuer URL that matches the JWT `iss` claim. It
135
+ > lives here rather than in `@mi9-identity/jwt-verifier` precisely so an
136
+ > outbound-only service takes no dependency on a JOSE stack. If your service
137
+ > also verifies inbound JWTs, that package's `mi9IdentityJwksEndpoint(issuer)`
138
+ > covers the third URL.
139
+
140
+ ---
141
+
142
+ ## Behavior
143
+
144
+ - **Proactive refresh** — `acquireToken()` returns the **cached** token while
145
+ `Date.now() < expiresAt - refreshLeadTimeMs` (default T-5 min for a 1 h TTL).
146
+ Call it on every outbound request — it is cheap on a cache hit.
147
+ - **Single-flight** — a burst of concurrent `acquireToken()` calls during a
148
+ refresh funnels through **one** `/oauth/token` round-trip. `forceRefresh()`
149
+ coalesces with an in-flight refresh for the same reason; it does not start a
150
+ second concurrent mint.
151
+ - **Backoff** — 429 / 503 / network failures are retried internally (up to 5
152
+ times) with exponential backoff + full jitter. Default curve:
153
+ `initialMs=1000`, `maxMs=30000`, `factor=2` (unjittered steps: 1 s → 2 s →
154
+ 4 s → 8 s → 16 s → capped at 30 s; actual delays are randomised in
155
+ `[0, base]`). `Retry-After` is honoured when present, clamped to
156
+ `[initialMs, maxMs]` — a past or malformed value cannot collapse to an
157
+ immediate retry, and a hostile `Retry-After: 3600` cannot stall the caller
158
+ past the configured ceiling.
159
+ - **No retry on 401** — raises `RevocationError`. A GCP (Tier 1) client
160
+ configured with `idTokenProvider` re-claims once automatically (see **GCP
161
+ Tier-1 lazy-claim** below) before surfacing this; other callers do not retry
162
+ with the same secret and escalate to operators (credential revoked or invalid).
163
+ - **Background rotation pickup** — on `credentialRotated: true` (body) or
164
+ `X-Mi9-Credential-Rotated: true` (header), `acquireToken()` returns the
165
+ freshly-minted valid token **immediately**. The secret pickup
166
+ (`POST /credentials/me` with `grantType=jwt`, reusing the just-minted token
167
+ as bearer) runs as a **detached background task**:
168
+ - On success: the new secret is written to in-memory state **before**
169
+ `onSecretRotated(newSecret)` is called, so a callback throw is logged and
170
+ swallowed — it never locks out the client. The application is responsible
171
+ for persisting the new secret atomically so a process restart picks it up.
172
+ - A failed pickup (401 already-acknowledged / 503 / other 4xx / malformed
173
+ body) is logged and **swallowed — it never rejects `acquireToken()`** and
174
+ never discards the valid token. The rotation signal keeps firing on
175
+ subsequent mints; the pickup is retried on the next refresh while still
176
+ pending.
177
+ - `204 No Content` from `/credentials/me` means the rotation was already
178
+ acknowledged by another instance — a clean no-op.
179
+ - Background pickup is single-flight: a second signal while a pickup is
180
+ in-flight is ignored until the current one settles.
181
+ - **`close()`** — call on graceful shutdown. Clears the in-memory cache and
182
+ drops the background pickup handle. Subsequent calls to `acquireToken()` or
183
+ `forceRefresh()` reject with `TokenClient is closed`.
184
+ - **`X-Request-ID` propagation** — every outbound request carries the request
185
+ ID from the caller's context (configurable), or a freshly generated UUIDv4.
186
+ - **`deadlineMs` (optional wall-clock budget)** — bounds the ENTIRE
187
+ `acquireToken()` / `forceRefresh()` call — claim, mint, re-claim, and retry
188
+ mint together — to a single wall-clock ceiling in ms, computed once when the
189
+ chain starts rather than reset at each step. Checked before every backoff
190
+ sleep (stops rather than sleeping past the deadline) and applied to every
191
+ fetch via `AbortSignal.timeout(remaining)`, so a single stalled socket
192
+ cannot outlive the budget either. Exceeding it raises a `TransientError`
193
+ naming the configured budget and the elapsed time. Unset by default —
194
+ omitting it preserves the unbounded retry behavior described above exactly.
195
+ Recommended for a GCP lazy-claim client invoked on the inbound request path
196
+ (see **GCP Tier-1 lazy-claim** below), where an unbounded chain can hold an
197
+ inbound request open for minutes during an issuer brownout.
198
+
199
+ ---
200
+
201
+ ## GCP Tier-1 lazy-claim
202
+
203
+ A service hosted on Google Cloud never holds a secret up front. It proves its
204
+ identity with a Google ID token and claims its credential on first use. Build it
205
+ with `createGcpTokenClient` and supply an `idTokenProvider`:
206
+
207
+ ```ts
208
+ import { createGcpTokenClient } from '@mi9-identity/token-client';
209
+
210
+ const issuer = process.env.MI9_IDENTITY_ISSUER!;
211
+
212
+ const client = createGcpTokenClient({
213
+ tokenEndpoint: `${issuer}/oauth/token`,
214
+ credentialEndpoint: `${issuer}/credentials/me`,
215
+ audience: ['https://lda.mi9retail.com/api/v1'],
216
+ // Return a FRESH Google ID token whose `aud` is the argument (the client
217
+ // always passes `credentialEndpoint`) — e.g. via google-auth's
218
+ // getIdTokenClient or the Google Cloud metadata server.
219
+ idTokenProvider: async (audience) => fetchGoogleIdToken(audience),
220
+ logger: log,
221
+ });
222
+
223
+ const { token } = await client.acquireToken(); // first call claims, then mints
224
+ ```
225
+
226
+ What happens:
227
+
228
+ 1. The first `acquireToken()` finds no secret and calls
229
+ `idTokenProvider(credentialEndpoint)` — the client always requests a token
230
+ scoped to the **credential endpoint URL**, which the issuer pins as the
231
+ required `aud` (so a leaked token cannot be replayed against any other endpoint).
232
+ 2. It `POST`s `/credentials/me` with `{ "grantType": "gcp_identity" }` and that
233
+ ID token as the bearer, receiving `{ clientId, clientSecret, audience, tokenUrl }`.
234
+ 3. It mints against `/oauth/token` as usual. Tier-1 credentials live **in memory
235
+ only** — nothing is persisted. `createGcpTokenClient` omits `onSecretRotated`
236
+ from its options entirely, so there is no persist hook to supply. (If you
237
+ instead use `createTokenClient({ …, idTokenProvider })`, `onSecretRotated`
238
+ remains available and still fires on rotation pickup — but for a Tier-1
239
+ credential there is normally nothing to persist.)
240
+ 4. If a later mint is rejected with **401** (secret rotated, force-revoked, or
241
+ the credential re-created), the client re-claims once automatically and
242
+ retries. A second consecutive 401 is terminal (`RevocationError`) — it never loops.
243
+
244
+ This means one `acquireToken()` call can chain up to four retried steps
245
+ (claim, mint, re-claim, retry mint) with no wall-clock ceiling by default. That
246
+ is normally fine for a background refresh, but a lazy-claim client is typically
247
+ invoked lazily on an **inbound** request path (a service calling another Mi9
248
+ service to serve its own caller) — during an issuer brownout, an unbounded chain
249
+ can hold that inbound request open for minutes before your own request timeout
250
+ kills it. Set **`deadlineMs`** to bound the whole chain to a wall-clock budget
251
+ instead (see the options reference below) — recommended for exactly this case.
252
+
253
+ The provider **must mint a fresh token per call** — the issuer single-uses each
254
+ ID token, so a cached one would be rejected on a retry. This package ships **no**
255
+ metadata-server helper and takes **no** `google-auth` dependency; how you obtain
256
+ the ID token (metadata server, `google-auth`, Workload Identity Federation) is
257
+ yours to choose.
258
+
259
+
260
+ ### Claiming for several retailers
261
+
262
+ A service account may hold one credential **per retailer**, which is how a
263
+ platform consumer mints tokens whose `retailerId` claim names the *destination*
264
+ tenant rather than itself. Pass `retailerCode` to name which one:
265
+
266
+ ```ts
267
+ const client = createGcpTokenClient({
268
+ tokenEndpoint: `${issuer}/oauth/token`,
269
+ credentialEndpoint: `${issuer}/credentials/me`,
270
+ audience: ['https://acme.pos.mi9retail.com/api/v1'],
271
+ retailerCode: 'acme',
272
+ idTokenProvider: async (audience) => fetchGoogleIdToken(audience),
273
+ });
274
+ ```
275
+
276
+ One client instance claims one retailer, so a consumer serving several builds
277
+ one client per retailer and each keeps its own cached token.
278
+
279
+ Omit `retailerCode` and the issuer resolves on the service account alone — which
280
+ is exactly today's behaviour while the account holds a single credential, so no
281
+ existing consumer needs to change. Once the account holds **two or more**, the
282
+ omitted-retailer claim is a `400` (`retailer_required`) rather than a guess at
283
+ which tenant was meant, surfaced as `ConfigurationError`. If you operate a
284
+ platform consumer, start passing `retailerCode` explicitly **before** the first
285
+ per-retailer credential is created against your account.
286
+
287
+ Naming a retailer is never a widening: the issuer still returns only a
288
+ credential an admin explicitly provisioned against your own service account.
289
+
290
+ For the claim without the caching state machine, the standalone
291
+ `claimGcpCredential(...)` primitive (returning a `CredentialClaim`) is also
292
+ exported. `createTokenClient({ ..., idTokenProvider })` works too if you prefer
293
+ the base factory.
294
+
295
+ ---
296
+
297
+ ## `TokenClientOptions` reference
298
+
299
+ | Option | Required | Default | Purpose |
300
+ | --- | --- | --- | --- |
301
+ | `tokenEndpoint` | **yes** | — | `POST` URL for minting tokens — `${issuer}/oauth/token`. |
302
+ | `credentialEndpoint` | **yes** | — | `POST` URL for the lazy claim and rotation pickup — `${issuer}/credentials/me`. |
303
+ | `clientId` | yes\* | — | Credential's client ID (UUIDv7). Omit in GCP lazy-claim mode. |
304
+ | `clientSecret` | yes\* | — | Initial secret; replaced in-memory on rotation. Omit in GCP lazy-claim mode. |
305
+ | `idTokenProvider` | no | — | Enables GCP Tier-1 lazy-claim mode — `(audience) => string \| Promise<string>` returning a fresh Google ID token (see **GCP Tier-1 lazy-claim** above). |
306
+ | `audience` | **yes** | — | Full-URL audience(s) the minted token targets — a single string or a non-empty array of strings. |
307
+ | `scope` | no | — | Space-separated scope string to request. **Leave it unset** — see [Scopes](#scopes) below. |
308
+ | `refreshLeadTimeMs` | no | `300_000` (5 min) | Refresh this far before `expiresAt`. |
309
+ | `backoff` | no | `DEFAULT_BACKOFF` | `BackoffPolicy`: `{ initialMs, maxMs, factor, jitter }`. |
310
+ | `onSecretRotated` | no | — | `(newSecret: string) => Promise<void>` — persist hook; see rotation behavior above. |
311
+ | `requestIdHeader` | no | `'X-Request-ID'` | Outbound correlation header name. |
312
+ | `requestId` | no | fresh UUID per call | A fixed string or `() => string` getter to thread your current request ID. |
313
+ | `fetch` | no | global `fetch` | Injection point for tests. |
314
+ | `logger` | no | no-op | pino-shaped logger (`debug`/`info`/`warn`/`error`). |
315
+ | `deadlineMs` | no | none (unbounded) | Wall-clock budget in ms for the whole `acquireToken()`/`forceRefresh()` call — claim + mint + re-claim + mint. Checked before each backoff sleep and enforced per-fetch via `AbortSignal.timeout`; exceeding it throws `TransientError`. |
316
+
317
+ \* `clientId` / `clientSecret` are required unless `idTokenProvider` (GCP lazy-claim mode) is set.
318
+
319
+ ---
320
+
321
+ ## Error taxonomy
322
+
323
+ Errors fall into five categories — branch on these, not on HTTP status alone:
324
+
325
+ | Category | Examples | Caller action |
326
+ | --- | --- | --- |
327
+ | **Transient** | 429, network timeout, and a `transient` 503 (all retried internally) | Surface as `503` upstream; let the caller retry with backoff. |
328
+ | **Revocation** | 401 from `/oauth/token` | Do **not** retry with the same secret. A GCP client re-claims automatically (once); otherwise page on-call. |
329
+ | **Provisioning** | 404 / no credential on record (from `/oauth/token` or the `gcp_identity` claim); also a `degraded` 503 on the claim | Page on-call — out-of-band recovery required; a later attempt succeeds once the credential exists or the mis-tiered row is corrected. |
330
+ | **Lazy-claim** | 401 on the `gcp_identity` claim, or `idTokenProvider` threw (`LazyClaimError`) | Fix the provider or the ID-token audience — a retry cannot succeed. |
331
+ | **Configuration** | Options that do not describe a usable credential mode, or a `403 invalid_target` from `/oauth/token` — an `audience` outside the credential's `authorizedAudiences` (`ConfigurationError`) | Fix the config or the credential's grants — no retry and no re-claim can clear it. |
332
+
333
+ > **The two 503s on the `gcp_identity` claim are not interchangeable**, and the
334
+ > status alone cannot separate them — the client branches on the envelope's
335
+ > `error` code. A `transient` 503 is a subsystem blip and is retried internally.
336
+ > A `degraded` 503 means the credential bound to your service account is not a
337
+ > `gcp`-tier row — operator misconfiguration that stays true until an admin
338
+ > edits it — so it raises `ProvisioningError` immediately rather than burning
339
+ > the whole backoff curve, and every retry would cost a freshly minted ID token
340
+ > against a condition that will not clear.
341
+
342
+ > **On 403.** The issuer never emits 403 on this grant; every ID-token failure
343
+ > path is `invalid_token` → 401. The client classifies 403 alongside 401 purely
344
+ > as a defence against an intermediary (a load balancer or WAF) answering
345
+ > first.
346
+
347
+ `ResponseShapeError` sits **outside** the four categories above. It indicates
348
+ an issuer response that did not match the expected schema — an issuer-side bug,
349
+ not a recoverable condition. It is re-exported so callers can narrow on it in
350
+ a custom error handler, but the client never retries on it.
351
+
352
+ A failed **background rotation pickup** never surfaces here — it is swallowed,
353
+ not thrown (see Behavior above).
354
+
355
+ ---
356
+
357
+ ## Error envelope
358
+
359
+ When the issuer returns a 4xx or 5xx from `/oauth/token` or `/credentials/me`,
360
+ the JSON body is a camelCase envelope. Only the `error` *code* stays
361
+ snake_case, because RFC 6749 §5.2 reserves the code identifiers:
362
+
363
+ ```jsonc
364
+ {
365
+ "error": "invalid_client", // RFC 6749 code — snake_case
366
+ "errorDescription": "Authentication failed.", // Mi9 field — camelCase
367
+ "requestId": "8f3c…", // echo of X-Request-ID
368
+ "details": [ /* field-level issues; only on invalid_request */ ]
369
+ }
370
+ ```
371
+
372
+ `token-client` maps this to the typed taxonomy above and surfaces only the
373
+ `error` code in exception messages. If you parse the body yourself, read
374
+ `errorDescription` (camelCase) — **not** the RFC `error_description`.
375
+
376
+ This is the **outbound** surface. It is not the same shape as the RFC 6750
377
+ Bearer challenge your own resource server returns to its callers, where
378
+ `error` / `error_description` stay snake_case because RFC 6750 defines them
379
+ that way; that surface belongs to
380
+ [`@mi9-identity/auth-middleware-express`](https://www.npmjs.com/package/@mi9-identity/auth-middleware-express).
381
+
382
+ ---
383
+
384
+ ## Troubleshooting
385
+
386
+ | Symptom | Likely cause | Fix |
387
+ | --- | --- | --- |
388
+ | `acquireToken()` throws `RevocationError` | Credential revoked, or the persisted secret is stale after a rotation | A GCP (Tier 1) client has already re-claimed once and still failed; otherwise confirm the persisted secret is current, then page on-call. |
389
+ | `ConfigurationError` naming `invalid_target` | The requested `audience` is outside the credential's authorized audiences | Request an audience the credential is granted, or have the grant added. |
390
+ | `ConfigurationError` naming `retailer_required` | The service account holds credentials for more than one retailer and the claim named none | Pass `retailerCode` (see **Claiming for several retailers**). |
391
+ | Outbound calls fail only under load with `TransientError` | The issuer returned 429/503 and the internal retries were exhausted | Surface as 503 upstream and back off. |
392
+ | `LazyClaimError` on the first `acquireToken()` | The Google ID token was rejected, or `idTokenProvider` threw | Return a **fresh** token per call whose `aud` is the credential endpoint URL the client passes in. |
393
+
394
+ ---
395
+
396
+ JWKS 503 from the verifier side is handled by
397
+ [`@mi9-identity/jwt-verifier`](https://www.npmjs.com/package/@mi9-identity/jwt-verifier)
398
+ (serves cached keys up to 1 h, never fails open).
399
+
400
+ ## License
401
+
402
+ Apache-2.0. The full text ships in the package as `LICENSE`.