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.
- package/README.md +25 -10
- package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +38653 -32291
- package/dist/s3db.es.js.map +1 -1
- package/package.json +218 -22
- package/src/concerns/id.js +90 -6
- package/src/concerns/index.js +2 -1
- package/src/concerns/password-hashing.js +150 -0
- package/src/database.class.js +6 -2
- package/src/plugins/api/auth/basic-auth.js +40 -10
- package/src/plugins/api/auth/index.js +49 -3
- package/src/plugins/api/auth/oauth2-auth.js +171 -0
- package/src/plugins/api/auth/oidc-auth.js +789 -0
- package/src/plugins/api/auth/oidc-client.js +462 -0
- package/src/plugins/api/auth/path-auth-matcher.js +284 -0
- package/src/plugins/api/concerns/event-emitter.js +134 -0
- package/src/plugins/api/concerns/failban-manager.js +651 -0
- package/src/plugins/api/concerns/guards-helpers.js +402 -0
- package/src/plugins/api/concerns/metrics-collector.js +346 -0
- package/src/plugins/api/index.js +510 -57
- package/src/plugins/api/middlewares/failban.js +305 -0
- package/src/plugins/api/middlewares/rate-limit.js +301 -0
- package/src/plugins/api/middlewares/request-id.js +74 -0
- package/src/plugins/api/middlewares/security-headers.js +120 -0
- package/src/plugins/api/middlewares/session-tracking.js +194 -0
- package/src/plugins/api/routes/auth-routes.js +119 -78
- package/src/plugins/api/routes/resource-routes.js +73 -30
- package/src/plugins/api/server.js +1139 -45
- package/src/plugins/api/utils/custom-routes.js +102 -0
- package/src/plugins/api/utils/guards.js +213 -0
- package/src/plugins/api/utils/mime-types.js +154 -0
- package/src/plugins/api/utils/openapi-generator.js +91 -12
- package/src/plugins/api/utils/path-matcher.js +173 -0
- package/src/plugins/api/utils/static-filesystem.js +262 -0
- package/src/plugins/api/utils/static-s3.js +231 -0
- package/src/plugins/api/utils/template-engine.js +188 -0
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
- package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
- package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
- package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
- package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
- package/src/plugins/cloud-inventory/index.js +20 -0
- package/src/plugins/cloud-inventory/registry.js +146 -0
- package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
- package/src/plugins/cloud-inventory.plugin.js +1333 -0
- package/src/plugins/concerns/plugin-dependencies.js +62 -2
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- package/src/plugins/eventual-consistency/consolidation.js +2 -2
- package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
- package/src/plugins/eventual-consistency/install.js +2 -2
- package/src/plugins/identity/README.md +335 -0
- package/src/plugins/identity/concerns/mfa-manager.js +204 -0
- package/src/plugins/identity/concerns/password.js +138 -0
- package/src/plugins/identity/concerns/resource-schemas.js +273 -0
- package/src/plugins/identity/concerns/token-generator.js +172 -0
- package/src/plugins/identity/email-service.js +422 -0
- package/src/plugins/identity/index.js +1052 -0
- package/src/plugins/identity/oauth2-server.js +1033 -0
- package/src/plugins/identity/oidc-discovery.js +285 -0
- package/src/plugins/identity/rsa-keys.js +323 -0
- package/src/plugins/identity/server.js +500 -0
- package/src/plugins/identity/session-manager.js +453 -0
- package/src/plugins/identity/ui/layouts/base.js +251 -0
- package/src/plugins/identity/ui/middleware.js +135 -0
- package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
- package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
- package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
- package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
- package/src/plugins/identity/ui/pages/admin/users.js +263 -0
- package/src/plugins/identity/ui/pages/consent.js +262 -0
- package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
- package/src/plugins/identity/ui/pages/login.js +144 -0
- package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
- package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
- package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
- package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
- package/src/plugins/identity/ui/pages/profile.js +361 -0
- package/src/plugins/identity/ui/pages/register.js +226 -0
- package/src/plugins/identity/ui/pages/reset-password.js +128 -0
- package/src/plugins/identity/ui/pages/verify-email.js +172 -0
- package/src/plugins/identity/ui/routes.js +2541 -0
- package/src/plugins/identity/ui/styles/main.css +465 -0
- package/src/plugins/index.js +4 -1
- package/src/plugins/ml/base-model.class.js +65 -16
- package/src/plugins/ml/classification-model.class.js +1 -1
- package/src/plugins/ml/timeseries-model.class.js +3 -1
- package/src/plugins/ml.plugin.js +584 -31
- package/src/plugins/shared/error-handler.js +147 -0
- package/src/plugins/shared/index.js +9 -0
- package/src/plugins/shared/middlewares/compression.js +117 -0
- package/src/plugins/shared/middlewares/cors.js +49 -0
- package/src/plugins/shared/middlewares/index.js +11 -0
- package/src/plugins/shared/middlewares/logging.js +54 -0
- package/src/plugins/shared/middlewares/rate-limit.js +73 -0
- package/src/plugins/shared/middlewares/security.js +158 -0
- package/src/plugins/shared/response-formatter.js +264 -0
- package/src/plugins/state-machine.plugin.js +57 -2
- package/src/resource.class.js +140 -12
- package/src/schema.class.js +30 -1
- package/src/validator.class.js +57 -6
- 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;
|