@technomoron/api-server-base 2.0.0-beta.16 → 2.0.0-beta.18
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 +49 -35
- package/dist/cjs/api-server-base.cjs +161 -45
- package/dist/cjs/api-server-base.d.ts +28 -2
- package/dist/cjs/auth-api/auth-module.js +55 -21
- package/dist/cjs/auth-api/mem-auth-store.js +2 -1
- package/dist/cjs/auth-api/sql-auth-store.js +2 -1
- package/dist/cjs/oauth/memory.js +2 -1
- package/dist/cjs/oauth/sequelize.js +2 -1
- package/dist/cjs/passkey/service.js +1 -1
- package/dist/cjs/passkey/types.d.ts +5 -0
- package/dist/esm/api-server-base.d.ts +28 -2
- package/dist/esm/api-server-base.js +161 -45
- package/dist/esm/auth-api/auth-module.js +55 -21
- package/dist/esm/auth-api/mem-auth-store.js +2 -1
- package/dist/esm/auth-api/sql-auth-store.js +2 -1
- package/dist/esm/oauth/memory.js +2 -1
- package/dist/esm/oauth/sequelize.js +2 -1
- package/dist/esm/passkey/service.js +1 -1
- package/dist/esm/passkey/types.d.ts +5 -0
- package/docs/swagger/openapi.json +11 -145
- package/package.json +17 -17
|
@@ -240,23 +240,31 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
240
240
|
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
241
241
|
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
242
242
|
const origin = typeof referer === 'string' ? referer : '';
|
|
243
|
-
const
|
|
243
|
+
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
244
|
+
.split(',')[0]
|
|
245
|
+
.trim()
|
|
246
|
+
.toLowerCase();
|
|
247
|
+
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
244
248
|
const isLocalhost = origin.includes('localhost');
|
|
249
|
+
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
250
|
+
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
251
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
252
|
+
sameSite = 'lax';
|
|
253
|
+
}
|
|
254
|
+
let resolvedSecure = secure;
|
|
255
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
256
|
+
resolvedSecure = true;
|
|
257
|
+
}
|
|
245
258
|
const options = {
|
|
246
|
-
httpOnly: true,
|
|
247
|
-
secure:
|
|
248
|
-
sameSite
|
|
259
|
+
httpOnly: conf.cookieHttpOnly ?? true,
|
|
260
|
+
secure: resolvedSecure,
|
|
261
|
+
sameSite,
|
|
249
262
|
domain: conf.cookieDomain || undefined,
|
|
250
|
-
path: '/',
|
|
263
|
+
path: conf.cookiePath || '/',
|
|
251
264
|
maxAge: undefined
|
|
252
265
|
};
|
|
253
|
-
if (conf.devMode) {
|
|
254
|
-
options.
|
|
255
|
-
options.httpOnly = false;
|
|
256
|
-
options.sameSite = 'lax';
|
|
257
|
-
if (isLocalhost) {
|
|
258
|
-
options.domain = undefined;
|
|
259
|
-
}
|
|
266
|
+
if (conf.devMode && isLocalhost) {
|
|
267
|
+
options.domain = undefined;
|
|
260
268
|
}
|
|
261
269
|
return options;
|
|
262
270
|
}
|
|
@@ -552,10 +560,39 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
552
560
|
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
553
561
|
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
554
562
|
if (shouldRefresh) {
|
|
555
|
-
const
|
|
563
|
+
const updateToken = this.storage.updateToken;
|
|
564
|
+
if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
565
|
+
throw new api_server_base_js_1.ApiError({ code: 501, message: 'Token update storage is not configured' });
|
|
566
|
+
}
|
|
567
|
+
// Sign a new access token without embedding stored token secrets into the JWT payload.
|
|
568
|
+
const metadata = {
|
|
569
|
+
ruid: stored.ruid,
|
|
570
|
+
domain: stored.domain,
|
|
571
|
+
fingerprint: stored.fingerprint,
|
|
572
|
+
label: stored.label,
|
|
573
|
+
clientId: stored.clientId,
|
|
574
|
+
scope: stored.scope,
|
|
575
|
+
browser: stored.browser,
|
|
576
|
+
device: stored.device,
|
|
577
|
+
ip: stored.ip,
|
|
578
|
+
os: stored.os,
|
|
579
|
+
loginType: stored.loginType,
|
|
580
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
|
|
581
|
+
sessionCookie: stored.sessionCookie
|
|
582
|
+
};
|
|
583
|
+
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
584
|
+
const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
|
|
556
585
|
if (!access.success || !access.token) {
|
|
557
586
|
throw new api_server_base_js_1.ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
558
587
|
}
|
|
588
|
+
const updated = await updateToken.call(this.storage, {
|
|
589
|
+
refreshToken,
|
|
590
|
+
accessToken: access.token,
|
|
591
|
+
lastSeenAt: new Date()
|
|
592
|
+
});
|
|
593
|
+
if (!updated) {
|
|
594
|
+
throw new api_server_base_js_1.ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
|
|
595
|
+
}
|
|
559
596
|
const cookiePrefs = this.mergeSessionPreferences({
|
|
560
597
|
sessionCookie: stored.sessionCookie,
|
|
561
598
|
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
|
|
@@ -1001,14 +1038,11 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
1001
1038
|
if (!secretProvided) {
|
|
1002
1039
|
throw new api_server_base_js_1.ApiError({ code: 400, message: 'Client authentication is required' });
|
|
1003
1040
|
}
|
|
1004
|
-
|
|
1005
|
-
if (this.
|
|
1006
|
-
|
|
1007
|
-
valid = await verifySecret(client, clientSecret);
|
|
1008
|
-
}
|
|
1009
|
-
else {
|
|
1010
|
-
valid = client.clientSecret === clientSecret;
|
|
1041
|
+
const verifySecret = this.storage.verifyClientSecret;
|
|
1042
|
+
if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
|
|
1043
|
+
throw new api_server_base_js_1.ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
|
|
1011
1044
|
}
|
|
1045
|
+
const valid = await verifySecret.call(this.storage, client, clientSecret);
|
|
1012
1046
|
if (!valid) {
|
|
1013
1047
|
throw new api_server_base_js_1.ApiError({ code: 401, message: 'Invalid client credentials' });
|
|
1014
1048
|
}
|
|
@@ -1156,7 +1190,7 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
1156
1190
|
auth: { type: 'strict', req: 'any' }
|
|
1157
1191
|
}, {
|
|
1158
1192
|
method: 'delete',
|
|
1159
|
-
path: '/v1/passkeys/:credentialId
|
|
1193
|
+
path: '/v1/passkeys/:credentialId',
|
|
1160
1194
|
handler: (req) => this.deletePasskey(req),
|
|
1161
1195
|
auth: { type: 'strict', req: 'any' }
|
|
1162
1196
|
});
|
|
@@ -25,7 +25,8 @@ function normalizePasskeyConfig(config = {}) {
|
|
|
25
25
|
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
26
26
|
? config.timeoutMs
|
|
27
27
|
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
28
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
|
|
28
|
+
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
29
|
+
debug: Boolean(config.debug)
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
class MemAuthStore {
|
|
@@ -41,7 +41,8 @@ function normalizePasskeyConfig(config = {}) {
|
|
|
41
41
|
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
42
42
|
? config.timeoutMs
|
|
43
43
|
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
44
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
|
|
44
|
+
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
45
|
+
debug: Boolean(config.debug)
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
class SqlAuthStore {
|
package/dist/cjs/oauth/memory.js
CHANGED
|
@@ -12,7 +12,8 @@ function cloneClient(client) {
|
|
|
12
12
|
}
|
|
13
13
|
return {
|
|
14
14
|
clientId: client.clientId,
|
|
15
|
-
clientSecret
|
|
15
|
+
// clientSecret is stored hashed; do not return the hash.
|
|
16
|
+
clientSecret: client.clientSecret ? '__stored__' : undefined,
|
|
16
17
|
name: client.name,
|
|
17
18
|
redirectUris: [...client.redirectUris],
|
|
18
19
|
scope: client.scope ? [...client.scope] : undefined,
|
|
@@ -204,7 +204,8 @@ class SequelizeOAuthStore extends base_js_1.OAuthStore {
|
|
|
204
204
|
toOAuthClient(model) {
|
|
205
205
|
return {
|
|
206
206
|
clientId: model.client_id,
|
|
207
|
-
|
|
207
|
+
// client_secret is stored hashed; do not return the hash.
|
|
208
|
+
clientSecret: model.client_secret ? '__stored__' : undefined,
|
|
208
209
|
name: model.name ?? undefined,
|
|
209
210
|
redirectUris: decodeStringArray(model.redirect_uris),
|
|
210
211
|
scope: decodeStringArray(model.scope),
|
|
@@ -268,7 +268,7 @@ class PasskeyService {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
|
|
271
|
-
if (this.logger?.warn) {
|
|
271
|
+
if (this.config.debug && this.logger?.warn) {
|
|
272
272
|
const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
|
|
273
273
|
const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
|
|
274
274
|
this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
|
|
@@ -8,6 +8,11 @@ export interface PasskeyServiceConfig {
|
|
|
8
8
|
origins: string[];
|
|
9
9
|
timeoutMs: number;
|
|
10
10
|
userVerification?: 'preferred' | 'required' | 'discouraged';
|
|
11
|
+
/**
|
|
12
|
+
* When enabled, PasskeyService emits additional diagnostic logs during registration/authentication.
|
|
13
|
+
* Defaults to false.
|
|
14
|
+
*/
|
|
15
|
+
debug?: boolean;
|
|
11
16
|
}
|
|
12
17
|
export interface PasskeyChallengeRecord {
|
|
13
18
|
challenge: string;
|
|
@@ -92,6 +92,7 @@ export interface ApiServerConf {
|
|
|
92
92
|
apiHost: string;
|
|
93
93
|
uploadPath: string;
|
|
94
94
|
uploadMax: number;
|
|
95
|
+
staticDirs?: Record<string, string>;
|
|
95
96
|
origins: string[];
|
|
96
97
|
debug: boolean;
|
|
97
98
|
apiBasePath: string;
|
|
@@ -99,7 +100,21 @@ export interface ApiServerConf {
|
|
|
99
100
|
swaggerPath?: string;
|
|
100
101
|
accessSecret: string;
|
|
101
102
|
refreshSecret: string;
|
|
103
|
+
/** Cookie domain for auth cookies. Prefer leaving empty for localhost/development. */
|
|
102
104
|
cookieDomain: string;
|
|
105
|
+
/** Cookie path for auth cookies. */
|
|
106
|
+
cookiePath?: string;
|
|
107
|
+
/** Cookie SameSite attribute for auth cookies. */
|
|
108
|
+
cookieSameSite?: 'lax' | 'strict' | 'none';
|
|
109
|
+
/**
|
|
110
|
+
* Cookie Secure attribute for auth cookies.
|
|
111
|
+
* - true: always secure
|
|
112
|
+
* - false: never secure
|
|
113
|
+
* - 'auto': secure when request is HTTPS (or forwarded as HTTPS)
|
|
114
|
+
*/
|
|
115
|
+
cookieSecure?: boolean | 'auto';
|
|
116
|
+
/** Cookie HttpOnly attribute for auth cookies. */
|
|
117
|
+
cookieHttpOnly?: boolean;
|
|
103
118
|
accessCookie: string;
|
|
104
119
|
refreshCookie: string;
|
|
105
120
|
accessExpiry: number;
|
|
@@ -117,10 +132,11 @@ export interface ApiServerConf {
|
|
|
117
132
|
}
|
|
118
133
|
export declare class ApiServer {
|
|
119
134
|
app: Application;
|
|
120
|
-
currReq: ApiRequest | null;
|
|
121
135
|
readonly config: ApiServerConf;
|
|
122
136
|
readonly startedAt: number;
|
|
123
137
|
private readonly apiBasePath;
|
|
138
|
+
private readonly apiRouter;
|
|
139
|
+
private finalized;
|
|
124
140
|
private storageAdapter;
|
|
125
141
|
private moduleAdapter;
|
|
126
142
|
private serverAuthAdapter;
|
|
@@ -131,7 +147,17 @@ export declare class ApiServer {
|
|
|
131
147
|
private oauthStoreAdapter;
|
|
132
148
|
private canImpersonateAdapter;
|
|
133
149
|
private readonly jwtHelper;
|
|
150
|
+
/**
|
|
151
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
152
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
153
|
+
* when mounting raw Express endpoints.
|
|
154
|
+
*/
|
|
155
|
+
get currReq(): ApiRequest | null;
|
|
156
|
+
set currReq(_value: ApiRequest | null);
|
|
134
157
|
constructor(config?: Partial<ApiServerConf>);
|
|
158
|
+
private assertNotFinalized;
|
|
159
|
+
private toApiRouterPath;
|
|
160
|
+
finalize(): this;
|
|
135
161
|
authStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
|
|
136
162
|
/**
|
|
137
163
|
* @deprecated Use {@link ApiServer.authStorage} instead.
|
|
@@ -193,12 +219,12 @@ export declare class ApiServer {
|
|
|
193
219
|
guessExceptionText(error: unknown, defMsg?: string): string;
|
|
194
220
|
protected authorize(apiReq: ApiRequest, requiredClass: ApiAuthClass): Promise<void>;
|
|
195
221
|
private middlewares;
|
|
222
|
+
private installStaticDirs;
|
|
196
223
|
private installPingHandler;
|
|
197
224
|
private loadSwaggerSpec;
|
|
198
225
|
private installSwaggerHandler;
|
|
199
226
|
private normalizeApiBasePath;
|
|
200
227
|
private installApiNotFoundHandler;
|
|
201
|
-
private ensureApiNotFoundOrdering;
|
|
202
228
|
private describeMissingEndpoint;
|
|
203
229
|
start(): this;
|
|
204
230
|
private verifyJWT;
|
|
@@ -335,6 +335,7 @@ function fillConfig(config) {
|
|
|
335
335
|
apiHost: config.apiHost ?? 'localhost',
|
|
336
336
|
uploadPath: config.uploadPath ?? '',
|
|
337
337
|
uploadMax: config.uploadMax ?? 30 * 1024 * 1024,
|
|
338
|
+
staticDirs: config.staticDirs,
|
|
338
339
|
origins: config.origins ?? [],
|
|
339
340
|
debug: config.debug ?? false,
|
|
340
341
|
apiBasePath: config.apiBasePath ?? '/api',
|
|
@@ -342,7 +343,11 @@ function fillConfig(config) {
|
|
|
342
343
|
swaggerPath: config.swaggerPath ?? '',
|
|
343
344
|
accessSecret: config.accessSecret ?? '',
|
|
344
345
|
refreshSecret: config.refreshSecret ?? '',
|
|
345
|
-
cookieDomain: config.cookieDomain ?? '
|
|
346
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
347
|
+
cookiePath: config.cookiePath ?? '/',
|
|
348
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
349
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
350
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
346
351
|
accessCookie: config.accessCookie ?? 'dat',
|
|
347
352
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
348
353
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
@@ -360,8 +365,19 @@ function fillConfig(config) {
|
|
|
360
365
|
};
|
|
361
366
|
}
|
|
362
367
|
export class ApiServer {
|
|
368
|
+
/**
|
|
369
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
370
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
371
|
+
* when mounting raw Express endpoints.
|
|
372
|
+
*/
|
|
373
|
+
get currReq() {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
set currReq(_value) {
|
|
377
|
+
void _value;
|
|
378
|
+
}
|
|
363
379
|
constructor(config = {}) {
|
|
364
|
-
this.
|
|
380
|
+
this.finalized = false;
|
|
365
381
|
this.serverAuthAdapter = null;
|
|
366
382
|
this.apiNotFoundHandler = null;
|
|
367
383
|
this.tokenStoreAdapter = null;
|
|
@@ -386,16 +402,67 @@ export class ApiServer {
|
|
|
386
402
|
this.storageAdapter = this.getServerAuthAdapter();
|
|
387
403
|
}
|
|
388
404
|
this.app = express();
|
|
405
|
+
// Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
|
|
406
|
+
// the API 404 handler ordered last without relying on Express internals.
|
|
407
|
+
this.apiRouter = express.Router();
|
|
389
408
|
if (config.uploadPath) {
|
|
390
|
-
const upload = multer({ dest: config.uploadPath });
|
|
409
|
+
const upload = multer({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
|
|
391
410
|
this.app.use(upload.any());
|
|
411
|
+
// Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
|
|
412
|
+
this.app.use((err, _req, res, next) => {
|
|
413
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
414
|
+
if (code === 'LIMIT_FILE_SIZE') {
|
|
415
|
+
res.status(413).json({
|
|
416
|
+
success: false,
|
|
417
|
+
code: 413,
|
|
418
|
+
message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
|
|
419
|
+
data: null,
|
|
420
|
+
errors: {}
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
next(err);
|
|
425
|
+
});
|
|
392
426
|
}
|
|
393
427
|
this.middlewares();
|
|
428
|
+
this.installStaticDirs();
|
|
394
429
|
this.installPingHandler();
|
|
395
430
|
this.installSwaggerHandler();
|
|
431
|
+
this.app.use(this.apiBasePath, this.apiRouter);
|
|
396
432
|
// addSwaggerUi(this.app);
|
|
397
433
|
this.installApiNotFoundHandler();
|
|
398
434
|
}
|
|
435
|
+
assertNotFinalized(action) {
|
|
436
|
+
if (this.finalized) {
|
|
437
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
toApiRouterPath(candidate) {
|
|
441
|
+
if (typeof candidate !== 'string') {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const trimmed = candidate.trim();
|
|
445
|
+
if (!trimmed) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
449
|
+
const base = this.apiBasePath;
|
|
450
|
+
if (base === '/') {
|
|
451
|
+
return normalized;
|
|
452
|
+
}
|
|
453
|
+
if (normalized === base) {
|
|
454
|
+
return '/';
|
|
455
|
+
}
|
|
456
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
457
|
+
return normalized.slice(base.length) || '/';
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
finalize() {
|
|
462
|
+
this.installApiNotFoundHandler();
|
|
463
|
+
this.finalized = true;
|
|
464
|
+
return this;
|
|
465
|
+
}
|
|
399
466
|
authStorage(storage) {
|
|
400
467
|
this.storageAdapter = storage;
|
|
401
468
|
return this;
|
|
@@ -621,7 +688,7 @@ export class ApiServer {
|
|
|
621
688
|
}
|
|
622
689
|
return false;
|
|
623
690
|
}
|
|
624
|
-
guessExceptionText(error, defMsg = '
|
|
691
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
625
692
|
return guess_exception_text(error, defMsg);
|
|
626
693
|
}
|
|
627
694
|
async authorize(apiReq, requiredClass) {
|
|
@@ -647,6 +714,41 @@ export class ApiServer {
|
|
|
647
714
|
credentials: true
|
|
648
715
|
};
|
|
649
716
|
this.app.use(cors(corsOptions));
|
|
717
|
+
// Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
|
|
718
|
+
this.app.use((err, req, res, next) => {
|
|
719
|
+
const message = err instanceof Error ? err.message : '';
|
|
720
|
+
if (message.includes('Not allowed by CORS')) {
|
|
721
|
+
const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
|
|
722
|
+
if (isApiRequest) {
|
|
723
|
+
res.status(403).json({
|
|
724
|
+
success: false,
|
|
725
|
+
code: 403,
|
|
726
|
+
message: 'Origin not allowed by CORS',
|
|
727
|
+
data: null,
|
|
728
|
+
errors: {}
|
|
729
|
+
});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
res.status(403).send('Origin not allowed by CORS');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
next(err);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
installStaticDirs() {
|
|
739
|
+
const staticDirs = this.config.staticDirs;
|
|
740
|
+
if (!staticDirs || !isPlainObject(staticDirs)) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
for (const [mountRaw, dirRaw] of Object.entries(staticDirs)) {
|
|
744
|
+
const mount = typeof mountRaw === 'string' ? mountRaw.trim() : '';
|
|
745
|
+
const dir = typeof dirRaw === 'string' ? dirRaw.trim() : '';
|
|
746
|
+
if (!mount || !dir) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
|
|
750
|
+
this.app.use(resolvedMount, express.static(dir));
|
|
751
|
+
}
|
|
650
752
|
}
|
|
651
753
|
installPingHandler() {
|
|
652
754
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
@@ -745,28 +847,16 @@ export class ApiServer {
|
|
|
745
847
|
};
|
|
746
848
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
747
849
|
}
|
|
748
|
-
ensureApiNotFoundOrdering() {
|
|
749
|
-
this.installApiNotFoundHandler();
|
|
750
|
-
if (!this.apiNotFoundHandler) {
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
const stack = this.app._router?.stack;
|
|
754
|
-
if (!stack) {
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
|
|
758
|
-
if (index === -1 || index === stack.length - 1) {
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
const [layer] = stack.splice(index, 1);
|
|
762
|
-
stack.push(layer);
|
|
763
|
-
}
|
|
764
850
|
describeMissingEndpoint(req) {
|
|
765
851
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
766
852
|
const target = req.originalUrl || req.url || this.apiBasePath;
|
|
767
853
|
return `No such endpoint: ${method} ${target}`;
|
|
768
854
|
}
|
|
769
855
|
start() {
|
|
856
|
+
if (!this.finalized) {
|
|
857
|
+
console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
858
|
+
this.finalize();
|
|
859
|
+
}
|
|
770
860
|
this.app
|
|
771
861
|
.listen({
|
|
772
862
|
port: this.config.apiPort,
|
|
@@ -776,19 +866,22 @@ export class ApiServer {
|
|
|
776
866
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
777
867
|
})
|
|
778
868
|
.on('error', (error) => {
|
|
869
|
+
let message;
|
|
779
870
|
if (error.code === 'EADDRINUSE') {
|
|
780
|
-
|
|
871
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
781
872
|
}
|
|
782
873
|
else if (error.code === 'EACCES') {
|
|
783
|
-
|
|
874
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
784
875
|
}
|
|
785
876
|
else if (error.code === 'EADDRNOTAVAIL') {
|
|
786
|
-
|
|
877
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
787
878
|
}
|
|
788
879
|
else {
|
|
789
|
-
|
|
880
|
+
message = `Failed to start server: ${error.message}`;
|
|
790
881
|
}
|
|
791
|
-
|
|
882
|
+
const err = new Error(message);
|
|
883
|
+
err.cause = error;
|
|
884
|
+
throw err;
|
|
792
885
|
});
|
|
793
886
|
return this;
|
|
794
887
|
}
|
|
@@ -810,23 +903,33 @@ export class ApiServer {
|
|
|
810
903
|
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
811
904
|
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
812
905
|
const origin = typeof referer === 'string' ? referer : '';
|
|
813
|
-
const
|
|
906
|
+
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
907
|
+
.split(',')[0]
|
|
908
|
+
.trim()
|
|
909
|
+
.toLowerCase();
|
|
910
|
+
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
814
911
|
const isLocalhost = origin.includes('localhost');
|
|
912
|
+
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
913
|
+
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
914
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
915
|
+
sameSite = 'lax';
|
|
916
|
+
}
|
|
917
|
+
let resolvedSecure = secure;
|
|
918
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
919
|
+
// Modern browsers reject SameSite=None cookies unless Secure is set.
|
|
920
|
+
resolvedSecure = true;
|
|
921
|
+
}
|
|
815
922
|
const options = {
|
|
816
|
-
httpOnly: true,
|
|
817
|
-
secure:
|
|
818
|
-
sameSite
|
|
923
|
+
httpOnly: conf.cookieHttpOnly ?? true,
|
|
924
|
+
secure: resolvedSecure,
|
|
925
|
+
sameSite,
|
|
819
926
|
domain: conf.cookieDomain || undefined,
|
|
820
|
-
path: '/',
|
|
927
|
+
path: conf.cookiePath || '/',
|
|
821
928
|
maxAge: undefined
|
|
822
929
|
};
|
|
823
|
-
if (conf.devMode) {
|
|
824
|
-
|
|
825
|
-
options.
|
|
826
|
-
options.sameSite = 'lax';
|
|
827
|
-
if (isLocalhost) {
|
|
828
|
-
options.domain = undefined;
|
|
829
|
-
}
|
|
930
|
+
if (conf.devMode && isLocalhost) {
|
|
931
|
+
// Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
|
|
932
|
+
options.domain = undefined;
|
|
830
933
|
}
|
|
831
934
|
return options;
|
|
832
935
|
}
|
|
@@ -974,7 +1077,7 @@ export class ApiServer {
|
|
|
974
1077
|
}
|
|
975
1078
|
}
|
|
976
1079
|
if (!tokenData) {
|
|
977
|
-
throw new ApiError({ code: 401, message: '
|
|
1080
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
978
1081
|
}
|
|
979
1082
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
980
1083
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
@@ -1026,6 +1129,11 @@ export class ApiServer {
|
|
|
1026
1129
|
}
|
|
1027
1130
|
apiReq.token = secret;
|
|
1028
1131
|
apiReq.apiKey = key;
|
|
1132
|
+
// Treat API keys as authenticated identities, consistent with JWT-based flows.
|
|
1133
|
+
const resolvedUid = this.normalizeAuthIdentifier(key.uid);
|
|
1134
|
+
if (resolvedUid !== null) {
|
|
1135
|
+
apiReq.realUid = resolvedUid;
|
|
1136
|
+
}
|
|
1029
1137
|
return {
|
|
1030
1138
|
uid: key.uid,
|
|
1031
1139
|
domain: '',
|
|
@@ -1085,13 +1193,19 @@ export class ApiServer {
|
|
|
1085
1193
|
return rawReal;
|
|
1086
1194
|
}
|
|
1087
1195
|
useExpress(pathOrHandler, ...handlers) {
|
|
1196
|
+
this.assertNotFinalized('useExpress');
|
|
1088
1197
|
if (typeof pathOrHandler === 'string') {
|
|
1089
|
-
this.
|
|
1198
|
+
const apiPath = this.toApiRouterPath(pathOrHandler);
|
|
1199
|
+
if (apiPath) {
|
|
1200
|
+
this.apiRouter.use(apiPath, ...handlers);
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1204
|
+
}
|
|
1090
1205
|
}
|
|
1091
1206
|
else {
|
|
1092
1207
|
this.app.use(pathOrHandler, ...handlers);
|
|
1093
1208
|
}
|
|
1094
|
-
this.ensureApiNotFoundOrdering();
|
|
1095
1209
|
return this;
|
|
1096
1210
|
}
|
|
1097
1211
|
createApiRequest(req, res) {
|
|
@@ -1125,7 +1239,6 @@ export class ApiServer {
|
|
|
1125
1239
|
const apiReq = this.createApiRequest(req, res);
|
|
1126
1240
|
req.apiReq = apiReq;
|
|
1127
1241
|
res.locals.apiReq = apiReq;
|
|
1128
|
-
this.currReq = apiReq;
|
|
1129
1242
|
try {
|
|
1130
1243
|
if (this.config.hydrateGetBody) {
|
|
1131
1244
|
hydrateGetBody(req);
|
|
@@ -1178,7 +1291,6 @@ export class ApiServer {
|
|
|
1178
1291
|
return async (req, res, next) => {
|
|
1179
1292
|
void next;
|
|
1180
1293
|
const apiReq = this.createApiRequest(req, res);
|
|
1181
|
-
this.currReq = apiReq;
|
|
1182
1294
|
try {
|
|
1183
1295
|
if (this.config.hydrateGetBody) {
|
|
1184
1296
|
hydrateGetBody(apiReq.req);
|
|
@@ -1241,13 +1353,18 @@ export class ApiServer {
|
|
|
1241
1353
|
};
|
|
1242
1354
|
}
|
|
1243
1355
|
api(module) {
|
|
1356
|
+
this.assertNotFinalized('api');
|
|
1244
1357
|
const router = express.Router();
|
|
1245
1358
|
module.server = this;
|
|
1246
1359
|
const moduleType = module.moduleType;
|
|
1247
1360
|
if (moduleType === 'auth') {
|
|
1248
1361
|
this.authModule(module);
|
|
1249
1362
|
}
|
|
1250
|
-
module.checkConfig();
|
|
1363
|
+
const configOk = module.checkConfig();
|
|
1364
|
+
if (configOk === false) {
|
|
1365
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1366
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1367
|
+
}
|
|
1251
1368
|
const base = this.apiBasePath;
|
|
1252
1369
|
const ns = module.namespace;
|
|
1253
1370
|
const mountPath = `${base}${ns}`;
|
|
@@ -1272,8 +1389,7 @@ export class ApiServer {
|
|
|
1272
1389
|
console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
|
|
1273
1390
|
}
|
|
1274
1391
|
});
|
|
1275
|
-
this.
|
|
1276
|
-
this.ensureApiNotFoundOrdering();
|
|
1392
|
+
this.apiRouter.use(ns, router);
|
|
1277
1393
|
return this;
|
|
1278
1394
|
}
|
|
1279
1395
|
dumpRequest(apiReq) {
|