@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/LICENSE +201 -0
- package/README.md +892 -0
- package/index.d.mts +258 -0
- package/index.d.ts +258 -0
- package/index.js +1001 -0
- package/index.mjs +954 -0
- package/package.json +48 -0
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`
|