@mi9-identity/token-client
v1.0.0
Published
Mi9 token client — consumer-side acquireToken/forceRefresh state machine for the Mi9 Identity Service. Handles proactive refresh, 429/503 backoff, 401 revocation, and rotation pickup.
Maintainers
Readme
@mi9-identity/token-client
Consumer-side acquireToken() / forceRefresh() state machine for the Mi9
Identity Service. Encapsulates the retry, rotation-pickup, and refresh logic
so every consumer service does not have to re-implement (and ship five subtly
different bugs).
Apache-2.0-licensed and free to use; access to the Mi9 Identity Platform it acquires tokens from is governed by your commercial agreement with Mi9 Retail.
Which package do I need?
| Package | Use it when |
| --- | --- |
| @mi9-identity/token-client (this one) | Your service calls a Mi9 API and needs a token. |
| @mi9-identity/jwt-verifier | Your service receives Mi9-issued tokens and must verify them. |
| @mi9-identity/auth-middleware-express | The same, and your service is an Express 5 app. |
A service that both calls Mi9 APIs and serves Mi9-authenticated requests installs two of them.
Install
npm install @mi9-identity/[email protected]Versions are semver, and the three @mi9-identity/* packages
(token-client, jwt-verifier, auth-middleware-express) are released in
lockstep at the same version — pin an exact version, no ^ or ~ range.
Breaking wire changes ship as a new API path version (/api/v2), not as a
package major bump.
ESM + Node 24+. The package is
"type": "module"and requires Node ≥ 24. Useawait import()from CJS if needed.
Getting credentials
A Mi9 administrator provisions the credential for your service and gives you
four things: the issuer URL, a clientId, a clientSecret, and the audience
URL or URLs your service is allowed to request. This package does not create,
register or renew credentials — it only uses them. Ask your Mi9 contact if you
do not have them.
Secrets rotate on a schedule. When the issuer signals a rotation, this client
fetches the new secret in the background and calls your onSecretRotated
callback with it; persist it there, or the old secret stops working at the end
of its grace period. The details are in Behavior below.
Configuration
Configure a single issuer URL and derive the endpoints from it:
MI9_IDENTITY_ISSUER # https://identity.mi9retail.com/api/v1 — matches the JWT `iss` claimThe issuer URL is the versioned one: /api/v1 (or a future /api/v2) is
part of the configured value, so a version cutover is a one-env-var change at
each consumer.
import { mi9IdentityEndpoints } from '@mi9-identity/token-client';
const { tokenEndpoint, credentialEndpoint } = mi9IdentityEndpoints(process.env.MI9_IDENTITY_ISSUER!);
// → `${issuer}/oauth/token`, `${issuer}/credentials/me`Audience values are always full URLs of the form https://<host>/<path>, never
a bare service name — https://lda.mi9retail.com/api/v1, not lda. The issuer
echoes the requested shape into the token's aud: a scalar for a string
request, a JSON array for an array request.
The Mi9 Identity Service does not publish
/.well-known/openid-configuration, so a generic OAuth client library that
auto-discovers its endpoints will fail — configure them explicitly, as above.
Usage
import { createTokenClient } from '@mi9-identity/token-client';
// The versioned issuer URL, e.g. https://identity.mi9retail.com/api/v1
const issuer = process.env.MI9_IDENTITY_ISSUER!;
const client = createTokenClient({
tokenEndpoint: `${issuer}/oauth/token`,
credentialEndpoint: `${issuer}/credentials/me`,
clientId: process.env.MERCHANT_CLIENT_ID!,
clientSecret: process.env.MERCHANT_CLIENT_SECRET!,
audience: ['https://lda.mi9retail.com/api/v1'],
onSecretRotated: async (newSecret) => {
await secretStore.atomicReplace(newSecret);
},
logger: log,
});
const { token } = await client.acquireToken();
await fetch('https://lda.mi9retail.com/api/v1/...', {
headers: { authorization: 'Bearer ' + token },
});
// On graceful shutdown:
client.close();
audienceaccepts one URL or many. Pass a single full-URL string, or a non-empty array of them for a multi-audience token. The issuer echoes the requested shape intoaud(scalar for a string, JSON array for an array).
Scopes
Do not set scope. Credentials are provisioned today with an empty
authorized-scope list, so there is no scope you are allowed to request.
Requesting one is rejected by the issuer with invalid_scope, and your token
request fails. With scope unset the issuer grants the credential's full
authorized set, which is empty, and the token's scope claim is an empty
string.
Authorization is carried by the audience, the retailer and the product in the
token, not by scopes. Set scope only if your Mi9 administrator tells you that
your credential has authorized scopes.
Deriving the endpoint URLs.
mi9IdentityEndpoints(issuer)returns{ tokenEndpoint, credentialEndpoint }— the two endpoints this package talks to — from the single issuer URL that matches the JWTissclaim. It lives here rather than in@mi9-identity/jwt-verifierprecisely so an outbound-only service takes no dependency on a JOSE stack. If your service also verifies inbound JWTs, that package'smi9IdentityJwksEndpoint(issuer)covers the third URL.
Behavior
- Proactive refresh —
acquireToken()returns the cached token whileDate.now() < expiresAt - refreshLeadTimeMs(default T-5 min for a 1 h TTL). Call it on every outbound request — it is cheap on a cache hit. - Single-flight — a burst of concurrent
acquireToken()calls during a refresh funnels through one/oauth/tokenround-trip.forceRefresh()coalesces with an in-flight refresh for the same reason; it does not start a second concurrent mint. - Backoff — 429 / 503 / network failures are retried internally (up to 5
times) with exponential backoff + full jitter. Default curve:
initialMs=1000,maxMs=30000,factor=2(unjittered steps: 1 s → 2 s → 4 s → 8 s → 16 s → capped at 30 s; actual delays are randomised in[0, base]).Retry-Afteris honoured when present, clamped to[initialMs, maxMs]— a past or malformed value cannot collapse to an immediate retry, and a hostileRetry-After: 3600cannot stall the caller past the configured ceiling. - No retry on 401 — raises
RevocationError. A GCP (Tier 1) client configured withidTokenProviderre-claims once automatically (see GCP Tier-1 lazy-claim below) before surfacing this; other callers do not retry with the same secret and escalate to operators (credential revoked or invalid). - Background rotation pickup — on
credentialRotated: true(body) orX-Mi9-Credential-Rotated: true(header),acquireToken()returns the freshly-minted valid token immediately. The secret pickup (POST /credentials/mewithgrantType=jwt, reusing the just-minted token as bearer) runs as a detached background task:- On success: the new secret is written to in-memory state before
onSecretRotated(newSecret)is called, so a callback throw is logged and swallowed — it never locks out the client. The application is responsible for persisting the new secret atomically so a process restart picks it up. - A failed pickup (401 already-acknowledged / 503 / other 4xx / malformed
body) is logged and swallowed — it never rejects
acquireToken()and never discards the valid token. The rotation signal keeps firing on subsequent mints; the pickup is retried on the next refresh while still pending. 204 No Contentfrom/credentials/memeans the rotation was already acknowledged by another instance — a clean no-op.- Background pickup is single-flight: a second signal while a pickup is in-flight is ignored until the current one settles.
- On success: the new secret is written to in-memory state before
close()— call on graceful shutdown. Clears the in-memory cache and drops the background pickup handle. Subsequent calls toacquireToken()orforceRefresh()reject withTokenClient is closed.X-Request-IDpropagation — every outbound request carries the request ID from the caller's context (configurable), or a freshly generated UUIDv4.deadlineMs(optional wall-clock budget) — bounds the ENTIREacquireToken()/forceRefresh()call — claim, mint, re-claim, and retry mint together — to a single wall-clock ceiling in ms, computed once when the chain starts rather than reset at each step. Checked before every backoff sleep (stops rather than sleeping past the deadline) and applied to every fetch viaAbortSignal.timeout(remaining), so a single stalled socket cannot outlive the budget either. Exceeding it raises aTransientErrornaming the configured budget and the elapsed time. Unset by default — omitting it preserves the unbounded retry behavior described above exactly. Recommended for a GCP lazy-claim client invoked on the inbound request path (see GCP Tier-1 lazy-claim below), where an unbounded chain can hold an inbound request open for minutes during an issuer brownout.
GCP Tier-1 lazy-claim
A service hosted on Google Cloud never holds a secret up front. It proves its
identity with a Google ID token and claims its credential on first use. Build it
with createGcpTokenClient and supply an idTokenProvider:
import { createGcpTokenClient } from '@mi9-identity/token-client';
const issuer = process.env.MI9_IDENTITY_ISSUER!;
const client = createGcpTokenClient({
tokenEndpoint: `${issuer}/oauth/token`,
credentialEndpoint: `${issuer}/credentials/me`,
audience: ['https://lda.mi9retail.com/api/v1'],
// Return a FRESH Google ID token whose `aud` is the argument (the client
// always passes `credentialEndpoint`) — e.g. via google-auth's
// getIdTokenClient or the Google Cloud metadata server.
idTokenProvider: async (audience) => fetchGoogleIdToken(audience),
logger: log,
});
const { token } = await client.acquireToken(); // first call claims, then mintsWhat happens:
- The first
acquireToken()finds no secret and callsidTokenProvider(credentialEndpoint)— the client always requests a token scoped to the credential endpoint URL, which the issuer pins as the requiredaud(so a leaked token cannot be replayed against any other endpoint). - It
POSTs/credentials/mewith{ "grantType": "gcp_identity" }and that ID token as the bearer, receiving{ clientId, clientSecret, audience, tokenUrl }. - It mints against
/oauth/tokenas usual. Tier-1 credentials live in memory only — nothing is persisted.createGcpTokenClientomitsonSecretRotatedfrom its options entirely, so there is no persist hook to supply. (If you instead usecreateTokenClient({ …, idTokenProvider }),onSecretRotatedremains available and still fires on rotation pickup — but for a Tier-1 credential there is normally nothing to persist.) - If a later mint is rejected with 401 (secret rotated, force-revoked, or
the credential re-created), the client re-claims once automatically and
retries. A second consecutive 401 is terminal (
RevocationError) — it never loops.
This means one acquireToken() call can chain up to four retried steps
(claim, mint, re-claim, retry mint) with no wall-clock ceiling by default. That
is normally fine for a background refresh, but a lazy-claim client is typically
invoked lazily on an inbound request path (a service calling another Mi9
service to serve its own caller) — during an issuer brownout, an unbounded chain
can hold that inbound request open for minutes before your own request timeout
kills it. Set deadlineMs to bound the whole chain to a wall-clock budget
instead (see the options reference below) — recommended for exactly this case.
The provider must mint a fresh token per call — the issuer single-uses each
ID token, so a cached one would be rejected on a retry. This package ships no
metadata-server helper and takes no google-auth dependency; how you obtain
the ID token (metadata server, google-auth, Workload Identity Federation) is
yours to choose.
Claiming for several retailers
A service account may hold one credential per retailer, which is how a
platform consumer mints tokens whose retailerId claim names the destination
tenant rather than itself. Pass retailerCode to name which one:
const client = createGcpTokenClient({
tokenEndpoint: `${issuer}/oauth/token`,
credentialEndpoint: `${issuer}/credentials/me`,
audience: ['https://acme.pos.mi9retail.com/api/v1'],
retailerCode: 'acme',
idTokenProvider: async (audience) => fetchGoogleIdToken(audience),
});One client instance claims one retailer, so a consumer serving several builds one client per retailer and each keeps its own cached token.
Omit retailerCode and the issuer resolves on the service account alone — which
is exactly today's behaviour while the account holds a single credential, so no
existing consumer needs to change. Once the account holds two or more, the
omitted-retailer claim is a 400 (retailer_required) rather than a guess at
which tenant was meant, surfaced as ConfigurationError. If you operate a
platform consumer, start passing retailerCode explicitly before the first
per-retailer credential is created against your account.
Naming a retailer is never a widening: the issuer still returns only a credential an admin explicitly provisioned against your own service account.
For the claim without the caching state machine, the standalone
claimGcpCredential(...) primitive (returning a CredentialClaim) is also
exported. createTokenClient({ ..., idTokenProvider }) works too if you prefer
the base factory.
TokenClientOptions reference
| Option | Required | Default | Purpose |
| --- | --- | --- | --- |
| tokenEndpoint | yes | — | POST URL for minting tokens — ${issuer}/oauth/token. |
| credentialEndpoint | yes | — | POST URL for the lazy claim and rotation pickup — ${issuer}/credentials/me. |
| clientId | yes* | — | Credential's client ID (UUIDv7). Omit in GCP lazy-claim mode. |
| clientSecret | yes* | — | Initial secret; replaced in-memory on rotation. Omit in GCP lazy-claim mode. |
| 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). |
| audience | yes | — | Full-URL audience(s) the minted token targets — a single string or a non-empty array of strings. |
| scope | no | — | Space-separated scope string to request. Leave it unset — see Scopes below. |
| refreshLeadTimeMs | no | 300_000 (5 min) | Refresh this far before expiresAt. |
| backoff | no | DEFAULT_BACKOFF | BackoffPolicy: { initialMs, maxMs, factor, jitter }. |
| onSecretRotated | no | — | (newSecret: string) => Promise<void> — persist hook; see rotation behavior above. |
| requestIdHeader | no | 'X-Request-ID' | Outbound correlation header name. |
| requestId | no | fresh UUID per call | A fixed string or () => string getter to thread your current request ID. |
| fetch | no | global fetch | Injection point for tests. |
| logger | no | no-op | pino-shaped logger (debug/info/warn/error). |
| 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. |
* clientId / clientSecret are required unless idTokenProvider (GCP lazy-claim mode) is set.
Error taxonomy
Errors fall into five categories — branch on these, not on HTTP status alone:
| Category | Examples | Caller action |
| --- | --- | --- |
| Transient | 429, network timeout, and a transient 503 (all retried internally) | Surface as 503 upstream; let the caller retry with backoff. |
| Revocation | 401 from /oauth/token | Do not retry with the same secret. A GCP client re-claims automatically (once); otherwise page on-call. |
| 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. |
| Lazy-claim | 401 on the gcp_identity claim, or idTokenProvider threw (LazyClaimError) | Fix the provider or the ID-token audience — a retry cannot succeed. |
| 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. |
The two 503s on the
gcp_identityclaim are not interchangeable, and the status alone cannot separate them — the client branches on the envelope'serrorcode. Atransient503 is a subsystem blip and is retried internally. Adegraded503 means the credential bound to your service account is not agcp-tier row — operator misconfiguration that stays true until an admin edits it — so it raisesProvisioningErrorimmediately rather than burning the whole backoff curve, and every retry would cost a freshly minted ID token against a condition that will not clear.
On 403. The issuer never emits 403 on this grant; every ID-token failure path is
invalid_token→ 401. The client classifies 403 alongside 401 purely as a defence against an intermediary (a load balancer or WAF) answering first.
ResponseShapeError sits outside the four categories above. It indicates
an issuer response that did not match the expected schema — an issuer-side bug,
not a recoverable condition. It is re-exported so callers can narrow on it in
a custom error handler, but the client never retries on it.
A failed background rotation pickup never surfaces here — it is swallowed, not thrown (see Behavior above).
Error envelope
When the issuer returns a 4xx or 5xx from /oauth/token or /credentials/me,
the JSON body is a camelCase envelope. Only the error code stays
snake_case, because RFC 6749 §5.2 reserves the code identifiers:
{
"error": "invalid_client", // RFC 6749 code — snake_case
"errorDescription": "Authentication failed.", // Mi9 field — camelCase
"requestId": "8f3c…", // echo of X-Request-ID
"details": [ /* field-level issues; only on invalid_request */ ]
}token-client maps this to the typed taxonomy above and surfaces only the
error code in exception messages. If you parse the body yourself, read
errorDescription (camelCase) — not the RFC error_description.
This is the outbound surface. It is not the same shape as the RFC 6750
Bearer challenge your own resource server returns to its callers, where
error / error_description stay snake_case because RFC 6750 defines them
that way; that surface belongs to
@mi9-identity/auth-middleware-express.
Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
JWKS 503 from the verifier side is handled by
@mi9-identity/jwt-verifier
(serves cached keys up to 1 h, never fails open).
License
Apache-2.0. The full text ships in the package as LICENSE.
