@technomoron/api-server-base 2.0.0-beta.2 → 2.0.0-beta.21
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.txt +81 -28
- package/dist/cjs/api-module.cjs +9 -0
- package/dist/cjs/api-module.d.ts +7 -4
- package/dist/cjs/api-server-base.cjs +607 -99
- package/dist/cjs/api-server-base.d.ts +80 -23
- package/dist/cjs/auth-api/auth-module.d.ts +23 -3
- package/dist/cjs/auth-api/auth-module.js +320 -124
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/cjs/auth-api/compat-auth-storage.js +15 -3
- package/dist/cjs/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/cjs/auth-api/mem-auth-store.js +14 -28
- package/dist/cjs/auth-api/module.d.ts +1 -1
- package/dist/cjs/auth-api/sql-auth-store.d.ts +16 -4
- package/dist/cjs/auth-api/sql-auth-store.js +43 -30
- package/dist/cjs/auth-api/storage.d.ts +6 -4
- package/dist/cjs/auth-api/storage.js +15 -5
- package/dist/cjs/auth-api/types.d.ts +7 -2
- package/dist/cjs/auth-api/user-id.d.ts +5 -0
- package/dist/cjs/auth-api/user-id.js +38 -0
- package/dist/cjs/auth-cookie-options.d.ts +11 -0
- package/dist/cjs/auth-cookie-options.js +66 -0
- package/dist/cjs/index.cjs +4 -14
- package/dist/cjs/index.d.ts +4 -9
- package/dist/cjs/oauth/memory.d.ts +6 -0
- package/dist/cjs/oauth/memory.js +44 -11
- package/dist/cjs/oauth/models.d.ts +7 -2
- package/dist/cjs/oauth/models.js +10 -21
- package/dist/cjs/oauth/sequelize.d.ts +10 -48
- package/dist/cjs/oauth/sequelize.js +44 -99
- package/dist/cjs/oauth/types.d.ts +1 -0
- package/dist/cjs/passkey/base.d.ts +2 -0
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/config.js +26 -0
- package/dist/cjs/passkey/memory.d.ts +8 -0
- package/dist/cjs/passkey/memory.js +57 -16
- package/dist/cjs/passkey/models.d.ts +13 -4
- package/dist/cjs/passkey/models.js +41 -14
- package/dist/cjs/passkey/sequelize.d.ts +13 -25
- package/dist/cjs/passkey/sequelize.js +68 -153
- package/dist/cjs/passkey/service.d.ts +6 -2
- package/dist/cjs/passkey/service.js +205 -27
- package/dist/cjs/passkey/types.d.ts +18 -9
- package/dist/cjs/sequelize-utils.d.ts +8 -0
- package/dist/cjs/sequelize-utils.js +57 -0
- package/dist/cjs/token/base.d.ts +2 -1
- package/dist/cjs/token/base.js +3 -1
- package/dist/cjs/token/memory.d.ts +10 -0
- package/dist/cjs/token/memory.js +122 -32
- package/dist/cjs/token/sequelize.d.ts +4 -4
- package/dist/cjs/token/sequelize.js +67 -85
- package/dist/cjs/token/types.d.ts +8 -1
- package/dist/cjs/user/base.d.ts +1 -0
- package/dist/cjs/user/base.js +11 -4
- package/dist/cjs/user/memory.d.ts +2 -0
- package/dist/cjs/user/memory.js +9 -10
- package/dist/cjs/user/sequelize.d.ts +7 -2
- package/dist/cjs/user/sequelize.js +19 -32
- package/dist/esm/api-module.d.ts +7 -4
- package/dist/esm/api-module.js +9 -0
- package/dist/esm/api-server-base.d.ts +80 -23
- package/dist/esm/api-server-base.js +608 -100
- package/dist/esm/auth-api/auth-module.d.ts +23 -3
- package/dist/esm/auth-api/auth-module.js +321 -125
- package/dist/esm/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/esm/auth-api/compat-auth-storage.js +13 -1
- package/dist/esm/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/esm/auth-api/mem-auth-store.js +14 -28
- package/dist/esm/auth-api/module.d.ts +1 -1
- package/dist/esm/auth-api/sql-auth-store.d.ts +16 -4
- package/dist/esm/auth-api/sql-auth-store.js +43 -30
- package/dist/esm/auth-api/storage.d.ts +6 -4
- package/dist/esm/auth-api/storage.js +13 -3
- package/dist/esm/auth-api/types.d.ts +7 -2
- package/dist/esm/auth-api/user-id.d.ts +5 -0
- package/dist/esm/auth-api/user-id.js +32 -0
- package/dist/esm/auth-cookie-options.d.ts +11 -0
- package/dist/esm/auth-cookie-options.js +63 -0
- package/dist/esm/index.d.ts +4 -9
- package/dist/esm/index.js +2 -7
- package/dist/esm/oauth/memory.d.ts +6 -0
- package/dist/esm/oauth/memory.js +44 -11
- package/dist/esm/oauth/models.d.ts +7 -2
- package/dist/esm/oauth/models.js +6 -19
- package/dist/esm/oauth/sequelize.d.ts +10 -48
- package/dist/esm/oauth/sequelize.js +32 -87
- package/dist/esm/oauth/types.d.ts +1 -0
- package/dist/esm/passkey/base.d.ts +2 -0
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.d.ts +8 -0
- package/dist/esm/passkey/memory.js +57 -16
- package/dist/esm/passkey/models.d.ts +13 -4
- package/dist/esm/passkey/models.js +39 -12
- package/dist/esm/passkey/sequelize.d.ts +13 -25
- package/dist/esm/passkey/sequelize.js +69 -154
- package/dist/esm/passkey/service.d.ts +6 -2
- package/dist/esm/passkey/service.js +173 -28
- package/dist/esm/passkey/types.d.ts +18 -9
- package/dist/esm/sequelize-utils.d.ts +8 -0
- package/dist/esm/sequelize-utils.js +48 -0
- package/dist/esm/token/base.d.ts +2 -1
- package/dist/esm/token/base.js +3 -1
- package/dist/esm/token/memory.d.ts +10 -0
- package/dist/esm/token/memory.js +122 -32
- package/dist/esm/token/sequelize.d.ts +4 -4
- package/dist/esm/token/sequelize.js +67 -85
- package/dist/esm/token/types.d.ts +8 -1
- package/dist/esm/user/base.d.ts +1 -0
- package/dist/esm/user/base.js +11 -4
- package/dist/esm/user/memory.d.ts +2 -0
- package/dist/esm/user/memory.js +9 -10
- package/dist/esm/user/sequelize.d.ts +7 -2
- package/dist/esm/user/sequelize.js +19 -32
- package/docs/swagger/openapi.json +1876 -0
- package/package.json +84 -34
|
@@ -11,12 +11,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.ApiServer = exports.ApiError = exports.ApiModule = void 0;
|
|
13
13
|
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const promises_1 = require("node:fs/promises");
|
|
15
|
+
const node_module_1 = require("node:module");
|
|
16
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
17
|
const cookie_parser_1 = __importDefault(require("cookie-parser"));
|
|
15
18
|
const cors_1 = __importDefault(require("cors"));
|
|
16
19
|
const express_1 = __importDefault(require("express"));
|
|
17
20
|
const multer_1 = __importDefault(require("multer"));
|
|
18
21
|
const module_js_1 = require("./auth-api/module.js");
|
|
19
22
|
const storage_js_1 = require("./auth-api/storage.js");
|
|
23
|
+
const user_id_js_1 = require("./auth-api/user-id.js");
|
|
24
|
+
const auth_cookie_options_js_1 = require("./auth-cookie-options.js");
|
|
20
25
|
const base_js_1 = require("./token/base.js");
|
|
21
26
|
class JwtHelperStore extends base_js_1.TokenStore {
|
|
22
27
|
async save() {
|
|
@@ -46,11 +51,14 @@ function guess_exception_text(error, defMsg = 'Unknown Error') {
|
|
|
46
51
|
msg.push(error);
|
|
47
52
|
}
|
|
48
53
|
else if (error && typeof error === 'object') {
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
const errorDetails = error;
|
|
55
|
+
if (typeof errorDetails.message === 'string' && errorDetails.message.trim() !== '') {
|
|
56
|
+
msg.push(errorDetails.message);
|
|
51
57
|
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
58
|
+
if (errorDetails.parent &&
|
|
59
|
+
typeof errorDetails.parent.message === 'string' &&
|
|
60
|
+
errorDetails.parent.message.trim() !== '') {
|
|
61
|
+
msg.push(errorDetails.parent.message);
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
return msg.length > 0 ? msg.join(' / ') : defMsg;
|
|
@@ -71,6 +79,7 @@ function hydrateGetBody(req) {
|
|
|
71
79
|
req.body = { ...query };
|
|
72
80
|
return;
|
|
73
81
|
}
|
|
82
|
+
// Keep explicit body fields authoritative when both query and body provide the same key.
|
|
74
83
|
req.body = { ...query, ...body };
|
|
75
84
|
}
|
|
76
85
|
function normalizeIpAddress(candidate) {
|
|
@@ -266,7 +275,9 @@ function collectClientIpChain(req) {
|
|
|
266
275
|
}
|
|
267
276
|
const realIp = req.headers['x-real-ip'];
|
|
268
277
|
if (Array.isArray(realIp)) {
|
|
269
|
-
|
|
278
|
+
for (const value of realIp) {
|
|
279
|
+
pushNormalized(normalizeIpAddress(value));
|
|
280
|
+
}
|
|
270
281
|
}
|
|
271
282
|
else if (typeof realIp === 'string') {
|
|
272
283
|
pushNormalized(normalizeIpAddress(realIp));
|
|
@@ -331,18 +342,36 @@ function isApiErrorLike(candidate) {
|
|
|
331
342
|
const maybeError = candidate;
|
|
332
343
|
return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
|
|
333
344
|
}
|
|
345
|
+
function asHttpStatus(error) {
|
|
346
|
+
if (!error || typeof error !== 'object') {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const maybe = error;
|
|
350
|
+
const status = typeof maybe.status === 'number' ? maybe.status : maybe.statusCode;
|
|
351
|
+
if (typeof status === 'number' && status >= 400 && status <= 599) {
|
|
352
|
+
return status;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
334
356
|
function fillConfig(config) {
|
|
335
357
|
return {
|
|
336
358
|
apiPort: config.apiPort ?? 3101,
|
|
337
359
|
apiHost: config.apiHost ?? 'localhost',
|
|
338
360
|
uploadPath: config.uploadPath ?? '',
|
|
339
361
|
uploadMax: config.uploadMax ?? 30 * 1024 * 1024,
|
|
362
|
+
staticDirs: config.staticDirs,
|
|
340
363
|
origins: config.origins ?? [],
|
|
341
364
|
debug: config.debug ?? false,
|
|
342
365
|
apiBasePath: config.apiBasePath ?? '/api',
|
|
366
|
+
swaggerEnabled: config.swaggerEnabled ?? false,
|
|
367
|
+
swaggerPath: config.swaggerPath ?? '',
|
|
343
368
|
accessSecret: config.accessSecret ?? '',
|
|
344
369
|
refreshSecret: config.refreshSecret ?? '',
|
|
345
|
-
cookieDomain: config.cookieDomain ?? '
|
|
370
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
371
|
+
cookiePath: config.cookiePath ?? '/',
|
|
372
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
373
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
374
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
346
375
|
accessCookie: config.accessCookie ?? 'dat',
|
|
347
376
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
348
377
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
@@ -352,25 +381,45 @@ function fillConfig(config) {
|
|
|
352
381
|
devMode: config.devMode ?? false,
|
|
353
382
|
hydrateGetBody: config.hydrateGetBody ?? true,
|
|
354
383
|
validateTokens: config.validateTokens ?? false,
|
|
384
|
+
refreshMaybe: config.refreshMaybe ?? false,
|
|
355
385
|
apiVersion: config.apiVersion ?? '',
|
|
356
386
|
minClientVersion: config.minClientVersion ?? '',
|
|
357
387
|
tokenStore: config.tokenStore,
|
|
358
|
-
authStores: config.authStores
|
|
388
|
+
authStores: config.authStores,
|
|
389
|
+
onStartError: config.onStartError
|
|
359
390
|
};
|
|
360
391
|
}
|
|
361
392
|
class ApiServer {
|
|
393
|
+
/**
|
|
394
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
395
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
396
|
+
* when mounting raw Express endpoints.
|
|
397
|
+
*/
|
|
398
|
+
get currReq() {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
set currReq(_value) {
|
|
402
|
+
if (this.config.devMode && !this.currReqDeprecationWarned) {
|
|
403
|
+
this.currReqDeprecationWarned = true;
|
|
404
|
+
console.warn('[api-server-base] ApiServer.currReq is deprecated and always null. Use req.apiReq or res.locals.apiReq.');
|
|
405
|
+
}
|
|
406
|
+
void _value;
|
|
407
|
+
}
|
|
362
408
|
constructor(config = {}) {
|
|
363
|
-
this.
|
|
409
|
+
this.finalized = false;
|
|
410
|
+
this.serverAuthAdapter = null;
|
|
364
411
|
this.apiNotFoundHandler = null;
|
|
412
|
+
this.apiErrorHandlerInstalled = false;
|
|
365
413
|
this.tokenStoreAdapter = null;
|
|
366
414
|
this.userStoreAdapter = null;
|
|
367
415
|
this.passkeyServiceAdapter = null;
|
|
368
416
|
this.oauthStoreAdapter = null;
|
|
369
417
|
this.canImpersonateAdapter = null;
|
|
418
|
+
this.currReqDeprecationWarned = false;
|
|
370
419
|
this.config = fillConfig(config);
|
|
371
420
|
this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
|
|
372
421
|
this.startedAt = Date.now();
|
|
373
|
-
this.storageAdapter = storage_js_1.
|
|
422
|
+
this.storageAdapter = storage_js_1.nullAuthAdapter;
|
|
374
423
|
this.moduleAdapter = module_js_1.nullAuthModule;
|
|
375
424
|
this.jwtHelper = new JwtHelperStore();
|
|
376
425
|
this.tokenStoreAdapter = this.config.tokenStore ?? null;
|
|
@@ -381,17 +430,75 @@ class ApiServer {
|
|
|
381
430
|
this.passkeyServiceAdapter = passkeyService ?? null;
|
|
382
431
|
this.oauthStoreAdapter = oauthStore ?? null;
|
|
383
432
|
this.canImpersonateAdapter = canImpersonate ?? null;
|
|
384
|
-
this.storageAdapter = this;
|
|
433
|
+
this.storageAdapter = this.getServerAuthAdapter();
|
|
434
|
+
}
|
|
435
|
+
if ((this.config.authApi || this.config.authStores) &&
|
|
436
|
+
(!this.config.accessSecret || !this.config.refreshSecret)) {
|
|
437
|
+
console.warn('[api-server-base] Auth is enabled but accessSecret and/or refreshSecret are empty. JWT signing will fail at runtime.');
|
|
385
438
|
}
|
|
386
439
|
this.app = (0, express_1.default)();
|
|
440
|
+
// Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
|
|
441
|
+
// the API 404 handler ordered last without relying on Express internals.
|
|
442
|
+
this.apiRouter = express_1.default.Router();
|
|
387
443
|
if (config.uploadPath) {
|
|
388
|
-
const upload = (0, multer_1.default)({ dest: config.uploadPath });
|
|
444
|
+
const upload = (0, multer_1.default)({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
|
|
389
445
|
this.app.use(upload.any());
|
|
446
|
+
// Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
|
|
447
|
+
this.app.use((err, _req, res, next) => {
|
|
448
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
449
|
+
if (code === 'LIMIT_FILE_SIZE') {
|
|
450
|
+
res.status(413).json({
|
|
451
|
+
success: false,
|
|
452
|
+
code: 413,
|
|
453
|
+
message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
|
|
454
|
+
data: null,
|
|
455
|
+
errors: {}
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
next(err);
|
|
460
|
+
});
|
|
390
461
|
}
|
|
391
462
|
this.middlewares();
|
|
463
|
+
this.installStaticDirs();
|
|
392
464
|
this.installPingHandler();
|
|
465
|
+
this.installSwaggerHandler();
|
|
466
|
+
this.app.use(this.apiBasePath, this.apiRouter);
|
|
393
467
|
// addSwaggerUi(this.app);
|
|
394
468
|
this.installApiNotFoundHandler();
|
|
469
|
+
this.installApiErrorHandler();
|
|
470
|
+
}
|
|
471
|
+
assertNotFinalized(action) {
|
|
472
|
+
if (this.finalized) {
|
|
473
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
toApiRouterPath(candidate) {
|
|
477
|
+
if (typeof candidate !== 'string') {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const trimmed = candidate.trim();
|
|
481
|
+
if (!trimmed) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
485
|
+
const base = this.apiBasePath;
|
|
486
|
+
if (base === '/') {
|
|
487
|
+
return normalized;
|
|
488
|
+
}
|
|
489
|
+
if (normalized === base) {
|
|
490
|
+
return '/';
|
|
491
|
+
}
|
|
492
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
493
|
+
return normalized.slice(base.length) || '/';
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
finalize() {
|
|
498
|
+
this.installApiNotFoundHandler();
|
|
499
|
+
this.installApiErrorHandler();
|
|
500
|
+
this.finalized = true;
|
|
501
|
+
return this;
|
|
395
502
|
}
|
|
396
503
|
authStorage(storage) {
|
|
397
504
|
this.storageAdapter = storage;
|
|
@@ -421,9 +528,9 @@ class ApiServer {
|
|
|
421
528
|
}
|
|
422
529
|
setTokenStore(store) {
|
|
423
530
|
this.tokenStoreAdapter = store;
|
|
424
|
-
// If using direct stores, expose
|
|
531
|
+
// If using direct stores, expose the server-backed auth adapter.
|
|
425
532
|
if (this.userStoreAdapter) {
|
|
426
|
-
this.storageAdapter = this;
|
|
533
|
+
this.storageAdapter = this.getServerAuthAdapter();
|
|
427
534
|
}
|
|
428
535
|
return this;
|
|
429
536
|
}
|
|
@@ -448,13 +555,46 @@ class ApiServer {
|
|
|
448
555
|
}
|
|
449
556
|
return this.passkeyServiceAdapter;
|
|
450
557
|
}
|
|
558
|
+
async listUserCredentials(userId) {
|
|
559
|
+
return this.ensurePasskeyService().listUserCredentials(userId);
|
|
560
|
+
}
|
|
561
|
+
async deletePasskeyCredential(credentialId) {
|
|
562
|
+
return this.ensurePasskeyService().deleteCredential(credentialId);
|
|
563
|
+
}
|
|
451
564
|
ensureOAuthStore() {
|
|
452
565
|
if (!this.oauthStoreAdapter) {
|
|
453
566
|
throw new Error('OAuth store is not configured');
|
|
454
567
|
}
|
|
455
568
|
return this.oauthStoreAdapter;
|
|
456
569
|
}
|
|
457
|
-
|
|
570
|
+
getServerAuthAdapter() {
|
|
571
|
+
if (this.serverAuthAdapter) {
|
|
572
|
+
return this.serverAuthAdapter;
|
|
573
|
+
}
|
|
574
|
+
const server = this;
|
|
575
|
+
this.serverAuthAdapter = {
|
|
576
|
+
getUser: (identifier) => server.getUser(identifier),
|
|
577
|
+
getUserPasswordHash: (user) => server.getUserPasswordHash(user),
|
|
578
|
+
getUserId: (user) => server.getUserId(user),
|
|
579
|
+
filterUser: (user) => server.filterUser(user),
|
|
580
|
+
verifyPassword: (password, hash) => server.verifyPassword(password, hash),
|
|
581
|
+
storeToken: (data) => server.storeToken(data),
|
|
582
|
+
getToken: (query, opts) => server.getToken(query, opts),
|
|
583
|
+
deleteToken: (query) => server.deleteToken(query),
|
|
584
|
+
updateToken: (updates) => server.updateToken(updates),
|
|
585
|
+
createPasskeyChallenge: (params) => server.createPasskeyChallenge(params),
|
|
586
|
+
verifyPasskeyResponse: (params) => server.verifyPasskeyResponse(params),
|
|
587
|
+
listUserCredentials: (userId) => server.listUserCredentials(userId),
|
|
588
|
+
deletePasskeyCredential: (credentialId) => server.deletePasskeyCredential(credentialId),
|
|
589
|
+
getClient: (clientId) => server.getClient(clientId),
|
|
590
|
+
verifyClientSecret: (client, clientSecret) => server.verifyClientSecret(client, clientSecret),
|
|
591
|
+
createAuthCode: (request) => server.createAuthCode(request),
|
|
592
|
+
consumeAuthCode: (code, clientId) => server.consumeAuthCode(code, clientId),
|
|
593
|
+
canImpersonate: (params) => server.canImpersonate(params)
|
|
594
|
+
};
|
|
595
|
+
return this.serverAuthAdapter;
|
|
596
|
+
}
|
|
597
|
+
// AuthAdapter-compatible helpers (used by AuthModule)
|
|
458
598
|
async getUser(identifier) {
|
|
459
599
|
return this.userStoreAdapter ? this.userStoreAdapter.findUser(identifier) : null;
|
|
460
600
|
}
|
|
@@ -474,36 +614,39 @@ class ApiServer {
|
|
|
474
614
|
if (this.tokenStoreAdapter) {
|
|
475
615
|
return this.tokenStoreAdapter.save(data);
|
|
476
616
|
}
|
|
477
|
-
|
|
478
|
-
|
|
617
|
+
const storage = this.storageAdapter;
|
|
618
|
+
if (typeof storage.storeToken === 'function') {
|
|
619
|
+
return storage.storeToken(data);
|
|
479
620
|
}
|
|
480
621
|
throw new Error('Token store is not configured');
|
|
481
622
|
}
|
|
482
623
|
async getToken(query, opts) {
|
|
483
624
|
const normalized = {
|
|
484
625
|
...query,
|
|
485
|
-
userId:
|
|
486
|
-
ruid:
|
|
626
|
+
userId: (0, user_id_js_1.toOptionalStringId)(query.userId),
|
|
627
|
+
ruid: (0, user_id_js_1.toOptionalStringId)(query.ruid)
|
|
487
628
|
};
|
|
488
629
|
if (this.tokenStoreAdapter) {
|
|
489
630
|
return this.tokenStoreAdapter.get(normalized, opts);
|
|
490
631
|
}
|
|
491
|
-
|
|
492
|
-
|
|
632
|
+
const storage = this.storageAdapter;
|
|
633
|
+
if (typeof storage.getToken === 'function') {
|
|
634
|
+
return storage.getToken(normalized, opts);
|
|
493
635
|
}
|
|
494
636
|
return null;
|
|
495
637
|
}
|
|
496
638
|
async deleteToken(query) {
|
|
497
639
|
const normalized = {
|
|
498
640
|
...query,
|
|
499
|
-
userId:
|
|
500
|
-
ruid:
|
|
641
|
+
userId: (0, user_id_js_1.toOptionalStringId)(query.userId),
|
|
642
|
+
ruid: (0, user_id_js_1.toOptionalStringId)(query.ruid)
|
|
501
643
|
};
|
|
502
644
|
if (this.tokenStoreAdapter) {
|
|
503
645
|
return this.tokenStoreAdapter.delete(normalized);
|
|
504
646
|
}
|
|
505
|
-
|
|
506
|
-
|
|
647
|
+
const storage = this.storageAdapter;
|
|
648
|
+
if (typeof storage.deleteToken === 'function') {
|
|
649
|
+
return storage.deleteToken(normalized);
|
|
507
650
|
}
|
|
508
651
|
return 0;
|
|
509
652
|
}
|
|
@@ -576,12 +719,13 @@ class ApiServer {
|
|
|
576
719
|
if (this.tokenStoreAdapter) {
|
|
577
720
|
return this.tokenStoreAdapter.update(updates);
|
|
578
721
|
}
|
|
579
|
-
|
|
580
|
-
|
|
722
|
+
const storage = this.storageAdapter;
|
|
723
|
+
if (typeof storage.updateToken === 'function') {
|
|
724
|
+
return storage.updateToken(updates);
|
|
581
725
|
}
|
|
582
726
|
return false;
|
|
583
727
|
}
|
|
584
|
-
guessExceptionText(error, defMsg = '
|
|
728
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
585
729
|
return guess_exception_text(error, defMsg);
|
|
586
730
|
}
|
|
587
731
|
async authorize(apiReq, requiredClass) {
|
|
@@ -607,11 +751,47 @@ class ApiServer {
|
|
|
607
751
|
credentials: true
|
|
608
752
|
};
|
|
609
753
|
this.app.use((0, cors_1.default)(corsOptions));
|
|
754
|
+
// Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
|
|
755
|
+
this.app.use((err, req, res, next) => {
|
|
756
|
+
const message = err instanceof Error ? err.message : '';
|
|
757
|
+
if (message.includes('Not allowed by CORS')) {
|
|
758
|
+
const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
|
|
759
|
+
if (isApiRequest) {
|
|
760
|
+
res.status(403).json({
|
|
761
|
+
success: false,
|
|
762
|
+
code: 403,
|
|
763
|
+
message: 'Origin not allowed by CORS',
|
|
764
|
+
data: null,
|
|
765
|
+
errors: {}
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
res.status(403).send('Origin not allowed by CORS');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
next(err);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
installStaticDirs() {
|
|
776
|
+
const staticDirs = this.config.staticDirs;
|
|
777
|
+
if (!staticDirs || !isPlainObject(staticDirs)) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
for (const [mountRaw, dirRaw] of Object.entries(staticDirs)) {
|
|
781
|
+
const mount = typeof mountRaw === 'string' ? mountRaw.trim() : '';
|
|
782
|
+
const dir = typeof dirRaw === 'string' ? dirRaw.trim() : '';
|
|
783
|
+
if (!mount || !dir) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
|
|
787
|
+
this.app.use(resolvedMount, express_1.default.static(dir));
|
|
788
|
+
}
|
|
610
789
|
}
|
|
611
790
|
installPingHandler() {
|
|
612
791
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
613
792
|
this.app.get(path, (_req, res) => {
|
|
614
793
|
const payload = {
|
|
794
|
+
success: true,
|
|
615
795
|
status: 'ok',
|
|
616
796
|
apiVersion: this.config.apiVersion ?? '',
|
|
617
797
|
minClientVersion: this.config.minClientVersion ?? '',
|
|
@@ -619,7 +799,66 @@ class ApiServer {
|
|
|
619
799
|
startedAt: this.startedAt,
|
|
620
800
|
timestamp: new Date().toISOString()
|
|
621
801
|
};
|
|
622
|
-
res.status(200).json({ code: 200, message: 'Success', data: payload });
|
|
802
|
+
res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async loadSwaggerSpec() {
|
|
806
|
+
const candidates = [node_path_1.default.resolve(process.cwd(), 'docs/swagger/openapi.json')];
|
|
807
|
+
if (typeof __dirname === 'string') {
|
|
808
|
+
candidates.push(node_path_1.default.resolve(__dirname, '../../docs/swagger/openapi.json'));
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const require = (0, node_module_1.createRequire)(node_path_1.default.join(process.cwd(), 'package.json'));
|
|
812
|
+
const entry = require.resolve('@technomoron/api-server-base');
|
|
813
|
+
const packageRoot = node_path_1.default.resolve(node_path_1.default.dirname(entry), '..', '..');
|
|
814
|
+
candidates.push(node_path_1.default.join(packageRoot, 'docs/swagger/openapi.json'));
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// Ignore resolution failures; fall back to any existing candidate.
|
|
818
|
+
}
|
|
819
|
+
for (const candidate of candidates) {
|
|
820
|
+
try {
|
|
821
|
+
await (0, promises_1.access)(candidate);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const raw = await (0, promises_1.readFile)(candidate, 'utf8');
|
|
828
|
+
return JSON.parse(raw);
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
installSwaggerHandler() {
|
|
837
|
+
const rawPath = typeof this.config.swaggerPath === 'string' ? this.config.swaggerPath.trim() : '';
|
|
838
|
+
const enabled = Boolean(this.config.swaggerEnabled) || rawPath.length > 0;
|
|
839
|
+
if (!enabled) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
|
|
843
|
+
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
844
|
+
const path = resolved.startsWith('/') ? resolved : `/${resolved}`;
|
|
845
|
+
let specPromise;
|
|
846
|
+
this.app.get(path, async (_req, res) => {
|
|
847
|
+
if (!specPromise) {
|
|
848
|
+
specPromise = this.loadSwaggerSpec();
|
|
849
|
+
}
|
|
850
|
+
const spec = await specPromise;
|
|
851
|
+
if (!spec) {
|
|
852
|
+
res.status(500).json({
|
|
853
|
+
success: false,
|
|
854
|
+
code: 500,
|
|
855
|
+
message: 'Swagger spec is unavailable',
|
|
856
|
+
data: null,
|
|
857
|
+
errors: {}
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
res.status(200).json(spec);
|
|
623
862
|
});
|
|
624
863
|
}
|
|
625
864
|
normalizeApiBasePath(path) {
|
|
@@ -642,6 +881,7 @@ class ApiServer {
|
|
|
642
881
|
}
|
|
643
882
|
this.apiNotFoundHandler = (req, res) => {
|
|
644
883
|
const payload = {
|
|
884
|
+
success: false,
|
|
645
885
|
code: 404,
|
|
646
886
|
message: this.describeMissingEndpoint(req),
|
|
647
887
|
data: null,
|
|
@@ -651,21 +891,12 @@ class ApiServer {
|
|
|
651
891
|
};
|
|
652
892
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
653
893
|
}
|
|
654
|
-
|
|
655
|
-
this.
|
|
656
|
-
if (!this.apiNotFoundHandler) {
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
const stack = this.app?._router?.stack;
|
|
660
|
-
if (!stack) {
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
|
|
664
|
-
if (index === -1 || index === stack.length - 1) {
|
|
894
|
+
installApiErrorHandler() {
|
|
895
|
+
if (this.apiErrorHandlerInstalled) {
|
|
665
896
|
return;
|
|
666
897
|
}
|
|
667
|
-
|
|
668
|
-
|
|
898
|
+
this.apiErrorHandlerInstalled = true;
|
|
899
|
+
this.app.use(this.apiBasePath, this.expressErrorHandler());
|
|
669
900
|
}
|
|
670
901
|
describeMissingEndpoint(req) {
|
|
671
902
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
@@ -673,6 +904,10 @@ class ApiServer {
|
|
|
673
904
|
return `No such endpoint: ${method} ${target}`;
|
|
674
905
|
}
|
|
675
906
|
start() {
|
|
907
|
+
if (!this.finalized) {
|
|
908
|
+
console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
909
|
+
this.finalize();
|
|
910
|
+
}
|
|
676
911
|
this.app
|
|
677
912
|
.listen({
|
|
678
913
|
port: this.config.apiPort,
|
|
@@ -682,34 +917,123 @@ class ApiServer {
|
|
|
682
917
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
683
918
|
})
|
|
684
919
|
.on('error', (error) => {
|
|
920
|
+
let message;
|
|
685
921
|
if (error.code === 'EADDRINUSE') {
|
|
686
|
-
|
|
922
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
687
923
|
}
|
|
688
924
|
else if (error.code === 'EACCES') {
|
|
689
|
-
|
|
925
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
690
926
|
}
|
|
691
927
|
else if (error.code === 'EADDRNOTAVAIL') {
|
|
692
|
-
|
|
928
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
693
929
|
}
|
|
694
930
|
else {
|
|
695
|
-
|
|
931
|
+
message = `Failed to start server: ${error.message}`;
|
|
932
|
+
}
|
|
933
|
+
const err = new Error(message);
|
|
934
|
+
err.cause = error;
|
|
935
|
+
if (typeof this.config.onStartError === 'function') {
|
|
936
|
+
this.config.onStartError(err);
|
|
937
|
+
return;
|
|
696
938
|
}
|
|
697
|
-
|
|
939
|
+
throw err;
|
|
698
940
|
});
|
|
699
941
|
return this;
|
|
700
942
|
}
|
|
943
|
+
internalServerErrorMessage(error) {
|
|
944
|
+
return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
|
|
945
|
+
}
|
|
701
946
|
async verifyJWT(token) {
|
|
702
947
|
if (!this.config.accessSecret) {
|
|
703
|
-
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set' };
|
|
948
|
+
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
|
|
704
949
|
}
|
|
705
950
|
const result = this.jwtVerify(token, this.config.accessSecret);
|
|
706
951
|
if (!result.success) {
|
|
707
|
-
return { tokenData: undefined, error: result.error };
|
|
952
|
+
return { tokenData: undefined, error: result.error, expired: result.expired };
|
|
708
953
|
}
|
|
709
954
|
if (!result.data.uid) {
|
|
710
|
-
return { tokenData: undefined, error: 'Missing/bad userid in token' };
|
|
955
|
+
return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
|
|
956
|
+
}
|
|
957
|
+
return { tokenData: result.data, error: undefined, expired: false };
|
|
958
|
+
}
|
|
959
|
+
jwtCookieOptions(apiReq) {
|
|
960
|
+
return (0, auth_cookie_options_js_1.buildAuthCookieOptions)(this.config, apiReq.req);
|
|
961
|
+
}
|
|
962
|
+
setAccessCookie(apiReq, accessToken, sessionCookie) {
|
|
963
|
+
const conf = this.config;
|
|
964
|
+
const options = this.jwtCookieOptions(apiReq);
|
|
965
|
+
const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
|
|
966
|
+
const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
|
|
967
|
+
apiReq.res.cookie(conf.accessCookie, accessToken, accessOptions);
|
|
968
|
+
}
|
|
969
|
+
async tryRefreshAccessToken(apiReq) {
|
|
970
|
+
const conf = this.config;
|
|
971
|
+
if (!conf.refreshSecret || !conf.accessSecret) {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
const rawRefresh = apiReq.req.cookies?.[conf.refreshCookie];
|
|
975
|
+
if (typeof rawRefresh !== 'string') {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
const refreshToken = rawRefresh.trim();
|
|
979
|
+
if (!refreshToken) {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
const verify = this.jwtVerify(refreshToken, conf.refreshSecret);
|
|
983
|
+
if (!verify.success || !verify.data) {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
let stored = null;
|
|
987
|
+
try {
|
|
988
|
+
stored = await this.storageAdapter.getToken({ refreshToken });
|
|
711
989
|
}
|
|
712
|
-
|
|
990
|
+
catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
if (!stored) {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
const storedUid = String(stored.userId);
|
|
997
|
+
const verifyUid = verify.data.uid === undefined || verify.data.uid === null ? null : String(verify.data.uid);
|
|
998
|
+
if (verifyUid && verifyUid !== storedUid) {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
const claims = verify.data;
|
|
1002
|
+
const { exp: _exp, iat: _iat, nbf: _nbf, ...payload } = claims;
|
|
1003
|
+
void _exp;
|
|
1004
|
+
void _iat;
|
|
1005
|
+
void _nbf;
|
|
1006
|
+
// Ensure we never embed token secrets into refreshed access tokens.
|
|
1007
|
+
delete payload.accessToken;
|
|
1008
|
+
delete payload.refreshToken;
|
|
1009
|
+
delete payload.userId;
|
|
1010
|
+
delete payload.expires;
|
|
1011
|
+
delete payload.issuedAt;
|
|
1012
|
+
delete payload.lastSeenAt;
|
|
1013
|
+
delete payload.status;
|
|
1014
|
+
const access = this.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
1015
|
+
if (!access.success || !access.token) {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
const updated = await this.updateToken({
|
|
1019
|
+
refreshToken,
|
|
1020
|
+
accessToken: access.token,
|
|
1021
|
+
lastSeenAt: new Date()
|
|
1022
|
+
});
|
|
1023
|
+
if (!updated) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
this.setAccessCookie(apiReq, access.token, stored.sessionCookie ?? false);
|
|
1027
|
+
if (apiReq.req.cookies) {
|
|
1028
|
+
apiReq.req.cookies[conf.accessCookie] = access.token;
|
|
1029
|
+
}
|
|
1030
|
+
const verifiedAccess = await this.verifyJWT(access.token);
|
|
1031
|
+
if (!verifiedAccess.tokenData) {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
const refreshedStored = { ...stored, accessToken: access.token };
|
|
1035
|
+
apiReq.authToken = refreshedStored;
|
|
1036
|
+
return { token: access.token, tokenData: verifiedAccess.tokenData, stored: refreshedStored };
|
|
713
1037
|
}
|
|
714
1038
|
async authenticate(apiReq, authType) {
|
|
715
1039
|
if (authType === 'none') {
|
|
@@ -719,6 +1043,7 @@ class ApiServer {
|
|
|
719
1043
|
let token = null;
|
|
720
1044
|
const authHeader = apiReq.req.headers.authorization;
|
|
721
1045
|
const requiresAuthToken = this.requiresAuthToken(authType);
|
|
1046
|
+
const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
|
|
722
1047
|
const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
|
|
723
1048
|
if (apiKeyAuth) {
|
|
724
1049
|
return apiKeyAuth;
|
|
@@ -727,32 +1052,84 @@ class ApiServer {
|
|
|
727
1052
|
token = authHeader.slice(7).trim();
|
|
728
1053
|
}
|
|
729
1054
|
if (!token) {
|
|
730
|
-
const access = apiReq.req.cookies?.
|
|
1055
|
+
const access = apiReq.req.cookies?.[this.config.accessCookie];
|
|
731
1056
|
if (access) {
|
|
732
1057
|
token = access;
|
|
733
1058
|
}
|
|
734
1059
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
}
|
|
1060
|
+
let tokenData;
|
|
1061
|
+
let error;
|
|
1062
|
+
let expired = false;
|
|
740
1063
|
if (!token) {
|
|
741
1064
|
if (authType === 'maybe') {
|
|
742
|
-
|
|
1065
|
+
if (!this.config.refreshMaybe) {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
1069
|
+
if (!refreshed) {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
token = refreshed.token;
|
|
1073
|
+
tokenData = refreshed.tokenData;
|
|
1074
|
+
error = undefined;
|
|
1075
|
+
expired = false;
|
|
743
1076
|
}
|
|
744
|
-
else {
|
|
745
|
-
|
|
1077
|
+
else if (requiresAuthToken) {
|
|
1078
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
1079
|
+
if (!refreshed) {
|
|
1080
|
+
throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
|
|
1081
|
+
}
|
|
1082
|
+
token = refreshed.token;
|
|
1083
|
+
tokenData = refreshed.tokenData;
|
|
1084
|
+
error = undefined;
|
|
1085
|
+
expired = false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (!token) {
|
|
1089
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - requires authentication' });
|
|
1090
|
+
}
|
|
1091
|
+
if (!tokenData) {
|
|
1092
|
+
const verified = await this.verifyJWT(token);
|
|
1093
|
+
tokenData = verified.tokenData;
|
|
1094
|
+
error = verified.error;
|
|
1095
|
+
expired = verified.expired ?? false;
|
|
1096
|
+
}
|
|
1097
|
+
if (!tokenData && allowRefresh && expired) {
|
|
1098
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
1099
|
+
if (refreshed) {
|
|
1100
|
+
token = refreshed.token;
|
|
1101
|
+
tokenData = refreshed.tokenData;
|
|
1102
|
+
error = undefined;
|
|
746
1103
|
}
|
|
747
1104
|
}
|
|
748
|
-
const { tokenData, error } = await this.verifyJWT(token);
|
|
749
1105
|
if (!tokenData) {
|
|
750
|
-
throw new ApiError({ code: 401, message: '
|
|
1106
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
751
1107
|
}
|
|
752
1108
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
753
1109
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
754
1110
|
if (this.shouldValidateStoredToken(authType)) {
|
|
755
|
-
|
|
1111
|
+
try {
|
|
1112
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
if (allowRefresh &&
|
|
1116
|
+
error instanceof ApiError &&
|
|
1117
|
+
error.code === 401 &&
|
|
1118
|
+
error.message === 'Authorization token is no longer valid') {
|
|
1119
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
1120
|
+
if (!refreshed) {
|
|
1121
|
+
throw error;
|
|
1122
|
+
}
|
|
1123
|
+
token = refreshed.token;
|
|
1124
|
+
tokenData = refreshed.tokenData;
|
|
1125
|
+
const refreshedUserId = this.extractTokenUserId(tokenData);
|
|
1126
|
+
apiReq.realUid = this.resolveRealUserId(tokenData, refreshedUserId);
|
|
1127
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
756
1133
|
}
|
|
757
1134
|
apiReq.token = token;
|
|
758
1135
|
return tokenData;
|
|
@@ -778,6 +1155,11 @@ class ApiServer {
|
|
|
778
1155
|
}
|
|
779
1156
|
apiReq.token = secret;
|
|
780
1157
|
apiReq.apiKey = key;
|
|
1158
|
+
// Treat API keys as authenticated identities, consistent with JWT-based flows.
|
|
1159
|
+
const resolvedUid = this.normalizeAuthIdentifier(key.uid);
|
|
1160
|
+
if (resolvedUid !== null) {
|
|
1161
|
+
apiReq.realUid = resolvedUid;
|
|
1162
|
+
}
|
|
781
1163
|
return {
|
|
782
1164
|
uid: key.uid,
|
|
783
1165
|
domain: '',
|
|
@@ -794,6 +1176,9 @@ class ApiServer {
|
|
|
794
1176
|
}
|
|
795
1177
|
async assertStoredAccessToken(apiReq, token, tokenData) {
|
|
796
1178
|
const userId = String(this.extractTokenUserId(tokenData));
|
|
1179
|
+
if (apiReq.authToken && apiReq.authToken.accessToken === token && String(apiReq.authToken.userId) === userId) {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
797
1182
|
const stored = await this.storageAdapter.getToken({
|
|
798
1183
|
accessToken: token,
|
|
799
1184
|
userId
|
|
@@ -828,38 +1213,118 @@ class ApiServer {
|
|
|
828
1213
|
if (rawReal === null) {
|
|
829
1214
|
return effectiveUserId;
|
|
830
1215
|
}
|
|
831
|
-
if (typeof rawReal === 'number' && rawReal === 0) {
|
|
832
|
-
return effectiveUserId;
|
|
833
|
-
}
|
|
834
1216
|
return rawReal;
|
|
835
1217
|
}
|
|
836
|
-
|
|
1218
|
+
useExpress(pathOrHandler, ...handlers) {
|
|
1219
|
+
this.assertNotFinalized('useExpress');
|
|
1220
|
+
if (typeof pathOrHandler === 'string') {
|
|
1221
|
+
const apiPath = this.toApiRouterPath(pathOrHandler);
|
|
1222
|
+
if (apiPath) {
|
|
1223
|
+
this.apiRouter.use(apiPath, ...handlers);
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1231
|
+
}
|
|
1232
|
+
return this;
|
|
1233
|
+
}
|
|
1234
|
+
createApiRequest(req, res) {
|
|
1235
|
+
const apiReq = {
|
|
1236
|
+
server: this,
|
|
1237
|
+
req,
|
|
1238
|
+
res,
|
|
1239
|
+
token: '',
|
|
1240
|
+
tokenData: null,
|
|
1241
|
+
realUid: null,
|
|
1242
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
1243
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
1244
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
1245
|
+
getRealUid: () => apiReq.realUid ?? null,
|
|
1246
|
+
isImpersonating: () => {
|
|
1247
|
+
const realUid = apiReq.realUid;
|
|
1248
|
+
const tokenUid = apiReq.tokenData?.uid;
|
|
1249
|
+
if (realUid === null || realUid === undefined) {
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
if (tokenUid === null || tokenUid === undefined) {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
return realUid !== tokenUid;
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
return apiReq;
|
|
1259
|
+
}
|
|
1260
|
+
expressAuth(auth) {
|
|
837
1261
|
return async (req, res, next) => {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
tokenData: null,
|
|
845
|
-
realUid: null,
|
|
846
|
-
getClientInfo: () => ensureClientInfo(apiReq),
|
|
847
|
-
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
848
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
849
|
-
getRealUid: () => apiReq.realUid ?? null,
|
|
850
|
-
isImpersonating: () => {
|
|
851
|
-
const realUid = apiReq.realUid;
|
|
852
|
-
const tokenUid = apiReq.tokenData?.uid;
|
|
853
|
-
if (realUid === null || realUid === undefined) {
|
|
854
|
-
return false;
|
|
855
|
-
}
|
|
856
|
-
if (tokenUid === null || tokenUid === undefined) {
|
|
857
|
-
return false;
|
|
858
|
-
}
|
|
859
|
-
return realUid !== tokenUid;
|
|
1262
|
+
const apiReq = this.createApiRequest(req, res);
|
|
1263
|
+
req.apiReq = apiReq;
|
|
1264
|
+
res.locals.apiReq = apiReq;
|
|
1265
|
+
try {
|
|
1266
|
+
if (this.config.hydrateGetBody) {
|
|
1267
|
+
hydrateGetBody(req);
|
|
860
1268
|
}
|
|
1269
|
+
if (this.config.debug) {
|
|
1270
|
+
this.dumpRequest(apiReq);
|
|
1271
|
+
}
|
|
1272
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
1273
|
+
await this.authorize(apiReq, auth.req);
|
|
1274
|
+
next();
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
next(error);
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
expressErrorHandler() {
|
|
1282
|
+
return (error, _req, res, next) => {
|
|
1283
|
+
void _req;
|
|
1284
|
+
if (res.headersSent) {
|
|
1285
|
+
next(error);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
1289
|
+
const apiError = error;
|
|
1290
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
1291
|
+
? apiError.errors
|
|
1292
|
+
: {};
|
|
1293
|
+
const errorPayload = {
|
|
1294
|
+
success: false,
|
|
1295
|
+
code: apiError.code,
|
|
1296
|
+
message: apiError.message,
|
|
1297
|
+
data: apiError.data ?? null,
|
|
1298
|
+
errors: normalizedErrors
|
|
1299
|
+
};
|
|
1300
|
+
res.status(apiError.code).json(errorPayload);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const status = asHttpStatus(error);
|
|
1304
|
+
if (status) {
|
|
1305
|
+
res.status(status).json({
|
|
1306
|
+
success: false,
|
|
1307
|
+
code: status,
|
|
1308
|
+
message: status === 400 ? 'Invalid request payload' : this.internalServerErrorMessage(error),
|
|
1309
|
+
data: null,
|
|
1310
|
+
errors: {}
|
|
1311
|
+
});
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const errorPayload = {
|
|
1315
|
+
success: false,
|
|
1316
|
+
code: 500,
|
|
1317
|
+
message: this.internalServerErrorMessage(error),
|
|
1318
|
+
data: null,
|
|
1319
|
+
errors: {}
|
|
861
1320
|
};
|
|
862
|
-
|
|
1321
|
+
res.status(500).json(errorPayload);
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
handle_request(handler, auth) {
|
|
1325
|
+
return async (req, res, next) => {
|
|
1326
|
+
void next;
|
|
1327
|
+
const apiReq = this.createApiRequest(req, res);
|
|
863
1328
|
try {
|
|
864
1329
|
if (this.config.hydrateGetBody) {
|
|
865
1330
|
hydrateGetBody(apiReq.req);
|
|
@@ -881,7 +1346,7 @@ class ApiServer {
|
|
|
881
1346
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
882
1347
|
}
|
|
883
1348
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
884
|
-
const responsePayload = { code, message, data };
|
|
1349
|
+
const responsePayload = { success: true, code, message, data, errors: {} };
|
|
885
1350
|
if (this.config.debug) {
|
|
886
1351
|
this.dumpResponse(apiReq, responsePayload, code);
|
|
887
1352
|
}
|
|
@@ -894,6 +1359,7 @@ class ApiServer {
|
|
|
894
1359
|
? apiError.errors
|
|
895
1360
|
: {};
|
|
896
1361
|
const errorPayload = {
|
|
1362
|
+
success: false,
|
|
897
1363
|
code: apiError.code,
|
|
898
1364
|
message: apiError.message,
|
|
899
1365
|
data: apiError.data ?? null,
|
|
@@ -906,8 +1372,9 @@ class ApiServer {
|
|
|
906
1372
|
}
|
|
907
1373
|
else {
|
|
908
1374
|
const errorPayload = {
|
|
1375
|
+
success: false,
|
|
909
1376
|
code: 500,
|
|
910
|
-
message: this.
|
|
1377
|
+
message: this.internalServerErrorMessage(error),
|
|
911
1378
|
data: null,
|
|
912
1379
|
errors: {}
|
|
913
1380
|
};
|
|
@@ -920,25 +1387,46 @@ class ApiServer {
|
|
|
920
1387
|
};
|
|
921
1388
|
}
|
|
922
1389
|
api(module) {
|
|
1390
|
+
this.assertNotFinalized('api');
|
|
923
1391
|
const router = express_1.default.Router();
|
|
924
1392
|
module.server = this;
|
|
925
|
-
|
|
1393
|
+
const moduleType = module.moduleType;
|
|
1394
|
+
if (moduleType === 'auth') {
|
|
926
1395
|
this.authModule(module);
|
|
927
1396
|
}
|
|
928
|
-
module.checkConfig();
|
|
1397
|
+
const configOk = module.checkConfig();
|
|
1398
|
+
if (configOk === false) {
|
|
1399
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1400
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1401
|
+
}
|
|
929
1402
|
const base = this.apiBasePath;
|
|
930
1403
|
const ns = module.namespace;
|
|
931
1404
|
const mountPath = `${base}${ns}`;
|
|
932
1405
|
module.mountpath = mountPath;
|
|
933
1406
|
module.defineRoutes().forEach((r) => {
|
|
934
1407
|
const handler = this.handle_request(r.handler, r.auth);
|
|
935
|
-
|
|
1408
|
+
switch (r.method) {
|
|
1409
|
+
case 'get':
|
|
1410
|
+
router.get(r.path, handler);
|
|
1411
|
+
break;
|
|
1412
|
+
case 'post':
|
|
1413
|
+
router.post(r.path, handler);
|
|
1414
|
+
break;
|
|
1415
|
+
case 'put':
|
|
1416
|
+
router.put(r.path, handler);
|
|
1417
|
+
break;
|
|
1418
|
+
case 'patch':
|
|
1419
|
+
router.patch(r.path, handler);
|
|
1420
|
+
break;
|
|
1421
|
+
case 'delete':
|
|
1422
|
+
router.delete(r.path, handler);
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
936
1425
|
if (this.config.debug) {
|
|
937
1426
|
console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
|
|
938
1427
|
}
|
|
939
1428
|
});
|
|
940
|
-
this.
|
|
941
|
-
this.ensureApiNotFoundOrdering();
|
|
1429
|
+
this.apiRouter.use(ns, router);
|
|
942
1430
|
return this;
|
|
943
1431
|
}
|
|
944
1432
|
dumpRequest(apiReq) {
|
|
@@ -948,9 +1436,29 @@ class ApiServer {
|
|
|
948
1436
|
console.log('URL:', url);
|
|
949
1437
|
console.log('Method:', req.method);
|
|
950
1438
|
console.log('Query Params:', req.query || {});
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1439
|
+
const sensitiveBodyKeys = ['password', 'client_secret', 'clientSecret', 'secret'];
|
|
1440
|
+
const body = req.body && typeof req.body === 'object' ? { ...req.body } : req.body;
|
|
1441
|
+
if (body && typeof body === 'object') {
|
|
1442
|
+
for (const key of sensitiveBodyKeys) {
|
|
1443
|
+
if (key in body) {
|
|
1444
|
+
body[key] = '[REDACTED]';
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
console.log('Body Params:', body || {});
|
|
1449
|
+
const cookies = req.cookies ? { ...req.cookies } : {};
|
|
1450
|
+
const sensitiveCookieKeys = [this.config.accessCookie, this.config.refreshCookie];
|
|
1451
|
+
for (const key of sensitiveCookieKeys) {
|
|
1452
|
+
if (key in cookies) {
|
|
1453
|
+
cookies[key] = '[REDACTED]';
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
console.log('Cookies:', cookies);
|
|
1457
|
+
const headers = { ...req.headers };
|
|
1458
|
+
if (headers.authorization) {
|
|
1459
|
+
headers.authorization = '[REDACTED]';
|
|
1460
|
+
}
|
|
1461
|
+
console.log('Headers:', headers);
|
|
954
1462
|
console.log('------------------------');
|
|
955
1463
|
}
|
|
956
1464
|
formatDebugValue(value, maxLength = 50, seen = new WeakSet()) {
|