@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/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`.
|