@web-ts-toolkit/express-oidc-vault 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,892 @@
1
+ # `@web-ts-toolkit/express-oidc-vault`
2
+
3
+ Cookie-free OIDC session middleware for Express with server-side storage of upstream refresh tokens and logout-capable `id_token`s.
4
+
5
+ ## Status
6
+
7
+ This package now implements the core cookie-free OIDC flow.
8
+
9
+ Current implementation includes:
10
+
11
+ - the core middleware factory
12
+ - OIDC login redirect with PKCE, `state`, and `nonce`
13
+ - callback token exchange, server-side session creation, and one-time local exchange codes
14
+ - session refresh with session ID rotation
15
+ - upstream logout URL generation using stored `id_token`
16
+ - OIDC backchannel logout handling via `logout_token`
17
+ - public TypeScript interfaces for hooks, sessions, config helpers, and store providers
18
+
19
+ ## Installation
20
+
21
+ ```sh
22
+ pnpm add @web-ts-toolkit/express-oidc-vault express
23
+ ```
24
+
25
+ ## Frontend Storage Policy
26
+
27
+ Default browser-side transport:
28
+
29
+ - mirror `sessionId` into `sessionStorage`
30
+ - keep `accessToken` in memory only
31
+ - do not store either value in `localStorage`
32
+
33
+ Why:
34
+
35
+ - `sessionId` needs to survive page refresh so the frontend can call `POST /auth/oidc/refresh` during app bootstrap
36
+ - `accessToken` is the credential used on normal API requests and should remain non-persistent in the browser
37
+ - `sessionStorage` is still readable by JavaScript, so it reduces persistence but does not remove XSS risk
38
+
39
+ Optional alternative:
40
+
41
+ - set `sessionTransport: 'cookie'`
42
+ - store `sessionId` in an `HttpOnly` browser cookie instead of `sessionStorage`
43
+ - keep `accessToken` in memory only
44
+
45
+ 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.
46
+
47
+ ## Session Transport Modes
48
+
49
+ The package supports two ways to move the opaque `sessionId` between browser and backend.
50
+
51
+ ### `sessionTransport: 'body'`
52
+
53
+ This is the default mode.
54
+
55
+ - `exchange` and `refresh` responses include `sessionId`
56
+ - the frontend stores `sessionId`, typically in `sessionStorage`
57
+ - the frontend sends `sessionId` back in the JSON body for `refresh` and `logout`
58
+
59
+ ### `sessionTransport: 'cookie'`
60
+
61
+ This mode stores `sessionId` in a backend-managed cookie.
62
+
63
+ - `exchange` sets the session cookie and does not need to return `sessionId` in the JSON body
64
+ - `refresh` reads the cookie, rotates the session, and updates the cookie
65
+ - `logout` reads the cookie and clears it
66
+ - the frontend does not need to keep `sessionId` in `sessionStorage`
67
+
68
+ 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.
69
+
70
+ Available cookie options:
71
+
72
+ - `cookie.name`
73
+ - `cookie.deploymentMode`: `'same-origin' | 'same-site' | 'cross-site'`
74
+ - `cookie.sameSite`: `'lax' | 'strict' | 'none'`
75
+ - `cookie.secure`
76
+ - `cookie.domain`
77
+ - `cookie.path`
78
+ - `cookie.httpOnly`
79
+ - `trustedOrigins`: required when cross-site cookie transport is enabled
80
+
81
+ Default cookie behavior:
82
+
83
+ - `name`: `oidc_vault_session`
84
+ - `path`: `/`
85
+ - `httpOnly`: `true`
86
+ - `deploymentMode`: `same-origin`
87
+ - `sameSite`: `lax` unless `deploymentMode` is `cross-site`
88
+ - `secure`: `true` when `sameSite` resolves to `none` or `deploymentMode` is `cross-site`
89
+
90
+ Recommended frontend boot flow:
91
+
92
+ 1. Read `sessionId` from `sessionStorage`.
93
+ 2. If present, call `POST /auth/oidc/refresh` immediately.
94
+ 3. If refresh succeeds, replace the stored `sessionId` with the rotated value and keep the returned `accessToken` in memory only.
95
+ 4. If refresh fails, clear `sessionStorage` and treat the user as logged out.
96
+
97
+ If you use `sessionTransport: 'cookie'`, the frontend boot flow becomes simpler:
98
+
99
+ 1. Keep `accessToken` in memory only.
100
+ 2. Call `POST /auth/oidc/refresh` on app startup.
101
+ 3. Let the backend read and rotate the session cookie.
102
+ 4. Clear in-memory auth state if refresh fails.
103
+
104
+ ## Endpoints
105
+
106
+ The core middleware exposes these endpoints under a configurable base path:
107
+
108
+ - `GET /auth/oidc/login`
109
+ - `GET /auth/oidc/callback`
110
+ - `POST /auth/oidc/exchange`
111
+ - `POST /auth/oidc/refresh`
112
+ - `POST /auth/oidc/logout`
113
+ - `POST /auth/oidc/backchannel-logout`
114
+
115
+ ## Quick Start
116
+
117
+ ```ts
118
+ import express from 'express';
119
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
120
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
121
+
122
+ const app = express();
123
+ const storeProvider = createMemoryOidcVaultStore();
124
+
125
+ app.use(
126
+ createOidcVaultMiddleware({
127
+ basePath: '/auth/oidc',
128
+ config: {
129
+ issuer: process.env.OIDC_ISSUER,
130
+ clientId: process.env.OIDC_CLIENT_ID,
131
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
132
+ },
133
+ frontendRedirectUri: 'https://frontend.example.com/callback',
134
+ storeProvider,
135
+ }),
136
+ );
137
+ ```
138
+
139
+ Use the memory store for local development and tests. For production deployments, prefer a Redis or MongoDB store provider.
140
+
141
+ ## Frontend Integration Example
142
+
143
+ 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.
144
+
145
+ ```ts
146
+ type AuthState = {
147
+ accessToken: string | null;
148
+ sessionId: string | null;
149
+ };
150
+
151
+ const authState: AuthState = {
152
+ accessToken: null,
153
+ sessionId: sessionStorage.getItem('sessionId'),
154
+ };
155
+
156
+ let refreshPromise: Promise<void> | null = null;
157
+
158
+ function persistSessionId(sessionId: string | null): void {
159
+ authState.sessionId = sessionId;
160
+
161
+ if (sessionId) {
162
+ sessionStorage.setItem('sessionId', sessionId);
163
+ } else {
164
+ sessionStorage.removeItem('sessionId');
165
+ }
166
+ }
167
+
168
+ function setAuthState(payload: { accessToken?: string; sessionId: string }): void {
169
+ authState.accessToken = payload.accessToken ?? null;
170
+ persistSessionId(payload.sessionId);
171
+ }
172
+
173
+ function clearAuthState(): void {
174
+ authState.accessToken = null;
175
+ persistSessionId(null);
176
+ }
177
+
178
+ async function exchangeCallbackCode(code: string): Promise<void> {
179
+ const response = await fetch('/auth/oidc/exchange', {
180
+ method: 'POST',
181
+ headers: { 'content-type': 'application/json' },
182
+ body: JSON.stringify({ code }),
183
+ });
184
+
185
+ if (!response.ok) {
186
+ clearAuthState();
187
+ throw new Error('OIDC code exchange failed.');
188
+ }
189
+
190
+ setAuthState(await response.json());
191
+ }
192
+
193
+ async function refreshAuthState(): Promise<void> {
194
+ if (!authState.sessionId) {
195
+ clearAuthState();
196
+ return;
197
+ }
198
+
199
+ const response = await fetch('/auth/oidc/refresh', {
200
+ method: 'POST',
201
+ headers: { 'content-type': 'application/json' },
202
+ body: JSON.stringify({ sessionId: authState.sessionId }),
203
+ });
204
+
205
+ if (!response.ok) {
206
+ clearAuthState();
207
+ throw new Error('OIDC refresh failed.');
208
+ }
209
+
210
+ setAuthState(await response.json());
211
+ }
212
+
213
+ async function ensureFreshAccessToken(): Promise<void> {
214
+ if (!refreshPromise) {
215
+ refreshPromise = refreshAuthState().finally(() => {
216
+ refreshPromise = null;
217
+ });
218
+ }
219
+
220
+ await refreshPromise;
221
+ }
222
+
223
+ async function fetchWithAuth(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
224
+ const headers = new Headers(init.headers);
225
+
226
+ if (authState.accessToken) {
227
+ headers.set('authorization', `Bearer ${authState.accessToken}`);
228
+ }
229
+
230
+ let response = await fetch(input, { ...init, headers });
231
+
232
+ if (response.status !== 401 || !authState.sessionId) {
233
+ return response;
234
+ }
235
+
236
+ await ensureFreshAccessToken();
237
+
238
+ const retryHeaders = new Headers(init.headers);
239
+
240
+ if (authState.accessToken) {
241
+ retryHeaders.set('authorization', `Bearer ${authState.accessToken}`);
242
+ }
243
+
244
+ response = await fetch(input, { ...init, headers: retryHeaders });
245
+ return response;
246
+ }
247
+
248
+ async function bootstrapAuth(): Promise<void> {
249
+ if (!authState.sessionId) {
250
+ return;
251
+ }
252
+
253
+ try {
254
+ await refreshAuthState();
255
+ } catch {
256
+ clearAuthState();
257
+ }
258
+ }
259
+
260
+ async function logout(): Promise<void> {
261
+ const sessionId = authState.sessionId;
262
+
263
+ clearAuthState();
264
+
265
+ if (!sessionId) {
266
+ return;
267
+ }
268
+
269
+ const response = await fetch('/auth/oidc/logout', {
270
+ method: 'POST',
271
+ headers: { 'content-type': 'application/json' },
272
+ body: JSON.stringify({ sessionId }),
273
+ });
274
+
275
+ if (!response.ok) {
276
+ return;
277
+ }
278
+
279
+ const payload = (await response.json()) as { upstreamLogoutUrl?: string };
280
+
281
+ if (payload.upstreamLogoutUrl) {
282
+ window.location.assign(payload.upstreamLogoutUrl);
283
+ }
284
+ }
285
+ ```
286
+
287
+ Recommended browser flow:
288
+
289
+ 1. Redirect the user to `GET /auth/oidc/login` when they click login.
290
+ 2. On the frontend callback route, read `code` from the query string and call `exchangeCallbackCode(code)`.
291
+ 3. Remove the `code` query parameter from the address bar after a successful exchange.
292
+ 4. Call `bootstrapAuth()` once during app startup so a reloaded tab can recover from `sessionStorage`.
293
+ 5. Use `fetchWithAuth(...)` or equivalent interceptor logic for normal API requests.
294
+
295
+ ### Cookie transport frontend example
296
+
297
+ When `sessionTransport` is set to `'cookie'`, the frontend no longer needs to store `sessionId`.
298
+
299
+ ```ts
300
+ type AuthState = {
301
+ accessToken: string | null;
302
+ };
303
+
304
+ const authState: AuthState = {
305
+ accessToken: null,
306
+ };
307
+
308
+ let refreshPromise: Promise<void> | null = null;
309
+
310
+ function setAuthState(payload: { accessToken?: string }): void {
311
+ authState.accessToken = payload.accessToken ?? null;
312
+ }
313
+
314
+ function clearAuthState(): void {
315
+ authState.accessToken = null;
316
+ }
317
+
318
+ async function refreshAuthState(): Promise<void> {
319
+ const response = await fetch('/auth/oidc/refresh', {
320
+ method: 'POST',
321
+ credentials: 'include',
322
+ });
323
+
324
+ if (!response.ok) {
325
+ clearAuthState();
326
+ throw new Error('OIDC refresh failed.');
327
+ }
328
+
329
+ setAuthState(await response.json());
330
+ }
331
+
332
+ async function ensureFreshAccessToken(): Promise<void> {
333
+ if (!refreshPromise) {
334
+ refreshPromise = refreshAuthState().finally(() => {
335
+ refreshPromise = null;
336
+ });
337
+ }
338
+
339
+ await refreshPromise;
340
+ }
341
+
342
+ async function exchangeCallbackCode(code: string): Promise<void> {
343
+ const response = await fetch('/auth/oidc/exchange', {
344
+ method: 'POST',
345
+ headers: { 'content-type': 'application/json' },
346
+ credentials: 'include',
347
+ body: JSON.stringify({ code }),
348
+ });
349
+
350
+ if (!response.ok) {
351
+ clearAuthState();
352
+ throw new Error('OIDC code exchange failed.');
353
+ }
354
+
355
+ setAuthState(await response.json());
356
+ }
357
+ ```
358
+
359
+ For cross-origin cookie deployments, also remember:
360
+
361
+ - the frontend requests must use `credentials: 'include'`
362
+ - the backend CORS policy must allow credentials
363
+ - the cookie typically needs `SameSite=None` and `Secure`
364
+ - set `trustedOrigins` so refresh and logout only accept requests from your frontend origin
365
+
366
+ ## Backchannel Logout
367
+
368
+ The package supports OIDC backchannel logout at:
369
+
370
+ - `POST /auth/oidc/backchannel-logout`
371
+
372
+ Expected request shape:
373
+
374
+ - `application/x-www-form-urlencoded`
375
+ - field: `logout_token=<provider-signed-jwt>`
376
+
377
+ The middleware validates the `logout_token` against the provider JWKS and then revokes matching local sessions by:
378
+
379
+ - upstream `sid` when present
380
+ - otherwise `sub`
381
+
382
+ Example request:
383
+
384
+ ```ts
385
+ await fetch('/auth/oidc/backchannel-logout', {
386
+ method: 'POST',
387
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
388
+ body: new URLSearchParams({
389
+ logout_token: '<provider-signed-logout-token>',
390
+ }),
391
+ });
392
+ ```
393
+
394
+ Example response:
395
+
396
+ ```json
397
+ {
398
+ "loggedOut": true,
399
+ "revokedSessions": 1
400
+ }
401
+ ```
402
+
403
+ Notes:
404
+
405
+ - this route is intended for the IdP to call directly, not the browser
406
+ - cookie transport does not change how backchannel logout works
407
+ - 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
408
+
409
+ ## Backend Wiring Examples
410
+
411
+ Use one of the store packages depending on your deployment model.
412
+
413
+ ### Memory store
414
+
415
+ ```ts
416
+ import express from 'express';
417
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
418
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
419
+
420
+ const app = express();
421
+
422
+ app.use(
423
+ createOidcVaultMiddleware({
424
+ basePath: '/auth/oidc',
425
+ config: {
426
+ issuer: process.env.OIDC_ISSUER,
427
+ clientId: process.env.OIDC_CLIENT_ID,
428
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
429
+ },
430
+ frontendRedirectUri: 'https://frontend.example.com/callback',
431
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
432
+ storeProvider: createMemoryOidcVaultStore(),
433
+ }),
434
+ );
435
+ ```
436
+
437
+ ### Redis store
438
+
439
+ ```ts
440
+ import express from 'express';
441
+ import { createClient } from 'redis';
442
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
443
+ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
444
+
445
+ const app = express();
446
+ const redis = createClient({ url: process.env.REDIS_URL });
447
+
448
+ await redis.connect();
449
+
450
+ app.use(
451
+ createOidcVaultMiddleware({
452
+ basePath: '/auth/oidc',
453
+ config: {
454
+ issuer: process.env.OIDC_ISSUER,
455
+ clientId: process.env.OIDC_CLIENT_ID,
456
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
457
+ },
458
+ frontendRedirectUri: 'https://frontend.example.com/callback',
459
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
460
+ storeProvider: createRedisOidcVaultStore({
461
+ client: redis,
462
+ keyPrefix: 'oidc-vault',
463
+ }),
464
+ }),
465
+ );
466
+ ```
467
+
468
+ ### MongoDB store
469
+
470
+ ```ts
471
+ import express from 'express';
472
+ import { MongoClient } from 'mongodb';
473
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
474
+ import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store';
475
+
476
+ const app = express();
477
+ const mongo = new MongoClient(process.env.MONGODB_URI!);
478
+
479
+ await mongo.connect();
480
+
481
+ app.use(
482
+ createOidcVaultMiddleware({
483
+ basePath: '/auth/oidc',
484
+ config: {
485
+ issuer: process.env.OIDC_ISSUER,
486
+ clientId: process.env.OIDC_CLIENT_ID,
487
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
488
+ },
489
+ frontendRedirectUri: 'https://frontend.example.com/callback',
490
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
491
+ storeProvider: createMongoOidcVaultStore({
492
+ db: mongo.db('app-auth'),
493
+ }),
494
+ }),
495
+ );
496
+ ```
497
+
498
+ ### Cookie transport
499
+
500
+ ```ts
501
+ import express from 'express';
502
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
503
+ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
504
+ import { createClient } from 'redis';
505
+
506
+ const app = express();
507
+ const redis = createClient({ url: process.env.REDIS_URL });
508
+
509
+ await redis.connect();
510
+
511
+ app.use(
512
+ createOidcVaultMiddleware({
513
+ basePath: '/auth/oidc',
514
+ config: {
515
+ issuer: process.env.OIDC_ISSUER,
516
+ clientId: process.env.OIDC_CLIENT_ID,
517
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
518
+ },
519
+ frontendRedirectUri: 'https://frontend.example.com/callback',
520
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
521
+ sessionTransport: 'cookie',
522
+ cookie: {
523
+ deploymentMode: 'same-site',
524
+ domain: '.example.com',
525
+ secure: true,
526
+ },
527
+ trustedOrigins: ['https://frontend.example.com'],
528
+ storeProvider: createRedisOidcVaultStore({
529
+ client: redis,
530
+ keyPrefix: 'oidc-vault',
531
+ }),
532
+ }),
533
+ );
534
+ ```
535
+
536
+ ## Main Exports
537
+
538
+ - `createOidcVaultAccessTokenMiddleware(...)`
539
+ - `createOidcVaultJwtAccessTokenValidator(...)`
540
+ - `createOidcVaultMiddleware(...)`
541
+ - `DEFAULT_OIDC_VAULT_BASE_PATH`
542
+ - `DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS`
543
+ - `DEFAULT_EXCHANGE_CODE_TTL_MS`
544
+ - `DEFAULT_OIDC_SCOPES`
545
+ - `OIDC_VAULT_ROUTE_PATHS`
546
+ - `normalizeOidcVaultBasePath(...)`
547
+ - `resolveOidcVaultConfig(...)`
548
+ - `resolveOidcVaultConfigFromEnv(...)`
549
+ - `type OidcVaultOptions`
550
+ - `type OidcVaultHooks`
551
+ - `type OidcVaultStoreProvider`
552
+ - `type OidcVaultSession`
553
+ - `type OidcVaultAccessTokenValidator`
554
+ - `type OidcVaultAuthenticatedRequest`
555
+ - `type OidcVaultJwtAccessTokenValidatorOptions`
556
+ - `type OidcVaultTokenIssuer`
557
+
558
+ ## Key Integration Notes
559
+
560
+ - The browser should never receive the upstream refresh token.
561
+ - The backend should store the latest upstream `id_token` so logout can call the upstream end-session endpoint with `id_token_hint`.
562
+ - `sessionId` should rotate on refresh.
563
+ - The frontend should deduplicate concurrent refresh calls so only one refresh is in-flight at a time.
564
+ - If `OIDC_ISSUER` is configured, issuer discovery should override explicit endpoint URLs.
565
+ - `frontendRedirectUri` is the default browser return target after the backend completes the upstream callback.
566
+ - `postLogoutRedirectUri` is added to the upstream logout URL when configured.
567
+ - backchannel logout revokes local sessions by upstream `sid` when available, otherwise by `sub`
568
+
569
+ ## Config Helpers
570
+
571
+ ```ts
572
+ import { resolveOidcVaultConfigFromEnv } from '@web-ts-toolkit/express-oidc-vault';
573
+
574
+ const config = resolveOidcVaultConfigFromEnv(process.env);
575
+ ```
576
+
577
+ Resolution behavior:
578
+
579
+ - if `OIDC_ISSUER` is set, discovery mode wins and endpoint-specific env vars are ignored
580
+ - if `OIDC_ISSUER` is not set, manual mode requires `OIDC_AUTHORIZATION_ENDPOINT`, `OIDC_TOKEN_ENDPOINT`, and `OIDC_JWKS_URI`
581
+ - `OIDC_SCOPES` defaults to `openid email profile`
582
+
583
+ ### Manual endpoint mode
584
+
585
+ If your provider metadata is not discoverable from `OIDC_ISSUER`, configure the endpoints directly.
586
+
587
+ ```ts
588
+ import express from 'express';
589
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
590
+ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
591
+ import { createClient } from 'redis';
592
+
593
+ const app = express();
594
+ const redis = createClient({ url: process.env.REDIS_URL });
595
+
596
+ await redis.connect();
597
+
598
+ app.use(
599
+ createOidcVaultMiddleware({
600
+ basePath: '/auth/oidc',
601
+ config: {
602
+ authorizationEndpoint: process.env.OIDC_AUTHORIZATION_ENDPOINT,
603
+ tokenEndpoint: process.env.OIDC_TOKEN_ENDPOINT,
604
+ userInfoEndpoint: process.env.OIDC_USERINFO_ENDPOINT,
605
+ jwksUri: process.env.OIDC_JWKS_URI,
606
+ endSessionEndpoint: process.env.OIDC_END_SESSION_ENDPOINT,
607
+ clientId: process.env.OIDC_CLIENT_ID,
608
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
609
+ scopes: process.env.OIDC_SCOPES,
610
+ },
611
+ frontendRedirectUri: 'https://frontend.example.com/callback',
612
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
613
+ storeProvider: createRedisOidcVaultStore({
614
+ client: redis,
615
+ keyPrefix: 'oidc-vault',
616
+ }),
617
+ }),
618
+ );
619
+ ```
620
+
621
+ In manual mode, the minimum required config is:
622
+
623
+ - `authorizationEndpoint`
624
+ - `tokenEndpoint`
625
+ - `jwksUri`
626
+ - `clientId`
627
+
628
+ `userInfoEndpoint` and `endSessionEndpoint` are optional but recommended when your provider supports them.
629
+
630
+ ## Local Access Token Example
631
+
632
+ The middleware can return a local backend access token during `exchange` and `refresh` by providing a `tokenIssuer`.
633
+
634
+ ```ts
635
+ import express from 'express';
636
+ import { SignJWT } from 'jose';
637
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
638
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
639
+
640
+ const app = express();
641
+ const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
642
+
643
+ app.use(
644
+ createOidcVaultMiddleware({
645
+ basePath: '/auth/oidc',
646
+ config: {
647
+ issuer: process.env.OIDC_ISSUER,
648
+ clientId: process.env.OIDC_CLIENT_ID,
649
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
650
+ },
651
+ frontendRedirectUri: 'https://frontend.example.com/callback',
652
+ postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
653
+ storeProvider: createMemoryOidcVaultStore(),
654
+ tokenIssuer: {
655
+ async issue({ session }) {
656
+ const accessToken = await new SignJWT({
657
+ sub: session.subject,
658
+ sid: session.sessionId,
659
+ scope: session.scope,
660
+ })
661
+ .setProtectedHeader({ alg: 'HS256' })
662
+ .setIssuedAt()
663
+ .setExpirationTime('15m')
664
+ .sign(jwtSecret);
665
+
666
+ return {
667
+ accessToken,
668
+ expiresIn: 900,
669
+ tokenType: 'Bearer',
670
+ };
671
+ },
672
+ },
673
+ }),
674
+ );
675
+ ```
676
+
677
+ That local token is separate from the upstream IdP access token:
678
+
679
+ - the upstream refresh token stays in the server-side vault
680
+ - the frontend receives only the app-issued access token and the opaque `sessionId`
681
+ - the app-issued access token can contain only the claims your backend APIs actually need
682
+
683
+ ## Access Token Validation Middleware
684
+
685
+ The OIDC route/session middleware and the normal API bearer-token middleware are separate concerns.
686
+
687
+ Use `createOidcVaultMiddleware(...)` for:
688
+
689
+ - login
690
+ - callback
691
+ - exchange
692
+ - refresh
693
+ - logout
694
+
695
+ Use `createOidcVaultAccessTokenMiddleware(...)` for:
696
+
697
+ - validating the app-issued local access token on protected API routes
698
+ - attaching authenticated auth context to `req.auth`
699
+ - rejecting missing, malformed, invalid, or expired bearer tokens with `401`
700
+
701
+ ```ts
702
+ import express from 'express';
703
+ import { createOidcVaultAccessTokenMiddleware } from '@web-ts-toolkit/express-oidc-vault';
704
+ import { jwtVerify } from 'jose';
705
+
706
+ const app = express();
707
+ const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
708
+
709
+ app.get(
710
+ '/api/me',
711
+ createOidcVaultAccessTokenMiddleware({
712
+ validator: {
713
+ async validate(token) {
714
+ const result = await jwtVerify(token, jwtSecret, {
715
+ algorithms: ['HS256'],
716
+ });
717
+
718
+ return {
719
+ subject: String(result.payload.sub),
720
+ sessionId: typeof result.payload.sid === 'string' ? result.payload.sid : undefined,
721
+ scope: typeof result.payload.scope === 'string' ? result.payload.scope : undefined,
722
+ claims: result.payload as Record<string, unknown>,
723
+ };
724
+ },
725
+ },
726
+ }),
727
+ (req, res) => {
728
+ res.json({
729
+ subject: req.auth?.subject,
730
+ sessionId: req.auth?.sessionId,
731
+ scope: req.auth?.scope,
732
+ });
733
+ },
734
+ );
735
+ ```
736
+
737
+ Returned auth context shape:
738
+
739
+ - `req.auth.token`
740
+ - `req.auth.subject`
741
+ - `req.auth.sessionId`
742
+ - `req.auth.scope`
743
+ - `req.auth.claims`
744
+
745
+ The package augments Express request typing so `req.auth` is available without casting in TypeScript route handlers.
746
+
747
+ ### JWT validator helper
748
+
749
+ If your local access token is a JWT, you can avoid rewriting the same `jwtVerify(...)` adapter each time.
750
+
751
+ ```ts
752
+ import {
753
+ createOidcVaultAccessTokenMiddleware,
754
+ createOidcVaultJwtAccessTokenValidator,
755
+ } from '@web-ts-toolkit/express-oidc-vault';
756
+
757
+ const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
758
+
759
+ app.get(
760
+ '/api/me',
761
+ createOidcVaultAccessTokenMiddleware({
762
+ validator: createOidcVaultJwtAccessTokenValidator({
763
+ key: jwtSecret,
764
+ issuer: 'https://api.example.com',
765
+ audience: 'api-audience',
766
+ algorithms: ['HS256'],
767
+ }),
768
+ }),
769
+ (req, res) => {
770
+ res.json({
771
+ subject: req.auth?.subject,
772
+ sessionId: req.auth?.sessionId,
773
+ scope: req.auth?.scope,
774
+ });
775
+ },
776
+ );
777
+ ```
778
+
779
+ Default JWT claim mapping:
780
+
781
+ - `sub` -> `auth.subject`
782
+ - `sid` -> `auth.sessionId`
783
+ - `scope` -> `auth.scope`
784
+ - full verified payload -> `auth.claims`
785
+
786
+ If you need a custom mapping, pass `mapClaims(...)` to `createOidcVaultJwtAccessTokenValidator(...)`.
787
+
788
+ Recommended separation:
789
+
790
+ - keep login/session lifecycle in `createOidcVaultMiddleware(...)`
791
+ - keep normal API bearer validation in `createOidcVaultAccessTokenMiddleware(...)`
792
+ - keep authorization decisions outside the validator middleware
793
+
794
+ ## Hook Examples
795
+
796
+ Hooks let the app observe or extend the core OIDC flow without forking the middleware.
797
+
798
+ ### Audit and user provisioning hooks
799
+
800
+ ```ts
801
+ import express from 'express';
802
+ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
803
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
804
+
805
+ const app = express();
806
+
807
+ app.use(
808
+ createOidcVaultMiddleware({
809
+ basePath: '/auth/oidc',
810
+ config: {
811
+ issuer: process.env.OIDC_ISSUER,
812
+ clientId: process.env.OIDC_CLIENT_ID,
813
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
814
+ },
815
+ frontendRedirectUri: 'https://frontend.example.com/callback',
816
+ storeProvider: createMemoryOidcVaultStore(),
817
+ hooks: {
818
+ async onLoginStart({ req }) {
819
+ console.log('OIDC login started', {
820
+ ip: req.ip,
821
+ userAgent: req.get('user-agent'),
822
+ });
823
+ },
824
+ async onSessionCreated({ session }) {
825
+ if (!session?.user) {
826
+ return;
827
+ }
828
+
829
+ await upsertLocalUser({
830
+ oidcSubject: session.subject,
831
+ email: typeof session.user.email === 'string' ? session.user.email : undefined,
832
+ displayName: typeof session.user.name === 'string' ? session.user.name : undefined,
833
+ });
834
+ },
835
+ async onSessionRefreshed({ session, metadata }) {
836
+ console.log('OIDC session rotated', {
837
+ previousSessionId: metadata?.previousSessionId,
838
+ nextSessionId: session?.sessionId,
839
+ });
840
+ },
841
+ async onLogout({ session, metadata }) {
842
+ console.log('OIDC logout completed', {
843
+ subject: session?.subject,
844
+ upstreamLogoutUrl: metadata?.upstreamLogoutUrl,
845
+ });
846
+ },
847
+ async onError({ error, route, req }) {
848
+ console.error('OIDC vault error', {
849
+ route,
850
+ path: req.originalUrl,
851
+ error,
852
+ });
853
+ },
854
+ },
855
+ }),
856
+ );
857
+
858
+ async function upsertLocalUser(input: { oidcSubject: string; email?: string; displayName?: string }): Promise<void> {
859
+ // replace with application-specific persistence logic
860
+ console.log('upsertLocalUser', input);
861
+ }
862
+ ```
863
+
864
+ Recommended hook usage:
865
+
866
+ - use `onSessionCreated` for local user provisioning or last-login updates
867
+ - use `onSessionRefreshed` for audit logs and session rotation tracing
868
+ - use `onLogout` to revoke local app state that depends on the session
869
+ - use `onError` for structured logging and alerting
870
+
871
+ ## Security Checklist
872
+
873
+ Use these defaults when deploying the package:
874
+
875
+ - keep `sessionId` in `sessionStorage` and keep `accessToken` in memory only
876
+ - never store the upstream refresh token in the browser
877
+ - use HTTPS end-to-end for frontend, backend, and IdP communication
878
+ - treat XSS prevention as critical because `sessionStorage` is still readable by JavaScript
879
+ - enable a strict Content Security Policy and avoid unsafe inline scripts
880
+ - rotate `sessionId` on refresh and overwrite the mirrored `sessionStorage` value immediately
881
+ - clear in-memory auth state and `sessionStorage` on logout, even if upstream logout fails
882
+ - set `postLogoutRedirectUri` explicitly so logout destinations stay predictable
883
+ - when using cross-site cookie transport, allow only your known frontend origins via `trustedOrigins`
884
+ - protect any app-issued local access token with a short lifetime, such as 5 to 15 minutes
885
+ - use Redis or MongoDB, not the memory store, for production or multi-instance deployments
886
+ - monitor `onError` and other hooks so failed callback, refresh, and logout flows are visible in logs
887
+
888
+ ## Store Packages
889
+
890
+ - `@web-ts-toolkit/express-oidc-vault-memory-store`
891
+ - `@web-ts-toolkit/express-oidc-vault-redis-store`
892
+ - `@web-ts-toolkit/express-oidc-vault-mongodb-store`