npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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. Use await 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` claim

The 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();

audience accepts 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 into aud (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 JWT iss claim. It lives here rather than in @mi9-identity/jwt-verifier precisely so an outbound-only service takes no dependency on a JOSE stack. If your service also verifies inbound JWTs, that package's mi9IdentityJwksEndpoint(issuer) covers the third URL.


Behavior

  • Proactive refresh — acquireToken() returns the cached token while Date.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/token round-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-After is honoured when present, clamped to [initialMs, maxMs] — a past or malformed value cannot collapse to an immediate retry, and a hostile Retry-After: 3600 cannot stall the caller past the configured ceiling.
  • No retry on 401 — raises RevocationError. A GCP (Tier 1) client configured with idTokenProvider re-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) or X-Mi9-Credential-Rotated: true (header), acquireToken() returns the freshly-minted valid token immediately. The secret pickup (POST /credentials/me with grantType=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 Content from /credentials/me means 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.
  • close() — call on graceful shutdown. Clears the in-memory cache and drops the background pickup handle. Subsequent calls to acquireToken() or forceRefresh() reject with TokenClient is closed.
  • X-Request-ID propagation — 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 ENTIRE acquireToken() / 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 via AbortSignal.timeout(remaining), so a single stalled socket cannot outlive the budget either. Exceeding it raises a TransientError naming 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 mints

What happens:

  1. The first acquireToken() finds no secret and calls idTokenProvider(credentialEndpoint) — the client always requests a token scoped to the credential endpoint URL, which the issuer pins as the required aud (so a leaked token cannot be replayed against any other endpoint).
  2. It POSTs /credentials/me with { "grantType": "gcp_identity" } and that ID token as the bearer, receiving { clientId, clientSecret, audience, tokenUrl }.
  3. It mints against /oauth/token as usual. Tier-1 credentials live in memory only — nothing is persisted. createGcpTokenClient omits onSecretRotated from its options entirely, so there is no persist hook to supply. (If you instead use createTokenClient({ …, idTokenProvider }), onSecretRotated remains available and still fires on rotation pickup — but for a Tier-1 credential there is normally nothing to persist.)
  4. 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_identity claim are not interchangeable, and the status alone cannot separate them — the client branches on the envelope's error code. A transient 503 is a subsystem blip and is retried internally. A degraded 503 means the credential bound to your service account is not a gcp-tier row — operator misconfiguration that stays true until an admin edits it — so it raises ProvisioningError immediately 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.