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,1033 @@
1
+ /**
2
+ * OAuth2/OIDC Authorization Server
3
+ *
4
+ * Provides endpoints for OAuth2 + OpenID Connect flows:
5
+ * - /.well-known/openid-configuration (Discovery)
6
+ * - /.well-known/jwks.json (Public keys)
7
+ * - /auth/token (Token endpoint)
8
+ * - /auth/userinfo (User info endpoint)
9
+ * - /auth/introspect (Token introspection)
10
+ *
11
+ * @example
12
+ * import { OAuth2Server } from 's3db.js/plugins/identity/oauth2-server';
13
+ *
14
+ * const oauth2 = new OAuth2Server({
15
+ * issuer: 'https://sso.example.com',
16
+ * keyResource: db.getResource('oauth_keys'),
17
+ * userResource: db.getResource('users'),
18
+ * clientResource: db.getResource('oauth_clients')
19
+ * });
20
+ *
21
+ * await oauth2.initialize();
22
+ *
23
+ * // Use with API plugin custom routes
24
+ * apiPlugin.addRoute({
25
+ * path: '/.well-known/openid-configuration',
26
+ * method: 'GET',
27
+ * handler: oauth2.discoveryHandler.bind(oauth2),
28
+ * auth: false
29
+ * });
30
+ */
31
+
32
+ import { KeyManager } from './rsa-keys.js';
33
+ import {
34
+ generateDiscoveryDocument,
35
+ validateClaims,
36
+ extractUserClaims,
37
+ parseScopes,
38
+ validateScopes,
39
+ generateAuthCode,
40
+ generateClientId,
41
+ generateClientSecret
42
+ } from './oidc-discovery.js';
43
+
44
+ /**
45
+ * OAuth2/OIDC Authorization Server
46
+ */
47
+ export class OAuth2Server {
48
+ constructor(options = {}) {
49
+ const {
50
+ issuer,
51
+ keyResource,
52
+ userResource,
53
+ clientResource,
54
+ authCodeResource,
55
+ supportedScopes = ['openid', 'profile', 'email', 'offline_access'],
56
+ supportedGrantTypes = ['authorization_code', 'client_credentials', 'refresh_token'],
57
+ supportedResponseTypes = ['code', 'token', 'id_token'],
58
+ accessTokenExpiry = '15m',
59
+ idTokenExpiry = '15m',
60
+ refreshTokenExpiry = '7d',
61
+ authCodeExpiry = '10m'
62
+ } = options;
63
+
64
+ if (!issuer) {
65
+ throw new Error('Issuer URL is required for OAuth2Server');
66
+ }
67
+
68
+ if (!keyResource) {
69
+ throw new Error('keyResource is required for OAuth2Server');
70
+ }
71
+
72
+ if (!userResource) {
73
+ throw new Error('userResource is required for OAuth2Server');
74
+ }
75
+
76
+ this.issuer = issuer.replace(/\/$/, '');
77
+ this.keyResource = keyResource;
78
+ this.userResource = userResource;
79
+ this.clientResource = clientResource;
80
+ this.authCodeResource = authCodeResource;
81
+ this.supportedScopes = supportedScopes;
82
+ this.supportedGrantTypes = supportedGrantTypes;
83
+ this.supportedResponseTypes = supportedResponseTypes;
84
+ this.accessTokenExpiry = accessTokenExpiry;
85
+ this.idTokenExpiry = idTokenExpiry;
86
+ this.refreshTokenExpiry = refreshTokenExpiry;
87
+ this.authCodeExpiry = authCodeExpiry;
88
+
89
+ this.keyManager = new KeyManager(keyResource);
90
+ }
91
+
92
+ /**
93
+ * Initialize OAuth2 server - load keys
94
+ */
95
+ async initialize() {
96
+ await this.keyManager.initialize();
97
+ }
98
+
99
+ /**
100
+ * OIDC Discovery endpoint handler
101
+ * GET /.well-known/openid-configuration
102
+ */
103
+ async discoveryHandler(req, res) {
104
+ try {
105
+ const document = generateDiscoveryDocument({
106
+ issuer: this.issuer,
107
+ grantTypes: this.supportedGrantTypes,
108
+ responseTypes: this.supportedResponseTypes,
109
+ scopes: this.supportedScopes
110
+ });
111
+
112
+ return res.status(200).json(document);
113
+ } catch (error) {
114
+ return res.status(500).json({
115
+ error: 'server_error',
116
+ error_description: error.message
117
+ });
118
+ }
119
+ }
120
+
121
+ /**
122
+ * JWKS endpoint handler
123
+ * GET /.well-known/jwks.json
124
+ */
125
+ async jwksHandler(req, res) {
126
+ try {
127
+ const jwks = await this.keyManager.getJWKS();
128
+ return res.status(200).json(jwks);
129
+ } catch (error) {
130
+ return res.status(500).json({
131
+ error: 'server_error',
132
+ error_description: error.message
133
+ });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Token endpoint handler
139
+ * POST /auth/token
140
+ *
141
+ * Supports:
142
+ * - client_credentials grant
143
+ * - authorization_code grant (if authCodeResource provided)
144
+ * - refresh_token grant (if authCodeResource provided)
145
+ */
146
+ async tokenHandler(req, res) {
147
+ try {
148
+ const { grant_type, scope, client_id, client_secret } = req.body;
149
+
150
+ // Validate grant type
151
+ if (!grant_type) {
152
+ return res.status(400).json({
153
+ error: 'invalid_request',
154
+ error_description: 'grant_type is required'
155
+ });
156
+ }
157
+
158
+ if (!this.supportedGrantTypes.includes(grant_type)) {
159
+ return res.status(400).json({
160
+ error: 'unsupported_grant_type',
161
+ error_description: `Grant type ${grant_type} is not supported`
162
+ });
163
+ }
164
+
165
+ // Validate client credentials
166
+ if (!client_id) {
167
+ return res.status(400).json({
168
+ error: 'invalid_request',
169
+ error_description: 'client_id is required'
170
+ });
171
+ }
172
+
173
+ // Authenticate client if clientResource is provided
174
+ if (this.clientResource) {
175
+ const client = await this.authenticateClient(client_id, client_secret);
176
+ if (!client) {
177
+ return res.status(401).json({
178
+ error: 'invalid_client',
179
+ error_description: 'Client authentication failed'
180
+ });
181
+ }
182
+ }
183
+
184
+ // Handle different grant types
185
+ switch (grant_type) {
186
+ case 'client_credentials':
187
+ return await this.handleClientCredentials(req, res, { client_id, scope });
188
+
189
+ case 'authorization_code':
190
+ return await this.handleAuthorizationCode(req, res);
191
+
192
+ case 'refresh_token':
193
+ return await this.handleRefreshToken(req, res);
194
+
195
+ default:
196
+ return res.status(400).json({
197
+ error: 'unsupported_grant_type',
198
+ error_description: `Grant type ${grant_type} is not supported`
199
+ });
200
+ }
201
+ } catch (error) {
202
+ return res.status(500).json({
203
+ error: 'server_error',
204
+ error_description: error.message
205
+ });
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Client Credentials flow handler
211
+ */
212
+ async handleClientCredentials(req, res, { client_id, scope }) {
213
+ const scopes = parseScopes(scope);
214
+
215
+ // Validate scopes
216
+ const scopeValidation = validateScopes(scopes, this.supportedScopes);
217
+ if (!scopeValidation.valid) {
218
+ return res.status(400).json({
219
+ error: 'invalid_scope',
220
+ error_description: scopeValidation.error
221
+ });
222
+ }
223
+
224
+ // Create access token
225
+ const accessToken = this.keyManager.createToken({
226
+ iss: this.issuer,
227
+ sub: client_id,
228
+ aud: this.issuer,
229
+ scope: scopeValidation.scopes.join(' '),
230
+ token_type: 'access_token'
231
+ }, this.accessTokenExpiry);
232
+
233
+ return res.status(200).json({
234
+ access_token: accessToken,
235
+ token_type: 'Bearer',
236
+ expires_in: this.parseExpiryToSeconds(this.accessTokenExpiry),
237
+ scope: scopeValidation.scopes.join(' ')
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Authorization Code flow handler
243
+ */
244
+ async handleAuthorizationCode(req, res) {
245
+ if (!this.authCodeResource) {
246
+ return res.status(400).json({
247
+ error: 'unsupported_grant_type',
248
+ error_description: 'Authorization code flow requires authCodeResource'
249
+ });
250
+ }
251
+
252
+ const { code, redirect_uri, code_verifier } = req.body;
253
+
254
+ if (!code) {
255
+ return res.status(400).json({
256
+ error: 'invalid_request',
257
+ error_description: 'code is required'
258
+ });
259
+ }
260
+
261
+ // Find authorization code
262
+ const authCodes = await this.authCodeResource.query({ code });
263
+
264
+ if (authCodes.length === 0) {
265
+ return res.status(400).json({
266
+ error: 'invalid_grant',
267
+ error_description: 'Invalid authorization code'
268
+ });
269
+ }
270
+
271
+ const authCode = authCodes[0];
272
+
273
+ // Validate code expiration
274
+ const now = Math.floor(Date.now() / 1000);
275
+ if (authCode.expiresAt < now) {
276
+ await this.authCodeResource.remove(authCode.id);
277
+ return res.status(400).json({
278
+ error: 'invalid_grant',
279
+ error_description: 'Authorization code has expired'
280
+ });
281
+ }
282
+
283
+ // Validate redirect_uri
284
+ if (authCode.redirectUri !== redirect_uri) {
285
+ return res.status(400).json({
286
+ error: 'invalid_grant',
287
+ error_description: 'redirect_uri mismatch'
288
+ });
289
+ }
290
+
291
+ // Validate PKCE if code_challenge was used
292
+ if (authCode.codeChallenge) {
293
+ if (!code_verifier) {
294
+ return res.status(400).json({
295
+ error: 'invalid_request',
296
+ error_description: 'code_verifier is required'
297
+ });
298
+ }
299
+
300
+ const isValid = await this.validatePKCE(
301
+ code_verifier,
302
+ authCode.codeChallenge,
303
+ authCode.codeChallengeMethod
304
+ );
305
+
306
+ if (!isValid) {
307
+ return res.status(400).json({
308
+ error: 'invalid_grant',
309
+ error_description: 'Invalid code_verifier'
310
+ });
311
+ }
312
+ }
313
+
314
+ // Get user
315
+ const user = await this.userResource.get(authCode.userId);
316
+ if (!user) {
317
+ return res.status(400).json({
318
+ error: 'invalid_grant',
319
+ error_description: 'User not found'
320
+ });
321
+ }
322
+
323
+ // Parse scopes
324
+ const scopes = parseScopes(authCode.scope);
325
+
326
+ // Create access token
327
+ const accessToken = this.keyManager.createToken({
328
+ iss: this.issuer,
329
+ sub: user.id,
330
+ aud: authCode.audience || this.issuer,
331
+ scope: scopes.join(' '),
332
+ token_type: 'access_token'
333
+ }, this.accessTokenExpiry);
334
+
335
+ const response = {
336
+ access_token: accessToken,
337
+ token_type: 'Bearer',
338
+ expires_in: this.parseExpiryToSeconds(this.accessTokenExpiry)
339
+ };
340
+
341
+ // Create ID token if openid scope requested
342
+ if (scopes.includes('openid')) {
343
+ const userClaims = extractUserClaims(user, scopes);
344
+
345
+ const idToken = this.keyManager.createToken({
346
+ iss: this.issuer,
347
+ sub: user.id,
348
+ aud: authCode.clientId,
349
+ nonce: authCode.nonce,
350
+ ...userClaims
351
+ }, this.idTokenExpiry);
352
+
353
+ response.id_token = idToken;
354
+ }
355
+
356
+ // Create refresh token if offline_access scope requested
357
+ if (scopes.includes('offline_access')) {
358
+ const refreshToken = this.keyManager.createToken({
359
+ iss: this.issuer,
360
+ sub: user.id,
361
+ aud: this.issuer,
362
+ scope: scopes.join(' '),
363
+ token_type: 'refresh_token'
364
+ }, this.refreshTokenExpiry);
365
+
366
+ response.refresh_token = refreshToken;
367
+ }
368
+
369
+ // Delete used authorization code
370
+ await this.authCodeResource.remove(authCode.id);
371
+
372
+ return res.status(200).json(response);
373
+ }
374
+
375
+ /**
376
+ * Refresh Token flow handler
377
+ */
378
+ async handleRefreshToken(req, res) {
379
+ const { refresh_token, scope } = req.body;
380
+
381
+ if (!refresh_token) {
382
+ return res.status(400).json({
383
+ error: 'invalid_request',
384
+ error_description: 'refresh_token is required'
385
+ });
386
+ }
387
+
388
+ // Verify refresh token
389
+ const verified = await this.keyManager.verifyToken(refresh_token);
390
+
391
+ if (!verified) {
392
+ return res.status(400).json({
393
+ error: 'invalid_grant',
394
+ error_description: 'Invalid refresh token'
395
+ });
396
+ }
397
+
398
+ const { payload } = verified;
399
+
400
+ // Validate token type
401
+ if (payload.token_type !== 'refresh_token') {
402
+ return res.status(400).json({
403
+ error: 'invalid_grant',
404
+ error_description: 'Token is not a refresh token'
405
+ });
406
+ }
407
+
408
+ // Validate claims
409
+ const claimValidation = validateClaims(payload, {
410
+ issuer: this.issuer,
411
+ clockTolerance: 60
412
+ });
413
+
414
+ if (!claimValidation.valid) {
415
+ return res.status(400).json({
416
+ error: 'invalid_grant',
417
+ error_description: claimValidation.error
418
+ });
419
+ }
420
+
421
+ // Parse scopes (use original scopes if not provided)
422
+ const requestedScopes = scope ? parseScopes(scope) : parseScopes(payload.scope);
423
+ const originalScopes = parseScopes(payload.scope);
424
+
425
+ // Requested scopes must be subset of original scopes
426
+ const invalidScopes = requestedScopes.filter(s => !originalScopes.includes(s));
427
+ if (invalidScopes.length > 0) {
428
+ return res.status(400).json({
429
+ error: 'invalid_scope',
430
+ error_description: `Cannot request scopes not in original grant: ${invalidScopes.join(', ')}`
431
+ });
432
+ }
433
+
434
+ // Get user
435
+ const user = await this.userResource.get(payload.sub);
436
+ if (!user) {
437
+ return res.status(400).json({
438
+ error: 'invalid_grant',
439
+ error_description: 'User not found'
440
+ });
441
+ }
442
+
443
+ // Create new access token
444
+ const accessToken = this.keyManager.createToken({
445
+ iss: this.issuer,
446
+ sub: user.id,
447
+ aud: payload.aud,
448
+ scope: requestedScopes.join(' '),
449
+ token_type: 'access_token'
450
+ }, this.accessTokenExpiry);
451
+
452
+ const response = {
453
+ access_token: accessToken,
454
+ token_type: 'Bearer',
455
+ expires_in: this.parseExpiryToSeconds(this.accessTokenExpiry)
456
+ };
457
+
458
+ // Create new ID token if openid scope requested
459
+ if (requestedScopes.includes('openid')) {
460
+ const userClaims = extractUserClaims(user, requestedScopes);
461
+
462
+ const idToken = this.keyManager.createToken({
463
+ iss: this.issuer,
464
+ sub: user.id,
465
+ aud: payload.aud,
466
+ ...userClaims
467
+ }, this.idTokenExpiry);
468
+
469
+ response.id_token = idToken;
470
+ }
471
+
472
+ return res.status(200).json(response);
473
+ }
474
+
475
+ /**
476
+ * UserInfo endpoint handler
477
+ * GET /auth/userinfo
478
+ */
479
+ async userinfoHandler(req, res) {
480
+ try {
481
+ // Extract token from Authorization header
482
+ const authHeader = req.headers.authorization;
483
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
484
+ return res.status(401).json({
485
+ error: 'invalid_token',
486
+ error_description: 'Missing or invalid Authorization header'
487
+ });
488
+ }
489
+
490
+ const token = authHeader.substring(7);
491
+
492
+ // Verify token
493
+ const verified = await this.keyManager.verifyToken(token);
494
+ if (!verified) {
495
+ return res.status(401).json({
496
+ error: 'invalid_token',
497
+ error_description: 'Invalid access token'
498
+ });
499
+ }
500
+
501
+ const { payload } = verified;
502
+
503
+ // Validate claims
504
+ const claimValidation = validateClaims(payload, {
505
+ issuer: this.issuer,
506
+ clockTolerance: 60
507
+ });
508
+
509
+ if (!claimValidation.valid) {
510
+ return res.status(401).json({
511
+ error: 'invalid_token',
512
+ error_description: claimValidation.error
513
+ });
514
+ }
515
+
516
+ // Get user
517
+ const user = await this.userResource.get(payload.sub);
518
+ if (!user) {
519
+ return res.status(404).json({
520
+ error: 'not_found',
521
+ error_description: 'User not found'
522
+ });
523
+ }
524
+
525
+ // Extract claims based on scopes
526
+ const scopes = parseScopes(payload.scope);
527
+ const userClaims = extractUserClaims(user, scopes);
528
+
529
+ return res.status(200).json({
530
+ sub: user.id,
531
+ ...userClaims
532
+ });
533
+ } catch (error) {
534
+ return res.status(500).json({
535
+ error: 'server_error',
536
+ error_description: error.message
537
+ });
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Token Introspection endpoint handler (RFC 7662)
543
+ * POST /auth/introspect
544
+ */
545
+ async introspectHandler(req, res) {
546
+ try {
547
+ const { token, token_type_hint } = req.body;
548
+
549
+ if (!token) {
550
+ return res.status(400).json({
551
+ error: 'invalid_request',
552
+ error_description: 'token is required'
553
+ });
554
+ }
555
+
556
+ // Verify token
557
+ const verified = await this.keyManager.verifyToken(token);
558
+
559
+ if (!verified) {
560
+ return res.status(200).json({ active: false });
561
+ }
562
+
563
+ const { payload } = verified;
564
+
565
+ // Validate claims
566
+ const claimValidation = validateClaims(payload, {
567
+ issuer: this.issuer,
568
+ clockTolerance: 60
569
+ });
570
+
571
+ if (!claimValidation.valid) {
572
+ return res.status(200).json({ active: false });
573
+ }
574
+
575
+ // Return token metadata
576
+ return res.status(200).json({
577
+ active: true,
578
+ scope: payload.scope,
579
+ client_id: payload.aud,
580
+ username: payload.sub,
581
+ token_type: payload.token_type || 'access_token',
582
+ exp: payload.exp,
583
+ iat: payload.iat,
584
+ sub: payload.sub,
585
+ iss: payload.iss,
586
+ aud: payload.aud
587
+ });
588
+ } catch (error) {
589
+ return res.status(500).json({
590
+ error: 'server_error',
591
+ error_description: error.message
592
+ });
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Authenticate client with credentials
598
+ */
599
+ async authenticateClient(clientId, clientSecret) {
600
+ if (!this.clientResource) {
601
+ return null;
602
+ }
603
+
604
+ try {
605
+ const clients = await this.clientResource.query({ clientId });
606
+
607
+ if (clients.length === 0) {
608
+ return null;
609
+ }
610
+
611
+ const client = clients[0];
612
+
613
+ // Verify client secret
614
+ if (client.clientSecret !== clientSecret) {
615
+ return null;
616
+ }
617
+
618
+ return client;
619
+ } catch (error) {
620
+ return null;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Validate PKCE code verifier
626
+ */
627
+ async validatePKCE(codeVerifier, codeChallenge, codeChallengeMethod = 'plain') {
628
+ if (codeChallengeMethod === 'plain') {
629
+ return codeVerifier === codeChallenge;
630
+ }
631
+
632
+ if (codeChallengeMethod === 'S256') {
633
+ const crypto = await import('crypto');
634
+ const hash = crypto.createHash('sha256')
635
+ .update(codeVerifier)
636
+ .digest('base64url');
637
+ return hash === codeChallenge;
638
+ }
639
+
640
+ return false;
641
+ }
642
+
643
+ /**
644
+ * Parse expiry string to seconds
645
+ */
646
+ parseExpiryToSeconds(expiresIn) {
647
+ const match = expiresIn.match(/^(\d+)([smhd])$/);
648
+ if (!match) {
649
+ throw new Error('Invalid expiresIn format');
650
+ }
651
+
652
+ const [, value, unit] = match;
653
+ const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
654
+ return parseInt(value) * multipliers[unit];
655
+ }
656
+
657
+ /**
658
+ * Authorization endpoint handler (GET /oauth/authorize)
659
+ * Implements OAuth2 authorization code flow
660
+ *
661
+ * Query params:
662
+ * - response_type: 'code' (required)
663
+ * - client_id: Client identifier (required)
664
+ * - redirect_uri: Callback URL (required)
665
+ * - scope: Requested scopes (optional)
666
+ * - state: CSRF protection (recommended)
667
+ * - code_challenge: PKCE challenge (optional)
668
+ * - code_challenge_method: PKCE method (optional, default: plain)
669
+ */
670
+ async authorizeHandler(req, res) {
671
+ try {
672
+ const {
673
+ response_type,
674
+ client_id,
675
+ redirect_uri,
676
+ scope,
677
+ state,
678
+ code_challenge,
679
+ code_challenge_method = 'plain'
680
+ } = req.query || {};
681
+
682
+ // Validate required parameters
683
+ if (!response_type || !client_id || !redirect_uri) {
684
+ return res.status(400).json({
685
+ error: 'invalid_request',
686
+ error_description: 'response_type, client_id, and redirect_uri are required'
687
+ });
688
+ }
689
+
690
+ // Validate response_type
691
+ if (!this.supportedResponseTypes.includes(response_type)) {
692
+ return res.status(400).json({
693
+ error: 'unsupported_response_type',
694
+ error_description: `Response type ${response_type} is not supported`
695
+ });
696
+ }
697
+
698
+ // Validate client
699
+ if (this.clientResource) {
700
+ const clients = await this.clientResource.query({ clientId: client_id });
701
+
702
+ if (clients.length === 0) {
703
+ return res.status(400).json({
704
+ error: 'invalid_client',
705
+ error_description: 'Client not found'
706
+ });
707
+ }
708
+
709
+ const client = clients[0];
710
+
711
+ // Validate redirect_uri
712
+ if (!client.redirectUris.includes(redirect_uri)) {
713
+ return res.status(400).json({
714
+ error: 'invalid_request',
715
+ error_description: 'Invalid redirect_uri'
716
+ });
717
+ }
718
+
719
+ // Validate scopes
720
+ if (scope) {
721
+ const requestedScopes = scope.split(' ');
722
+ const invalidScopes = requestedScopes.filter(s =>
723
+ !client.allowedScopes.includes(s)
724
+ );
725
+
726
+ if (invalidScopes.length > 0) {
727
+ return res.status(400).json({
728
+ error: 'invalid_scope',
729
+ error_description: `Invalid scopes: ${invalidScopes.join(', ')}`
730
+ });
731
+ }
732
+ }
733
+ }
734
+
735
+ // For now, return a simple HTML form for user authentication
736
+ // In production, this would be a proper login UI with session management
737
+ const html = `
738
+ <!DOCTYPE html>
739
+ <html>
740
+ <head>
741
+ <title>Authorization - ${this.issuer}</title>
742
+ <style>
743
+ body { font-family: system-ui; max-width: 400px; margin: 100px auto; padding: 20px; }
744
+ form { background: #f5f5f5; padding: 20px; border-radius: 8px; }
745
+ input { width: 100%; padding: 10px; margin: 10px 0; box-sizing: border-box; }
746
+ button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
747
+ button:hover { background: #0056b3; }
748
+ .info { background: #e7f3ff; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
749
+ </style>
750
+ </head>
751
+ <body>
752
+ <div class="info">
753
+ <strong>Application requesting access:</strong><br>
754
+ Client ID: ${client_id}<br>
755
+ Scopes: ${scope || 'none'}<br>
756
+ Redirect: ${redirect_uri}
757
+ </div>
758
+ <form method="POST" action="/oauth/authorize">
759
+ <input type="hidden" name="response_type" value="${response_type}">
760
+ <input type="hidden" name="client_id" value="${client_id}">
761
+ <input type="hidden" name="redirect_uri" value="${redirect_uri}">
762
+ <input type="hidden" name="scope" value="${scope || ''}">
763
+ <input type="hidden" name="state" value="${state || ''}">
764
+ <input type="hidden" name="code_challenge" value="${code_challenge || ''}">
765
+ <input type="hidden" name="code_challenge_method" value="${code_challenge_method}">
766
+
767
+ <input type="email" name="username" placeholder="Email" required>
768
+ <input type="password" name="password" placeholder="Password" required>
769
+ <button type="submit">Authorize</button>
770
+ </form>
771
+ </body>
772
+ </html>`;
773
+
774
+ return res.status(200).header('Content-Type', 'text/html').send(html);
775
+
776
+ } catch (error) {
777
+ console.error('[OAuth2Server] Authorization error:', error);
778
+ return res.status(500).json({
779
+ error: 'server_error',
780
+ error_description: error.message
781
+ });
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Authorization endpoint handler (POST /oauth/authorize)
787
+ * Processes user authentication and generates authorization code
788
+ */
789
+ async authorizePostHandler(req, res) {
790
+ try {
791
+ const {
792
+ response_type,
793
+ client_id,
794
+ redirect_uri,
795
+ scope,
796
+ state,
797
+ code_challenge,
798
+ code_challenge_method = 'plain',
799
+ username,
800
+ password
801
+ } = req.body || {};
802
+
803
+ // Authenticate user
804
+ const users = await this.userResource.query({ email: username });
805
+
806
+ if (users.length === 0) {
807
+ return res.status(401).json({
808
+ error: 'access_denied',
809
+ error_description: 'Invalid credentials'
810
+ });
811
+ }
812
+
813
+ const user = users[0];
814
+
815
+ // Verify password (assuming password is hashed with bcrypt or similar)
816
+ // In production, use proper password verification
817
+ if (user.password !== password) {
818
+ return res.status(401).json({
819
+ error: 'access_denied',
820
+ error_description: 'Invalid credentials'
821
+ });
822
+ }
823
+
824
+ // Generate authorization code
825
+ const code = generateAuthCode();
826
+ const expiresAt = new Date(Date.now() + this.parseExpiryToSeconds(this.authCodeExpiry) * 1000).toISOString();
827
+
828
+ // Store authorization code
829
+ if (this.authCodeResource) {
830
+ await this.authCodeResource.insert({
831
+ code,
832
+ clientId: client_id,
833
+ userId: user.id,
834
+ redirectUri: redirect_uri,
835
+ scope: scope || '',
836
+ expiresAt,
837
+ used: false,
838
+ codeChallenge: code_challenge || null,
839
+ codeChallengeMethod: code_challenge_method
840
+ });
841
+ }
842
+
843
+ // Build redirect URL with authorization code
844
+ const url = new URL(redirect_uri);
845
+ url.searchParams.set('code', code);
846
+ if (state) {
847
+ url.searchParams.set('state', state);
848
+ }
849
+
850
+ // Redirect user back to client application
851
+ return res.redirect(url.toString());
852
+
853
+ } catch (error) {
854
+ console.error('[OAuth2Server] Authorization POST error:', error);
855
+ return res.status(500).json({
856
+ error: 'server_error',
857
+ error_description: error.message
858
+ });
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Client Registration endpoint handler (POST /oauth/register)
864
+ * Implements RFC 7591 - OAuth 2.0 Dynamic Client Registration
865
+ *
866
+ * Request body:
867
+ * - redirect_uris: Array of redirect URIs (required)
868
+ * - token_endpoint_auth_method: 'client_secret_basic' | 'client_secret_post'
869
+ * - grant_types: Array of grant types (optional)
870
+ * - response_types: Array of response types (optional)
871
+ * - client_name: Human-readable name (optional)
872
+ * - client_uri: URL of client homepage (optional)
873
+ * - logo_uri: URL of client logo (optional)
874
+ * - scope: Space-separated scopes (optional)
875
+ * - contacts: Array of contact emails (optional)
876
+ * - tos_uri: Terms of service URL (optional)
877
+ * - policy_uri: Privacy policy URL (optional)
878
+ */
879
+ async registerClientHandler(req, res) {
880
+ try {
881
+ const {
882
+ redirect_uris,
883
+ token_endpoint_auth_method = 'client_secret_basic',
884
+ grant_types,
885
+ response_types,
886
+ client_name,
887
+ client_uri,
888
+ logo_uri,
889
+ scope,
890
+ contacts,
891
+ tos_uri,
892
+ policy_uri
893
+ } = req.body || {};
894
+
895
+ // Validate required fields
896
+ if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
897
+ return res.status(400).json({
898
+ error: 'invalid_redirect_uri',
899
+ error_description: 'redirect_uris is required and must be a non-empty array'
900
+ });
901
+ }
902
+
903
+ // Validate redirect URIs (must be HTTPS in production)
904
+ for (const uri of redirect_uris) {
905
+ try {
906
+ new URL(uri);
907
+ } catch {
908
+ return res.status(400).json({
909
+ error: 'invalid_redirect_uri',
910
+ error_description: `Invalid redirect URI: ${uri}`
911
+ });
912
+ }
913
+ }
914
+
915
+ // Generate client credentials
916
+ const clientId = generateClientId();
917
+ const clientSecret = generateClientSecret();
918
+
919
+ // Prepare client metadata
920
+ const clientData = {
921
+ clientId,
922
+ clientSecret,
923
+ name: client_name || `Client ${clientId}`,
924
+ redirectUris: redirect_uris,
925
+ allowedScopes: scope ? scope.split(' ') : this.supportedScopes,
926
+ grantTypes: grant_types || ['authorization_code', 'refresh_token'],
927
+ responseTypes: response_types || ['code'],
928
+ tokenEndpointAuthMethod: token_endpoint_auth_method,
929
+ active: true
930
+ };
931
+
932
+ // Optional fields
933
+ if (client_uri) clientData.clientUri = client_uri;
934
+ if (logo_uri) clientData.logoUri = logo_uri;
935
+ if (contacts) clientData.contacts = contacts;
936
+ if (tos_uri) clientData.tosUri = tos_uri;
937
+ if (policy_uri) clientData.policyUri = policy_uri;
938
+
939
+ // Store client
940
+ if (!this.clientResource) {
941
+ return res.status(500).json({
942
+ error: 'server_error',
943
+ error_description: 'Client registration not available'
944
+ });
945
+ }
946
+
947
+ const client = await this.clientResource.insert(clientData);
948
+
949
+ // Return client credentials (RFC 7591 response format)
950
+ return res.status(201).json({
951
+ client_id: clientId,
952
+ client_secret: clientSecret,
953
+ client_id_issued_at: Math.floor(Date.now() / 1000),
954
+ client_secret_expires_at: 0, // 0 = never expires
955
+ redirect_uris: redirect_uris,
956
+ token_endpoint_auth_method,
957
+ grant_types: clientData.grantTypes,
958
+ response_types: clientData.responseTypes,
959
+ client_name: clientData.name,
960
+ client_uri,
961
+ logo_uri,
962
+ scope: clientData.allowedScopes.join(' '),
963
+ contacts,
964
+ tos_uri,
965
+ policy_uri
966
+ });
967
+
968
+ } catch (error) {
969
+ console.error('[OAuth2Server] Client registration error:', error);
970
+ return res.status(500).json({
971
+ error: 'server_error',
972
+ error_description: error.message
973
+ });
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Token Revocation endpoint handler (POST /oauth/revoke)
979
+ * Implements RFC 7009 - OAuth 2.0 Token Revocation
980
+ *
981
+ * Request body:
982
+ * - token: Token to revoke (required)
983
+ * - token_type_hint: 'access_token' | 'refresh_token' (optional)
984
+ */
985
+ async revokeHandler(req, res) {
986
+ try {
987
+ const { token, token_type_hint } = req.body || {};
988
+
989
+ if (!token) {
990
+ return res.status(400).json({
991
+ error: 'invalid_request',
992
+ error_description: 'token is required'
993
+ });
994
+ }
995
+
996
+ // Verify and decode token
997
+ const { publicKey, privateKey, kid } = await this.keyManager.getCurrentKey();
998
+ const { verifyRS256Token } = await import('./rsa-keys.js');
999
+
1000
+ const [valid, payload] = verifyRS256Token(token, publicKey);
1001
+
1002
+ if (!valid) {
1003
+ // RFC 7009: "The authorization server responds with HTTP status code 200"
1004
+ // even if token is invalid (prevents token scanning)
1005
+ return res.status(200).send();
1006
+ }
1007
+
1008
+ // In a production system, you would:
1009
+ // 1. Store revoked tokens in a blacklist (Redis, database, etc.)
1010
+ // 2. Check blacklist during token validation
1011
+ // 3. Set TTL on blacklist entries matching token expiry
1012
+
1013
+ // For now, just return success
1014
+ // TODO: Implement token blacklist storage
1015
+
1016
+ return res.status(200).send();
1017
+
1018
+ } catch (error) {
1019
+ console.error('[OAuth2Server] Token revocation error:', error);
1020
+ // RFC 7009: Return 200 even on error (security best practice)
1021
+ return res.status(200).send();
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Rotate signing keys
1027
+ */
1028
+ async rotateKeys() {
1029
+ return await this.keyManager.rotateKey();
1030
+ }
1031
+ }
1032
+
1033
+ export default OAuth2Server;