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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery - OpenID Connect Discovery Document Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates .well-known/openid-configuration and JWKS endpoints
|
|
5
|
+
* Implements OpenID Connect Discovery 1.0 specification
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate OpenID Connect Discovery Document
|
|
10
|
+
* @param {Object} options - Configuration options
|
|
11
|
+
* @param {string} options.issuer - Issuer URL (e.g., 'https://sso.example.com')
|
|
12
|
+
* @param {Array} options.grantTypes - Supported grant types
|
|
13
|
+
* @param {Array} options.responseTypes - Supported response types
|
|
14
|
+
* @param {Array} options.scopes - Supported scopes
|
|
15
|
+
* @returns {Object} OIDC Discovery document
|
|
16
|
+
*/
|
|
17
|
+
export function generateDiscoveryDocument(options = {}) {
|
|
18
|
+
const {
|
|
19
|
+
issuer,
|
|
20
|
+
grantTypes = ['authorization_code', 'client_credentials', 'refresh_token'],
|
|
21
|
+
responseTypes = ['code', 'token', 'id_token', 'code id_token', 'code token', 'id_token token', 'code id_token token'],
|
|
22
|
+
scopes = ['openid', 'profile', 'email', 'offline_access']
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
if (!issuer) {
|
|
26
|
+
throw new Error('Issuer URL is required for OIDC discovery');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Remove trailing slash from issuer
|
|
30
|
+
const baseUrl = issuer.replace(/\/$/, '');
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
issuer: baseUrl,
|
|
34
|
+
authorization_endpoint: `${baseUrl}/auth/authorize`,
|
|
35
|
+
token_endpoint: `${baseUrl}/auth/token`,
|
|
36
|
+
userinfo_endpoint: `${baseUrl}/auth/userinfo`,
|
|
37
|
+
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|
38
|
+
registration_endpoint: `${baseUrl}/auth/register`,
|
|
39
|
+
introspection_endpoint: `${baseUrl}/auth/introspect`,
|
|
40
|
+
revocation_endpoint: `${baseUrl}/auth/revoke`,
|
|
41
|
+
end_session_endpoint: `${baseUrl}/auth/logout`,
|
|
42
|
+
|
|
43
|
+
// Supported features
|
|
44
|
+
scopes_supported: scopes,
|
|
45
|
+
response_types_supported: responseTypes,
|
|
46
|
+
response_modes_supported: ['query', 'fragment', 'form_post'],
|
|
47
|
+
grant_types_supported: grantTypes,
|
|
48
|
+
subject_types_supported: ['public'],
|
|
49
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
50
|
+
token_endpoint_auth_methods_supported: [
|
|
51
|
+
'client_secret_basic',
|
|
52
|
+
'client_secret_post',
|
|
53
|
+
'none'
|
|
54
|
+
],
|
|
55
|
+
|
|
56
|
+
// Claims
|
|
57
|
+
claims_supported: [
|
|
58
|
+
'sub',
|
|
59
|
+
'iss',
|
|
60
|
+
'aud',
|
|
61
|
+
'exp',
|
|
62
|
+
'iat',
|
|
63
|
+
'auth_time',
|
|
64
|
+
'nonce',
|
|
65
|
+
'email',
|
|
66
|
+
'email_verified',
|
|
67
|
+
'name',
|
|
68
|
+
'given_name',
|
|
69
|
+
'family_name',
|
|
70
|
+
'picture',
|
|
71
|
+
'locale'
|
|
72
|
+
],
|
|
73
|
+
|
|
74
|
+
// Code challenge methods (PKCE)
|
|
75
|
+
code_challenge_methods_supported: ['plain', 'S256'],
|
|
76
|
+
|
|
77
|
+
// UI locales
|
|
78
|
+
ui_locales_supported: ['en', 'pt-BR'],
|
|
79
|
+
|
|
80
|
+
// Service documentation
|
|
81
|
+
service_documentation: `${baseUrl}/docs`,
|
|
82
|
+
|
|
83
|
+
// Additional metadata
|
|
84
|
+
claim_types_supported: ['normal'],
|
|
85
|
+
claims_parameter_supported: false,
|
|
86
|
+
request_parameter_supported: false,
|
|
87
|
+
request_uri_parameter_supported: false,
|
|
88
|
+
require_request_uri_registration: false,
|
|
89
|
+
|
|
90
|
+
// Discovery document version
|
|
91
|
+
version: '1.0'
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate OAuth2/OIDC claims in JWT payload
|
|
97
|
+
* @param {Object} payload - JWT payload
|
|
98
|
+
* @param {Object} options - Validation options
|
|
99
|
+
* @param {string} options.issuer - Expected issuer
|
|
100
|
+
* @param {string} options.audience - Expected audience
|
|
101
|
+
* @param {number} options.clockTolerance - Clock skew tolerance in seconds (default: 60)
|
|
102
|
+
* @returns {Object} { valid: boolean, error: string|null }
|
|
103
|
+
*/
|
|
104
|
+
export function validateClaims(payload, options = {}) {
|
|
105
|
+
const {
|
|
106
|
+
issuer,
|
|
107
|
+
audience,
|
|
108
|
+
clockTolerance = 60
|
|
109
|
+
} = options;
|
|
110
|
+
|
|
111
|
+
const now = Math.floor(Date.now() / 1000);
|
|
112
|
+
|
|
113
|
+
// Check required claims
|
|
114
|
+
if (!payload.sub) {
|
|
115
|
+
return { valid: false, error: 'Missing required claim: sub' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!payload.iat) {
|
|
119
|
+
return { valid: false, error: 'Missing required claim: iat' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!payload.exp) {
|
|
123
|
+
return { valid: false, error: 'Missing required claim: exp' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate issuer
|
|
127
|
+
if (issuer && payload.iss !== issuer) {
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
error: `Invalid issuer. Expected: ${issuer}, Got: ${payload.iss}`
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Validate audience
|
|
135
|
+
if (audience) {
|
|
136
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
137
|
+
|
|
138
|
+
if (!audiences.includes(audience)) {
|
|
139
|
+
return {
|
|
140
|
+
valid: false,
|
|
141
|
+
error: `Invalid audience. Expected: ${audience}, Got: ${audiences.join(', ')}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate expiration with clock tolerance
|
|
147
|
+
if (payload.exp < (now - clockTolerance)) {
|
|
148
|
+
return { valid: false, error: 'Token has expired' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate not before (if present)
|
|
152
|
+
if (payload.nbf && payload.nbf > (now + clockTolerance)) {
|
|
153
|
+
return { valid: false, error: 'Token not yet valid (nbf)' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Validate issued at (basic sanity check - not in future)
|
|
157
|
+
if (payload.iat > (now + clockTolerance)) {
|
|
158
|
+
return { valid: false, error: 'Token issued in the future' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { valid: true, error: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract user claims from user object for ID token
|
|
166
|
+
* @param {Object} user - User object from database
|
|
167
|
+
* @param {Array} scopes - Requested scopes
|
|
168
|
+
* @returns {Object} User claims
|
|
169
|
+
*/
|
|
170
|
+
export function extractUserClaims(user, scopes = []) {
|
|
171
|
+
const claims = {
|
|
172
|
+
sub: user.id // Subject - user ID
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Add email claims if 'email' scope requested
|
|
176
|
+
if (scopes.includes('email') && user.email) {
|
|
177
|
+
claims.email = user.email;
|
|
178
|
+
claims.email_verified = user.emailVerified || false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Add profile claims if 'profile' scope requested
|
|
182
|
+
if (scopes.includes('profile')) {
|
|
183
|
+
if (user.name) claims.name = user.name;
|
|
184
|
+
if (user.givenName) claims.given_name = user.givenName;
|
|
185
|
+
if (user.familyName) claims.family_name = user.familyName;
|
|
186
|
+
if (user.picture) claims.picture = user.picture;
|
|
187
|
+
if (user.locale) claims.locale = user.locale;
|
|
188
|
+
if (user.zoneinfo) claims.zoneinfo = user.zoneinfo;
|
|
189
|
+
if (user.birthdate) claims.birthdate = user.birthdate;
|
|
190
|
+
if (user.gender) claims.gender = user.gender;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return claims;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse scope string into array
|
|
198
|
+
* @param {string} scopeString - Space-separated scopes (e.g., 'openid profile email')
|
|
199
|
+
* @returns {Array} Array of scopes
|
|
200
|
+
*/
|
|
201
|
+
export function parseScopes(scopeString) {
|
|
202
|
+
if (!scopeString || typeof scopeString !== 'string') {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return scopeString
|
|
207
|
+
.trim()
|
|
208
|
+
.split(/\s+/)
|
|
209
|
+
.filter(s => s.length > 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Validate requested scopes against supported scopes
|
|
214
|
+
* @param {Array} requestedScopes - Scopes requested by client
|
|
215
|
+
* @param {Array} supportedScopes - Scopes supported by server
|
|
216
|
+
* @returns {Object} { valid: boolean, error: string|null, scopes: Array }
|
|
217
|
+
*/
|
|
218
|
+
export function validateScopes(requestedScopes, supportedScopes) {
|
|
219
|
+
if (!Array.isArray(requestedScopes)) {
|
|
220
|
+
requestedScopes = parseScopes(requestedScopes);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if all requested scopes are supported
|
|
224
|
+
const invalidScopes = requestedScopes.filter(scope => !supportedScopes.includes(scope));
|
|
225
|
+
|
|
226
|
+
if (invalidScopes.length > 0) {
|
|
227
|
+
return {
|
|
228
|
+
valid: false,
|
|
229
|
+
error: `Unsupported scopes: ${invalidScopes.join(', ')}`,
|
|
230
|
+
scopes: []
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
valid: true,
|
|
236
|
+
error: null,
|
|
237
|
+
scopes: requestedScopes
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generate authorization code (random string)
|
|
243
|
+
* @param {number} length - Code length (default: 32)
|
|
244
|
+
* @returns {string} Authorization code
|
|
245
|
+
*/
|
|
246
|
+
export function generateAuthCode(length = 32) {
|
|
247
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
248
|
+
let code = '';
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < length; i++) {
|
|
251
|
+
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return code;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Generate client ID
|
|
259
|
+
* @returns {string} Client ID (UUID-like)
|
|
260
|
+
*/
|
|
261
|
+
export function generateClientId() {
|
|
262
|
+
return crypto.randomUUID();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate client secret
|
|
267
|
+
* @param {number} length - Secret length (default: 64)
|
|
268
|
+
* @returns {string} Client secret
|
|
269
|
+
*/
|
|
270
|
+
export function generateClientSecret(length = 64) {
|
|
271
|
+
return crypto.randomBytes(length / 2).toString('hex');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
import crypto from 'crypto';
|
|
275
|
+
|
|
276
|
+
export default {
|
|
277
|
+
generateDiscoveryDocument,
|
|
278
|
+
validateClaims,
|
|
279
|
+
extractUserClaims,
|
|
280
|
+
parseScopes,
|
|
281
|
+
validateScopes,
|
|
282
|
+
generateAuthCode,
|
|
283
|
+
generateClientId,
|
|
284
|
+
generateClientSecret
|
|
285
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSA Key Management for OAuth2/OIDC
|
|
3
|
+
*
|
|
4
|
+
* Manages RS256 key pairs for signing and verifying JWTs
|
|
5
|
+
* Zero external dependencies - uses Node.js crypto only
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateKeyPairSync, createSign, createVerify, createHash } from 'crypto';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate RSA key pair for RS256
|
|
12
|
+
* @param {number} modulusLength - Key size in bits (default: 2048)
|
|
13
|
+
* @returns {Object} { publicKey, privateKey, kid }
|
|
14
|
+
*/
|
|
15
|
+
export function generateKeyPair(modulusLength = 2048) {
|
|
16
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
17
|
+
modulusLength,
|
|
18
|
+
publicKeyEncoding: {
|
|
19
|
+
type: 'spki',
|
|
20
|
+
format: 'pem'
|
|
21
|
+
},
|
|
22
|
+
privateKeyEncoding: {
|
|
23
|
+
type: 'pkcs8',
|
|
24
|
+
format: 'pem'
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Generate key ID (kid) from public key fingerprint
|
|
29
|
+
const kid = createHash('sha256')
|
|
30
|
+
.update(publicKey)
|
|
31
|
+
.digest('hex')
|
|
32
|
+
.substring(0, 16);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
publicKey,
|
|
36
|
+
privateKey,
|
|
37
|
+
kid,
|
|
38
|
+
algorithm: 'RS256',
|
|
39
|
+
use: 'sig',
|
|
40
|
+
createdAt: new Date().toISOString()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert PEM public key to JWK format
|
|
46
|
+
* @param {string} publicKeyPem - PEM formatted public key
|
|
47
|
+
* @param {string} kid - Key ID
|
|
48
|
+
* @returns {Object} JWK (JSON Web Key)
|
|
49
|
+
*/
|
|
50
|
+
export function pemToJwk(publicKeyPem, kid) {
|
|
51
|
+
// Extract key components using Node.js crypto
|
|
52
|
+
const keyObject = createPublicKey(publicKeyPem);
|
|
53
|
+
const exported = keyObject.export({ format: 'jwk' });
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
kty: 'RSA',
|
|
57
|
+
use: 'sig',
|
|
58
|
+
alg: 'RS256',
|
|
59
|
+
kid,
|
|
60
|
+
n: exported.n, // modulus
|
|
61
|
+
e: exported.e // exponent
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create RS256 JWT token
|
|
67
|
+
* @param {Object} payload - Token payload
|
|
68
|
+
* @param {string} privateKey - PEM formatted private key
|
|
69
|
+
* @param {string} kid - Key ID
|
|
70
|
+
* @param {string} expiresIn - Token expiration (e.g., '15m')
|
|
71
|
+
* @returns {string} JWT token
|
|
72
|
+
*/
|
|
73
|
+
export function createRS256Token(payload, privateKey, kid, expiresIn = '15m') {
|
|
74
|
+
// Parse expiresIn
|
|
75
|
+
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
76
|
+
if (!match) {
|
|
77
|
+
throw new Error('Invalid expiresIn format. Use: 60s, 30m, 24h, 7d');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [, value, unit] = match;
|
|
81
|
+
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
82
|
+
const expiresInSeconds = parseInt(value) * multipliers[unit];
|
|
83
|
+
|
|
84
|
+
const header = {
|
|
85
|
+
alg: 'RS256',
|
|
86
|
+
typ: 'JWT',
|
|
87
|
+
kid
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const now = Math.floor(Date.now() / 1000);
|
|
91
|
+
|
|
92
|
+
const data = {
|
|
93
|
+
...payload,
|
|
94
|
+
iat: now,
|
|
95
|
+
exp: now + expiresInSeconds
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Encode header and payload
|
|
99
|
+
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
100
|
+
const encodedPayload = Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
101
|
+
|
|
102
|
+
// Sign with RSA private key
|
|
103
|
+
const sign = createSign('RSA-SHA256');
|
|
104
|
+
sign.update(`${encodedHeader}.${encodedPayload}`);
|
|
105
|
+
sign.end();
|
|
106
|
+
|
|
107
|
+
const signature = sign.sign(privateKey, 'base64url');
|
|
108
|
+
|
|
109
|
+
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Verify RS256 JWT token
|
|
114
|
+
* @param {string} token - JWT token
|
|
115
|
+
* @param {string} publicKey - PEM formatted public key
|
|
116
|
+
* @returns {Object|null} Decoded payload or null if invalid
|
|
117
|
+
*/
|
|
118
|
+
export function verifyRS256Token(token, publicKey) {
|
|
119
|
+
try {
|
|
120
|
+
const parts = token.split('.');
|
|
121
|
+
if (parts.length !== 3) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [encodedHeader, encodedPayload, signature] = parts;
|
|
126
|
+
|
|
127
|
+
// Verify signature
|
|
128
|
+
const verify = createVerify('RSA-SHA256');
|
|
129
|
+
verify.update(`${encodedHeader}.${encodedPayload}`);
|
|
130
|
+
verify.end();
|
|
131
|
+
|
|
132
|
+
const isValid = verify.verify(publicKey, signature, 'base64url');
|
|
133
|
+
|
|
134
|
+
if (!isValid) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Decode header and payload
|
|
139
|
+
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
|
|
140
|
+
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString());
|
|
141
|
+
|
|
142
|
+
// Verify algorithm
|
|
143
|
+
if (header.alg !== 'RS256') {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check expiration
|
|
148
|
+
const now = Math.floor(Date.now() / 1000);
|
|
149
|
+
if (payload.exp && payload.exp < now) {
|
|
150
|
+
return null; // Expired
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
header,
|
|
155
|
+
payload
|
|
156
|
+
};
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get key ID (kid) from JWT token header
|
|
164
|
+
* @param {string} token - JWT token
|
|
165
|
+
* @returns {string|null} Key ID or null
|
|
166
|
+
*/
|
|
167
|
+
export function getKidFromToken(token) {
|
|
168
|
+
try {
|
|
169
|
+
const [encodedHeader] = token.split('.');
|
|
170
|
+
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
|
|
171
|
+
return header.kid || null;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Import createPublicKey for JWK conversion
|
|
179
|
+
*/
|
|
180
|
+
import { createPublicKey } from 'crypto';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Key Manager class - manages key rotation and storage
|
|
184
|
+
*/
|
|
185
|
+
export class KeyManager {
|
|
186
|
+
constructor(keyResource) {
|
|
187
|
+
this.keyResource = keyResource;
|
|
188
|
+
this.currentKey = null;
|
|
189
|
+
this.keys = new Map(); // kid → key
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Initialize key manager - load or generate keys
|
|
194
|
+
*/
|
|
195
|
+
async initialize() {
|
|
196
|
+
// Try to load existing keys
|
|
197
|
+
const existingKeys = await this.keyResource.list();
|
|
198
|
+
|
|
199
|
+
if (existingKeys.length > 0) {
|
|
200
|
+
// Load keys into memory
|
|
201
|
+
for (const keyRecord of existingKeys) {
|
|
202
|
+
this.keys.set(keyRecord.kid, {
|
|
203
|
+
publicKey: keyRecord.publicKey,
|
|
204
|
+
privateKey: keyRecord.privateKey,
|
|
205
|
+
kid: keyRecord.kid,
|
|
206
|
+
createdAt: keyRecord.createdAt,
|
|
207
|
+
active: keyRecord.active
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (keyRecord.active) {
|
|
211
|
+
this.currentKey = keyRecord;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// If no active key, generate one
|
|
217
|
+
if (!this.currentKey) {
|
|
218
|
+
await this.rotateKey();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Rotate keys - generate new key pair
|
|
224
|
+
*/
|
|
225
|
+
async rotateKey() {
|
|
226
|
+
const keyPair = generateKeyPair();
|
|
227
|
+
|
|
228
|
+
// Mark old keys as inactive
|
|
229
|
+
const oldKeys = await this.keyResource.query({ active: true });
|
|
230
|
+
for (const oldKey of oldKeys) {
|
|
231
|
+
await this.keyResource.update(oldKey.id, { active: false });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Store new key
|
|
235
|
+
const keyRecord = await this.keyResource.insert({
|
|
236
|
+
kid: keyPair.kid,
|
|
237
|
+
publicKey: keyPair.publicKey,
|
|
238
|
+
privateKey: keyPair.privateKey,
|
|
239
|
+
algorithm: keyPair.algorithm,
|
|
240
|
+
use: keyPair.use,
|
|
241
|
+
active: true,
|
|
242
|
+
createdAt: keyPair.createdAt
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
this.currentKey = keyRecord;
|
|
246
|
+
this.keys.set(keyRecord.kid, keyRecord);
|
|
247
|
+
|
|
248
|
+
return keyRecord;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get current active key
|
|
253
|
+
*/
|
|
254
|
+
getCurrentKey() {
|
|
255
|
+
return this.currentKey;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get key by kid
|
|
260
|
+
*/
|
|
261
|
+
getKey(kid) {
|
|
262
|
+
return this.keys.get(kid);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get all public keys in JWKS format
|
|
267
|
+
*/
|
|
268
|
+
async getJWKS() {
|
|
269
|
+
const keys = Array.from(this.keys.values()).map(key => ({
|
|
270
|
+
kty: 'RSA',
|
|
271
|
+
use: 'sig',
|
|
272
|
+
alg: 'RS256',
|
|
273
|
+
kid: key.kid,
|
|
274
|
+
...pemToJwk(key.publicKey, key.kid)
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
return { keys };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create JWT with current active key
|
|
282
|
+
*/
|
|
283
|
+
createToken(payload, expiresIn = '15m') {
|
|
284
|
+
if (!this.currentKey) {
|
|
285
|
+
throw new Error('No active key available');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return createRS256Token(
|
|
289
|
+
payload,
|
|
290
|
+
this.currentKey.privateKey,
|
|
291
|
+
this.currentKey.kid,
|
|
292
|
+
expiresIn
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Verify JWT token
|
|
298
|
+
*/
|
|
299
|
+
async verifyToken(token) {
|
|
300
|
+
const kid = getKidFromToken(token);
|
|
301
|
+
|
|
302
|
+
if (!kid) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const key = this.getKey(kid);
|
|
307
|
+
|
|
308
|
+
if (!key) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return verifyRS256Token(token, key.publicKey);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default {
|
|
317
|
+
generateKeyPair,
|
|
318
|
+
pemToJwk,
|
|
319
|
+
createRS256Token,
|
|
320
|
+
verifyRS256Token,
|
|
321
|
+
getKidFromToken,
|
|
322
|
+
KeyManager
|
|
323
|
+
};
|