aegisnode 0.0.1
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/LICENSE +21 -0
- package/README.md +2461 -0
- package/bin/aegisnode.js +9 -0
- package/package.json +56 -0
- package/scripts/smoke-test.js +1831 -0
- package/src/cli/commands/createapp.js +191 -0
- package/src/cli/commands/doctor.js +199 -0
- package/src/cli/commands/generate.js +266 -0
- package/src/cli/commands/runserver.js +17 -0
- package/src/cli/commands/startproject.js +72 -0
- package/src/cli/commands/updatedeps.js +355 -0
- package/src/cli/index.js +151 -0
- package/src/cli/utils/fs.js +53 -0
- package/src/cli/utils/project.js +67 -0
- package/src/cli/utils/scaffolds.js +596 -0
- package/src/index.js +20 -0
- package/src/runtime/auth.js +2291 -0
- package/src/runtime/cache.js +37 -0
- package/src/runtime/config.js +482 -0
- package/src/runtime/container.js +43 -0
- package/src/runtime/database.js +195 -0
- package/src/runtime/events.js +33 -0
- package/src/runtime/helpers.js +575 -0
- package/src/runtime/kernel.js +3713 -0
- package/src/runtime/loaders.js +46 -0
- package/src/runtime/logger.js +56 -0
- package/src/runtime/mail.js +225 -0
- package/src/runtime/upload.js +272 -0
- package/src/runtime/views/default-install.ejs +183 -0
- package/src/runtime/views/default-maintenance.ejs +148 -0
|
@@ -0,0 +1,2291 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
6
|
+
|
|
7
|
+
const SUPPORTED_PROVIDERS = new Set(['jwt', 'oauth2']);
|
|
8
|
+
const SUPPORTED_JWT_ALGORITHMS = new Set([
|
|
9
|
+
'HS256',
|
|
10
|
+
'HS384',
|
|
11
|
+
'HS512',
|
|
12
|
+
]);
|
|
13
|
+
const SUPPORTED_STORAGE_DRIVERS = new Set(['cache', 'memory', 'file', 'database']);
|
|
14
|
+
const SUPPORTED_OAUTH2_GRANTS = new Set([
|
|
15
|
+
'authorization_code',
|
|
16
|
+
'client_credentials',
|
|
17
|
+
'refresh_token',
|
|
18
|
+
]);
|
|
19
|
+
const SUPPORTED_OAUTH2_CLIENT_AUTH_METHODS = new Set([
|
|
20
|
+
'client_secret_basic',
|
|
21
|
+
'client_secret_post',
|
|
22
|
+
'none',
|
|
23
|
+
]);
|
|
24
|
+
const SUPPORTED_OAUTH2_PKCE_METHODS = new Set(['S256', 'plain']);
|
|
25
|
+
const scryptAsync = promisify(crypto.scrypt);
|
|
26
|
+
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asNonEmptyString(value, fallback = '') {
|
|
32
|
+
if (typeof value !== 'string') {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asPositiveInteger(value, fallback) {
|
|
41
|
+
const parsed = Number(value);
|
|
42
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
43
|
+
return Math.floor(parsed);
|
|
44
|
+
}
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function asBoolean(value, fallback = false) {
|
|
49
|
+
if (typeof value === 'boolean') {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRoutePath(value, fallback) {
|
|
56
|
+
const candidate = asNonEmptyString(value, fallback);
|
|
57
|
+
if (!candidate) {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const withLeadingSlash = candidate.startsWith('/') ? candidate : `/${candidate}`;
|
|
62
|
+
const cleaned = withLeadingSlash.replace(/\/+/g, '/');
|
|
63
|
+
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
|
64
|
+
return cleaned.slice(0, -1);
|
|
65
|
+
}
|
|
66
|
+
return cleaned;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asStringList(value) {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value
|
|
72
|
+
.map((entry) => asNonEmptyString(entry))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value === 'string') {
|
|
77
|
+
return value
|
|
78
|
+
.split(/[,\s]+/)
|
|
79
|
+
.map((entry) => entry.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function uniqueStrings(values) {
|
|
87
|
+
return [...new Set(values)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sanitizeTablePrefix(value, fallback = 'aegisnode') {
|
|
91
|
+
const candidate = asNonEmptyString(value, fallback)
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
94
|
+
.replace(/_+/g, '_')
|
|
95
|
+
.replace(/^_+|_+$/g, '');
|
|
96
|
+
|
|
97
|
+
return candidate.length > 0 ? candidate : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildAuthTables(tablePrefix) {
|
|
101
|
+
return {
|
|
102
|
+
users: `${tablePrefix}_users`,
|
|
103
|
+
jwtRevocations: `${tablePrefix}_jwt_revocations`,
|
|
104
|
+
oauthClients: `${tablePrefix}_oauth_clients`,
|
|
105
|
+
oauthAuthorizationCodes: `${tablePrefix}_oauth_authorization_codes`,
|
|
106
|
+
oauthAccessTokens: `${tablePrefix}_oauth_access_tokens`,
|
|
107
|
+
oauthRefreshTokens: `${tablePrefix}_oauth_refresh_tokens`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeJwtConfig(rawJwt, appName, appSecret) {
|
|
112
|
+
const jwtConfig = isPlainObject(rawJwt) ? rawJwt : {};
|
|
113
|
+
const configuredSecret = asNonEmptyString(jwtConfig.secret);
|
|
114
|
+
const fallbackSecret = asNonEmptyString(appSecret);
|
|
115
|
+
const algorithmCandidate = String(jwtConfig.algorithm || 'HS256').toUpperCase();
|
|
116
|
+
const algorithm = SUPPORTED_JWT_ALGORITHMS.has(algorithmCandidate)
|
|
117
|
+
? algorithmCandidate
|
|
118
|
+
: 'HS256';
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
secret: configuredSecret || fallbackSecret,
|
|
122
|
+
algorithm,
|
|
123
|
+
issuer: asNonEmptyString(jwtConfig.issuer, appName || 'aegisnode'),
|
|
124
|
+
audience: asNonEmptyString(jwtConfig.audience, appName || 'aegisnode'),
|
|
125
|
+
expiresIn: asNonEmptyString(jwtConfig.expiresIn, '15m'),
|
|
126
|
+
refreshExpiresIn: asNonEmptyString(jwtConfig.refreshExpiresIn, '7d'),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeOAuth2GrantList(value) {
|
|
131
|
+
const grants = uniqueStrings(
|
|
132
|
+
asStringList(value)
|
|
133
|
+
.map((entry) => entry.toLowerCase())
|
|
134
|
+
.filter((entry) => SUPPORTED_OAUTH2_GRANTS.has(entry)),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (grants.length > 0) {
|
|
138
|
+
return grants;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return ['authorization_code', 'refresh_token', 'client_credentials'];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeOAuth2ClientAuthMethod(value, fallback = 'client_secret_basic') {
|
|
145
|
+
const method = asNonEmptyString(value, fallback).toLowerCase();
|
|
146
|
+
if (SUPPORTED_OAUTH2_CLIENT_AUTH_METHODS.has(method)) {
|
|
147
|
+
return method;
|
|
148
|
+
}
|
|
149
|
+
return fallback;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeOAuth2Scopes(value) {
|
|
153
|
+
return uniqueStrings(asStringList(value));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeOAuth2ServerConfig(rawServer, appName) {
|
|
157
|
+
const server = isPlainObject(rawServer) ? rawServer : {};
|
|
158
|
+
const basePath = normalizeRoutePath(server.basePath, '/oauth');
|
|
159
|
+
const authorizePath = normalizeRoutePath(server.authorizePath, `${basePath}/authorize`);
|
|
160
|
+
const tokenPath = normalizeRoutePath(server.tokenPath, `${basePath}/token`);
|
|
161
|
+
const introspectionPath = normalizeRoutePath(server.introspectionPath, `${basePath}/introspect`);
|
|
162
|
+
const revocationPath = normalizeRoutePath(server.revocationPath, `${basePath}/revoke`);
|
|
163
|
+
const metadataPath = normalizeRoutePath(server.metadataPath, '/.well-known/oauth-authorization-server');
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
enabled: server.enabled !== false,
|
|
167
|
+
basePath,
|
|
168
|
+
authorizePath,
|
|
169
|
+
tokenPath,
|
|
170
|
+
introspectionPath,
|
|
171
|
+
revocationPath,
|
|
172
|
+
metadataPath,
|
|
173
|
+
issuer: asNonEmptyString(server.issuer, asNonEmptyString(server.baseUrl, appName || 'aegisnode')),
|
|
174
|
+
autoApprove: server.autoApprove !== false,
|
|
175
|
+
requireAuthenticatedUser: server.requireAuthenticatedUser !== false,
|
|
176
|
+
requireConsent: asBoolean(server.requireConsent, false),
|
|
177
|
+
allowSubjectFromParams: asBoolean(server.allowSubjectFromParams, false),
|
|
178
|
+
allowHttp: asBoolean(server.allowHttp, false),
|
|
179
|
+
resolveSubject: typeof server.resolveSubject === 'function' ? server.resolveSubject : null,
|
|
180
|
+
resolveConsent: typeof server.resolveConsent === 'function' ? server.resolveConsent : null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeOAuth2Config(rawOAuth2, appName) {
|
|
185
|
+
const oauth2 = isPlainObject(rawOAuth2) ? rawOAuth2 : {};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
accessTokenTtlSeconds: asPositiveInteger(oauth2.accessTokenTtlSeconds, 3600),
|
|
189
|
+
refreshTokenTtlSeconds: asPositiveInteger(oauth2.refreshTokenTtlSeconds, 1209600),
|
|
190
|
+
authorizationCodeTtlSeconds: asPositiveInteger(oauth2.authorizationCodeTtlSeconds, 600),
|
|
191
|
+
rotateRefreshToken: oauth2.rotateRefreshToken !== false,
|
|
192
|
+
requireClientSecret: oauth2.requireClientSecret !== false,
|
|
193
|
+
requirePkce: oauth2.requirePkce !== false,
|
|
194
|
+
allowPlainPkce: asBoolean(oauth2.allowPlainPkce, false),
|
|
195
|
+
grants: normalizeOAuth2GrantList(oauth2.grants),
|
|
196
|
+
defaultScopes: normalizeOAuth2Scopes(oauth2.defaultScopes),
|
|
197
|
+
clientAuthMethod: normalizeOAuth2ClientAuthMethod(oauth2.clientAuthMethod, 'client_secret_basic'),
|
|
198
|
+
server: normalizeOAuth2ServerConfig(oauth2.server, appName),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeAuthStorageConfig(rawStorage, tablePrefix = 'aegisnode') {
|
|
203
|
+
const storage = isPlainObject(rawStorage) ? rawStorage : {};
|
|
204
|
+
const driverCandidate = String(storage.driver || 'cache').toLowerCase();
|
|
205
|
+
const driver = SUPPORTED_STORAGE_DRIVERS.has(driverCandidate)
|
|
206
|
+
? driverCandidate
|
|
207
|
+
: 'cache';
|
|
208
|
+
const defaultStoreName = `${sanitizeTablePrefix(tablePrefix, 'aegisnode')}_auth_store`;
|
|
209
|
+
const tableNameCandidate = asNonEmptyString(
|
|
210
|
+
storage.tableName,
|
|
211
|
+
asNonEmptyString(storage.collectionName, defaultStoreName),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
driver,
|
|
216
|
+
filePath: asNonEmptyString(storage.filePath, 'storage/aegisnode-auth-store.json'),
|
|
217
|
+
tableName: sanitizeTablePrefix(tableNameCandidate, defaultStoreName),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeScopes(value) {
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
return value
|
|
224
|
+
.map((entry) => asNonEmptyString(entry))
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.join(' ');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (typeof value === 'string') {
|
|
230
|
+
return value
|
|
231
|
+
.split(/\s+/)
|
|
232
|
+
.map((entry) => entry.trim())
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join(' ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getNowSeconds() {
|
|
241
|
+
return Math.floor(Date.now() / 1000);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function randomToken(bytes = 32) {
|
|
245
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hashToken(token) {
|
|
249
|
+
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getJti() {
|
|
253
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
254
|
+
return crypto.randomUUID();
|
|
255
|
+
}
|
|
256
|
+
return randomToken(16);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function base64UrlEncode(input) {
|
|
260
|
+
return Buffer.from(input)
|
|
261
|
+
.toString('base64')
|
|
262
|
+
.replace(/\+/g, '-')
|
|
263
|
+
.replace(/\//g, '_')
|
|
264
|
+
.replace(/=+$/g, '');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function toSha256Base64Url(value) {
|
|
268
|
+
return base64UrlEncode(crypto.createHash('sha256').update(String(value || '')).digest());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function constantTimeEqual(left, right) {
|
|
272
|
+
const a = Buffer.from(String(left || ''));
|
|
273
|
+
const b = Buffer.from(String(right || ''));
|
|
274
|
+
if (a.length !== b.length) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
return crypto.timingSafeEqual(a, b);
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isValidPkceVerifier(value) {
|
|
286
|
+
const candidate = String(value || '');
|
|
287
|
+
return /^[A-Za-z0-9\-._~]{43,128}$/.test(candidate);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isValidPkceChallenge(value) {
|
|
291
|
+
const candidate = String(value || '');
|
|
292
|
+
return /^[A-Za-z0-9\-._~]{43,128}$/.test(candidate);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizePkceMethod(value) {
|
|
296
|
+
const method = asNonEmptyString(value, 'plain');
|
|
297
|
+
if (SUPPORTED_OAUTH2_PKCE_METHODS.has(method)) {
|
|
298
|
+
return method;
|
|
299
|
+
}
|
|
300
|
+
return '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isValidRedirectUri(value) {
|
|
304
|
+
try {
|
|
305
|
+
const url = new URL(String(value || ''));
|
|
306
|
+
if (!url.protocol || !url.hostname) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (url.hash) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseBasicAuthHeader(headerValue) {
|
|
319
|
+
const header = String(headerValue || '');
|
|
320
|
+
if (!header.toLowerCase().startsWith('basic ')) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const encoded = header.slice(6).trim();
|
|
325
|
+
if (!encoded) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
|
331
|
+
const separator = decoded.indexOf(':');
|
|
332
|
+
if (separator < 0) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
clientId: decoded.slice(0, separator),
|
|
338
|
+
clientSecret: decoded.slice(separator + 1),
|
|
339
|
+
};
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function readRequestParam(req, key) {
|
|
346
|
+
if (req && req.body && typeof req.body === 'object' && Object.prototype.hasOwnProperty.call(req.body, key)) {
|
|
347
|
+
const value = req.body[key];
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
return value.length > 0 ? String(value[0]) : '';
|
|
350
|
+
}
|
|
351
|
+
return value === undefined || value === null ? '' : String(value);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (req && req.query && typeof req.query === 'object' && Object.prototype.hasOwnProperty.call(req.query, key)) {
|
|
355
|
+
const value = req.query[key];
|
|
356
|
+
if (Array.isArray(value)) {
|
|
357
|
+
return value.length > 0 ? String(value[0]) : '';
|
|
358
|
+
}
|
|
359
|
+
return value === undefined || value === null ? '' : String(value);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return '';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildUrlWithQuery(baseUrl, params) {
|
|
366
|
+
const url = new URL(baseUrl);
|
|
367
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
368
|
+
if (value === undefined || value === null || value === '') {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
url.searchParams.set(key, String(value));
|
|
372
|
+
}
|
|
373
|
+
return url.toString();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function resolveRequestOrigin(req) {
|
|
377
|
+
const protocol = asNonEmptyString(req?.protocol, req?.secure === true ? 'https' : 'http');
|
|
378
|
+
const host = asNonEmptyString(
|
|
379
|
+
typeof req?.get === 'function' ? req.get('host') : req?.headers?.host,
|
|
380
|
+
'localhost',
|
|
381
|
+
);
|
|
382
|
+
return `${protocol}://${host}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function toOAuthErrorResponse(res, {
|
|
386
|
+
statusCode = 400,
|
|
387
|
+
error = 'invalid_request',
|
|
388
|
+
errorDescription = '',
|
|
389
|
+
wwwAuthenticate = '',
|
|
390
|
+
} = {}) {
|
|
391
|
+
if (!res.headersSent && wwwAuthenticate) {
|
|
392
|
+
res.set('WWW-Authenticate', wwwAuthenticate);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return res.status(statusCode).json({
|
|
396
|
+
error,
|
|
397
|
+
...(errorDescription ? { error_description: errorDescription } : {}),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function createOAuthError(error, errorDescription, statusCode = 400, options = {}) {
|
|
402
|
+
return {
|
|
403
|
+
isOAuthError: true,
|
|
404
|
+
error,
|
|
405
|
+
errorDescription,
|
|
406
|
+
statusCode,
|
|
407
|
+
shouldRedirect: options.shouldRedirect !== false,
|
|
408
|
+
wwwAuthenticate: options.wwwAuthenticate || '',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function hashClientSecret(secret) {
|
|
413
|
+
const salt = randomToken(16);
|
|
414
|
+
const derived = crypto.scryptSync(String(secret || ''), salt, 32).toString('hex');
|
|
415
|
+
return `scrypt$${salt}$${derived}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function hashClientSecretAsync(secret) {
|
|
419
|
+
const salt = randomToken(16);
|
|
420
|
+
const derivedBuffer = await scryptAsync(String(secret || ''), salt, 32);
|
|
421
|
+
return `scrypt$${salt}$${Buffer.from(derivedBuffer).toString('hex')}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function verifyClientSecret(secret, storedHash) {
|
|
425
|
+
const rawHash = String(storedHash || '');
|
|
426
|
+
if (!rawHash) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!rawHash.startsWith('scrypt$')) {
|
|
431
|
+
return constantTimeEqual(secret, rawHash);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const parts = rawHash.split('$');
|
|
435
|
+
if (parts.length !== 3) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const salt = parts[1];
|
|
440
|
+
const expected = parts[2];
|
|
441
|
+
const derived = crypto.scryptSync(String(secret || ''), salt, 32).toString('hex');
|
|
442
|
+
return constantTimeEqual(derived, expected);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function verifyClientSecretAsync(secret, storedHash) {
|
|
446
|
+
const rawHash = String(storedHash || '');
|
|
447
|
+
if (!rawHash) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!rawHash.startsWith('scrypt$')) {
|
|
452
|
+
return constantTimeEqual(secret, rawHash);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const parts = rawHash.split('$');
|
|
456
|
+
if (parts.length !== 3) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const salt = parts[1];
|
|
461
|
+
const expected = parts[2];
|
|
462
|
+
const derivedBuffer = await scryptAsync(String(secret || ''), salt, 32);
|
|
463
|
+
const derived = Buffer.from(derivedBuffer).toString('hex');
|
|
464
|
+
return constantTimeEqual(derived, expected);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function createMemoryStoreAdapter() {
|
|
468
|
+
const fallback = new Map();
|
|
469
|
+
return {
|
|
470
|
+
get: (key) => fallback.get(key),
|
|
471
|
+
set: (key, value) => {
|
|
472
|
+
fallback.set(key, value);
|
|
473
|
+
return value;
|
|
474
|
+
},
|
|
475
|
+
delete: (key) => fallback.delete(key),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function createCacheStoreAdapter(cache) {
|
|
480
|
+
if (cache && typeof cache.get === 'function' && typeof cache.set === 'function' && typeof cache.delete === 'function') {
|
|
481
|
+
return {
|
|
482
|
+
get: (key) => cache.get(key),
|
|
483
|
+
set: (key, value) => cache.set(key, value),
|
|
484
|
+
delete: (key) => cache.delete(key),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return createMemoryStoreAdapter();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function createFileStoreAdapter(filePath, rootDir, logger) {
|
|
492
|
+
const resolvedPath = path.isAbsolute(filePath)
|
|
493
|
+
? filePath
|
|
494
|
+
: path.join(rootDir || process.cwd(), filePath);
|
|
495
|
+
const directory = path.dirname(resolvedPath);
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
499
|
+
} catch (error) {
|
|
500
|
+
logger.warn('Auth file store directory setup failed at %s: %s', directory, error?.message || String(error));
|
|
501
|
+
return createMemoryStoreAdapter();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let snapshot = {};
|
|
505
|
+
if (fs.existsSync(resolvedPath)) {
|
|
506
|
+
try {
|
|
507
|
+
const raw = fs.readFileSync(resolvedPath, 'utf8');
|
|
508
|
+
const parsed = JSON.parse(raw);
|
|
509
|
+
if (isPlainObject(parsed)) {
|
|
510
|
+
snapshot = parsed;
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
logger.warn('Auth file store could not parse %s: %s', resolvedPath, error?.message || String(error));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const map = new Map(Object.entries(snapshot));
|
|
518
|
+
let writeQueue = Promise.resolve();
|
|
519
|
+
|
|
520
|
+
const flush = async () => {
|
|
521
|
+
const payload = JSON.stringify(Object.fromEntries(map), null, 2);
|
|
522
|
+
await fs.promises.writeFile(resolvedPath, `${payload}\n`, 'utf8');
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const enqueueFlush = () => {
|
|
526
|
+
writeQueue = writeQueue
|
|
527
|
+
.then(() => flush())
|
|
528
|
+
.catch((error) => {
|
|
529
|
+
logger.warn('Auth file store write failed at %s: %s', resolvedPath, error?.message || String(error));
|
|
530
|
+
});
|
|
531
|
+
return writeQueue;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
get: (key) => map.get(key),
|
|
536
|
+
set: (key, value) => {
|
|
537
|
+
map.set(key, value);
|
|
538
|
+
enqueueFlush();
|
|
539
|
+
return value;
|
|
540
|
+
},
|
|
541
|
+
delete: (key) => {
|
|
542
|
+
const deleted = map.delete(key);
|
|
543
|
+
if (deleted) {
|
|
544
|
+
enqueueFlush();
|
|
545
|
+
}
|
|
546
|
+
return deleted;
|
|
547
|
+
},
|
|
548
|
+
ready: Promise.resolve(),
|
|
549
|
+
close: async () => {
|
|
550
|
+
await writeQueue;
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function isTableExistsError(error) {
|
|
555
|
+
const message = String(error?.message || '').toLowerCase();
|
|
556
|
+
return (
|
|
557
|
+
message.includes('already exists')
|
|
558
|
+
|| message.includes('exists')
|
|
559
|
+
|| message.includes('duplicate')
|
|
560
|
+
|| message.includes('is there')
|
|
561
|
+
|| message.includes('ora-00955')
|
|
562
|
+
|| message.includes('42p07')
|
|
563
|
+
|| message.includes('2714')
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function ensureSqlStoreTable(client, tableName, logger) {
|
|
568
|
+
if (!client || typeof client.schema !== 'function') {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
await client.schema()
|
|
574
|
+
.createTable(tableName, (t) => {
|
|
575
|
+
t.string('id', 191).primary();
|
|
576
|
+
t.text('payload').notNull();
|
|
577
|
+
})
|
|
578
|
+
.exec();
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (!isTableExistsError(error)) {
|
|
581
|
+
logger.warn('Auth SQL store table setup failed for %s: %s', tableName, error?.message || String(error));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function parseSqlPayload(raw, key, logger) {
|
|
587
|
+
try {
|
|
588
|
+
return JSON.parse(String(raw));
|
|
589
|
+
} catch (error) {
|
|
590
|
+
logger.warn('Auth SQL store payload parsing failed for key %s: %s', key, error?.message || String(error));
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function resolveMongoCollection(database, collectionName) {
|
|
596
|
+
const candidates = [
|
|
597
|
+
database?.mongoose?.connection?.db,
|
|
598
|
+
database?.client?.connection?.db,
|
|
599
|
+
database?.client?.db,
|
|
600
|
+
database?.client,
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
for (const candidate of candidates) {
|
|
604
|
+
if (candidate && typeof candidate.collection === 'function') {
|
|
605
|
+
return candidate.collection(collectionName);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function createDatabaseStoreAdapter({ config, database, logger }) {
|
|
613
|
+
const map = new Map();
|
|
614
|
+
const storeTableName = config.storage.tableName;
|
|
615
|
+
let backend = 'memory';
|
|
616
|
+
let sqlClient = null;
|
|
617
|
+
let mongoCollection = null;
|
|
618
|
+
let writeQueue = Promise.resolve();
|
|
619
|
+
|
|
620
|
+
const loadFromSql = async () => {
|
|
621
|
+
if (!sqlClient || typeof sqlClient.table !== 'function') {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const rows = await sqlClient.table(storeTableName).select(['id', 'payload']).get();
|
|
626
|
+
for (const row of rows) {
|
|
627
|
+
const key = asNonEmptyString(row?.id);
|
|
628
|
+
if (!key) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const payload = parseSqlPayload(row?.payload, key, logger);
|
|
633
|
+
if (payload !== undefined) {
|
|
634
|
+
map.set(key, payload);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const flushToSql = async () => {
|
|
640
|
+
if (!sqlClient || typeof sqlClient.table !== 'function') {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await sqlClient.table(storeTableName).delete().run();
|
|
645
|
+
if (map.size === 0) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const rows = [];
|
|
650
|
+
for (const [id, value] of map.entries()) {
|
|
651
|
+
rows.push({
|
|
652
|
+
id,
|
|
653
|
+
payload: JSON.stringify(value),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await sqlClient.table(storeTableName).insert(rows).run();
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const upsertSqlEntry = async (key, value) => {
|
|
661
|
+
if (!sqlClient || typeof sqlClient.table !== 'function') {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const table = sqlClient.table(storeTableName);
|
|
666
|
+
if (typeof table.where !== 'function') {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
await table.where('id', key).delete().run();
|
|
671
|
+
await sqlClient.table(storeTableName).insert([
|
|
672
|
+
{
|
|
673
|
+
id: key,
|
|
674
|
+
payload: JSON.stringify(value),
|
|
675
|
+
},
|
|
676
|
+
]).run();
|
|
677
|
+
return true;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const deleteSqlEntry = async (key) => {
|
|
681
|
+
if (!sqlClient || typeof sqlClient.table !== 'function') {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const table = sqlClient.table(storeTableName);
|
|
686
|
+
if (typeof table.where !== 'function') {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
await table.where('id', key).delete().run();
|
|
691
|
+
return true;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const loadFromMongo = async () => {
|
|
695
|
+
if (!mongoCollection || typeof mongoCollection.find !== 'function') {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const docs = await mongoCollection.find({}, { projection: { _id: 1, payload: 1 } }).toArray();
|
|
700
|
+
for (const doc of docs) {
|
|
701
|
+
const key = asNonEmptyString(doc?._id);
|
|
702
|
+
if (!key) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
map.set(key, doc.payload);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const flushToMongo = async () => {
|
|
710
|
+
if (!mongoCollection || typeof mongoCollection.deleteMany !== 'function') {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
await mongoCollection.deleteMany({});
|
|
715
|
+
if (map.size === 0 || typeof mongoCollection.insertMany !== 'function') {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const docs = [];
|
|
720
|
+
for (const [id, value] of map.entries()) {
|
|
721
|
+
docs.push({
|
|
722
|
+
_id: id,
|
|
723
|
+
payload: value,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
await mongoCollection.insertMany(docs, { ordered: false });
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const upsertMongoEntry = async (key, value) => {
|
|
730
|
+
if (!mongoCollection || typeof mongoCollection.updateOne !== 'function') {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
await mongoCollection.updateOne(
|
|
735
|
+
{ _id: key },
|
|
736
|
+
{ $set: { payload: value } },
|
|
737
|
+
{ upsert: true },
|
|
738
|
+
);
|
|
739
|
+
return true;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const deleteMongoEntry = async (key) => {
|
|
743
|
+
if (!mongoCollection || typeof mongoCollection.deleteOne !== 'function') {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
await mongoCollection.deleteOne({ _id: key });
|
|
748
|
+
return true;
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const ready = (async () => {
|
|
752
|
+
if (!database) {
|
|
753
|
+
logger.warn('Auth storage driver "database" selected but database is disabled; using in-memory auth store.');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (database.type === 'sql' && database.client && typeof database.client.table === 'function') {
|
|
758
|
+
sqlClient = database.client;
|
|
759
|
+
await ensureSqlStoreTable(sqlClient, storeTableName, logger);
|
|
760
|
+
await loadFromSql();
|
|
761
|
+
backend = 'sql';
|
|
762
|
+
logger.info('Auth database store ready using SQL table %s (%d records loaded)', storeTableName, map.size);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (database.type === 'nosql') {
|
|
767
|
+
mongoCollection = resolveMongoCollection(database, storeTableName);
|
|
768
|
+
if (mongoCollection) {
|
|
769
|
+
await loadFromMongo();
|
|
770
|
+
backend = 'mongo';
|
|
771
|
+
logger.info('Auth database store ready using Mongo collection %s (%d records loaded)', storeTableName, map.size);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
logger.warn('Auth storage driver "database" could not use the active database client; using in-memory auth store.');
|
|
777
|
+
})().catch((error) => {
|
|
778
|
+
logger.warn('Auth database store initialization failed: %s', error?.message || String(error));
|
|
779
|
+
});
|
|
780
|
+
writeQueue = ready;
|
|
781
|
+
|
|
782
|
+
const persist = async (operation = null) => {
|
|
783
|
+
if (backend === 'sql') {
|
|
784
|
+
if (operation?.type === 'set' && await upsertSqlEntry(operation.key, operation.value)) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (operation?.type === 'delete' && await deleteSqlEntry(operation.key)) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
await flushToSql();
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (backend === 'mongo') {
|
|
795
|
+
if (operation?.type === 'set' && await upsertMongoEntry(operation.key, operation.value)) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (operation?.type === 'delete' && await deleteMongoEntry(operation.key)) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
await flushToMongo();
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const enqueuePersist = (operation = null) => {
|
|
806
|
+
writeQueue = writeQueue
|
|
807
|
+
.then(() => persist(operation))
|
|
808
|
+
.catch((error) => {
|
|
809
|
+
logger.warn('Auth database store flush failed: %s', error?.message || String(error));
|
|
810
|
+
});
|
|
811
|
+
return writeQueue;
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
get: (key) => map.get(key),
|
|
816
|
+
set: (key, value) => {
|
|
817
|
+
map.set(key, value);
|
|
818
|
+
enqueuePersist({ type: 'set', key, value });
|
|
819
|
+
return value;
|
|
820
|
+
},
|
|
821
|
+
delete: (key) => {
|
|
822
|
+
const deleted = map.delete(key);
|
|
823
|
+
if (deleted) {
|
|
824
|
+
enqueuePersist({ type: 'delete', key });
|
|
825
|
+
}
|
|
826
|
+
return deleted;
|
|
827
|
+
},
|
|
828
|
+
ready,
|
|
829
|
+
close: async () => {
|
|
830
|
+
await ready;
|
|
831
|
+
await writeQueue;
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function createStoreAdapter({ config, cache, rootDir, logger, database }) {
|
|
836
|
+
const noopClose = async () => {};
|
|
837
|
+
|
|
838
|
+
if (config.storage.driver === 'database') {
|
|
839
|
+
const adapter = createDatabaseStoreAdapter({
|
|
840
|
+
config,
|
|
841
|
+
database,
|
|
842
|
+
logger,
|
|
843
|
+
});
|
|
844
|
+
return {
|
|
845
|
+
adapter,
|
|
846
|
+
ready: adapter.ready || Promise.resolve(),
|
|
847
|
+
close: typeof adapter.close === 'function' ? adapter.close : noopClose,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (config.storage.driver === 'memory') {
|
|
852
|
+
return {
|
|
853
|
+
adapter: createMemoryStoreAdapter(),
|
|
854
|
+
ready: Promise.resolve(),
|
|
855
|
+
close: noopClose,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (config.storage.driver === 'file') {
|
|
860
|
+
const adapter = createFileStoreAdapter(config.storage.filePath, rootDir, logger);
|
|
861
|
+
return {
|
|
862
|
+
adapter,
|
|
863
|
+
ready: adapter.ready || Promise.resolve(),
|
|
864
|
+
close: typeof adapter.close === 'function' ? adapter.close : noopClose,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
adapter: createCacheStoreAdapter(cache),
|
|
870
|
+
ready: Promise.resolve(),
|
|
871
|
+
close: noopClose,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function createNamespacedStore(adapter, tableName) {
|
|
876
|
+
return {
|
|
877
|
+
key(id) {
|
|
878
|
+
return `${tableName}:${id}`;
|
|
879
|
+
},
|
|
880
|
+
get(id) {
|
|
881
|
+
return adapter.get(`${tableName}:${id}`);
|
|
882
|
+
},
|
|
883
|
+
set(id, value) {
|
|
884
|
+
return adapter.set(`${tableName}:${id}`, value);
|
|
885
|
+
},
|
|
886
|
+
delete(id) {
|
|
887
|
+
return adapter.delete(`${tableName}:${id}`);
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function extractBearerToken(req) {
|
|
893
|
+
const raw = String(req?.headers?.authorization || '');
|
|
894
|
+
if (!raw.toLowerCase().startsWith('bearer ')) {
|
|
895
|
+
return '';
|
|
896
|
+
}
|
|
897
|
+
return raw.slice(7).trim();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function createJwtManager({ config, storeAdapter }) {
|
|
901
|
+
if (!config.jwt.secret) {
|
|
902
|
+
throw new Error('Auth provider "jwt" requires auth.jwt.secret (or security.appSecret fallback).');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const revokedStore = createNamespacedStore(storeAdapter, config.tables.jwtRevocations);
|
|
906
|
+
|
|
907
|
+
function isRevoked(jti, nowSeconds = getNowSeconds()) {
|
|
908
|
+
if (!jti) {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const revoked = revokedStore.get(jti);
|
|
913
|
+
if (!revoked) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (revoked.expiresAt && Number(revoked.expiresAt) <= nowSeconds) {
|
|
918
|
+
revokedStore.delete(jti);
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function issueAccessToken({ subject, claims = {}, scope = '', expiresIn } = {}) {
|
|
926
|
+
const sub = asNonEmptyString(subject);
|
|
927
|
+
if (!sub) {
|
|
928
|
+
throw new Error('JWT issueAccessToken requires subject.');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const payload = {
|
|
932
|
+
...claims,
|
|
933
|
+
sub,
|
|
934
|
+
scope: normalizeScopes(scope),
|
|
935
|
+
token_type: 'access',
|
|
936
|
+
jti: getJti(),
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
return jwt.sign(payload, config.jwt.secret, {
|
|
940
|
+
algorithm: config.jwt.algorithm,
|
|
941
|
+
issuer: config.jwt.issuer,
|
|
942
|
+
audience: config.jwt.audience,
|
|
943
|
+
expiresIn: asNonEmptyString(expiresIn, config.jwt.expiresIn),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function issueRefreshToken({ subject, claims = {}, scope = '', expiresIn } = {}) {
|
|
948
|
+
const sub = asNonEmptyString(subject);
|
|
949
|
+
if (!sub) {
|
|
950
|
+
throw new Error('JWT issueRefreshToken requires subject.');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const payload = {
|
|
954
|
+
...claims,
|
|
955
|
+
sub,
|
|
956
|
+
scope: normalizeScopes(scope),
|
|
957
|
+
token_type: 'refresh',
|
|
958
|
+
jti: getJti(),
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
return jwt.sign(payload, config.jwt.secret, {
|
|
962
|
+
algorithm: config.jwt.algorithm,
|
|
963
|
+
issuer: config.jwt.issuer,
|
|
964
|
+
audience: config.jwt.audience,
|
|
965
|
+
expiresIn: asNonEmptyString(expiresIn, config.jwt.refreshExpiresIn),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function verify(token, options = {}) {
|
|
970
|
+
const decoded = jwt.verify(String(token || ''), config.jwt.secret, {
|
|
971
|
+
algorithms: [config.jwt.algorithm],
|
|
972
|
+
issuer: config.jwt.issuer,
|
|
973
|
+
audience: config.jwt.audience,
|
|
974
|
+
ignoreExpiration: options.ignoreExpiration === true,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
if (decoded && typeof decoded === 'object' && isRevoked(decoded.jti)) {
|
|
978
|
+
throw new Error('Token has been revoked.');
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return decoded;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function revoke(token) {
|
|
985
|
+
const decoded = jwt.decode(String(token || ''));
|
|
986
|
+
if (!decoded || typeof decoded !== 'object' || !decoded.jti) {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
revokedStore.set(decoded.jti, {
|
|
991
|
+
revokedAt: getNowSeconds(),
|
|
992
|
+
expiresAt: Number(decoded.exp) || 0,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function middleware(options = {}) {
|
|
999
|
+
const optional = options.optional === true;
|
|
1000
|
+
|
|
1001
|
+
return (req, res, next) => {
|
|
1002
|
+
const token = extractBearerToken(req);
|
|
1003
|
+
if (!token) {
|
|
1004
|
+
if (optional) {
|
|
1005
|
+
return next();
|
|
1006
|
+
}
|
|
1007
|
+
return res.status(401).json({ error: 'Missing bearer token' });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
try {
|
|
1011
|
+
const payload = verify(token);
|
|
1012
|
+
req.auth = payload;
|
|
1013
|
+
req.user = payload;
|
|
1014
|
+
return next();
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
if (optional) {
|
|
1017
|
+
return next();
|
|
1018
|
+
}
|
|
1019
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
enabled: true,
|
|
1026
|
+
provider: 'jwt',
|
|
1027
|
+
tablePrefix: config.tablePrefix,
|
|
1028
|
+
tables: config.tables,
|
|
1029
|
+
issue: issueAccessToken,
|
|
1030
|
+
issueAccessToken,
|
|
1031
|
+
issueRefreshToken,
|
|
1032
|
+
verify,
|
|
1033
|
+
revoke,
|
|
1034
|
+
middleware,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function createOAuth2Manager({ config, storeAdapter }) {
|
|
1039
|
+
const clientsStore = createNamespacedStore(storeAdapter, config.tables.oauthClients);
|
|
1040
|
+
const codeStore = createNamespacedStore(storeAdapter, config.tables.oauthAuthorizationCodes);
|
|
1041
|
+
const accessStore = createNamespacedStore(storeAdapter, config.tables.oauthAccessTokens);
|
|
1042
|
+
const refreshStore = createNamespacedStore(storeAdapter, config.tables.oauthRefreshTokens);
|
|
1043
|
+
const oauthConfig = config.oauth2 || {};
|
|
1044
|
+
const serverConfig = oauthConfig.server || {};
|
|
1045
|
+
const tokenEndpointPaths = new Set([
|
|
1046
|
+
serverConfig.tokenPath,
|
|
1047
|
+
serverConfig.introspectionPath,
|
|
1048
|
+
serverConfig.revocationPath,
|
|
1049
|
+
].filter(Boolean));
|
|
1050
|
+
const oauthServerPaths = new Set([
|
|
1051
|
+
serverConfig.authorizePath,
|
|
1052
|
+
serverConfig.tokenPath,
|
|
1053
|
+
serverConfig.introspectionPath,
|
|
1054
|
+
serverConfig.revocationPath,
|
|
1055
|
+
serverConfig.metadataPath,
|
|
1056
|
+
].filter(Boolean));
|
|
1057
|
+
|
|
1058
|
+
function deriveClientAllowedScopes(client) {
|
|
1059
|
+
if (Array.isArray(client.scopes) && client.scopes.length > 0) {
|
|
1060
|
+
return client.scopes;
|
|
1061
|
+
}
|
|
1062
|
+
return Array.isArray(oauthConfig.defaultScopes) ? oauthConfig.defaultScopes : [];
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function normalizeStoredClient(clientId, client) {
|
|
1066
|
+
if (!client || typeof client !== 'object') {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const normalizedId = asNonEmptyString(client.clientId, clientId);
|
|
1071
|
+
if (!normalizedId) {
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const grants = normalizeOAuth2GrantList(client.grants || oauthConfig.grants);
|
|
1076
|
+
const redirectUris = uniqueStrings(
|
|
1077
|
+
asStringList(client.redirectUris)
|
|
1078
|
+
.filter((uri) => isValidRedirectUri(uri)),
|
|
1079
|
+
);
|
|
1080
|
+
const scopes = uniqueStrings(asStringList(client.scopes));
|
|
1081
|
+
const clientSecretHash = asNonEmptyString(client.clientSecretHash, asNonEmptyString(client.clientSecret, ''));
|
|
1082
|
+
const tokenEndpointAuthMethod = normalizeOAuth2ClientAuthMethod(
|
|
1083
|
+
client.tokenEndpointAuthMethod,
|
|
1084
|
+
oauthConfig.clientAuthMethod || 'client_secret_basic',
|
|
1085
|
+
);
|
|
1086
|
+
const publicClient = client.publicClient === true || tokenEndpointAuthMethod === 'none';
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
clientId: normalizedId,
|
|
1090
|
+
clientSecretHash,
|
|
1091
|
+
tokenEndpointAuthMethod,
|
|
1092
|
+
publicClient,
|
|
1093
|
+
redirectUris,
|
|
1094
|
+
grants,
|
|
1095
|
+
scopes,
|
|
1096
|
+
createdAt: Number(client.createdAt) || getNowSeconds(),
|
|
1097
|
+
updatedAt: Number(client.updatedAt) || Number(client.createdAt) || getNowSeconds(),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function getClient(clientId) {
|
|
1102
|
+
const id = asNonEmptyString(clientId);
|
|
1103
|
+
if (!id) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
return normalizeStoredClient(id, clientsStore.get(id));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function sanitizeRegisteredClient(client) {
|
|
1110
|
+
if (!client) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
clientId: client.clientId,
|
|
1116
|
+
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
1117
|
+
publicClient: client.publicClient,
|
|
1118
|
+
redirectUris: [...client.redirectUris],
|
|
1119
|
+
grants: [...client.grants],
|
|
1120
|
+
scopes: [...client.scopes],
|
|
1121
|
+
createdAt: client.createdAt,
|
|
1122
|
+
updatedAt: client.updatedAt,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function assertGrantAllowed(client, grantType) {
|
|
1127
|
+
if (!client.grants.includes(grantType)) {
|
|
1128
|
+
throw createOAuthError('unauthorized_client', `Client is not allowed to use grant "${grantType}".`, 400, {
|
|
1129
|
+
shouldRedirect: false,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function validateRequestedScope(client, scopeValue) {
|
|
1135
|
+
const requestedScope = normalizeScopes(scopeValue);
|
|
1136
|
+
const allowedScopes = deriveClientAllowedScopes(client);
|
|
1137
|
+
|
|
1138
|
+
if (!requestedScope) {
|
|
1139
|
+
return normalizeScopes(allowedScopes);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (allowedScopes.length === 0) {
|
|
1143
|
+
return requestedScope;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const allowed = new Set(allowedScopes);
|
|
1147
|
+
const requested = requestedScope.split(/\s+/).filter(Boolean);
|
|
1148
|
+
for (const scope of requested) {
|
|
1149
|
+
if (!allowed.has(scope)) {
|
|
1150
|
+
throw createOAuthError('invalid_scope', `Scope "${scope}" is not allowed for this client.`, 400, {
|
|
1151
|
+
shouldRedirect: false,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return requested.join(' ');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function resolveClientRedirectUri(client, requestedRedirectUri) {
|
|
1160
|
+
const value = asNonEmptyString(requestedRedirectUri);
|
|
1161
|
+
const registered = Array.isArray(client.redirectUris) ? client.redirectUris : [];
|
|
1162
|
+
|
|
1163
|
+
if (value) {
|
|
1164
|
+
if (!isValidRedirectUri(value)) {
|
|
1165
|
+
throw createOAuthError('invalid_request', 'Invalid redirect_uri format.', 400, {
|
|
1166
|
+
shouldRedirect: false,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
if (!registered.includes(value)) {
|
|
1170
|
+
throw createOAuthError('invalid_request', 'redirect_uri is not registered for this client.', 400, {
|
|
1171
|
+
shouldRedirect: false,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
return value;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (registered.length === 1) {
|
|
1178
|
+
return registered[0];
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
throw createOAuthError('invalid_request', 'redirect_uri is required for this client.', 400, {
|
|
1182
|
+
shouldRedirect: false,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function authenticateClientCredentials({
|
|
1187
|
+
clientId,
|
|
1188
|
+
clientSecret = '',
|
|
1189
|
+
allowPublicClient = false,
|
|
1190
|
+
} = {}) {
|
|
1191
|
+
const id = asNonEmptyString(clientId);
|
|
1192
|
+
if (!id) {
|
|
1193
|
+
throw createOAuthError('invalid_client', 'Missing client_id.', 401, {
|
|
1194
|
+
shouldRedirect: false,
|
|
1195
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const client = getClient(id);
|
|
1200
|
+
if (!client) {
|
|
1201
|
+
throw createOAuthError('invalid_client', 'Unknown client.', 401, {
|
|
1202
|
+
shouldRedirect: false,
|
|
1203
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const secret = asNonEmptyString(clientSecret);
|
|
1208
|
+
const authMethod = client.tokenEndpointAuthMethod || oauthConfig.clientAuthMethod || 'client_secret_basic';
|
|
1209
|
+
const isPublic = client.publicClient === true || authMethod === 'none';
|
|
1210
|
+
|
|
1211
|
+
if (isPublic) {
|
|
1212
|
+
if (!allowPublicClient) {
|
|
1213
|
+
throw createOAuthError('invalid_client', 'Public client cannot authenticate on this endpoint.', 401, {
|
|
1214
|
+
shouldRedirect: false,
|
|
1215
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (secret && !verifyClientSecret(secret, client.clientSecretHash)) {
|
|
1220
|
+
throw createOAuthError('invalid_client', 'Invalid client credentials.', 401, {
|
|
1221
|
+
shouldRedirect: false,
|
|
1222
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
return client;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (!secret) {
|
|
1230
|
+
throw createOAuthError('invalid_client', 'Missing client_secret.', 401, {
|
|
1231
|
+
shouldRedirect: false,
|
|
1232
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (!verifyClientSecret(secret, client.clientSecretHash)) {
|
|
1237
|
+
throw createOAuthError('invalid_client', 'Invalid client credentials.', 401, {
|
|
1238
|
+
shouldRedirect: false,
|
|
1239
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return client;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function resolveClientFromRequest(req, { allowPublicClient = false } = {}) {
|
|
1247
|
+
const basic = parseBasicAuthHeader(req?.headers?.authorization);
|
|
1248
|
+
const bodyClientId = asNonEmptyString(readRequestParam(req, 'client_id'));
|
|
1249
|
+
const bodyClientSecret = asNonEmptyString(readRequestParam(req, 'client_secret'));
|
|
1250
|
+
const clientId = asNonEmptyString(basic?.clientId, bodyClientId);
|
|
1251
|
+
const clientSecret = asNonEmptyString(basic?.clientSecret, bodyClientSecret);
|
|
1252
|
+
return authenticateClientCredentials({
|
|
1253
|
+
clientId,
|
|
1254
|
+
clientSecret,
|
|
1255
|
+
allowPublicClient,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function authenticateClientCredentialsAsync({
|
|
1260
|
+
clientId,
|
|
1261
|
+
clientSecret = '',
|
|
1262
|
+
allowPublicClient = false,
|
|
1263
|
+
} = {}) {
|
|
1264
|
+
const id = asNonEmptyString(clientId);
|
|
1265
|
+
if (!id) {
|
|
1266
|
+
throw createOAuthError('invalid_client', 'Missing client_id.', 401, {
|
|
1267
|
+
shouldRedirect: false,
|
|
1268
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const client = getClient(id);
|
|
1273
|
+
if (!client) {
|
|
1274
|
+
throw createOAuthError('invalid_client', 'Unknown client.', 401, {
|
|
1275
|
+
shouldRedirect: false,
|
|
1276
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const secret = asNonEmptyString(clientSecret);
|
|
1281
|
+
const authMethod = client.tokenEndpointAuthMethod || oauthConfig.clientAuthMethod || 'client_secret_basic';
|
|
1282
|
+
const isPublic = client.publicClient === true || authMethod === 'none';
|
|
1283
|
+
|
|
1284
|
+
if (isPublic) {
|
|
1285
|
+
if (!allowPublicClient) {
|
|
1286
|
+
throw createOAuthError('invalid_client', 'Public client cannot authenticate on this endpoint.', 401, {
|
|
1287
|
+
shouldRedirect: false,
|
|
1288
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (secret && !await verifyClientSecretAsync(secret, client.clientSecretHash)) {
|
|
1293
|
+
throw createOAuthError('invalid_client', 'Invalid client credentials.', 401, {
|
|
1294
|
+
shouldRedirect: false,
|
|
1295
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return client;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (!secret) {
|
|
1303
|
+
throw createOAuthError('invalid_client', 'Missing client_secret.', 401, {
|
|
1304
|
+
shouldRedirect: false,
|
|
1305
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (!await verifyClientSecretAsync(secret, client.clientSecretHash)) {
|
|
1310
|
+
throw createOAuthError('invalid_client', 'Invalid client credentials.', 401, {
|
|
1311
|
+
shouldRedirect: false,
|
|
1312
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return client;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function resolveClientFromRequestAsync(req, { allowPublicClient = false } = {}) {
|
|
1320
|
+
const basic = parseBasicAuthHeader(req?.headers?.authorization);
|
|
1321
|
+
const bodyClientId = asNonEmptyString(readRequestParam(req, 'client_id'));
|
|
1322
|
+
const bodyClientSecret = asNonEmptyString(readRequestParam(req, 'client_secret'));
|
|
1323
|
+
const clientId = asNonEmptyString(basic?.clientId, bodyClientId);
|
|
1324
|
+
const clientSecret = asNonEmptyString(basic?.clientSecret, bodyClientSecret);
|
|
1325
|
+
return authenticateClientCredentialsAsync({
|
|
1326
|
+
clientId,
|
|
1327
|
+
clientSecret,
|
|
1328
|
+
allowPublicClient,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function issueTokens({
|
|
1333
|
+
client,
|
|
1334
|
+
subject = '',
|
|
1335
|
+
scope = '',
|
|
1336
|
+
grantType = 'client_credentials',
|
|
1337
|
+
includeRefreshToken = true,
|
|
1338
|
+
} = {}) {
|
|
1339
|
+
const now = getNowSeconds();
|
|
1340
|
+
const resolvedScope = validateRequestedScope(client, scope);
|
|
1341
|
+
const accessToken = randomToken(32);
|
|
1342
|
+
const accessExpiresAt = now + oauthConfig.accessTokenTtlSeconds;
|
|
1343
|
+
const accessRecord = {
|
|
1344
|
+
tokenType: 'Bearer',
|
|
1345
|
+
grantType,
|
|
1346
|
+
clientId: client.clientId,
|
|
1347
|
+
sub: asNonEmptyString(subject),
|
|
1348
|
+
scope: resolvedScope,
|
|
1349
|
+
issuedAt: now,
|
|
1350
|
+
expiresAt: accessExpiresAt,
|
|
1351
|
+
};
|
|
1352
|
+
accessStore.set(hashToken(accessToken), accessRecord);
|
|
1353
|
+
|
|
1354
|
+
let refreshToken = '';
|
|
1355
|
+
if (includeRefreshToken && client.grants.includes('refresh_token')) {
|
|
1356
|
+
refreshToken = randomToken(48);
|
|
1357
|
+
const refreshExpiresAt = now + oauthConfig.refreshTokenTtlSeconds;
|
|
1358
|
+
const refreshRecord = {
|
|
1359
|
+
tokenType: 'refresh_token',
|
|
1360
|
+
grantType,
|
|
1361
|
+
clientId: client.clientId,
|
|
1362
|
+
sub: asNonEmptyString(subject),
|
|
1363
|
+
scope: resolvedScope,
|
|
1364
|
+
issuedAt: now,
|
|
1365
|
+
expiresAt: refreshExpiresAt,
|
|
1366
|
+
};
|
|
1367
|
+
refreshStore.set(hashToken(refreshToken), refreshRecord);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return {
|
|
1371
|
+
accessToken,
|
|
1372
|
+
tokenType: 'Bearer',
|
|
1373
|
+
expiresIn: oauthConfig.accessTokenTtlSeconds,
|
|
1374
|
+
scope: resolvedScope,
|
|
1375
|
+
...(refreshToken
|
|
1376
|
+
? {
|
|
1377
|
+
refreshToken,
|
|
1378
|
+
refreshExpiresIn: oauthConfig.refreshTokenTtlSeconds,
|
|
1379
|
+
}
|
|
1380
|
+
: {}),
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function getAccessTokenRecord(token) {
|
|
1385
|
+
const hashed = hashToken(token);
|
|
1386
|
+
const access = accessStore.get(hashed);
|
|
1387
|
+
if (!access) {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const now = getNowSeconds();
|
|
1392
|
+
if (Number(access.expiresAt) <= now) {
|
|
1393
|
+
accessStore.delete(hashed);
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
return access;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function getRefreshTokenRecord(token) {
|
|
1401
|
+
const hashed = hashToken(token);
|
|
1402
|
+
const refresh = refreshStore.get(hashed);
|
|
1403
|
+
if (!refresh) {
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const now = getNowSeconds();
|
|
1408
|
+
if (Number(refresh.expiresAt) <= now) {
|
|
1409
|
+
refreshStore.delete(hashed);
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return refresh;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function getAuthorizationCodeRecord(code) {
|
|
1417
|
+
const hashed = hashToken(code);
|
|
1418
|
+
const record = codeStore.get(hashed);
|
|
1419
|
+
if (!record) {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const now = getNowSeconds();
|
|
1424
|
+
if (Number(record.expiresAt) <= now) {
|
|
1425
|
+
codeStore.delete(hashed);
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
return record;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function registerClient({
|
|
1433
|
+
clientId,
|
|
1434
|
+
clientSecret = '',
|
|
1435
|
+
redirectUris = [],
|
|
1436
|
+
grants = [],
|
|
1437
|
+
scopes = [],
|
|
1438
|
+
publicClient = false,
|
|
1439
|
+
tokenEndpointAuthMethod = '',
|
|
1440
|
+
} = {}) {
|
|
1441
|
+
const normalizedId = asNonEmptyString(clientId);
|
|
1442
|
+
if (!normalizedId) {
|
|
1443
|
+
throw new Error('registerClient requires clientId.');
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const normalizedRedirectUris = uniqueStrings(
|
|
1447
|
+
asStringList(redirectUris)
|
|
1448
|
+
.filter((uri) => isValidRedirectUri(uri)),
|
|
1449
|
+
);
|
|
1450
|
+
const defaultGrants = normalizedRedirectUris.length > 0
|
|
1451
|
+
? oauthConfig.grants
|
|
1452
|
+
: (oauthConfig.grants || []).filter((grant) => grant !== 'authorization_code');
|
|
1453
|
+
const normalizedGrants = normalizeOAuth2GrantList(grants.length > 0 ? grants : defaultGrants);
|
|
1454
|
+
const normalizedScopes = normalizeOAuth2Scopes(scopes);
|
|
1455
|
+
const normalizedSecret = asNonEmptyString(clientSecret);
|
|
1456
|
+
const authMethod = normalizeOAuth2ClientAuthMethod(
|
|
1457
|
+
tokenEndpointAuthMethod,
|
|
1458
|
+
publicClient === true ? 'none' : oauthConfig.clientAuthMethod,
|
|
1459
|
+
);
|
|
1460
|
+
const isPublicClient = publicClient === true || authMethod === 'none';
|
|
1461
|
+
|
|
1462
|
+
if (normalizedGrants.includes('authorization_code') && normalizedRedirectUris.length === 0) {
|
|
1463
|
+
throw new Error('registerClient requires at least one redirect URI for authorization_code grant.');
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (!isPublicClient && oauthConfig.requireClientSecret && !normalizedSecret) {
|
|
1467
|
+
throw new Error('registerClient requires clientSecret for confidential clients.');
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const now = getNowSeconds();
|
|
1471
|
+
const existing = getClient(normalizedId);
|
|
1472
|
+
const storedClient = {
|
|
1473
|
+
clientId: normalizedId,
|
|
1474
|
+
clientSecretHash: normalizedSecret
|
|
1475
|
+
? hashClientSecret(normalizedSecret)
|
|
1476
|
+
: (existing?.clientSecretHash || ''),
|
|
1477
|
+
tokenEndpointAuthMethod: isPublicClient ? 'none' : authMethod,
|
|
1478
|
+
publicClient: isPublicClient,
|
|
1479
|
+
redirectUris: normalizedRedirectUris,
|
|
1480
|
+
grants: normalizedGrants,
|
|
1481
|
+
scopes: normalizedScopes,
|
|
1482
|
+
createdAt: existing?.createdAt || now,
|
|
1483
|
+
updatedAt: now,
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
clientsStore.set(normalizedId, storedClient);
|
|
1487
|
+
return sanitizeRegisteredClient(storedClient);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function createAuthorizationCode({
|
|
1491
|
+
clientId,
|
|
1492
|
+
redirectUri,
|
|
1493
|
+
subject,
|
|
1494
|
+
scope = '',
|
|
1495
|
+
codeChallenge = '',
|
|
1496
|
+
codeChallengeMethod = 'plain',
|
|
1497
|
+
nonce = '',
|
|
1498
|
+
} = {}) {
|
|
1499
|
+
const client = getClient(clientId);
|
|
1500
|
+
if (!client) {
|
|
1501
|
+
throw createOAuthError('invalid_client', 'Unknown client.', 400);
|
|
1502
|
+
}
|
|
1503
|
+
assertGrantAllowed(client, 'authorization_code');
|
|
1504
|
+
|
|
1505
|
+
const resolvedRedirectUri = resolveClientRedirectUri(client, redirectUri);
|
|
1506
|
+
const sub = asNonEmptyString(subject);
|
|
1507
|
+
if (!sub) {
|
|
1508
|
+
throw createOAuthError('access_denied', 'Authenticated subject is required.', 401);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const requestedCodeChallenge = asNonEmptyString(codeChallenge);
|
|
1512
|
+
const resolvedMethod = normalizePkceMethod(codeChallengeMethod || 'plain');
|
|
1513
|
+
|
|
1514
|
+
if (oauthConfig.requirePkce && !requestedCodeChallenge) {
|
|
1515
|
+
throw createOAuthError('invalid_request', 'PKCE code_challenge is required.');
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (requestedCodeChallenge) {
|
|
1519
|
+
if (!isValidPkceChallenge(requestedCodeChallenge)) {
|
|
1520
|
+
throw createOAuthError('invalid_request', 'Invalid PKCE code_challenge.');
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (!resolvedMethod) {
|
|
1524
|
+
throw createOAuthError('invalid_request', 'Unsupported code_challenge_method.');
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (resolvedMethod === 'plain' && !oauthConfig.allowPlainPkce) {
|
|
1528
|
+
throw createOAuthError('invalid_request', 'PKCE plain challenge is disabled.');
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const now = getNowSeconds();
|
|
1533
|
+
const code = randomToken(32);
|
|
1534
|
+
const expiresAt = now + oauthConfig.authorizationCodeTtlSeconds;
|
|
1535
|
+
const normalizedScope = validateRequestedScope(client, scope);
|
|
1536
|
+
const record = {
|
|
1537
|
+
clientId: client.clientId,
|
|
1538
|
+
redirectUri: resolvedRedirectUri,
|
|
1539
|
+
sub,
|
|
1540
|
+
scope: normalizedScope,
|
|
1541
|
+
codeChallenge: requestedCodeChallenge,
|
|
1542
|
+
codeChallengeMethod: resolvedMethod || 'plain',
|
|
1543
|
+
nonce: asNonEmptyString(nonce),
|
|
1544
|
+
issuedAt: now,
|
|
1545
|
+
expiresAt,
|
|
1546
|
+
};
|
|
1547
|
+
codeStore.set(hashToken(code), record);
|
|
1548
|
+
return {
|
|
1549
|
+
code,
|
|
1550
|
+
expiresIn: oauthConfig.authorizationCodeTtlSeconds,
|
|
1551
|
+
redirectUri: resolvedRedirectUri,
|
|
1552
|
+
scope: normalizedScope,
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function exchangeAuthorizationCode({
|
|
1557
|
+
code,
|
|
1558
|
+
client,
|
|
1559
|
+
redirectUri,
|
|
1560
|
+
codeVerifier = '',
|
|
1561
|
+
} = {}) {
|
|
1562
|
+
const normalizedCode = asNonEmptyString(code);
|
|
1563
|
+
if (!normalizedCode) {
|
|
1564
|
+
throw createOAuthError('invalid_request', 'Missing authorization code.');
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const resolvedClient = client;
|
|
1568
|
+
if (!resolvedClient) {
|
|
1569
|
+
throw createOAuthError('invalid_client', 'Client authentication failed.', 401, {
|
|
1570
|
+
shouldRedirect: false,
|
|
1571
|
+
wwwAuthenticate: 'Basic realm="oauth2"',
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
assertGrantAllowed(resolvedClient, 'authorization_code');
|
|
1575
|
+
|
|
1576
|
+
const codeHash = hashToken(normalizedCode);
|
|
1577
|
+
const record = codeStore.get(codeHash);
|
|
1578
|
+
if (!record) {
|
|
1579
|
+
throw createOAuthError('invalid_grant', 'Authorization code is invalid.');
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const now = getNowSeconds();
|
|
1583
|
+
if (Number(record.expiresAt) <= now) {
|
|
1584
|
+
codeStore.delete(codeHash);
|
|
1585
|
+
throw createOAuthError('invalid_grant', 'Authorization code is expired.');
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (record.clientId !== resolvedClient.clientId) {
|
|
1589
|
+
throw createOAuthError('invalid_grant', 'Authorization code does not belong to this client.');
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const resolvedRedirectUri = resolveClientRedirectUri(resolvedClient, redirectUri);
|
|
1593
|
+
if (!constantTimeEqual(resolvedRedirectUri, record.redirectUri)) {
|
|
1594
|
+
throw createOAuthError('invalid_grant', 'redirect_uri mismatch.');
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (record.codeChallenge) {
|
|
1598
|
+
const verifier = asNonEmptyString(codeVerifier);
|
|
1599
|
+
if (!isValidPkceVerifier(verifier)) {
|
|
1600
|
+
throw createOAuthError('invalid_grant', 'Invalid or missing code_verifier.');
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (record.codeChallengeMethod === 'S256') {
|
|
1604
|
+
if (!constantTimeEqual(toSha256Base64Url(verifier), record.codeChallenge)) {
|
|
1605
|
+
throw createOAuthError('invalid_grant', 'PKCE verification failed.');
|
|
1606
|
+
}
|
|
1607
|
+
} else if (!constantTimeEqual(verifier, record.codeChallenge)) {
|
|
1608
|
+
throw createOAuthError('invalid_grant', 'PKCE verification failed.');
|
|
1609
|
+
}
|
|
1610
|
+
} else if (oauthConfig.requirePkce) {
|
|
1611
|
+
throw createOAuthError('invalid_grant', 'Authorization code is missing PKCE challenge.');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
codeStore.delete(codeHash);
|
|
1615
|
+
return issueTokens({
|
|
1616
|
+
client: resolvedClient,
|
|
1617
|
+
subject: record.sub,
|
|
1618
|
+
scope: record.scope,
|
|
1619
|
+
grantType: 'authorization_code',
|
|
1620
|
+
includeRefreshToken: true,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function issue({ clientId, clientSecret = '', subject = '', scope = '' } = {}) {
|
|
1625
|
+
const client = authenticateClientCredentials({
|
|
1626
|
+
clientId,
|
|
1627
|
+
clientSecret,
|
|
1628
|
+
allowPublicClient: true,
|
|
1629
|
+
});
|
|
1630
|
+
const grantType = client.grants.includes('authorization_code') ? 'authorization_code' : 'client_credentials';
|
|
1631
|
+
return issueTokens({
|
|
1632
|
+
client,
|
|
1633
|
+
subject,
|
|
1634
|
+
scope,
|
|
1635
|
+
grantType,
|
|
1636
|
+
includeRefreshToken: true,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function issueClientCredentialsForClient(client, scope = '') {
|
|
1641
|
+
assertGrantAllowed(client, 'client_credentials');
|
|
1642
|
+
return issueTokens({
|
|
1643
|
+
client,
|
|
1644
|
+
subject: '',
|
|
1645
|
+
scope,
|
|
1646
|
+
grantType: 'client_credentials',
|
|
1647
|
+
includeRefreshToken: false,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function issueClientCredentials({ clientId, clientSecret = '', scope = '' } = {}) {
|
|
1652
|
+
const client = authenticateClientCredentials({
|
|
1653
|
+
clientId,
|
|
1654
|
+
clientSecret,
|
|
1655
|
+
allowPublicClient: false,
|
|
1656
|
+
});
|
|
1657
|
+
return issueClientCredentialsForClient(client, scope);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function introspect(token) {
|
|
1661
|
+
const access = getAccessTokenRecord(token);
|
|
1662
|
+
if (access) {
|
|
1663
|
+
return {
|
|
1664
|
+
active: true,
|
|
1665
|
+
clientId: access.clientId,
|
|
1666
|
+
sub: access.sub,
|
|
1667
|
+
scope: access.scope,
|
|
1668
|
+
iat: access.issuedAt,
|
|
1669
|
+
exp: access.expiresAt,
|
|
1670
|
+
tokenType: 'Bearer',
|
|
1671
|
+
grantType: access.grantType,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const refresh = getRefreshTokenRecord(token);
|
|
1676
|
+
if (refresh) {
|
|
1677
|
+
return {
|
|
1678
|
+
active: true,
|
|
1679
|
+
clientId: refresh.clientId,
|
|
1680
|
+
sub: refresh.sub,
|
|
1681
|
+
scope: refresh.scope,
|
|
1682
|
+
iat: refresh.issuedAt,
|
|
1683
|
+
exp: refresh.expiresAt,
|
|
1684
|
+
tokenType: 'refresh_token',
|
|
1685
|
+
grantType: refresh.grantType,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const code = getAuthorizationCodeRecord(token);
|
|
1690
|
+
if (code) {
|
|
1691
|
+
return {
|
|
1692
|
+
active: true,
|
|
1693
|
+
clientId: code.clientId,
|
|
1694
|
+
sub: code.sub,
|
|
1695
|
+
scope: code.scope,
|
|
1696
|
+
iat: code.issuedAt,
|
|
1697
|
+
exp: code.expiresAt,
|
|
1698
|
+
tokenType: 'authorization_code',
|
|
1699
|
+
grantType: 'authorization_code',
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
return { active: false };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function introspectForEndpoint(token) {
|
|
1707
|
+
const info = introspect(token);
|
|
1708
|
+
if (!info.active) {
|
|
1709
|
+
return { active: false };
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
return {
|
|
1713
|
+
active: true,
|
|
1714
|
+
scope: info.scope || '',
|
|
1715
|
+
client_id: info.clientId,
|
|
1716
|
+
username: info.sub || undefined,
|
|
1717
|
+
sub: info.sub || undefined,
|
|
1718
|
+
token_type: info.tokenType || 'Bearer',
|
|
1719
|
+
exp: info.exp,
|
|
1720
|
+
iat: info.iat,
|
|
1721
|
+
iss: serverConfig.issuer || undefined,
|
|
1722
|
+
grant_type: info.grantType || undefined,
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function revoke(token, options = {}) {
|
|
1727
|
+
const normalized = asNonEmptyString(token);
|
|
1728
|
+
if (!normalized) {
|
|
1729
|
+
return false;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const clientId = asNonEmptyString(options.clientId);
|
|
1733
|
+
const hashed = hashToken(normalized);
|
|
1734
|
+
const access = accessStore.get(hashed);
|
|
1735
|
+
const refresh = refreshStore.get(hashed);
|
|
1736
|
+
const code = codeStore.get(hashed);
|
|
1737
|
+
let removed = false;
|
|
1738
|
+
|
|
1739
|
+
if (access && (!clientId || access.clientId === clientId)) {
|
|
1740
|
+
removed = accessStore.delete(hashed) || removed;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (refresh && (!clientId || refresh.clientId === clientId)) {
|
|
1744
|
+
removed = refreshStore.delete(hashed) || removed;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (code && (!clientId || code.clientId === clientId)) {
|
|
1748
|
+
removed = codeStore.delete(hashed) || removed;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
return removed;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function exchangeRefreshTokenForClient({
|
|
1755
|
+
refreshToken,
|
|
1756
|
+
client,
|
|
1757
|
+
scope = '',
|
|
1758
|
+
} = {}) {
|
|
1759
|
+
assertGrantAllowed(client, 'refresh_token');
|
|
1760
|
+
|
|
1761
|
+
const normalizedRefreshToken = asNonEmptyString(refreshToken);
|
|
1762
|
+
if (!normalizedRefreshToken) {
|
|
1763
|
+
throw new Error('Invalid refresh token.');
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const hashed = hashToken(normalizedRefreshToken);
|
|
1767
|
+
const refreshRecord = refreshStore.get(hashed);
|
|
1768
|
+
if (!refreshRecord) {
|
|
1769
|
+
throw new Error('Invalid refresh token.');
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const now = getNowSeconds();
|
|
1773
|
+
if (Number(refreshRecord.expiresAt) <= now) {
|
|
1774
|
+
refreshStore.delete(hashed);
|
|
1775
|
+
throw new Error('Expired refresh token.');
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (refreshRecord.clientId !== client.clientId) {
|
|
1779
|
+
throw new Error('Refresh token does not belong to the provided client.');
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
if (oauthConfig.rotateRefreshToken) {
|
|
1783
|
+
refreshStore.delete(hashed);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const requestedScope = normalizeScopes(scope) || refreshRecord.scope;
|
|
1787
|
+
return issueTokens({
|
|
1788
|
+
client,
|
|
1789
|
+
subject: refreshRecord.sub,
|
|
1790
|
+
scope: requestedScope,
|
|
1791
|
+
grantType: 'refresh_token',
|
|
1792
|
+
includeRefreshToken: true,
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function exchangeRefreshToken({
|
|
1797
|
+
refreshToken,
|
|
1798
|
+
clientId,
|
|
1799
|
+
clientSecret = '',
|
|
1800
|
+
scope = '',
|
|
1801
|
+
} = {}) {
|
|
1802
|
+
const client = authenticateClientCredentials({
|
|
1803
|
+
clientId,
|
|
1804
|
+
clientSecret,
|
|
1805
|
+
allowPublicClient: true,
|
|
1806
|
+
});
|
|
1807
|
+
return exchangeRefreshTokenForClient({
|
|
1808
|
+
refreshToken,
|
|
1809
|
+
client,
|
|
1810
|
+
scope,
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function middleware(options = {}) {
|
|
1815
|
+
const optional = options.optional === true;
|
|
1816
|
+
|
|
1817
|
+
return (req, res, next) => {
|
|
1818
|
+
const token = extractBearerToken(req);
|
|
1819
|
+
if (!token) {
|
|
1820
|
+
if (optional) {
|
|
1821
|
+
return next();
|
|
1822
|
+
}
|
|
1823
|
+
return res.status(401).json({ error: 'Missing bearer token' });
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const tokenInfo = introspect(token);
|
|
1827
|
+
if (!tokenInfo.active || tokenInfo.tokenType !== 'Bearer') {
|
|
1828
|
+
if (optional) {
|
|
1829
|
+
return next();
|
|
1830
|
+
}
|
|
1831
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
req.auth = tokenInfo;
|
|
1835
|
+
req.user = tokenInfo.sub ? { id: tokenInfo.sub } : null;
|
|
1836
|
+
return next();
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function resolveAuthorizationSubject(req, params, client) {
|
|
1841
|
+
if (typeof serverConfig.resolveSubject === 'function') {
|
|
1842
|
+
const resolved = await serverConfig.resolveSubject({ req, params, client });
|
|
1843
|
+
return asNonEmptyString(resolved);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const fromReqUser = asNonEmptyString(req?.user?.id, asNonEmptyString(req?.user?.sub));
|
|
1847
|
+
const fromReqAuth = asNonEmptyString(req?.auth?.sub);
|
|
1848
|
+
|
|
1849
|
+
if (fromReqUser || fromReqAuth) {
|
|
1850
|
+
return fromReqUser || fromReqAuth;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (serverConfig.allowSubjectFromParams === true) {
|
|
1854
|
+
return asNonEmptyString(params.subject, asNonEmptyString(params.user_id));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
return '';
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
async function isConsentApproved(req, params, client, subject) {
|
|
1861
|
+
if (typeof serverConfig.resolveConsent === 'function') {
|
|
1862
|
+
const approved = await serverConfig.resolveConsent({
|
|
1863
|
+
req,
|
|
1864
|
+
params,
|
|
1865
|
+
client,
|
|
1866
|
+
subject,
|
|
1867
|
+
});
|
|
1868
|
+
return approved === true;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (serverConfig.requireConsent === true) {
|
|
1872
|
+
const approveValue = asNonEmptyString(readRequestParam(req, 'approve')).toLowerCase();
|
|
1873
|
+
return approveValue === '1' || approveValue === 'true' || approveValue === 'yes';
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
return serverConfig.autoApprove !== false;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function shouldRejectInsecureTransport(req) {
|
|
1880
|
+
if (serverConfig.allowHttp === true) {
|
|
1881
|
+
return false;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
return req?.secure !== true;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function ensureSecureTransport(req) {
|
|
1888
|
+
if (shouldRejectInsecureTransport(req)) {
|
|
1889
|
+
throw createOAuthError('invalid_request', 'OAuth2 endpoints require HTTPS.', 400, {
|
|
1890
|
+
shouldRedirect: false,
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function resolveAuthorizeParams(req) {
|
|
1896
|
+
return {
|
|
1897
|
+
response_type: asNonEmptyString(readRequestParam(req, 'response_type')).toLowerCase(),
|
|
1898
|
+
client_id: asNonEmptyString(readRequestParam(req, 'client_id')),
|
|
1899
|
+
redirect_uri: asNonEmptyString(readRequestParam(req, 'redirect_uri')),
|
|
1900
|
+
scope: normalizeScopes(readRequestParam(req, 'scope')),
|
|
1901
|
+
state: asNonEmptyString(readRequestParam(req, 'state')),
|
|
1902
|
+
code_challenge: asNonEmptyString(readRequestParam(req, 'code_challenge')),
|
|
1903
|
+
code_challenge_method: asNonEmptyString(readRequestParam(req, 'code_challenge_method'), 'plain'),
|
|
1904
|
+
nonce: asNonEmptyString(readRequestParam(req, 'nonce')),
|
|
1905
|
+
subject: asNonEmptyString(readRequestParam(req, 'subject')),
|
|
1906
|
+
user_id: asNonEmptyString(readRequestParam(req, 'user_id')),
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
async function authorizeEndpoint(req, res) {
|
|
1911
|
+
const params = resolveAuthorizeParams(req);
|
|
1912
|
+
let redirectUri = '';
|
|
1913
|
+
let state = '';
|
|
1914
|
+
|
|
1915
|
+
try {
|
|
1916
|
+
ensureSecureTransport(req);
|
|
1917
|
+
|
|
1918
|
+
if (params.response_type !== 'code') {
|
|
1919
|
+
throw createOAuthError('unsupported_response_type', 'Only response_type=code is supported.');
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (!params.client_id) {
|
|
1923
|
+
throw createOAuthError('invalid_request', 'Missing client_id.', 400, {
|
|
1924
|
+
shouldRedirect: false,
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const client = getClient(params.client_id);
|
|
1929
|
+
if (!client) {
|
|
1930
|
+
throw createOAuthError('invalid_client', 'Unknown client.', 400, {
|
|
1931
|
+
shouldRedirect: false,
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
assertGrantAllowed(client, 'authorization_code');
|
|
1936
|
+
redirectUri = resolveClientRedirectUri(client, params.redirect_uri);
|
|
1937
|
+
state = params.state;
|
|
1938
|
+
|
|
1939
|
+
const subject = await resolveAuthorizationSubject(req, params, client);
|
|
1940
|
+
if (!subject && serverConfig.requireAuthenticatedUser !== false) {
|
|
1941
|
+
throw createOAuthError('access_denied', 'Authenticated user is required.', 401);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const consentApproved = await isConsentApproved(req, params, client, subject);
|
|
1945
|
+
if (!consentApproved) {
|
|
1946
|
+
throw createOAuthError('access_denied', 'Resource owner denied the authorization request.');
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
const issued = createAuthorizationCode({
|
|
1950
|
+
clientId: client.clientId,
|
|
1951
|
+
redirectUri,
|
|
1952
|
+
subject,
|
|
1953
|
+
scope: params.scope,
|
|
1954
|
+
codeChallenge: params.code_challenge,
|
|
1955
|
+
codeChallengeMethod: params.code_challenge_method,
|
|
1956
|
+
nonce: params.nonce,
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
return res.redirect(302, buildUrlWithQuery(redirectUri, {
|
|
1960
|
+
code: issued.code,
|
|
1961
|
+
state: state || undefined,
|
|
1962
|
+
}));
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
if (redirectUri && error?.shouldRedirect !== false) {
|
|
1965
|
+
return res.redirect(302, buildUrlWithQuery(redirectUri, {
|
|
1966
|
+
error: error?.error || 'server_error',
|
|
1967
|
+
error_description: error?.errorDescription || 'Authorization failed.',
|
|
1968
|
+
state: state || undefined,
|
|
1969
|
+
}));
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
if (error?.isOAuthError) {
|
|
1973
|
+
return toOAuthErrorResponse(res, {
|
|
1974
|
+
statusCode: error.statusCode || 400,
|
|
1975
|
+
error: error.error,
|
|
1976
|
+
errorDescription: error.errorDescription,
|
|
1977
|
+
wwwAuthenticate: error.wwwAuthenticate,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
return toOAuthErrorResponse(res, {
|
|
1982
|
+
statusCode: 500,
|
|
1983
|
+
error: 'server_error',
|
|
1984
|
+
errorDescription: error?.message || 'Authorization server error.',
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function toTokenEndpointPayload(tokens) {
|
|
1990
|
+
return {
|
|
1991
|
+
access_token: tokens.accessToken,
|
|
1992
|
+
token_type: tokens.tokenType || 'Bearer',
|
|
1993
|
+
expires_in: tokens.expiresIn,
|
|
1994
|
+
scope: tokens.scope || '',
|
|
1995
|
+
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
|
1996
|
+
...(tokens.refreshExpiresIn ? { refresh_expires_in: tokens.refreshExpiresIn } : {}),
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async function tokenEndpoint(req, res) {
|
|
2001
|
+
try {
|
|
2002
|
+
ensureSecureTransport(req);
|
|
2003
|
+
const grantType = asNonEmptyString(readRequestParam(req, 'grant_type')).toLowerCase();
|
|
2004
|
+
if (!grantType) {
|
|
2005
|
+
throw createOAuthError('invalid_request', 'Missing grant_type.');
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
if (!SUPPORTED_OAUTH2_GRANTS.has(grantType)) {
|
|
2009
|
+
throw createOAuthError('unsupported_grant_type', `Unsupported grant_type "${grantType}".`);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
let tokens;
|
|
2013
|
+
if (grantType === 'authorization_code') {
|
|
2014
|
+
const client = await resolveClientFromRequestAsync(req, { allowPublicClient: true });
|
|
2015
|
+
assertGrantAllowed(client, 'authorization_code');
|
|
2016
|
+
tokens = exchangeAuthorizationCode({
|
|
2017
|
+
code: readRequestParam(req, 'code'),
|
|
2018
|
+
client,
|
|
2019
|
+
redirectUri: readRequestParam(req, 'redirect_uri'),
|
|
2020
|
+
codeVerifier: readRequestParam(req, 'code_verifier'),
|
|
2021
|
+
});
|
|
2022
|
+
} else if (grantType === 'refresh_token') {
|
|
2023
|
+
const client = await resolveClientFromRequestAsync(req, { allowPublicClient: true });
|
|
2024
|
+
assertGrantAllowed(client, 'refresh_token');
|
|
2025
|
+
tokens = exchangeRefreshTokenForClient({
|
|
2026
|
+
refreshToken: readRequestParam(req, 'refresh_token'),
|
|
2027
|
+
client,
|
|
2028
|
+
scope: readRequestParam(req, 'scope'),
|
|
2029
|
+
});
|
|
2030
|
+
} else {
|
|
2031
|
+
const client = await resolveClientFromRequestAsync(req, { allowPublicClient: false });
|
|
2032
|
+
tokens = issueClientCredentialsForClient(client, readRequestParam(req, 'scope'));
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
res.set('Cache-Control', 'no-store');
|
|
2036
|
+
res.set('Pragma', 'no-cache');
|
|
2037
|
+
return res.json(toTokenEndpointPayload(tokens));
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
if (error?.isOAuthError) {
|
|
2040
|
+
return toOAuthErrorResponse(res, {
|
|
2041
|
+
statusCode: error.statusCode || 400,
|
|
2042
|
+
error: error.error,
|
|
2043
|
+
errorDescription: error.errorDescription,
|
|
2044
|
+
wwwAuthenticate: error.wwwAuthenticate,
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
return toOAuthErrorResponse(res, {
|
|
2049
|
+
statusCode: 400,
|
|
2050
|
+
error: 'invalid_grant',
|
|
2051
|
+
errorDescription: error?.message || 'Token issuance failed.',
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
async function introspectionEndpoint(req, res) {
|
|
2057
|
+
try {
|
|
2058
|
+
ensureSecureTransport(req);
|
|
2059
|
+
await resolveClientFromRequestAsync(req, { allowPublicClient: false });
|
|
2060
|
+
const token = asNonEmptyString(readRequestParam(req, 'token'));
|
|
2061
|
+
if (!token) {
|
|
2062
|
+
throw createOAuthError('invalid_request', 'Missing token.');
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const payload = introspectForEndpoint(token);
|
|
2066
|
+
return res.json(payload);
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if (error?.isOAuthError) {
|
|
2069
|
+
return toOAuthErrorResponse(res, {
|
|
2070
|
+
statusCode: error.statusCode || 400,
|
|
2071
|
+
error: error.error,
|
|
2072
|
+
errorDescription: error.errorDescription,
|
|
2073
|
+
wwwAuthenticate: error.wwwAuthenticate,
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
return toOAuthErrorResponse(res, {
|
|
2078
|
+
statusCode: 400,
|
|
2079
|
+
error: 'invalid_request',
|
|
2080
|
+
errorDescription: error?.message || 'Token introspection failed.',
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
async function revocationEndpoint(req, res) {
|
|
2086
|
+
try {
|
|
2087
|
+
ensureSecureTransport(req);
|
|
2088
|
+
const client = await resolveClientFromRequestAsync(req, { allowPublicClient: false });
|
|
2089
|
+
const token = asNonEmptyString(readRequestParam(req, 'token'));
|
|
2090
|
+
if (!token) {
|
|
2091
|
+
throw createOAuthError('invalid_request', 'Missing token.');
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
revoke(token, { clientId: client.clientId });
|
|
2095
|
+
return res.status(200).end();
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
if (error?.isOAuthError) {
|
|
2098
|
+
return toOAuthErrorResponse(res, {
|
|
2099
|
+
statusCode: error.statusCode || 400,
|
|
2100
|
+
error: error.error,
|
|
2101
|
+
errorDescription: error.errorDescription,
|
|
2102
|
+
wwwAuthenticate: error.wwwAuthenticate,
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
return toOAuthErrorResponse(res, {
|
|
2107
|
+
statusCode: 400,
|
|
2108
|
+
error: 'invalid_request',
|
|
2109
|
+
errorDescription: error?.message || 'Token revocation failed.',
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function metadataEndpoint(req, res) {
|
|
2115
|
+
const origin = resolveRequestOrigin(req);
|
|
2116
|
+
const issuer = asNonEmptyString(serverConfig.issuer, origin);
|
|
2117
|
+
const endpointUrl = (value) => {
|
|
2118
|
+
if (String(value).startsWith('http://') || String(value).startsWith('https://')) {
|
|
2119
|
+
return String(value);
|
|
2120
|
+
}
|
|
2121
|
+
return `${origin}${value}`;
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
return res.json({
|
|
2125
|
+
issuer,
|
|
2126
|
+
authorization_endpoint: endpointUrl(serverConfig.authorizePath),
|
|
2127
|
+
token_endpoint: endpointUrl(serverConfig.tokenPath),
|
|
2128
|
+
introspection_endpoint: endpointUrl(serverConfig.introspectionPath),
|
|
2129
|
+
revocation_endpoint: endpointUrl(serverConfig.revocationPath),
|
|
2130
|
+
response_types_supported: ['code'],
|
|
2131
|
+
grant_types_supported: [...SUPPORTED_OAUTH2_GRANTS],
|
|
2132
|
+
token_endpoint_auth_methods_supported: [...SUPPORTED_OAUTH2_CLIENT_AUTH_METHODS],
|
|
2133
|
+
code_challenge_methods_supported: oauthConfig.allowPlainPkce ? ['S256', 'plain'] : ['S256'],
|
|
2134
|
+
scopes_supported: oauthConfig.defaultScopes,
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function isOAuthServerRequestPath(requestPath) {
|
|
2139
|
+
return oauthServerPaths.has(String(requestPath || ''));
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
return {
|
|
2143
|
+
enabled: true,
|
|
2144
|
+
provider: 'oauth2',
|
|
2145
|
+
tablePrefix: config.tablePrefix,
|
|
2146
|
+
tables: config.tables,
|
|
2147
|
+
registerClient,
|
|
2148
|
+
issue,
|
|
2149
|
+
issueClientCredentials,
|
|
2150
|
+
createAuthorizationCode,
|
|
2151
|
+
exchangeAuthorizationCode,
|
|
2152
|
+
introspect,
|
|
2153
|
+
revoke,
|
|
2154
|
+
exchangeRefreshToken,
|
|
2155
|
+
middleware,
|
|
2156
|
+
isOAuthServerRequestPath,
|
|
2157
|
+
oauth2Server: {
|
|
2158
|
+
enabled: serverConfig.enabled === true,
|
|
2159
|
+
paths: {
|
|
2160
|
+
basePath: serverConfig.basePath,
|
|
2161
|
+
authorize: serverConfig.authorizePath,
|
|
2162
|
+
token: serverConfig.tokenPath,
|
|
2163
|
+
introspect: serverConfig.introspectionPath,
|
|
2164
|
+
revoke: serverConfig.revocationPath,
|
|
2165
|
+
metadata: serverConfig.metadataPath,
|
|
2166
|
+
},
|
|
2167
|
+
handlers: {
|
|
2168
|
+
authorize: authorizeEndpoint,
|
|
2169
|
+
token: tokenEndpoint,
|
|
2170
|
+
introspect: introspectionEndpoint,
|
|
2171
|
+
revoke: revocationEndpoint,
|
|
2172
|
+
metadata: metadataEndpoint,
|
|
2173
|
+
},
|
|
2174
|
+
isTokenEndpointPath: (requestPath) => tokenEndpointPaths.has(String(requestPath || '')),
|
|
2175
|
+
},
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function createAuthDisabledError(actionName) {
|
|
2180
|
+
const error = new Error(`Auth is disabled. Cannot execute "${actionName}".`);
|
|
2181
|
+
error.code = 'AUTH_DISABLED';
|
|
2182
|
+
error.statusCode = 503;
|
|
2183
|
+
return error;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function createDisabledAuthManager(config) {
|
|
2187
|
+
const safeConfig = isPlainObject(config)
|
|
2188
|
+
? config
|
|
2189
|
+
: {
|
|
2190
|
+
provider: 'jwt',
|
|
2191
|
+
tablePrefix: 'aegisnode',
|
|
2192
|
+
tables: buildAuthTables('aegisnode'),
|
|
2193
|
+
};
|
|
2194
|
+
const fail = (actionName) => {
|
|
2195
|
+
throw createAuthDisabledError(actionName);
|
|
2196
|
+
};
|
|
2197
|
+
|
|
2198
|
+
const middleware = (options = {}) => {
|
|
2199
|
+
const optional = options.optional === true;
|
|
2200
|
+
return (req, res, next) => {
|
|
2201
|
+
if (optional) {
|
|
2202
|
+
return next();
|
|
2203
|
+
}
|
|
2204
|
+
return res.status(503).json({ error: 'Auth is disabled' });
|
|
2205
|
+
};
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
return {
|
|
2209
|
+
enabled: false,
|
|
2210
|
+
provider: safeConfig.provider,
|
|
2211
|
+
tablePrefix: safeConfig.tablePrefix,
|
|
2212
|
+
tables: safeConfig.tables,
|
|
2213
|
+
ready: Promise.resolve(),
|
|
2214
|
+
close: async () => {},
|
|
2215
|
+
middleware,
|
|
2216
|
+
guard: middleware,
|
|
2217
|
+
issue: () => fail('issue'),
|
|
2218
|
+
issueAccessToken: () => fail('issueAccessToken'),
|
|
2219
|
+
issueRefreshToken: () => fail('issueRefreshToken'),
|
|
2220
|
+
verify: () => fail('verify'),
|
|
2221
|
+
revoke: () => fail('revoke'),
|
|
2222
|
+
registerClient: () => fail('registerClient'),
|
|
2223
|
+
createAuthorizationCode: () => fail('createAuthorizationCode'),
|
|
2224
|
+
exchangeAuthorizationCode: () => fail('exchangeAuthorizationCode'),
|
|
2225
|
+
introspect: () => fail('introspect'),
|
|
2226
|
+
exchangeRefreshToken: () => fail('exchangeRefreshToken'),
|
|
2227
|
+
issueClientCredentials: () => fail('issueClientCredentials'),
|
|
2228
|
+
isOAuthServerRequestPath: () => false,
|
|
2229
|
+
oauth2Server: {
|
|
2230
|
+
enabled: false,
|
|
2231
|
+
paths: {},
|
|
2232
|
+
handlers: {},
|
|
2233
|
+
isTokenEndpointPath: () => false,
|
|
2234
|
+
},
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
export function createAuthGuard(auth, options = {}) {
|
|
2239
|
+
if (auth && typeof auth.middleware === 'function') {
|
|
2240
|
+
return auth.middleware(options);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
const optional = options.optional === true;
|
|
2244
|
+
return (req, res, next) => {
|
|
2245
|
+
if (optional) {
|
|
2246
|
+
return next();
|
|
2247
|
+
}
|
|
2248
|
+
return res.status(503).json({ error: 'Auth is disabled' });
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
export function normalizeAuthConfig(rawAuth, { appName = 'aegisnode', appSecret = '' } = {}) {
|
|
2253
|
+
const auth = isPlainObject(rawAuth) ? rawAuth : {};
|
|
2254
|
+
const providerCandidate = String(auth.provider || 'jwt').toLowerCase();
|
|
2255
|
+
const provider = SUPPORTED_PROVIDERS.has(providerCandidate) ? providerCandidate : 'jwt';
|
|
2256
|
+
const tablePrefix = sanitizeTablePrefix(auth.tablePrefix, 'aegisnode');
|
|
2257
|
+
|
|
2258
|
+
return {
|
|
2259
|
+
enabled: auth.enabled === true,
|
|
2260
|
+
provider,
|
|
2261
|
+
tablePrefix,
|
|
2262
|
+
tables: buildAuthTables(tablePrefix),
|
|
2263
|
+
storage: normalizeAuthStorageConfig(auth.storage, tablePrefix),
|
|
2264
|
+
jwt: normalizeJwtConfig(auth.jwt, appName, appSecret),
|
|
2265
|
+
oauth2: normalizeOAuth2Config(auth.oauth2, appName),
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
export function createAuthManager({ config, cache, logger, rootDir, database }) {
|
|
2270
|
+
if (!config?.enabled) {
|
|
2271
|
+
logger.debug('Auth subsystem disabled by configuration.');
|
|
2272
|
+
return createDisabledAuthManager(config);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const { adapter: storeAdapter, ready, close } = createStoreAdapter({
|
|
2276
|
+
config,
|
|
2277
|
+
cache,
|
|
2278
|
+
rootDir,
|
|
2279
|
+
logger,
|
|
2280
|
+
database,
|
|
2281
|
+
});
|
|
2282
|
+
const manager = config.provider === 'oauth2'
|
|
2283
|
+
? createOAuth2Manager({ config, storeAdapter })
|
|
2284
|
+
: createJwtManager({ config, storeAdapter });
|
|
2285
|
+
|
|
2286
|
+
manager.guard = manager.middleware;
|
|
2287
|
+
manager.ready = ready || Promise.resolve();
|
|
2288
|
+
manager.close = typeof close === 'function' ? close : async () => {};
|
|
2289
|
+
logger.info('Auth subsystem enabled with provider %s (table prefix: %s)', manager.provider, manager.tablePrefix);
|
|
2290
|
+
return manager;
|
|
2291
|
+
}
|