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,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;
|