@web-ts-toolkit/express-oidc-vault
v0.34.3
Published
OIDC session middleware for Express with body or cookie transport and pluggable vault stores
Downloads
2,164
Maintainers
Readme
@web-ts-toolkit/express-oidc-vault
OIDC session middleware for Express with body or cookie session transport and server-side storage of upstream refresh tokens and logout-capable id_tokens.
Status
This package now implements the core OIDC flow with body or cookie session transport.
Current implementation includes:
- the core middleware factory
- OIDC login redirect with PKCE,
state, andnonce - callback token exchange, server-side session creation, and one-time local exchange codes
- session refresh with session ID rotation
- server-driven upstream logout redirect using stored
id_token - OIDC backchannel logout handling via
logout_token - public TypeScript interfaces for hooks, sessions, config helpers, and store providers
Installation
pnpm add @web-ts-toolkit/express-oidc-vault expressFrontend Storage Policy
Default browser-side transport:
- mirror
sessionIdintosessionStorage - keep
accessTokenin memory only - do not store either value in
localStorage
Why:
sessionIdneeds to survive page refresh so the frontend can callPOST /auth/oidc/refreshduring app bootstrapaccessTokenis the credential used on normal API requests and should remain non-persistent in the browsersessionStorageis still readable by JavaScript, so it reduces persistence but does not remove XSS risk
Optional alternative:
- set
sessionTransport: 'cookie' - store
sessionIdin anHttpOnlybrowser cookie instead ofsessionStorage - keep
accessTokenin memory only
That mode simplifies the frontend and keeps the session pointer out of JavaScript-visible storage, but it reintroduces cookie deployment concerns such as SameSite, Secure, and cross-origin credential handling.
Session Transport Modes
The package supports two ways to move the opaque sessionId between browser and backend.
sessionTransport: 'body'
This is the default mode.
exchangeandrefreshresponses includesessionId- the frontend stores
sessionId, typically insessionStorage - the frontend sends
sessionIdback in the JSON body forrefreshandlogout refreshandlogoutdo not read session cookies in this mode
sessionTransport: 'cookie'
This mode stores sessionId in a backend-managed cookie.
exchangesets the session cookie and does not need to returnsessionIdin the JSON bodyrefreshreads the cookie, rotates the session, and updates the cookielogoutreads the cookie and clears itrefreshandlogoutrequire the cookie and reject body-onlysessionIdvalues- the frontend does not need to keep
sessionIdinsessionStorage
Backchannel logout is separate from both transport modes because it is a server-to-server request from the IdP and does not rely on browser storage at all.
Available cookie options:
cookie.namecookie.deploymentMode:'same-origin' | 'same-site' | 'cross-site'cookie.sameSite:'lax' | 'strict' | 'none'cookie.securecookie.domaincookie.pathtrustedOrigins: browser origins allowed to call cookie-authenticatedrefreshandlogout; required when cross-site cookie transport is enabled
cookie.httpOnly is always enforced as true. Middleware creation rejects httpOnly: false and unsafe cookie names, domains, or paths so untrusted values cannot be serialized into Set-Cookie headers.
Default cookie behavior:
name:oidc_vault_sessionpath:/httpOnly:truedeploymentMode:same-originsameSite:laxunlessdeploymentModeiscross-sitesecure:truewhensameSiteresolves tononeordeploymentModeiscross-site
Cookie-authenticated refresh and logout requests use a fail-closed CSRF policy for every SameSite mode. The request must include an Origin header, or a valid Referer header, whose origin matches backendOrigin or one of the configured trustedOrigins. Requests with no source-origin header are rejected. Backchannel logout is not affected because it is authenticated with the signed OIDC logout token rather than the browser session cookie.
Recommended frontend boot flow:
- Read
sessionIdfromsessionStorage. - If present, call
POST /auth/oidc/refreshimmediately. - If refresh succeeds, replace the stored
sessionIdwith the rotated value and keep the returnedaccessTokenin memory only. - If refresh fails, clear
sessionStorageand treat the user as logged out.
If you use sessionTransport: 'cookie', the frontend boot flow becomes simpler:
- Keep
accessTokenin memory only. - Call
POST /auth/oidc/refreshon app startup. - Let the backend read and rotate the session cookie.
- Clear in-memory auth state if refresh fails.
Endpoints
The core middleware exposes these endpoints under a configurable base path:
GET /auth/oidc/loginGET /auth/oidc/callbackPOST /auth/oidc/exchangePOST /auth/oidc/refreshPOST /auth/oidc/logoutPOST /auth/oidc/backchannel-logout
The mounted OIDC router parses JSON and application/x-www-form-urlencoded request bodies with an explicit default limit of 16kb. This covers the small route payloads used by exchange, refresh, logout, and form-encoded backchannel logout. If an IdP requires a larger logout_token, set requestBodyLimit to a string or byte count accepted by Express body parsers.
Parser failures return a JSON client error before route handlers or store/provider hooks run. The stable error codes are:
OIDC_VAULT_REQUEST_BODY_TOO_LARGEOIDC_VAULT_REQUEST_BODY_PARAMETER_LIMIT_EXCEEDEDOIDC_VAULT_UNSUPPORTED_REQUEST_BODY_ENCODINGOIDC_VAULT_MALFORMED_REQUEST_BODYOIDC_VAULT_INVALID_REQUEST_BODY
Quick Start
import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
const app = express();
const storeProvider = createMemoryOidcVaultStore();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
storeProvider,
}),
);Use the memory store for local development and tests. For production deployments, prefer a Redis or MongoDB store provider.
backendOrigin must be the public backend origin registered with your OIDC provider, such as https://api.example.com. Callback redirect_uri values are built from this pinned origin and the configured basePath, so reverse proxies and untrusted Host headers cannot change the provider callback URL. Configure Express trust proxy only for other request metadata needs; it is not used to derive the OIDC callback origin.
Public Options And Defaults
| Option | Default | Contract |
| ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| basePath | /auth/oidc | Mount path for the OIDC router. Route paths listed in this README are relative to this value. |
| backendOrigin | required | Public backend origin registered with the OIDC provider. Callback redirect URIs are derived from this pinned origin, not request host headers. |
| storeProvider | required | Durable vault store provider. Use Redis or MongoDB for production and multi-instance deployments. |
| config | env-compatible helper input | Provider config. issuer is required for discovery and manual modes so ID and logout tokens are issuer-bound. |
| frontendRedirectUri | unset | Default browser return target after backend callback completion. Required if login accepts a custom returnTo. |
| postLogoutRedirectUri | unset | Optional provider-registered HTTP(S) URL used in the upstream end-session redirect. |
| fetchUserInfo | implementation default | When enabled, UserInfo claims are fetched and merged only after the sub matches the verified ID token subject. |
| authorizationTransactionTtlMs | 600000 | TTL for one-time authorization transactions created during login. |
| exchangeCodeTtlMs | 30000 | TTL for one-time local exchange codes returned to the frontend callback route. |
| sessionTransport | body | body returns and accepts JSON sessionId; cookie stores the session pointer in an HttpOnly cookie and rejects body-only refresh/logout IDs. |
| cookie | see cookie defaults above | Cookie transport options. httpOnly is always enforced as true; unsafe names, paths, and domains are rejected. |
| trustedOrigins | [] plus backendOrigin internally | Browser origins allowed to call cookie-authenticated refresh and logout. Required for cross-site cookie transport. |
| requestBodyLimit | 16kb | Express JSON and URL-encoded parser limit for OIDC route bodies. Increase only for known provider backchannel logout token size needs. |
| providerRequestTimeoutMs | 5000 | Timeout for discovery, token, UserInfo, and remote JWKS requests. Must be a positive finite integer. |
| hooks | unset | Pre-commit hooks can veto operations by throwing; post-commit notification hook failures are reported to onError without undoing committed state. |
| tokenIssuer | unset | Issues app-local access tokens for exchange and refresh. This lifetime is separate from upstream token and vault-session lifetimes. |
Frontend Integration Example
The backend flow is only half of the integration. In default body transport mode, keep accessToken in memory, mirror sessionId into sessionStorage, and deduplicate refresh calls.
type AuthState = {
accessToken: string | null;
sessionId: string | null;
};
const authState: AuthState = {
accessToken: null,
sessionId: sessionStorage.getItem('sessionId'),
};
let refreshPromise: Promise<void> | null = null;
function persistSessionId(sessionId: string | null): void {
authState.sessionId = sessionId;
if (sessionId) {
sessionStorage.setItem('sessionId', sessionId);
} else {
sessionStorage.removeItem('sessionId');
}
}
function setAuthState(payload: { accessToken?: string; sessionId: string }): void {
authState.accessToken = payload.accessToken ?? null;
persistSessionId(payload.sessionId);
}
function clearAuthState(): void {
authState.accessToken = null;
persistSessionId(null);
}
async function exchangeCallbackCode(code: string): Promise<void> {
const response = await fetch('/auth/oidc/exchange', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
clearAuthState();
throw new Error('OIDC code exchange failed.');
}
setAuthState(await response.json());
}
async function refreshAuthState(): Promise<void> {
if (!authState.sessionId) {
clearAuthState();
return;
}
const response = await fetch('/auth/oidc/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: authState.sessionId }),
});
if (!response.ok) {
clearAuthState();
throw new Error('OIDC refresh failed.');
}
setAuthState(await response.json());
}
async function ensureFreshAccessToken(): Promise<void> {
if (!refreshPromise) {
refreshPromise = refreshAuthState().finally(() => {
refreshPromise = null;
});
}
await refreshPromise;
}
async function fetchWithAuth(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (authState.accessToken) {
headers.set('authorization', `Bearer ${authState.accessToken}`);
}
let response = await fetch(input, { ...init, headers });
if (response.status !== 401 || !authState.sessionId) {
return response;
}
await ensureFreshAccessToken();
const retryHeaders = new Headers(init.headers);
if (authState.accessToken) {
retryHeaders.set('authorization', `Bearer ${authState.accessToken}`);
}
response = await fetch(input, { ...init, headers: retryHeaders });
return response;
}
async function bootstrapAuth(): Promise<void> {
if (!authState.sessionId) {
return;
}
try {
await refreshAuthState();
} catch {
clearAuthState();
}
}
async function logout(): Promise<void> {
const sessionId = authState.sessionId;
clearAuthState();
if (!sessionId) {
return;
}
await fetch('/auth/oidc/logout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
}Recommended browser flow:
- Redirect the user to
GET /auth/oidc/loginwhen they click login. - On the frontend callback route, read
codefrom the query string and callexchangeCallbackCode(code). - Remove the
codequery parameter from the address bar after a successful exchange. - Call
bootstrapAuth()once during app startup so a reloaded tab can recover fromsessionStorage. - Use
fetchWithAuth(...)or equivalent interceptor logic for normal API requests.
Cookie transport frontend example
When sessionTransport is set to 'cookie', the frontend no longer needs to store sessionId.
type AuthState = {
accessToken: string | null;
};
const authState: AuthState = {
accessToken: null,
};
let refreshPromise: Promise<void> | null = null;
function setAuthState(payload: { accessToken?: string }): void {
authState.accessToken = payload.accessToken ?? null;
}
function clearAuthState(): void {
authState.accessToken = null;
}
async function refreshAuthState(): Promise<void> {
const response = await fetch('/auth/oidc/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
clearAuthState();
throw new Error('OIDC refresh failed.');
}
setAuthState(await response.json());
}
async function ensureFreshAccessToken(): Promise<void> {
if (!refreshPromise) {
refreshPromise = refreshAuthState().finally(() => {
refreshPromise = null;
});
}
await refreshPromise;
}
async function exchangeCallbackCode(code: string): Promise<void> {
const response = await fetch('/auth/oidc/exchange', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code }),
});
if (!response.ok) {
clearAuthState();
throw new Error('OIDC code exchange failed.');
}
setAuthState(await response.json());
}For cross-origin cookie deployments, also remember:
- the frontend requests must use
credentials: 'include' - the backend CORS policy must allow credentials
- the cookie typically needs
SameSite=NoneandSecure - set
trustedOriginsso refresh and logout only accept requests from your frontend origin
Backchannel Logout
The package supports OIDC backchannel logout at:
POST /auth/oidc/backchannel-logout
Expected request shape:
application/x-www-form-urlencoded- field:
logout_token=<provider-signed-jwt>
The middleware validates the logout_token against the provider JWKS and then revokes matching local sessions by:
- upstream
sidwhen present - otherwise
sub
The logout token must include iat, exp, jti, the standard backchannel logout event claim, and either sid or sub. If the protected header includes typ, it must be logout+jwt; tokens without typ remain accepted for provider compatibility. Each jti is consumed once and remembered until the token exp, so replaying the same valid token returns a successful no-op response without repeating revocation hooks.
Example request:
await fetch('/auth/oidc/backchannel-logout', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
logout_token: '<provider-signed-logout-token>',
}),
});Example response:
{
"loggedOut": true,
"revokedSessions": 1
}Notes:
- this route is intended for the IdP to call directly, not the browser
- cookie transport does not change how backchannel logout works
- after a successful backchannel logout, the next browser refresh will fail because the local session is gone; in cookie mode the package clears the stale session cookie on that failed refresh
Backend Wiring Examples
Use one of the store packages depending on your deployment model.
Memory store
import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
const app = express();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMemoryOidcVaultStore(),
}),
);Redis store
import express from 'express';
import { createClient } from 'redis';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createRedisOidcVaultStore({
client: redis,
keyPrefix: 'oidc-vault',
}),
}),
);MongoDB store
import express from 'express';
import { MongoClient } from 'mongodb';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store';
const app = express();
const mongo = new MongoClient(process.env.MONGODB_URI!);
await mongo.connect();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMongoOidcVaultStore({
db: mongo.db('app-auth'),
}),
}),
);Cookie transport
import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
import { createClient } from 'redis';
const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
sessionTransport: 'cookie',
cookie: {
deploymentMode: 'same-site',
domain: '.example.com',
secure: true,
},
trustedOrigins: ['https://frontend.example.com'],
storeProvider: createRedisOidcVaultStore({
client: redis,
keyPrefix: 'oidc-vault',
}),
}),
);Main Exports
createOidcVaultAccessTokenMiddleware(...)createOidcVaultJwtAccessTokenValidator(...)createOidcVaultMiddleware(...)DEFAULT_OIDC_VAULT_BASE_PATHDEFAULT_AUTHORIZATION_TRANSACTION_TTL_MSDEFAULT_EXCHANGE_CODE_TTL_MSDEFAULT_OIDC_SCOPESOIDC_VAULT_ROUTE_PATHSnormalizeOidcVaultBasePath(...)resolveOidcVaultConfig(...)resolveOidcVaultConfigFromEnv(...)type OidcVaultOptionstype OidcVaultHookstype OidcVaultStoreProvidertype OidcVaultSessiontype OidcVaultAccessTokenValidatortype OidcVaultAuthenticatedRequesttype OidcVaultJwtAccessTokenValidatorOptionstype OidcVaultTokenIssuer
Store Provider Contract
The built-in memory, Redis, and MongoDB store packages share the same behavioral contract.
createAuthorizationTransaction,createExchangeCode, andcreateSessionare deliberate upserts keyed bystate,code, andsessionId.- Store metadata is portable when it is JSON-compatible: strings, finite numbers, booleans, null, arrays, and plain objects. Do not rely on functions, symbols, Dates, Maps, Sets, custom prototypes, undefined object properties, or object identity surviving a store round-trip.
- Store methods return owned values or serialization round-trips. Mutating an input after a create call or mutating a returned value does not mutate persisted state.
- Expiry timestamps are epoch milliseconds. Records are expired at
expiresAt <= now; backchannel logout JTI expiry must be finite and in the future or the consume call returnsfalsewithout storing the JTI. rotateSessionrequires an existing source session and a distinct unused targetsessionId. Equivalent missing-source, same-ID, and existing-target rotation conflicts throwOidcVaultStoreConflictErrorwithout deleting or overwriting source or target data.- Session rotation preserves the logical session ID when the next session omits one. Old public session IDs remain revocation aliases while the logical lineage remains live, so deleting by an old public ID can revoke the current rotated session.
Key Integration Notes
- The browser should never receive the upstream refresh token.
- The backend should store the latest upstream
id_tokenso logout can call the upstream end-session endpoint withid_token_hint. sessionIdshould rotate on refresh.- The frontend should deduplicate concurrent refresh calls so only one refresh is in-flight at a time.
- Upstream OAuth
expires_indescribes the upstream access token only. It does not setOidcVaultSession.expiresAtor shorten the refresh-token-backed vault session. OidcVaultSession.expiresAt, when set by application code or store policy, is an explicit vault-session expiry in epoch milliseconds and remains enforced by store providers.- If only
OIDC_ISSUERis configured, issuer discovery is used and the discovered issuer must match the configured issuer. - Provider discovery metadata and remote JWKS resolvers are cached in bounded process-wide maps keyed by configured issuer URL and
jwks_uri; these keys are intended to come from static middleware configuration, not request input. - Successful discovery entries are reused for up to 10 minutes and both discovery and JWKS resolver maps retain at most 32 entries with oldest-entry eviction. Failed discovery requests are removed from the cache so a later request can retry.
- Discovery, token, UserInfo, and remote JWKS HTTP requests use a 5 second default timeout and manual redirect handling. Set
providerRequestTimeoutMsoncreateOidcVaultMiddleware(...)to a positive integer number of milliseconds if your provider needs a different bound. - Provider response parse errors return sanitized client messages; oversized or malformed provider bodies are not returned to callers.
- If manual endpoints are configured,
issueris still required so ID and logout tokens are issuer-bound. - Token responses must include
token_type: Bearer;expires_in, when present, must be a finite non-negative integer. - ID tokens must include
sub,exp, andiat;azpmust matchclientIdwhen present and is required for multi-audience ID tokens. - UserInfo responses must include a
submatching the verified ID-token subject before claims are merged into the session user. - Refresh responses may omit
id_token; in that case, the middleware keeps the existing verified ID token and identity claims without requiring that original ID token to still be current. If refresh returns a newid_token, itssubmust match the current session subject. backendOriginis the public origin registered with your OIDC provider for the backend callback URI. The middleware normalizes it to an origin and uses it for/callbackredirect URIs instead of trusting requestHostheaders.frontendRedirectUriis the default browser return target after the backend completes the upstream callback.postLogoutRedirectUriis optional. When configured, it must be an absolute HTTP(S) URL registered with the OIDC provider for post-logout redirects. It may be hosted on a different origin fromfrontendRedirectUriwhen that exact URL is provider-registered.- backchannel logout revokes local sessions by upstream
sidwhen available, otherwise bysub
Config Helpers
import { resolveOidcVaultConfigFromEnv } from '@web-ts-toolkit/express-oidc-vault';
const config = resolveOidcVaultConfigFromEnv(process.env);Resolution behavior:
- if only
OIDC_ISSUERis set, discovery mode resolves the provider endpoints and validates that the discovered issuer matches - if endpoint-specific env vars are set, manual mode requires
OIDC_ISSUER,OIDC_AUTHORIZATION_ENDPOINT,OIDC_TOKEN_ENDPOINT, andOIDC_JWKS_URI OIDC_SCOPESdefaults toopenid email profile
Manual endpoint mode
If your provider metadata is not discoverable from OIDC_ISSUER, configure the endpoints directly.
import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
import { createClient } from 'redis';
const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
authorizationEndpoint: process.env.OIDC_AUTHORIZATION_ENDPOINT,
tokenEndpoint: process.env.OIDC_TOKEN_ENDPOINT,
userInfoEndpoint: process.env.OIDC_USERINFO_ENDPOINT,
jwksUri: process.env.OIDC_JWKS_URI,
endSessionEndpoint: process.env.OIDC_END_SESSION_ENDPOINT,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: process.env.OIDC_SCOPES,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createRedisOidcVaultStore({
client: redis,
keyPrefix: 'oidc-vault',
}),
}),
);In manual mode, the minimum required config is:
authorizationEndpointtokenEndpointjwksUriclientIdissuer
userInfoEndpoint and endSessionEndpoint are optional but recommended when your provider supports them.
Local Access Token Example
The middleware can return a local backend access token during exchange and refresh by providing a tokenIssuer.
import express from 'express';
import { SignJWT } from 'jose';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
const app = express();
const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMemoryOidcVaultStore(),
tokenIssuer: {
async issue({ session }) {
const accessToken = await new SignJWT({
sub: session.subject,
sid: session.sessionId,
scope: session.scope,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(jwtSecret);
return {
accessToken,
expiresIn: 900,
tokenType: 'Bearer',
};
},
},
}),
);That local token is separate from the upstream IdP access token:
- the upstream refresh token stays in the server-side vault
- the frontend receives only the app-issued access token and the opaque
sessionId - the app-issued access token can contain only the claims your backend APIs actually need
Access Token Validation Middleware
The OIDC route/session middleware and the normal API bearer-token middleware are separate concerns.
Use createOidcVaultMiddleware(...) for:
- login
- callback
- exchange
- refresh
- logout
Use createOidcVaultAccessTokenMiddleware(...) for:
- validating the app-issued local access token on protected API routes
- attaching authenticated auth context to
req.auth - rejecting missing, malformed, invalid, or expired bearer tokens with
401
import express from 'express';
import { createOidcVaultAccessTokenMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { jwtVerify } from 'jose';
const app = express();
const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
app.get(
'/api/me',
createOidcVaultAccessTokenMiddleware({
validator: {
async validate(token) {
const result = await jwtVerify(token, jwtSecret, {
algorithms: ['HS256'],
});
return {
subject: String(result.payload.sub),
sessionId: typeof result.payload.sid === 'string' ? result.payload.sid : undefined,
scope: typeof result.payload.scope === 'string' ? result.payload.scope : undefined,
claims: result.payload as Record<string, unknown>,
};
},
},
}),
(req, res) => {
res.json({
subject: req.auth?.subject,
sessionId: req.auth?.sessionId,
scope: req.auth?.scope,
});
},
);Returned auth context shape:
req.auth.tokenreq.auth.subjectreq.auth.sessionIdreq.auth.scopereq.auth.claims
The package augments Express request typing so req.auth is available without casting in TypeScript route handlers.
JWT validator helper
If your local access token is a JWT, you can avoid rewriting the same jwtVerify(...) adapter each time.
import {
createOidcVaultAccessTokenMiddleware,
createOidcVaultJwtAccessTokenValidator,
} from '@web-ts-toolkit/express-oidc-vault';
const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
app.get(
'/api/me',
createOidcVaultAccessTokenMiddleware({
validator: createOidcVaultJwtAccessTokenValidator({
key: jwtSecret,
issuer: 'https://api.example.com',
audience: 'api-audience',
algorithms: ['HS256'],
}),
}),
(req, res) => {
res.json({
subject: req.auth?.subject,
sessionId: req.auth?.sessionId,
scope: req.auth?.scope,
});
},
);Default JWT claim mapping:
sub->auth.subjectsid->auth.sessionIdscope->auth.scope- full verified payload ->
auth.claims
If you need a custom mapping, pass mapClaims(...) to createOidcVaultJwtAccessTokenValidator(...).
Recommended separation:
- keep login/session lifecycle in
createOidcVaultMiddleware(...) - keep normal API bearer validation in
createOidcVaultAccessTokenMiddleware(...) - keep authorization decisions outside the validator middleware
Hook Examples
Hooks let the app observe or extend the core OIDC flow without forking the middleware.
Audit and user provisioning hooks
import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
const app = express();
app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
backendOrigin: 'https://api.example.com',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
storeProvider: createMemoryOidcVaultStore(),
hooks: {
async onLoginStart({ req }) {
console.log('OIDC login started', {
ip: req.ip,
userAgent: req.get('user-agent'),
});
},
async onSessionCreated({ session }) {
if (!session?.user) {
return;
}
await upsertLocalUser({
oidcSubject: session.subject,
email: typeof session.user.email === 'string' ? session.user.email : undefined,
displayName: typeof session.user.name === 'string' ? session.user.name : undefined,
});
},
async onSessionRefreshed({ session, metadata }) {
console.log('OIDC session rotated', {
previousSessionId: metadata?.previousSessionId,
nextSessionId: session?.sessionId,
});
},
async onLogout({ session, metadata }) {
console.log('OIDC logout completed', {
subject: session?.subject,
revokedSessions: metadata?.revokedSessions,
});
},
async onError({ error, route, req }) {
console.error('OIDC vault error', {
route,
path: req.originalUrl,
error,
});
},
},
}),
);
async function upsertLocalUser(input: { oidcSubject: string; email?: string; displayName?: string }): Promise<void> {
// replace with application-specific persistence logic
console.log('upsertLocalUser', input);
}Recommended hook usage:
onLoginStart,onAuthorizationUrl,onCallbackTokens,onUserInfo,onBeforeSessionCreate, andonBeforeLogoutare pre-commit hooks. Throwing from one of these hooks vetoes the operation before the related durable session state is created, rotated, or deleted.onSessionCreated,onSessionRefreshed, andonLogoutare post-commit notification hooks. Their failures are reported toonErrorbut do not change a successful callback redirect, refresh response, logout response, or already-committed store mutation.- use
onSessionCreatedfor local user provisioning or last-login updates - use
onSessionRefreshedfor audit logs and session rotation tracing - use
onLogoutto revoke local app state that depends on the session - use
onErrorfor structured logging and alerting
Client error responses keep a stable { code, message } shape and intentionally avoid returning raw provider, store, hook, token issuer, or access-token validator details. Use onError to observe the original error object for private server-side logs.
Security Checklist
Use these defaults when deploying the package:
- keep
sessionIdinsessionStorageand keepaccessTokenin memory only - never store the upstream refresh token in the browser
- use HTTPS end-to-end for frontend, backend, and IdP communication
- set
backendOriginto the public backend origin registered with the provider; do not rely on request host or proxy headers for callback URL construction - keep the default
requestBodyLimitof16kbunless a provider requires a larger form-encoded backchannellogout_token - treat XSS prevention as critical because
sessionStorageis still readable by JavaScript - enable a strict Content Security Policy and avoid unsafe inline scripts
- rotate
sessionIdon refresh and overwrite the mirroredsessionStoragevalue immediately - clear in-memory auth state and
sessionStorageon logout, even if upstream logout fails - set
postLogoutRedirectUriexplicitly so logout destinations stay predictable - when using cookie transport, rely on cookie credentials only for
refreshandlogout; do not send fallback bodysessionIdvalues - when using cross-site cookie transport, send frontend requests with
credentials: 'include', enable credentialed CORS, useSameSite=None; Secure, and allow only known frontend origins viatrustedOrigins - keep cookie-authenticated CSRF protection fail-closed for every
SameSitemode by requiring anOriginor validReferermatchingbackendOriginortrustedOrigins - protect any app-issued local access token with a short lifetime, such as 5 to 15 minutes
- treat upstream OAuth
expires_in, local access-token lifetime, and vault-session expiry as separate policies - use Redis or MongoDB, not the memory store, for production or multi-instance deployments
- monitor
onErrorand other hooks so failed callback, refresh, and logout flows are visible in private server logs without returning raw provider, token, store, or hook errors to clients
Store Packages
@web-ts-toolkit/express-oidc-vault-memory-store@web-ts-toolkit/express-oidc-vault-redis-store@web-ts-toolkit/express-oidc-vault-mongodb-store
