s3db.js 13.4.0 → 13.6.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.
Files changed (110) hide show
  1. package/README.md +25 -10
  2. package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
  3. package/dist/s3db.cjs.map +1 -0
  4. package/dist/s3db.es.js +38653 -32291
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +218 -22
  7. package/src/concerns/id.js +90 -6
  8. package/src/concerns/index.js +2 -1
  9. package/src/concerns/password-hashing.js +150 -0
  10. package/src/database.class.js +6 -2
  11. package/src/plugins/api/auth/basic-auth.js +40 -10
  12. package/src/plugins/api/auth/index.js +49 -3
  13. package/src/plugins/api/auth/oauth2-auth.js +171 -0
  14. package/src/plugins/api/auth/oidc-auth.js +789 -0
  15. package/src/plugins/api/auth/oidc-client.js +462 -0
  16. package/src/plugins/api/auth/path-auth-matcher.js +284 -0
  17. package/src/plugins/api/concerns/event-emitter.js +134 -0
  18. package/src/plugins/api/concerns/failban-manager.js +651 -0
  19. package/src/plugins/api/concerns/guards-helpers.js +402 -0
  20. package/src/plugins/api/concerns/metrics-collector.js +346 -0
  21. package/src/plugins/api/index.js +510 -57
  22. package/src/plugins/api/middlewares/failban.js +305 -0
  23. package/src/plugins/api/middlewares/rate-limit.js +301 -0
  24. package/src/plugins/api/middlewares/request-id.js +74 -0
  25. package/src/plugins/api/middlewares/security-headers.js +120 -0
  26. package/src/plugins/api/middlewares/session-tracking.js +194 -0
  27. package/src/plugins/api/routes/auth-routes.js +119 -78
  28. package/src/plugins/api/routes/resource-routes.js +73 -30
  29. package/src/plugins/api/server.js +1139 -45
  30. package/src/plugins/api/utils/custom-routes.js +102 -0
  31. package/src/plugins/api/utils/guards.js +213 -0
  32. package/src/plugins/api/utils/mime-types.js +154 -0
  33. package/src/plugins/api/utils/openapi-generator.js +91 -12
  34. package/src/plugins/api/utils/path-matcher.js +173 -0
  35. package/src/plugins/api/utils/static-filesystem.js +262 -0
  36. package/src/plugins/api/utils/static-s3.js +231 -0
  37. package/src/plugins/api/utils/template-engine.js +188 -0
  38. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
  39. package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
  40. package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
  41. package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
  42. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
  43. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
  44. package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
  45. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
  46. package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
  47. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
  48. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
  49. package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
  50. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
  51. package/src/plugins/cloud-inventory/index.js +20 -0
  52. package/src/plugins/cloud-inventory/registry.js +146 -0
  53. package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
  54. package/src/plugins/cloud-inventory.plugin.js +1333 -0
  55. package/src/plugins/concerns/plugin-dependencies.js +62 -2
  56. package/src/plugins/eventual-consistency/analytics.js +1 -0
  57. package/src/plugins/eventual-consistency/consolidation.js +2 -2
  58. package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
  59. package/src/plugins/eventual-consistency/install.js +2 -2
  60. package/src/plugins/identity/README.md +335 -0
  61. package/src/plugins/identity/concerns/mfa-manager.js +204 -0
  62. package/src/plugins/identity/concerns/password.js +138 -0
  63. package/src/plugins/identity/concerns/resource-schemas.js +273 -0
  64. package/src/plugins/identity/concerns/token-generator.js +172 -0
  65. package/src/plugins/identity/email-service.js +422 -0
  66. package/src/plugins/identity/index.js +1052 -0
  67. package/src/plugins/identity/oauth2-server.js +1033 -0
  68. package/src/plugins/identity/oidc-discovery.js +285 -0
  69. package/src/plugins/identity/rsa-keys.js +323 -0
  70. package/src/plugins/identity/server.js +500 -0
  71. package/src/plugins/identity/session-manager.js +453 -0
  72. package/src/plugins/identity/ui/layouts/base.js +251 -0
  73. package/src/plugins/identity/ui/middleware.js +135 -0
  74. package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
  75. package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
  76. package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
  77. package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
  78. package/src/plugins/identity/ui/pages/admin/users.js +263 -0
  79. package/src/plugins/identity/ui/pages/consent.js +262 -0
  80. package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
  81. package/src/plugins/identity/ui/pages/login.js +144 -0
  82. package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
  83. package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
  84. package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
  85. package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
  86. package/src/plugins/identity/ui/pages/profile.js +361 -0
  87. package/src/plugins/identity/ui/pages/register.js +226 -0
  88. package/src/plugins/identity/ui/pages/reset-password.js +128 -0
  89. package/src/plugins/identity/ui/pages/verify-email.js +172 -0
  90. package/src/plugins/identity/ui/routes.js +2541 -0
  91. package/src/plugins/identity/ui/styles/main.css +465 -0
  92. package/src/plugins/index.js +4 -1
  93. package/src/plugins/ml/base-model.class.js +65 -16
  94. package/src/plugins/ml/classification-model.class.js +1 -1
  95. package/src/plugins/ml/timeseries-model.class.js +3 -1
  96. package/src/plugins/ml.plugin.js +584 -31
  97. package/src/plugins/shared/error-handler.js +147 -0
  98. package/src/plugins/shared/index.js +9 -0
  99. package/src/plugins/shared/middlewares/compression.js +117 -0
  100. package/src/plugins/shared/middlewares/cors.js +49 -0
  101. package/src/plugins/shared/middlewares/index.js +11 -0
  102. package/src/plugins/shared/middlewares/logging.js +54 -0
  103. package/src/plugins/shared/middlewares/rate-limit.js +73 -0
  104. package/src/plugins/shared/middlewares/security.js +158 -0
  105. package/src/plugins/shared/response-formatter.js +264 -0
  106. package/src/plugins/state-machine.plugin.js +57 -2
  107. package/src/resource.class.js +140 -12
  108. package/src/schema.class.js +30 -1
  109. package/src/validator.class.js +57 -6
  110. package/dist/s3db.cjs.js.map +0 -1
@@ -0,0 +1,789 @@
1
+ /**
2
+ * OIDC Authentication Driver (Authorization Code Flow) - Production Ready
3
+ *
4
+ * Implements OpenID Connect Authorization Code Flow with enterprise features:
5
+ * - Auto user creation/update from token claims
6
+ * - Session management (rolling + absolute duration)
7
+ * - Token refresh before expiry
8
+ * - IdP logout support (Azure AD/Entra compatible)
9
+ * - Startup configuration validation
10
+ * - User data cached in session (zero DB lookups per request)
11
+ *
12
+ * @example
13
+ * {
14
+ * driver: 'oidc',
15
+ * config: {
16
+ * issuer: 'http://localhost:4000',
17
+ * clientId: 'app-client-123',
18
+ * clientSecret: 'super-secret-key-456',
19
+ * redirectUri: 'http://localhost:3000/auth/callback',
20
+ * scopes: ['openid', 'profile', 'email', 'offline_access'],
21
+ * cookieSecret: 'my-cookie-secret-32-chars!!!',
22
+ * rollingDuration: 86400000, // 24 hours
23
+ * absoluteDuration: 604800000, // 7 days
24
+ * idpLogout: true,
25
+ * autoCreateUser: true,
26
+ * // 🎯 Hook: Called after user is authenticated
27
+ * onUserAuthenticated: async ({ user, created, claims, tokens, context }) => {
28
+ * if (created) {
29
+ * // User was just created - create profile, send welcome email, etc.
30
+ * await db.resources.profiles.insert({
31
+ * id: `profile-${user.id}`,
32
+ * userId: user.id,
33
+ * bio: '',
34
+ * onboarded: false
35
+ * });
36
+ * }
37
+ *
38
+ * // Set cookie with API token
39
+ * context.cookie('api_token', user.apiToken, {
40
+ * httpOnly: true,
41
+ * secure: true,
42
+ * sameSite: 'Lax',
43
+ * maxAge: 7 * 24 * 60 * 60 // 7 days
44
+ * });
45
+ * }
46
+ * }
47
+ * }
48
+ */
49
+
50
+ import { SignJWT, jwtVerify } from 'jose';
51
+ import { unauthorized } from '../utils/response-formatter.js';
52
+ import { createAuthDriverRateLimiter } from '../middlewares/rate-limit.js';
53
+
54
+ /**
55
+ * Validate OIDC configuration at startup
56
+ * @throws {Error} If configuration is invalid
57
+ */
58
+ export function validateOidcConfig(config) {
59
+ const errors = [];
60
+
61
+ // Required fields
62
+ if (!config.issuer) {
63
+ errors.push('issuer is required');
64
+ } else if (config.issuer.includes('{tenant-id}')) {
65
+ errors.push('issuer contains placeholder {tenant-id}');
66
+ }
67
+
68
+ if (!config.clientId) {
69
+ errors.push('clientId is required');
70
+ } else if (config.clientId === 'your-client-id-here') {
71
+ errors.push('clientId contains placeholder value');
72
+ }
73
+
74
+ if (!config.clientSecret) {
75
+ errors.push('clientSecret is required');
76
+ } else if (config.clientSecret === 'your-client-secret-here') {
77
+ errors.push('clientSecret contains placeholder value');
78
+ }
79
+
80
+ if (!config.redirectUri) {
81
+ errors.push('redirectUri is required');
82
+ }
83
+
84
+ if (!config.cookieSecret) {
85
+ errors.push('cookieSecret is required');
86
+ } else if (config.cookieSecret.length < 32) {
87
+ errors.push('cookieSecret must be at least 32 characters');
88
+ } else if (config.cookieSecret === 'CHANGE_THIS_SECRET' || config.cookieSecret === 'long-random-string-for-session-encryption') {
89
+ errors.push('cookieSecret contains placeholder/default value');
90
+ }
91
+
92
+ // Validate UUID format for clientId (common for Azure AD/Entra)
93
+ if (config.clientId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(config.clientId)) {
94
+ console.warn('[OIDC] clientId is not in UUID format (may be expected for some providers)');
95
+ }
96
+
97
+ if (errors.length > 0) {
98
+ throw new Error(`OIDC driver configuration is invalid:\n${errors.map(e => ` - ${e}`).join('\n')}\n\nSee documentation for configuration requirements.`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get or create user from OIDC claims
104
+ * @param {Object} usersResource - s3db.js users resource
105
+ * @param {Object} claims - ID token claims
106
+ * @param {Object} config - OIDC config
107
+ * @returns {Promise<{user: Object, created: boolean}>} User object and creation status
108
+ */
109
+ async function getOrCreateUser(usersResource, claims, config) {
110
+ const userId = claims.email || claims.preferred_username || claims.sub;
111
+
112
+ if (!userId) {
113
+ throw new Error('Cannot extract user ID from OIDC claims (no email/preferred_username/sub)');
114
+ }
115
+
116
+ // Try to get existing user
117
+ let user = null;
118
+ let userExists = false;
119
+ try {
120
+ user = await usersResource.get(userId);
121
+ userExists = true;
122
+ } catch (err) {
123
+ // User not found, will create below
124
+ }
125
+
126
+ const now = new Date().toISOString();
127
+
128
+ if (user) {
129
+ // Update existing user
130
+ const updates = {
131
+ lastLoginAt: now,
132
+ metadata: {
133
+ ...user.metadata,
134
+ oidc: {
135
+ sub: claims.sub,
136
+ provider: config.issuer,
137
+ lastSync: now,
138
+ claims: {
139
+ name: claims.name,
140
+ email: claims.email,
141
+ picture: claims.picture
142
+ }
143
+ }
144
+ }
145
+ };
146
+
147
+ // Update name if changed
148
+ if (claims.name && claims.name !== user.name) {
149
+ updates.name = claims.name;
150
+ }
151
+
152
+ // Call beforeUpdateUser hook if configured (allows refreshing external API data)
153
+ if (config.beforeUpdateUser && typeof config.beforeUpdateUser === 'function') {
154
+ try {
155
+ const enrichedData = await config.beforeUpdateUser({
156
+ user,
157
+ updates,
158
+ claims,
159
+ usersResource
160
+ });
161
+
162
+ // Merge enriched data into updates
163
+ if (enrichedData && typeof enrichedData === 'object') {
164
+ Object.assign(updates, enrichedData);
165
+ // Deep merge metadata
166
+ if (enrichedData.metadata) {
167
+ updates.metadata = {
168
+ ...updates.metadata,
169
+ ...enrichedData.metadata
170
+ };
171
+ }
172
+ }
173
+ } catch (hookErr) {
174
+ console.error('[OIDC] beforeUpdateUser hook failed:', hookErr);
175
+ // Continue with default updates (don't block auth)
176
+ }
177
+ }
178
+
179
+ user = await usersResource.update(userId, updates);
180
+ return { user, created: false };
181
+ }
182
+
183
+ // Create new user
184
+ const newUser = {
185
+ id: userId,
186
+ email: claims.email || userId,
187
+ username: claims.preferred_username || claims.email || userId,
188
+ name: claims.name || claims.email || userId,
189
+ picture: claims.picture || null,
190
+ role: config.defaultRole || 'user',
191
+ scopes: config.defaultScopes || ['openid', 'profile', 'email'],
192
+ active: true,
193
+ apiKey: null, // Will be generated on first API usage if needed
194
+ lastLoginAt: now,
195
+ metadata: {
196
+ oidc: {
197
+ sub: claims.sub,
198
+ provider: config.issuer,
199
+ createdAt: now,
200
+ claims: {
201
+ name: claims.name,
202
+ email: claims.email,
203
+ picture: claims.picture
204
+ }
205
+ },
206
+ costCenterId: config.defaultCostCenter || null,
207
+ teamId: config.defaultTeam || null
208
+ }
209
+ };
210
+
211
+ // Call beforeCreateUser hook if configured (allows enriching with external API data)
212
+ if (config.beforeCreateUser && typeof config.beforeCreateUser === 'function') {
213
+ try {
214
+ const enrichedData = await config.beforeCreateUser({
215
+ user: newUser,
216
+ claims,
217
+ usersResource
218
+ });
219
+
220
+ // Merge enriched data into newUser
221
+ if (enrichedData && typeof enrichedData === 'object') {
222
+ Object.assign(newUser, enrichedData);
223
+ // Deep merge metadata
224
+ if (enrichedData.metadata) {
225
+ newUser.metadata = {
226
+ ...newUser.metadata,
227
+ ...enrichedData.metadata
228
+ };
229
+ }
230
+ }
231
+ } catch (hookErr) {
232
+ console.error('[OIDC] beforeCreateUser hook failed:', hookErr);
233
+ // Continue with default user data (don't block auth)
234
+ }
235
+ }
236
+
237
+ user = await usersResource.insert(newUser);
238
+ return { user, created: true };
239
+ }
240
+
241
+ /**
242
+ * Refresh access token using refresh token
243
+ */
244
+ async function refreshAccessToken(tokenEndpoint, refreshToken, clientId, clientSecret) {
245
+ const response = await fetch(tokenEndpoint, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/x-www-form-urlencoded',
249
+ 'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
250
+ },
251
+ body: new URLSearchParams({
252
+ grant_type: 'refresh_token',
253
+ refresh_token: refreshToken
254
+ })
255
+ });
256
+
257
+ if (!response.ok) {
258
+ throw new Error(`Token refresh failed: ${response.status}`);
259
+ }
260
+
261
+ return await response.json();
262
+ }
263
+
264
+ /**
265
+ * Create OIDC authentication handler and routes
266
+ */
267
+ export function createOIDCHandler(config, app, usersResource, events = null) {
268
+ // Apply defaults
269
+ const finalConfig = {
270
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
271
+ cookieName: 'oidc_session',
272
+ cookieMaxAge: 604800000, // 7 days (same as absolute duration)
273
+ rollingDuration: 86400000, // 24 hours
274
+ absoluteDuration: 604800000, // 7 days
275
+ loginPath: '/auth/login',
276
+ callbackPath: '/auth/callback',
277
+ logoutPath: '/auth/logout',
278
+ postLoginRedirect: '/',
279
+ postLogoutRedirect: '/',
280
+ idpLogout: true,
281
+ autoCreateUser: true,
282
+ autoRefreshTokens: true,
283
+ refreshThreshold: 300000, // 5 minutes before expiry
284
+ cookieSecure: process.env.NODE_ENV === 'production',
285
+ cookieSameSite: 'Lax',
286
+ defaultRole: 'user',
287
+ defaultScopes: ['openid', 'profile', 'email'],
288
+ rateLimit: config.rateLimit !== undefined ? config.rateLimit : {
289
+ enabled: true,
290
+ windowMs: 900000, // 15 minutes
291
+ maxAttempts: 5,
292
+ skipSuccessfulRequests: true
293
+ },
294
+ ...config
295
+ };
296
+
297
+ const {
298
+ issuer,
299
+ clientId,
300
+ clientSecret,
301
+ redirectUri,
302
+ scopes,
303
+ cookieSecret,
304
+ cookieName,
305
+ cookieMaxAge,
306
+ rollingDuration,
307
+ absoluteDuration,
308
+ loginPath,
309
+ callbackPath,
310
+ logoutPath,
311
+ postLoginRedirect,
312
+ postLogoutRedirect,
313
+ idpLogout,
314
+ autoCreateUser,
315
+ autoRefreshTokens,
316
+ refreshThreshold,
317
+ cookieSecure,
318
+ cookieSameSite
319
+ } = finalConfig;
320
+
321
+ // OAuth2 endpoints
322
+ const authorizationEndpoint = `${issuer}/oauth/authorize`;
323
+ const tokenEndpoint = `${issuer}/oauth/token`;
324
+ const logoutEndpoint = `${issuer}/oauth2/v2.0/logout`; // Azure AD format
325
+
326
+ /**
327
+ * Encode session data as signed JWT
328
+ */
329
+ async function encodeSession(data) {
330
+ const secret = new TextEncoder().encode(cookieSecret);
331
+ const jwt = await new SignJWT(data)
332
+ .setProtectedHeader({ alg: 'HS256' })
333
+ .setIssuedAt()
334
+ .setExpirationTime(`${Math.floor(cookieMaxAge / 1000)}s`)
335
+ .sign(secret);
336
+ return jwt;
337
+ }
338
+
339
+ /**
340
+ * Decode and verify session JWT
341
+ */
342
+ async function decodeSession(jwt) {
343
+ try {
344
+ const secret = new TextEncoder().encode(cookieSecret);
345
+ const { payload } = await jwtVerify(jwt, secret);
346
+ return payload;
347
+ } catch (err) {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Validate session (rolling + absolute duration)
354
+ */
355
+ function validateSessionDuration(session) {
356
+ const now = Date.now();
357
+
358
+ // Check absolute expiry
359
+ if (session.issued_at + absoluteDuration < now) {
360
+ return { valid: false, reason: 'absolute_expired' };
361
+ }
362
+
363
+ // Check rolling expiry
364
+ if (session.last_activity + rollingDuration < now) {
365
+ return { valid: false, reason: 'rolling_expired' };
366
+ }
367
+
368
+ return { valid: true };
369
+ }
370
+
371
+ /**
372
+ * Generate random state for CSRF protection
373
+ */
374
+ function generateState() {
375
+ return Math.random().toString(36).substring(2, 15) +
376
+ Math.random().toString(36).substring(2, 15);
377
+ }
378
+
379
+ /**
380
+ * Decode JWT without verification (for id_token claims)
381
+ */
382
+ function decodeIdToken(idToken) {
383
+ try {
384
+ const parts = idToken.split('.');
385
+ if (parts.length !== 3) return null;
386
+ const payload = Buffer.from(parts[1], 'base64').toString('utf-8');
387
+ return JSON.parse(payload);
388
+ } catch (err) {
389
+ return null;
390
+ }
391
+ }
392
+
393
+ // ==================== ROUTES ====================
394
+
395
+ // Create rate limiter if enabled
396
+ let rateLimiter = null;
397
+ if (finalConfig.rateLimit?.enabled) {
398
+ rateLimiter = createAuthDriverRateLimiter('oidc', finalConfig.rateLimit);
399
+ }
400
+
401
+ /**
402
+ * LOGIN Route
403
+ */
404
+ app.get(loginPath, async (c) => {
405
+ const state = generateState();
406
+
407
+ // Store state in short-lived cookie
408
+ const stateJWT = await encodeSession({ state, type: 'csrf', expires: Date.now() + 600000 });
409
+ c.header('Set-Cookie', `${cookieName}_state=${stateJWT}; Path=/; HttpOnly; Max-Age=600; SameSite=Lax`);
410
+
411
+ // Build authorization URL
412
+ const params = new URLSearchParams({
413
+ response_type: 'code',
414
+ client_id: clientId,
415
+ redirect_uri: redirectUri,
416
+ scope: scopes.join(' '),
417
+ state
418
+ });
419
+
420
+ return c.redirect(`${authorizationEndpoint}?${params.toString()}`, 302);
421
+ });
422
+
423
+ /**
424
+ * CALLBACK Route (with rate limiting)
425
+ */
426
+ const callbackHandler = async (c) => {
427
+ const code = c.req.query('code');
428
+ const state = c.req.query('state');
429
+
430
+ // Validate CSRF state
431
+ const stateCookie = c.req.cookie(`${cookieName}_state`);
432
+ if (!stateCookie) {
433
+ return c.json({ error: 'Missing state cookie (CSRF protection)' }, 400);
434
+ }
435
+
436
+ const stateData = await decodeSession(stateCookie);
437
+ if (!stateData || stateData.state !== state) {
438
+ return c.json({ error: 'Invalid state (CSRF protection)' }, 400);
439
+ }
440
+
441
+ // Clear state cookie
442
+ c.header('Set-Cookie', `${cookieName}_state=; Path=/; HttpOnly; Max-Age=0`);
443
+
444
+ if (!code) {
445
+ return c.json({ error: 'Missing authorization code' }, 400);
446
+ }
447
+
448
+ // Exchange code for tokens
449
+ try {
450
+ const tokenResponse = await fetch(tokenEndpoint, {
451
+ method: 'POST',
452
+ headers: {
453
+ 'Content-Type': 'application/x-www-form-urlencoded',
454
+ 'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
455
+ },
456
+ body: new URLSearchParams({
457
+ grant_type: 'authorization_code',
458
+ code,
459
+ redirect_uri: redirectUri
460
+ })
461
+ });
462
+
463
+ if (!tokenResponse.ok) {
464
+ const error = await tokenResponse.text();
465
+ console.error('[OIDC] Token exchange failed:', error);
466
+ return c.json({ error: 'Failed to exchange code for tokens' }, 500);
467
+ }
468
+
469
+ const tokens = await tokenResponse.json();
470
+
471
+ // Decode id_token claims
472
+ const idTokenClaims = decodeIdToken(tokens.id_token);
473
+ if (!idTokenClaims) {
474
+ return c.json({ error: 'Failed to decode id_token' }, 500);
475
+ }
476
+
477
+ // Auto-create/update user
478
+ let user = null;
479
+ let userCreated = false;
480
+ if (autoCreateUser && usersResource) {
481
+ try {
482
+ const result = await getOrCreateUser(usersResource, idTokenClaims, finalConfig);
483
+ user = result.user;
484
+ userCreated = result.created;
485
+
486
+ // Emit user events
487
+ if (events) {
488
+ if (userCreated) {
489
+ events.emitUserEvent('created', {
490
+ user: { id: user.id, email: user.email, name: user.name },
491
+ source: 'oidc',
492
+ provider: finalConfig.issuer
493
+ });
494
+ }
495
+
496
+ events.emitUserEvent('login', {
497
+ user: { id: user.id, email: user.email, name: user.name },
498
+ source: 'oidc',
499
+ provider: finalConfig.issuer,
500
+ newUser: userCreated
501
+ });
502
+ }
503
+
504
+ // Call onUserAuthenticated hook if configured
505
+ if (finalConfig.onUserAuthenticated && typeof finalConfig.onUserAuthenticated === 'function') {
506
+ try {
507
+ await finalConfig.onUserAuthenticated({
508
+ user,
509
+ created: userCreated,
510
+ claims: idTokenClaims,
511
+ tokens: {
512
+ access_token: tokens.access_token,
513
+ id_token: tokens.id_token,
514
+ refresh_token: tokens.refresh_token
515
+ },
516
+ context: c // 🔥 Pass Hono context for cookie/header manipulation
517
+ });
518
+ } catch (hookErr) {
519
+ console.error('[OIDC] onUserAuthenticated hook failed:', hookErr);
520
+ // Don't block authentication if hook fails
521
+ }
522
+ }
523
+ } catch (err) {
524
+ console.error('[OIDC] Failed to create/update user:', err);
525
+ // Continue without user (will use token claims only)
526
+ }
527
+ }
528
+
529
+ // Create session with user data
530
+ const now = Date.now();
531
+ const sessionData = {
532
+ access_token: tokens.access_token,
533
+ id_token: tokens.id_token,
534
+ refresh_token: tokens.refresh_token,
535
+ expires_at: now + (tokens.expires_in * 1000),
536
+ issued_at: now,
537
+ last_activity: now,
538
+
539
+ // User data (avoid DB lookup on every request)
540
+ user: user ? {
541
+ id: user.id,
542
+ email: user.email,
543
+ username: user.username,
544
+ name: user.name,
545
+ picture: user.picture,
546
+ role: user.role,
547
+ scopes: user.scopes,
548
+ active: user.active,
549
+ metadata: {
550
+ costCenterId: user.metadata?.costCenterId,
551
+ teamId: user.metadata?.teamId
552
+ }
553
+ } : {
554
+ id: idTokenClaims.sub,
555
+ email: idTokenClaims.email,
556
+ username: idTokenClaims.preferred_username || idTokenClaims.email,
557
+ name: idTokenClaims.name,
558
+ picture: idTokenClaims.picture,
559
+ role: 'user',
560
+ scopes: scopes,
561
+ active: true,
562
+ isVirtual: true
563
+ }
564
+ };
565
+
566
+ const sessionJWT = await encodeSession(sessionData);
567
+
568
+ // Set session cookie
569
+ const cookieOptions = [
570
+ `${cookieName}=${sessionJWT}`,
571
+ 'Path=/',
572
+ 'HttpOnly',
573
+ `Max-Age=${Math.floor(cookieMaxAge / 1000)}`,
574
+ `SameSite=${cookieSameSite}`
575
+ ];
576
+
577
+ if (cookieSecure) {
578
+ cookieOptions.push('Secure');
579
+ }
580
+
581
+ c.header('Set-Cookie', cookieOptions.join('; '));
582
+
583
+ return c.redirect(postLoginRedirect, 302);
584
+
585
+ } catch (err) {
586
+ console.error('[OIDC] Error during token exchange:', err);
587
+ return c.json({ error: 'Authentication failed' }, 500);
588
+ }
589
+ };
590
+
591
+ // Register callback route with optional rate limiting
592
+ if (rateLimiter) {
593
+ app.get(callbackPath, rateLimiter, callbackHandler);
594
+ } else {
595
+ app.get(callbackPath, callbackHandler);
596
+ }
597
+
598
+ /**
599
+ * LOGOUT Route
600
+ */
601
+ app.get(logoutPath, async (c) => {
602
+ const sessionCookie = c.req.cookie(cookieName);
603
+ let idToken = null;
604
+
605
+ if (sessionCookie) {
606
+ const session = await decodeSession(sessionCookie);
607
+ idToken = session?.id_token;
608
+ }
609
+
610
+ // Clear session cookie
611
+ c.header('Set-Cookie', `${cookieName}=; Path=/; HttpOnly; Max-Age=0`);
612
+
613
+ // IdP logout (Azure AD/Entra compatible)
614
+ if (idpLogout && idToken) {
615
+ const params = new URLSearchParams({
616
+ id_token_hint: idToken,
617
+ post_logout_redirect_uri: `${postLogoutRedirect}`
618
+ });
619
+ return c.redirect(`${logoutEndpoint}?${params.toString()}`, 302);
620
+ }
621
+
622
+ return c.redirect(postLogoutRedirect, 302);
623
+ });
624
+
625
+ // ==================== MIDDLEWARE ====================
626
+
627
+ /**
628
+ * Simple glob pattern matcher (supports * and **)
629
+ */
630
+ function matchPath(path, pattern) {
631
+ // Exact match
632
+ if (pattern === path) return true;
633
+
634
+ // Convert glob pattern to regex
635
+ const regexPattern = pattern
636
+ .replace(/\*\*/g, '___GLOBSTAR___') // Temporary placeholder
637
+ .replace(/\*/g, '[^/]*') // * matches anything except /
638
+ .replace(/___GLOBSTAR___/g, '.*') // ** matches everything including /
639
+ .replace(/\//g, '\\/') // Escape forward slashes
640
+ + '$'; // End of string
641
+
642
+ const regex = new RegExp('^' + regexPattern);
643
+ return regex.test(path);
644
+ }
645
+
646
+ /**
647
+ * Authentication middleware
648
+ */
649
+ const middleware = async (c, next) => {
650
+ // Check if this path should be protected by OIDC
651
+ const protectedPaths = finalConfig.protectedPaths || [];
652
+ const currentPath = c.req.path;
653
+
654
+ // If protectedPaths is configured, only enforce OIDC on matching paths
655
+ if (protectedPaths.length > 0) {
656
+ const isProtected = protectedPaths.some(pattern => matchPath(currentPath, pattern));
657
+
658
+ if (!isProtected) {
659
+ // Not a protected path, skip OIDC check (allows other auth methods)
660
+ return await next();
661
+ }
662
+ }
663
+
664
+ const sessionCookie = c.req.cookie(cookieName);
665
+
666
+ if (!sessionCookie) {
667
+ // No session cookie - require OIDC for protected paths
668
+ if (protectedPaths.length > 0) {
669
+ // Content negotiation: check if client expects HTML
670
+ const acceptHeader = c.req.header('accept') || '';
671
+ const acceptsHtml = acceptHeader.includes('text/html');
672
+
673
+ if (acceptsHtml) {
674
+ // Browser request - redirect to login
675
+ const returnTo = encodeURIComponent(currentPath);
676
+ return c.redirect(`${loginPath}?returnTo=${returnTo}`, 302);
677
+ } else {
678
+ // API request - return JSON 401
679
+ const response = unauthorized('Authentication required');
680
+ return c.json(response, response._status);
681
+ }
682
+ }
683
+ return await next();
684
+ }
685
+
686
+ const session = await decodeSession(sessionCookie);
687
+
688
+ if (!session || !session.access_token) {
689
+ return await next();
690
+ }
691
+
692
+ // Validate session duration
693
+ const validation = validateSessionDuration(session);
694
+ if (!validation.valid) {
695
+ // Session expired, clear cookie
696
+ c.header('Set-Cookie', `${cookieName}=; Path=/; HttpOnly; Max-Age=0`);
697
+ return await next();
698
+ }
699
+
700
+ // Auto-refresh tokens if needed
701
+ if (autoRefreshTokens && session.refresh_token && session.expires_at) {
702
+ const timeUntilExpiry = session.expires_at - Date.now();
703
+
704
+ if (timeUntilExpiry < refreshThreshold) {
705
+ try {
706
+ const newTokens = await refreshAccessToken(
707
+ tokenEndpoint,
708
+ session.refresh_token,
709
+ clientId,
710
+ clientSecret
711
+ );
712
+
713
+ session.access_token = newTokens.access_token;
714
+ session.expires_at = Date.now() + (newTokens.expires_in * 1000);
715
+
716
+ // If new refresh token provided, update it
717
+ if (newTokens.refresh_token) {
718
+ session.refresh_token = newTokens.refresh_token;
719
+ }
720
+ } catch (err) {
721
+ console.error('[OIDC] Token refresh failed:', err);
722
+ // Continue with existing token (will expire soon)
723
+ }
724
+ }
725
+ }
726
+
727
+ // Update last_activity (rolling session)
728
+ session.last_activity = Date.now();
729
+
730
+ // Check if user is active (if field exists)
731
+ if (session.user.active !== undefined && !session.user.active) {
732
+ // User account is inactive, clear session
733
+ c.header('Set-Cookie', `${cookieName}=; Path=/; HttpOnly; Max-Age=0`);
734
+
735
+ // Content negotiation for inactive account
736
+ const acceptHeader = c.req.header('accept') || '';
737
+ const acceptsHtml = acceptHeader.includes('text/html');
738
+
739
+ if (acceptsHtml) {
740
+ return c.redirect(`${loginPath}?error=account_inactive`, 302);
741
+ } else {
742
+ const response = unauthorized('User account is inactive');
743
+ return c.json(response, response._status);
744
+ }
745
+ }
746
+
747
+ // Set user in context
748
+ c.set('user', {
749
+ ...session.user,
750
+ authMethod: 'oidc',
751
+ session: {
752
+ access_token: session.access_token,
753
+ refresh_token: session.refresh_token,
754
+ expires_at: session.expires_at
755
+ }
756
+ });
757
+
758
+ // Re-encode session with updated last_activity and tokens
759
+ const newSessionJWT = await encodeSession(session);
760
+
761
+ const cookieOptions = [
762
+ `${cookieName}=${newSessionJWT}`,
763
+ 'Path=/',
764
+ 'HttpOnly',
765
+ `Max-Age=${Math.floor(cookieMaxAge / 1000)}`,
766
+ `SameSite=${cookieSameSite}`
767
+ ];
768
+
769
+ if (cookieSecure) {
770
+ cookieOptions.push('Secure');
771
+ }
772
+
773
+ c.header('Set-Cookie', cookieOptions.join('; '));
774
+
775
+ return await next();
776
+ };
777
+
778
+ return {
779
+ middleware,
780
+ routes: {
781
+ [loginPath]: 'Login (redirect to SSO)',
782
+ [callbackPath]: 'OAuth2 callback',
783
+ [logoutPath]: 'Logout (local + IdP)'
784
+ },
785
+ config: finalConfig
786
+ };
787
+ }
788
+
789
+ export default createOIDCHandler;