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