@mi9-identity/auth-middleware-express
v1.0.0
Published
Express adapter for the Mi9 JWT verifier — drop-in middleware that verifies Mi9-issued JWTs and attaches AuthContext to req.auth.
Maintainers
Readme
@mi9-identity/auth-middleware-express
Express middleware that verifies Mi9-issued JWTs, attaches AuthContext to
req.auth, and ships scope/retailer guard factories. Layered on
@mi9-identity/jwt-verifier.
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/auth-middleware-express (this one) | Your service receives Mi9-issued tokens and is an Express 5 app. |
| @mi9-identity/jwt-verifier | The same, but not Express — the framework-agnostic verifier this package wraps. Installed alongside this one. |
| @mi9-identity/token-client | Your service calls a Mi9 API and needs a token. |
A service that both calls Mi9 APIs and serves Mi9-authenticated requests installs the token client too.
Install
npm install @mi9-identity/[email protected] @mi9-identity/[email protected]express is a peer dependency. Express 5 is required (peer >=5.0.0);
Express 4 is no longer supported. Packages are ESM-only and require Node 24+.
Versions are semver, and the three @mi9-identity/* packages
(auth-middleware-express, jwt-verifier, token-client) are released in
lockstep at the same version — pin an exact version, not a ^ range. Breaking
wire changes ship as new API path versions, not as new package majors.
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 for your own service:
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, and the JWKS URI is derived from it
with mi9IdentityJwksEndpoint(issuer) from @mi9-identity/jwt-verifier.
Audience values are full URLs (https://merchant.mi9retail.com/api), never a
bare service name.
Usage
import express, { type Request } from 'express';
import { createVerifier } from '@mi9-identity/jwt-verifier';
import { mi9Auth, requireRetailer } from '@mi9-identity/auth-middleware-express';
const verifier = createVerifier({
jwksUri: process.env.MI9_IDENTITY_JWKS_URI,
issuers: ['https://identity.mi9retail.com/api/v1'],
audience: 'https://merchant.mi9retail.com/api',
});
const resolveRetailerCode = (req: Request): string => {
const code = req.params.retailerCode;
if (typeof code !== 'string' || code.length === 0) {
throw new Error('retailerCode path parameter is required');
}
return code;
};
const app = express();
app.use(mi9Auth({ verifier }));
app.get(
'/retailer/:retailerCode/orders',
requireRetailer(resolveRetailerCode),
(req, res) => res.json({ retailer: req.auth?.retailerId }),
);The scope guards are not usable today.
requireScopeandrequireAnyScopeare exported and work, but Mi9 credentials are provisioned with an empty authorized-scope list, so issued tokens carry an emptyscopeclaim and every scope guard rejects with 403insufficient_scope. Authorize on the retailer, the product or the audience instead. Use the scope guards only once your Mi9 administrator confirms that your credentials carry authorized scopes.
Mi9AuthOptions reference
| Option | Required | Default | Purpose |
| --- | --- | --- | --- |
| verifier | yes | — | A Verifier from createVerifier(...). |
| headerName | no | 'authorization' | Inbound header to read the Bearer token from (case-insensitive). |
| requestIdHeader | no | 'X-Request-ID' | Inbound correlation-id header; its value flows to AuthEvent.request_id. |
| onAuthEvent | no | no-op | Sink for the structured AuthEvent (wire your logger / analytics here). |
| onError | no | RFC 6750 §3 mapping | Override the error envelope (status + headers + body) — see defaultErrorMapping. |
req.auth — AuthContext
On a successful verify, mi9Auth attaches the verified token payload to
req.auth. The type is AuthContext from @mi9-identity/jwt-verifier:
import type { AuthContext } from '@mi9-identity/jwt-verifier';| 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 — see the note under Usage. |
| 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. |
Issuer-internal claims are stripped by the verifier before AuthContext is
built, so nothing outside this table reaches req.auth.
createGuards — guards bound to an audit sink
The top-level requireScope / requireAnyScope / requireRetailer exports are convenience wrappers that emit nothing on rejection. Production consumers should call createGuards({ onAuthEvent }) so scope and retailer failures land in the same AuthEvent stream as token verification failures — otherwise an entire class of authorization rejection becomes invisible to your audit sink.
import { createGuards } from '@mi9-identity/auth-middleware-express';
const { requireScope, requireAnyScope, requireRetailer } = createGuards({
onAuthEvent: (event) => log.info(event, event.event),
});The returned object satisfies the Guards interface and is otherwise drop-in compatible with the top-level guard primitives.
Express version
This adapter requires Express 5 (peer >=5.0.0). The adapter calls
next(err) itself, so mi9Auth works without additional wrappers. Express 5
also propagates rejections from async middleware automatically, so any custom
middleware you write around req.auth can be plain async — no
express-async-handler needed.
Default error mapping (RFC 6750 §3)
| Error | Status | WWW-Authenticate error= | Body error |
| ------------------------------ | ------ | ---------------------------------------- | -------------------- |
| MissingTokenError | 401 | omitted (RFC 6750 §3.1) | omitted |
| InvalidTokenError family¹ | 401 | invalid_token | invalid_token |
| InsufficientScopeError | 403 | insufficient_scope + scope="..."² | insufficient_scope |
| RetailerMismatchError | 403 | mi9_retailer_mismatch⁴ | mi9_retailer_mismatch |
| JwksUnavailableError | 503 | invalid_token + Retry-After: 30³ | invalid_token |
¹ Includes TokenExpiredError, IssuerNotAllowedError, AudienceMismatchError, AlgorithmNotAllowedError, MissingRequiredClaimError, InvalidTokenError.
² scope="..." parameter is appended automatically when requireScope / requireAnyScope is the source. Space-delimited per RFC 6749 §3.3.
³ temporarily_unavailable is registered for the authorization endpoint (RFC 6749 §4.1.2.1), not the resource server. RFC 6750 §6.2.1 registers only invalid_request, invalid_token, insufficient_scope. We use invalid_token + 503 + Retry-After to convey transience without emitting an unregistered code.
⁴ Mi9-private (non-standard). The token is fully valid — signature, exp, and aud all pass — but the retailerId claim does not match the resource. Registered codes don't fit: insufficient_scope is a scope shortage, invalid_token is a token defect. RFC 6750 §3 does not restrict the error= parameter to registered codes; the mi9_ namespace prefix marks it as application-specific.
The body shape is { error, error_description } for every row except MissingTokenError. RFC 6750 §3.1 forbids the error= parameter on a bare 401 challenge, so MissingTokenError emits a body of { error_description } only — clients narrowing on body.error must tolerate undefined. defaultErrorMapping is exported as a load-bearing primitive — consumers who want to override one error class while delegating the rest can compose:
mi9Auth({
verifier,
onError: (err) => err instanceof MyCustomError ? customResponse(err) : defaultErrorMapping(err),
});To emit scope="..." in your own InsufficientScopeError instances, pass the required scope list to the constructor: new InsufficientScopeError('msg', ['orders:read', 'pos:write']).
This RFC 6750 surface, which your resource server returns to its callers,
keeps the snake_case error / error_description names that RFC 6750 defines.
It is not the same shape as the issuer's own error envelope, whose
errorDescription field is camelCase — that one is handled by
@mi9-identity/token-client.
If you override the envelope via onError, keep the WWW-Authenticate
parameters: proxies and clients key off them.
AuthEvent shape is a contract
onAuthEvent receives a fixed-shape object that analytics sinks consume.
Don't rename or drop fields — pass it straight to your structured logger:
mi9Auth({ verifier, onAuthEvent: (e) => log.info(e, e.event) });Field names are snake_case by analyst convention, independent of the camelCase wire surface:
interface AuthEvent {
event: 'auth_succeeded' | 'auth_failed';
request_id: string;
client_id?: string; // present on success
retailer_code?: string; // present on success
product?: string; // present on success
success: boolean;
latency_ms: number;
jti?: string; // present on success
failure_reason?: // present on failure
| 'missing_token' | 'invalid_token' | 'token_expired'
| 'issuer_not_allowed' | 'audience_mismatch' | 'algorithm_not_allowed'
| 'missing_required_claim' | 'insufficient_scope'
| 'retailer_mismatch' | 'jwks_unavailable';
}Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Every request 401s with invalid_token right after a deploy | The token was signed by a key not yet in the cached JWKS | Self-heals within the verifier's unknown-kid cooldown (30 s by default). |
| Requests 503 with Retry-After: 30 | JwksUnavailableError — the JWKS is unreachable and no fresh cache is left | Transient; confirm the service can reach ${issuer}/.well-known/jwks.json. |
| 401 invalid_token with audience_mismatch in the audit event | 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. |
| Scope rejections never appear in your logs | The top-level guards were used instead of createGuards({ onAuthEvent }) | Build the guards with createGuards so rejections emit an AuthEvent. |
Inbound verification internals (JWKS caching, key rotation, the error taxonomy)
live in
@mi9-identity/jwt-verifier;
outbound token acquisition lives in
@mi9-identity/token-client.
License
Apache-2.0. The full text ships in the package as LICENSE.
