@mi9-identity/jwt-verifier
v1.0.0
Published
Mi9 JWT verifier — pure RS256/JWKS core for verifying tokens issued by the Mi9 Identity Service. Framework-agnostic.
Maintainers
Readme
@mi9-identity/jwt-verifier
Pure RS256/JWKS verifier for Mi9-issued JWTs. Framework-agnostic — used as
the verification core by @mi9-identity/auth-middleware-express, raw HTTP handlers,
and operational scripts.
Apache-2.0-licensed and free to use; access to the Mi9 Identity Platform whose tokens it verifies is governed by your commercial agreement with Mi9 Retail.
Which package do I need?
| Package | Use it when |
| --- | --- |
| @mi9-identity/token-client | Your service calls a Mi9 API and needs a token. |
| @mi9-identity/jwt-verifier (this one) | 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 — it wraps this verifier as middleware. |
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
(jwt-verifier, token-client, auth-middleware-express) are released in
lockstep at the same version — pin an exact version rather than a ^ or ~
range. A breaking change to the HTTP wire shapes ships as a new API path
version (/api/v2), not as a package major bump.
The package targets ESM and Node 24+. Use await import() if you consume it
from CommonJS.
Configuration
Verifying needs no credential: the signing keys are public and served from the issuer's JWKS URL. You need two values, both given to you by your Mi9 contact when your service is registered — the issuer URL, and the audience identifier assigned to your service.
Configure one issuer URL and one audience:
MI9_IDENTITY_ISSUER # https://identity.mi9retail.com/api/v1 — matches the JWT `iss` claim
MI9_IDENTITY_AUDIENCE # this service's own audience identifier (a full URL)The issuer URL is the versioned one: /api/v1 (or a future /api/v2) is
part of the configured value, and every endpoint URL is derived from it.
Audience values are always full URLs of the form https://<host>/<path>, never
a bare service name — for example https://merchant.mi9retail.com/api, not
merchant. A token is valid for your service when your configured audience
appears in its aud.
Usage
import { createVerifier, mi9IdentityJwksEndpoint } from '@mi9-identity/jwt-verifier';
const jwksUri = mi9IdentityJwksEndpoint('https://identity.mi9retail.com/api/v1');
const verifier = createVerifier({
jwksUri,
issuers: ['https://identity.mi9retail.com/api/v1'],
audience: 'https://merchant.mi9retail.com/api',
});
const ctx = await verifier.verify(token);
console.log(ctx.retailerId, ctx.product);mi9IdentityJwksEndpoint — single-source-of-truth helper
Consumers configure one issuer URL (MI9_IDENTITY_ISSUER) that matches the
JWT iss claim — e.g. https://identity.mi9retail.com/api/v1. The helper
appends the bare JWKS path to that single value. It contains no /api/v1
literal; the version lives in the caller's issuer URL, so a future v2
cutover is a one-env-var change at each consumer.
Inbound verification is this package's only role, so the mint and
credential-claim URLs are not derived here — mi9IdentityEndpoints(issuer)
in @mi9-identity/token-client
returns { tokenEndpoint, credentialEndpoint }. Neither package depends on the
other, so an outbound-only service never installs a JOSE stack for a string
concat.
The Mi9 Identity Service does not publish
/.well-known/openid-configuration. Generic OAuth client libraries that
auto-discover will fail; configure the issuer URL, the JWKS URI, and the token
endpoint explicitly, as above.
VerifierOptions reference
| Option | Required | Default | Purpose |
| --- | --- | --- | --- |
| jwksUri | yes | — | Full JWKS URL; use mi9IdentityJwksEndpoint(...). |
| issuers | yes | — | Allowlist of acceptable iss values (no wildcards). |
| audience | yes | — | string \| string[] — the token's aud must intersect this. |
| clockToleranceSec | no | 30 | Leeway for exp/iat/nbf, in seconds. |
| jwksCacheTtlMs | no | 3_600_000 (1 h) | How long a fetched JWKS is served before a re-fetch. |
| jwksInitialJitterMs | no | 30_000 | Random delay before the first JWKS fetch — thundering-herd guard. |
| jwksCooldownMs | no | 30_000 | Min interval between unknown-kid re-fetches. |
| jwksFetchTimeoutMs | no | 5_000 | Per-fetch timeout for a JWKS download (jose's timeoutDuration). |
| logger | no | none | Optional pino-shaped Logger; see Logging below. |
Logging
Pass a pino-compatible Logger to receive observability events from the
verifier core. The verifier emits exactly one log event:
warn—event: 'jwks_unavailable', message'jwt-verifier.jwks_unavailable': fired whenJwksUnavailableErroris thrown (JWKS fetch failed and no fresh cache is available). This is the only event the framework-agnostic core emits; richer observability (per-request audit, scope/retailer guard events) is the adapter's job.
See the JWKS availability section below for the failure semantics this event signals.
Validation pipeline
- Extract Bearer token (caller's responsibility — verifier accepts the raw string).
- Decode header, read
kidandiss. issmust be in the configured allowlist.- Fetch JWKS from the explicitly-configured
jwksUri(the verifier never derives the URL fromiss). The underlyingjose.createRemoteJWKSetcaches the document forjwksCacheTtlMs(default 1h) and applies acooldownDuration(default 30s) between unknown-kidrefreshes — so an unknownkidtriggers a single re-fetch and is then memoised for the cooldown window before retrying. - Verify RS256 signature against the key for
kid. - Validate
aud(OR-membership against configured audiences) andexp/nbf/iatwith a 30-second default clock skew leeway. - Extract custom claims and return
AuthContext.
AuthContext
Returned on success:
| Field | Type | Notes |
| --- | --- | --- |
| iss | string | Issuer (versioned, e.g. …/api/v1). |
| sub | string | Is the clientId (UUIDv7) — no separate clientId claim. |
| aud | string \| string[] | Scalar for one audience, array for several. |
| exp / iat / nbf | number | Unix seconds. |
| jti | string | Token id. |
| scope | string | Raw space-separated scope string. Empty today — Mi9 credentials are provisioned with no authorized scopes, so do not authorize on it. |
| scopes | readonly string[] | Pre-split, deduped view of scope. Empty today. |
| retailerId | string | From the https://mi9retail.com/retailerId claim. |
| product | string | Product the credential belongs to. |
| storeCode | string \| null | Always present; null unless the credential is store-bound. |
The authClass claim (issuer-internal, used only by the rotation-pickup
path) is stripped before AuthContext is returned. The verifier
exposes only an explicit allowlist of fields — anything else is dropped, so
future internal claims added by the issuer cannot leak to gateway code.
Error taxonomy
Typed errors thrown on failure:
| Error | When |
| --------------------------- | ---------------------------------------------- |
| MissingTokenError | (Adapter-level — the verifier itself receives a string.) |
| InvalidTokenError | Signature mismatch, malformed token, decode failure. |
| TokenExpiredError | exp in the past (after leeway). |
| IssuerNotAllowedError | iss not in allowlist. |
| AudienceMismatchError | aud does not intersect configured audiences. |
| AlgorithmNotAllowedError | Header alg ≠ RS256. |
| MissingRequiredClaimError | Required claim absent (e.g. retailerId). |
| JwksUnavailableError | JWKS fetch failed and no fresh cached key set (within jwksCacheTtlMs) is available. |
JWKS availability
jose.createRemoteJWKSet serves keys from its in-memory cache only while the
cache is fresh — younger than jwksCacheTtlMs (default 1h). Once the cache
is stale, the next verify() triggers a refetch; if that refetch fails, the
verifier throws JwksUnavailableError (a 503-shaped error). A fresh cache
is still served through a transient endpoint outage, so a blip shorter than the
TTL is invisible — but a stale cache is not used as a fallback. The
verifier never falls back to an unauthenticated path; it never fails open.
Unknown kid after signing-key rotation
A token signed by a newly-rotated signing key whose kid is not yet in the
cached JWKS maps to InvalidTokenError (401), not JwksUnavailableError
(503): the JWKS is reachable, the key is simply absent from it for now. jose
refetches once on an unknown kid, then memoises the miss for jwksCooldownMs
(default 30s) before retrying — so such tokens 401 for at most that window,
then recover on the next mint. This is deliberately conservative; the issuer
publishes a rotated signing key to the JWKS document ahead of minting tokens
under it, so the window should not arise in a normal rotation.
Signing-key rotation is otherwise transparent to consumers — there is nothing to configure or rotate on your side.
Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Requests fail with JwksUnavailableError (503) | The JWKS document is unreachable and no fresh cache is left | Transient; confirm your service can reach ${issuer}/.well-known/jwks.json. |
| 401s right after a deploy or a signing-key rotation | The token was signed by a key not yet in the cached JWKS | Self-heals within jwksCooldownMs; callers recover on their next mint. |
| AudienceMismatchError | The configured audience is a bare name, or does not match the token's aud | Set it to the full-URL audience the issuer mints for your service. |
| IssuerNotAllowedError | The configured issuer URL omits the /api/v1 path segment | Configure the versioned issuer URL, exactly as it appears in the token's iss. |
Outbound token acquisition lives in
@mi9-identity/token-client;
the Express 5 adapter — scope/retailer guards and the RFC 6750 error mapping —
lives in
@mi9-identity/auth-middleware-express.
License
Apache-2.0. The full text ships in the package as LICENSE.
