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

@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

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, and nonce
  • 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 express

Frontend Storage Policy

Default browser-side transport:

  • mirror sessionId into sessionStorage
  • keep accessToken in memory only
  • do not store either value in localStorage

Why:

  • sessionId needs to survive page refresh so the frontend can call POST /auth/oidc/refresh during app bootstrap
  • accessToken is the credential used on normal API requests and should remain non-persistent in the browser
  • sessionStorage is still readable by JavaScript, so it reduces persistence but does not remove XSS risk

Optional alternative:

  • set sessionTransport: 'cookie'
  • store sessionId in an HttpOnly browser cookie instead of sessionStorage
  • keep accessToken in 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.

  • exchange and refresh responses include sessionId
  • the frontend stores sessionId, typically in sessionStorage
  • the frontend sends sessionId back in the JSON body for refresh and logout
  • refresh and logout do not read session cookies in this mode

sessionTransport: 'cookie'

This mode stores sessionId in a backend-managed cookie.

  • exchange sets the session cookie and does not need to return sessionId in the JSON body
  • refresh reads the cookie, rotates the session, and updates the cookie
  • logout reads the cookie and clears it
  • refresh and logout require the cookie and reject body-only sessionId values
  • the frontend does not need to keep sessionId in sessionStorage

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.name
  • cookie.deploymentMode: 'same-origin' | 'same-site' | 'cross-site'
  • cookie.sameSite: 'lax' | 'strict' | 'none'
  • cookie.secure
  • cookie.domain
  • cookie.path
  • trustedOrigins: browser origins allowed to call cookie-authenticated refresh and logout; 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_session
  • path: /
  • httpOnly: true
  • deploymentMode: same-origin
  • sameSite: lax unless deploymentMode is cross-site
  • secure: true when sameSite resolves to none or deploymentMode is cross-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:

  1. Read sessionId from sessionStorage.
  2. If present, call POST /auth/oidc/refresh immediately.
  3. If refresh succeeds, replace the stored sessionId with the rotated value and keep the returned accessToken in memory only.
  4. If refresh fails, clear sessionStorage and treat the user as logged out.

If you use sessionTransport: 'cookie', the frontend boot flow becomes simpler:

  1. Keep accessToken in memory only.
  2. Call POST /auth/oidc/refresh on app startup.
  3. Let the backend read and rotate the session cookie.
  4. Clear in-memory auth state if refresh fails.

Endpoints

The core middleware exposes these endpoints under a configurable base path:

  • GET /auth/oidc/login
  • GET /auth/oidc/callback
  • POST /auth/oidc/exchange
  • POST /auth/oidc/refresh
  • POST /auth/oidc/logout
  • POST /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_LARGE
  • OIDC_VAULT_REQUEST_BODY_PARAMETER_LIMIT_EXCEEDED
  • OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_ENCODING
  • OIDC_VAULT_MALFORMED_REQUEST_BODY
  • OIDC_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:

  1. Redirect the user to GET /auth/oidc/login when they click login.
  2. On the frontend callback route, read code from the query string and call exchangeCallbackCode(code).
  3. Remove the code query parameter from the address bar after a successful exchange.
  4. Call bootstrapAuth() once during app startup so a reloaded tab can recover from sessionStorage.
  5. 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=None and Secure
  • set trustedOrigins so 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 sid when 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_PATH
  • DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS
  • DEFAULT_EXCHANGE_CODE_TTL_MS
  • DEFAULT_OIDC_SCOPES
  • OIDC_VAULT_ROUTE_PATHS
  • normalizeOidcVaultBasePath(...)
  • resolveOidcVaultConfig(...)
  • resolveOidcVaultConfigFromEnv(...)
  • type OidcVaultOptions
  • type OidcVaultHooks
  • type OidcVaultStoreProvider
  • type OidcVaultSession
  • type OidcVaultAccessTokenValidator
  • type OidcVaultAuthenticatedRequest
  • type OidcVaultJwtAccessTokenValidatorOptions
  • type OidcVaultTokenIssuer

Store Provider Contract

The built-in memory, Redis, and MongoDB store packages share the same behavioral contract.

  • createAuthorizationTransaction, createExchangeCode, and createSession are deliberate upserts keyed by state, code, and sessionId.
  • 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 returns false without storing the JTI.
  • rotateSession requires an existing source session and a distinct unused target sessionId. Equivalent missing-source, same-ID, and existing-target rotation conflicts throw OidcVaultStoreConflictError without 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_token so logout can call the upstream end-session endpoint with id_token_hint.
  • sessionId should rotate on refresh.
  • The frontend should deduplicate concurrent refresh calls so only one refresh is in-flight at a time.
  • Upstream OAuth expires_in describes the upstream access token only. It does not set OidcVaultSession.expiresAt or 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_ISSUER is 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 providerRequestTimeoutMs on createOidcVaultMiddleware(...) 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, issuer is 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, and iat; azp must match clientId when present and is required for multi-audience ID tokens.
  • UserInfo responses must include a sub matching 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 new id_token, its sub must match the current session subject.
  • backendOrigin is the public origin registered with your OIDC provider for the backend callback URI. The middleware normalizes it to an origin and uses it for /callback redirect URIs instead of trusting request Host headers.
  • frontendRedirectUri is the default browser return target after the backend completes the upstream callback.
  • postLogoutRedirectUri is 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 from frontendRedirectUri when that exact URL is provider-registered.
  • backchannel logout revokes local sessions by upstream sid when available, otherwise by sub

Config Helpers

import { resolveOidcVaultConfigFromEnv } from '@web-ts-toolkit/express-oidc-vault';

const config = resolveOidcVaultConfigFromEnv(process.env);

Resolution behavior:

  • if only OIDC_ISSUER is 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, and OIDC_JWKS_URI
  • OIDC_SCOPES defaults to openid 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:

  • authorizationEndpoint
  • tokenEndpoint
  • jwksUri
  • clientId
  • issuer

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.token
  • req.auth.subject
  • req.auth.sessionId
  • req.auth.scope
  • req.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.subject
  • sid -> auth.sessionId
  • scope -> 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, and onBeforeLogout are 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, and onLogout are post-commit notification hooks. Their failures are reported to onError but do not change a successful callback redirect, refresh response, logout response, or already-committed store mutation.
  • use onSessionCreated for local user provisioning or last-login updates
  • use onSessionRefreshed for audit logs and session rotation tracing
  • use onLogout to revoke local app state that depends on the session
  • use onError for 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 sessionId in sessionStorage and keep accessToken in memory only
  • never store the upstream refresh token in the browser
  • use HTTPS end-to-end for frontend, backend, and IdP communication
  • set backendOrigin to the public backend origin registered with the provider; do not rely on request host or proxy headers for callback URL construction
  • keep the default requestBodyLimit of 16kb unless a provider requires a larger form-encoded backchannel logout_token
  • treat XSS prevention as critical because sessionStorage is still readable by JavaScript
  • enable a strict Content Security Policy and avoid unsafe inline scripts
  • rotate sessionId on refresh and overwrite the mirrored sessionStorage value immediately
  • clear in-memory auth state and sessionStorage on logout, even if upstream logout fails
  • set postLogoutRedirectUri explicitly so logout destinations stay predictable
  • when using cookie transport, rely on cookie credentials only for refresh and logout; do not send fallback body sessionId values
  • when using cross-site cookie transport, send frontend requests with credentials: 'include', enable credentialed CORS, use SameSite=None; Secure, and allow only known frontend origins via trustedOrigins
  • keep cookie-authenticated CSRF protection fail-closed for every SameSite mode by requiring an Origin or valid Referer matching backendOrigin or trustedOrigins
  • 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 onError and 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