@technomoron/api-server-base 1.1.4 → 1.1.6
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 +3 -2
- package/dist/cjs/api-module.d.ts +1 -1
- package/dist/cjs/api-server-base.cjs +143 -39
- package/dist/cjs/api-server-base.d.ts +11 -2
- package/dist/cjs/auth-storage.d.ts +1 -0
- package/dist/esm/api-module.d.ts +1 -1
- package/dist/esm/api-server-base.d.ts +11 -2
- package/dist/esm/api-server-base.js +143 -39
- package/dist/esm/auth-storage.d.ts +1 -0
- package/package.json +1 -1
package/README.txt
CHANGED
|
@@ -7,7 +7,7 @@ Toolkit for building authenticated Express APIs in TypeScript. ApiServer wraps E
|
|
|
7
7
|
- The server can be extended and methods related to user authentication, API keys and more can be overridden in the derived class.
|
|
8
8
|
- Create API endpoints that are either public, protected or open API calls that may or may not have an authenticated session for dual behaviour.
|
|
9
9
|
- Standardized request handling (POST, GET, file uploads if enabled and more).
|
|
10
|
-
- Authentication system using JWT or simple API bearer keys, fully customizable by overriding class methods.
|
|
10
|
+
- Authentication system using JWT or simple API bearer keys, fully customizable by overriding class methods (now exposes both the resolved API key and stored token row to handlers).
|
|
11
11
|
- Unified error handling. Just throw new ApiError(...) in any API callback in order ot emit the correct API response.
|
|
12
12
|
- Create structured, standardized API response as JSON data, containing typed return data, response codes and more.
|
|
13
13
|
|
|
@@ -113,6 +113,7 @@ authApi (boolean, default false) Toggle you can use when mounting auth routes.
|
|
|
113
113
|
devMode (boolean, default false) Custom hook for development only features.
|
|
114
114
|
debug (boolean, default false) When true the server logs inbound requests via dumpRequest.
|
|
115
115
|
hydrateGetBody (boolean, default true) Copy query parameters into `req.body` for GET requests; set false if you prefer untouched bodies.
|
|
116
|
+
validateTokens (boolean, default false) When true, every JWT-authenticated request must match a stored token row (access token + user id) before reaching your handler. API keys remain stateless either way.
|
|
116
117
|
|
|
117
118
|
Tip: If you add new configuration fields in downstream projects, extend ApiServerConf and update fillConfig so defaults stay aligned.
|
|
118
119
|
|
|
@@ -120,7 +121,7 @@ Request Lifecycle
|
|
|
120
121
|
-----------------
|
|
121
122
|
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
122
123
|
2. ApiServer wraps the route inside handle_request, setting currReq and logging when debug is enabled.
|
|
123
|
-
3. authenticate enforces the ApiRoute auth type: none
|
|
124
|
+
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the `dat` cookie are accepted for `yes`/`strict`, while API key tokens prefixed with `apikey-` always delegate to `getApiKey`. The optional `strict` type (or server-wide `validateTokens` flag) requires the signed JWT to exist in storage; when it does, the persisted row is attached to `apiReq.authToken`. The dedicated `apikey` type simply means “an API key is required”; otherwise API keys are still accepted by `yes`/`strict` routes alongside JWTs, and `apiReq.apiKey` is populated when present.
|
|
124
125
|
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
125
126
|
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
126
127
|
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
package/dist/cjs/api-module.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ApiRequest } from './api-server-base.js';
|
|
2
2
|
export type ApiHandler = (apiReq: ApiRequest) => Promise<[number] | [number, any] | [number, any, string]>;
|
|
3
|
-
export type ApiAuthType = 'none' | 'maybe' | 'yes';
|
|
3
|
+
export type ApiAuthType = 'none' | 'maybe' | 'yes' | 'strict' | 'apikey';
|
|
4
4
|
export type ApiAuthClass = 'any' | 'admin';
|
|
5
5
|
export interface ApiKey {
|
|
6
6
|
uid: unknown;
|
|
@@ -325,10 +325,11 @@ function fillConfig(config) {
|
|
|
325
325
|
accessCookie: config.accessCookie ?? 'dat',
|
|
326
326
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
327
327
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
328
|
-
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60
|
|
328
|
+
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60,
|
|
329
329
|
authApi: config.authApi ?? false,
|
|
330
330
|
devMode: config.devMode ?? false,
|
|
331
|
-
hydrateGetBody: config.hydrateGetBody ?? true
|
|
331
|
+
hydrateGetBody: config.hydrateGetBody ?? true,
|
|
332
|
+
validateTokens: config.validateTokens ?? false
|
|
332
333
|
};
|
|
333
334
|
}
|
|
334
335
|
class ApiServer {
|
|
@@ -537,37 +538,23 @@ class ApiServer {
|
|
|
537
538
|
}
|
|
538
539
|
let token = null;
|
|
539
540
|
const authHeader = apiReq.req.headers.authorization;
|
|
541
|
+
const requiresAuthToken = this.requiresAuthToken(authType);
|
|
542
|
+
const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
|
|
543
|
+
if (apiKeyAuth) {
|
|
544
|
+
return apiKeyAuth;
|
|
545
|
+
}
|
|
540
546
|
if (authHeader?.startsWith('Bearer ')) {
|
|
541
547
|
token = authHeader.slice(7).trim();
|
|
542
548
|
}
|
|
543
|
-
else if (
|
|
549
|
+
else if (requiresAuthToken && !authHeader) {
|
|
544
550
|
throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
|
|
545
551
|
}
|
|
546
|
-
if (token) {
|
|
547
|
-
const m = token.match(/^apikey-(.+)$/);
|
|
548
|
-
if (m) {
|
|
549
|
-
const key = await this.getApiKey(m[1]);
|
|
550
|
-
if (key) {
|
|
551
|
-
apiReq.token = m[1];
|
|
552
|
-
return {
|
|
553
|
-
uid: key.uid,
|
|
554
|
-
domain: '',
|
|
555
|
-
fingerprint: '',
|
|
556
|
-
iat: 0,
|
|
557
|
-
exp: 0
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
552
|
if (!token || token === null) {
|
|
566
553
|
const access = apiReq.req.cookies?.dat;
|
|
567
554
|
if (access) {
|
|
568
555
|
token = access;
|
|
569
556
|
}
|
|
570
|
-
else if (
|
|
557
|
+
else if (requiresAuthToken) {
|
|
571
558
|
throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
|
|
572
559
|
}
|
|
573
560
|
}
|
|
@@ -583,24 +570,76 @@ class ApiServer {
|
|
|
583
570
|
if (!tokenData) {
|
|
584
571
|
throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
|
|
585
572
|
}
|
|
573
|
+
if (this.shouldValidateStoredToken(authType)) {
|
|
574
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
575
|
+
}
|
|
586
576
|
apiReq.token = token;
|
|
587
577
|
return tokenData;
|
|
588
578
|
}
|
|
579
|
+
async tryAuthenticateApiKey(apiReq, authType, authHeader) {
|
|
580
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
581
|
+
if (authType === 'apikey') {
|
|
582
|
+
throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
const keyToken = authHeader.slice(7).trim();
|
|
587
|
+
if (!keyToken.startsWith('apikey-')) {
|
|
588
|
+
if (authType === 'apikey') {
|
|
589
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const secret = keyToken.replace(/^apikey-/, '');
|
|
594
|
+
const key = await this.getApiKey(secret);
|
|
595
|
+
if (!key) {
|
|
596
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
597
|
+
}
|
|
598
|
+
apiReq.token = secret;
|
|
599
|
+
apiReq.apiKey = key;
|
|
600
|
+
return {
|
|
601
|
+
uid: key.uid,
|
|
602
|
+
domain: '',
|
|
603
|
+
fingerprint: '',
|
|
604
|
+
iat: 0,
|
|
605
|
+
exp: 0
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
requiresAuthToken(authType) {
|
|
609
|
+
return authType === 'yes' || authType === 'strict';
|
|
610
|
+
}
|
|
611
|
+
shouldValidateStoredToken(authType) {
|
|
612
|
+
return this.config.validateTokens || authType === 'strict';
|
|
613
|
+
}
|
|
614
|
+
async assertStoredAccessToken(apiReq, token, tokenData) {
|
|
615
|
+
if (typeof tokenData.uid !== 'string' && typeof tokenData.uid !== 'number') {
|
|
616
|
+
throw new ApiError({ code: 401, message: 'Authorization token is malformed' });
|
|
617
|
+
}
|
|
618
|
+
const userId = tokenData.uid;
|
|
619
|
+
const stored = await this.storageAdapter.getToken({
|
|
620
|
+
accessToken: token,
|
|
621
|
+
userId
|
|
622
|
+
});
|
|
623
|
+
if (!stored) {
|
|
624
|
+
throw new ApiError({ code: 401, message: 'Authorization token is no longer valid' });
|
|
625
|
+
}
|
|
626
|
+
apiReq.authToken = stored;
|
|
627
|
+
}
|
|
589
628
|
handle_request(handler, auth) {
|
|
590
629
|
return async (req, res, next) => {
|
|
591
630
|
void next;
|
|
631
|
+
const apiReq = {
|
|
632
|
+
server: this,
|
|
633
|
+
req,
|
|
634
|
+
res,
|
|
635
|
+
token: '',
|
|
636
|
+
tokenData: null,
|
|
637
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
638
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
639
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
640
|
+
};
|
|
641
|
+
this.currReq = apiReq;
|
|
592
642
|
try {
|
|
593
|
-
const apiReq = {
|
|
594
|
-
server: this,
|
|
595
|
-
req,
|
|
596
|
-
res,
|
|
597
|
-
token: '',
|
|
598
|
-
tokenData: null,
|
|
599
|
-
getClientInfo: () => ensureClientInfo(apiReq),
|
|
600
|
-
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
601
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
602
|
-
};
|
|
603
|
-
this.currReq = apiReq;
|
|
604
643
|
if (this.config.hydrateGetBody) {
|
|
605
644
|
hydrateGetBody(apiReq.req);
|
|
606
645
|
}
|
|
@@ -621,7 +660,11 @@ class ApiServer {
|
|
|
621
660
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
622
661
|
}
|
|
623
662
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
624
|
-
|
|
663
|
+
const responsePayload = { code, message, data };
|
|
664
|
+
if (this.config.debug) {
|
|
665
|
+
this.dumpResponse(apiReq, responsePayload, code);
|
|
666
|
+
}
|
|
667
|
+
res.status(code).json(responsePayload);
|
|
625
668
|
}
|
|
626
669
|
catch (error) {
|
|
627
670
|
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
@@ -629,20 +672,28 @@ class ApiServer {
|
|
|
629
672
|
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
630
673
|
? apiError.errors
|
|
631
674
|
: {};
|
|
632
|
-
|
|
675
|
+
const errorPayload = {
|
|
633
676
|
code: apiError.code,
|
|
634
677
|
message: apiError.message,
|
|
635
678
|
data: apiError.data ?? null,
|
|
636
679
|
errors: normalizedErrors
|
|
637
|
-
}
|
|
680
|
+
};
|
|
681
|
+
if (this.config.debug) {
|
|
682
|
+
this.dumpResponse(apiReq, errorPayload, apiError.code);
|
|
683
|
+
}
|
|
684
|
+
res.status(apiError.code).json(errorPayload);
|
|
638
685
|
}
|
|
639
686
|
else {
|
|
640
|
-
|
|
687
|
+
const errorPayload = {
|
|
641
688
|
code: 500,
|
|
642
689
|
message: this.guessExceptionText(error),
|
|
643
690
|
data: null,
|
|
644
691
|
errors: {}
|
|
645
|
-
}
|
|
692
|
+
};
|
|
693
|
+
if (this.config.debug) {
|
|
694
|
+
this.dumpResponse(apiReq, errorPayload, 500);
|
|
695
|
+
}
|
|
696
|
+
res.status(500).json(errorPayload);
|
|
646
697
|
}
|
|
647
698
|
}
|
|
648
699
|
};
|
|
@@ -680,6 +731,59 @@ class ApiServer {
|
|
|
680
731
|
console.log('Headers:', req.headers);
|
|
681
732
|
console.log('------------------------');
|
|
682
733
|
}
|
|
734
|
+
formatDebugValue(value, maxLength = 50, seen = new WeakSet()) {
|
|
735
|
+
if (value === null || value === undefined) {
|
|
736
|
+
return value;
|
|
737
|
+
}
|
|
738
|
+
if (typeof value === 'string') {
|
|
739
|
+
return value.length <= maxLength
|
|
740
|
+
? value
|
|
741
|
+
: `${value.slice(0, maxLength)}… [truncated ${value.length - maxLength} chars]`;
|
|
742
|
+
}
|
|
743
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
744
|
+
return value;
|
|
745
|
+
}
|
|
746
|
+
if (typeof value === 'symbol') {
|
|
747
|
+
return value.toString();
|
|
748
|
+
}
|
|
749
|
+
if (value instanceof Date) {
|
|
750
|
+
return value.toISOString();
|
|
751
|
+
}
|
|
752
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
|
|
753
|
+
return `<Buffer length=${value.length}>`;
|
|
754
|
+
}
|
|
755
|
+
if (typeof value === 'function') {
|
|
756
|
+
return `[Function ${value.name || 'anonymous'}]`;
|
|
757
|
+
}
|
|
758
|
+
if (typeof value === 'object') {
|
|
759
|
+
const obj = value;
|
|
760
|
+
if (seen.has(obj)) {
|
|
761
|
+
return '[Circular]';
|
|
762
|
+
}
|
|
763
|
+
seen.add(obj);
|
|
764
|
+
if (Array.isArray(value)) {
|
|
765
|
+
const arr = value.map((item) => this.formatDebugValue(item, maxLength, seen));
|
|
766
|
+
seen.delete(obj);
|
|
767
|
+
return arr;
|
|
768
|
+
}
|
|
769
|
+
const recordValue = value;
|
|
770
|
+
const entries = Object.entries(recordValue).reduce((acc, [key, val]) => {
|
|
771
|
+
acc[key] = this.formatDebugValue(val, maxLength, seen);
|
|
772
|
+
return acc;
|
|
773
|
+
}, {});
|
|
774
|
+
seen.delete(obj);
|
|
775
|
+
return entries;
|
|
776
|
+
}
|
|
777
|
+
return value;
|
|
778
|
+
}
|
|
779
|
+
dumpResponse(apiReq, payload, status) {
|
|
780
|
+
const url = apiReq.req.originalUrl || apiReq.req.url;
|
|
781
|
+
console.log('--- Outgoing Response! ---');
|
|
782
|
+
console.log('URL:', url);
|
|
783
|
+
console.log('Status:', status);
|
|
784
|
+
console.log('Payload:', this.formatDebugValue(payload));
|
|
785
|
+
console.log('--------------------------');
|
|
786
|
+
}
|
|
683
787
|
}
|
|
684
788
|
exports.ApiServer = ApiServer;
|
|
685
789
|
exports.default = ApiServer;
|
|
@@ -9,7 +9,7 @@ import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
|
|
9
9
|
import { ApiModule } from './api-module.js';
|
|
10
10
|
import type { ApiAuthClass, ApiKey } from './api-module.js';
|
|
11
11
|
import type { AuthProviderModule } from './auth-module.js';
|
|
12
|
-
import type { AuthStorage } from './auth-storage.js';
|
|
12
|
+
import type { AuthStorage, AuthTokenData, AuthTokenMetadata } from './auth-storage.js';
|
|
13
13
|
export type { Application, Request, Response, NextFunction, Router } from 'express';
|
|
14
14
|
export type { Multer } from 'multer';
|
|
15
15
|
export type { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
|
@@ -35,7 +35,7 @@ interface JwtDecodeResult<T> {
|
|
|
35
35
|
data?: T;
|
|
36
36
|
error?: string;
|
|
37
37
|
}
|
|
38
|
-
export interface ApiTokenData extends JwtPayload {
|
|
38
|
+
export interface ApiTokenData extends JwtPayload, AuthTokenMetadata {
|
|
39
39
|
uid: unknown;
|
|
40
40
|
iat?: number;
|
|
41
41
|
exp?: number;
|
|
@@ -46,6 +46,8 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
authToken?: AuthTokenData | null;
|
|
50
|
+
apiKey?: ApiKey | null;
|
|
49
51
|
clientInfo?: ClientInfo;
|
|
50
52
|
getClientInfo: () => ClientInfo;
|
|
51
53
|
getClientIp: () => string | null;
|
|
@@ -93,6 +95,7 @@ export interface ApiServerConf {
|
|
|
93
95
|
authApi: boolean;
|
|
94
96
|
devMode: boolean;
|
|
95
97
|
hydrateGetBody: boolean;
|
|
98
|
+
validateTokens: boolean;
|
|
96
99
|
}
|
|
97
100
|
export declare class ApiServer {
|
|
98
101
|
app: Application;
|
|
@@ -134,8 +137,14 @@ export declare class ApiServer {
|
|
|
134
137
|
start(): this;
|
|
135
138
|
private verifyJWT;
|
|
136
139
|
private authenticate;
|
|
140
|
+
private tryAuthenticateApiKey;
|
|
141
|
+
private requiresAuthToken;
|
|
142
|
+
private shouldValidateStoredToken;
|
|
143
|
+
private assertStoredAccessToken;
|
|
137
144
|
private handle_request;
|
|
138
145
|
api<T extends ApiModule<any>>(module: T): this;
|
|
139
146
|
dumpRequest(apiReq: ApiRequest): void;
|
|
147
|
+
private formatDebugValue;
|
|
148
|
+
dumpResponse(apiReq: ApiRequest, payload: unknown, status: number): void;
|
|
140
149
|
}
|
|
141
150
|
export default ApiServer;
|
package/dist/esm/api-module.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ApiRequest } from './api-server-base.js';
|
|
2
2
|
export type ApiHandler = (apiReq: ApiRequest) => Promise<[number] | [number, any] | [number, any, string]>;
|
|
3
|
-
export type ApiAuthType = 'none' | 'maybe' | 'yes';
|
|
3
|
+
export type ApiAuthType = 'none' | 'maybe' | 'yes' | 'strict' | 'apikey';
|
|
4
4
|
export type ApiAuthClass = 'any' | 'admin';
|
|
5
5
|
export interface ApiKey {
|
|
6
6
|
uid: unknown;
|
|
@@ -9,7 +9,7 @@ import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
|
|
9
9
|
import { ApiModule } from './api-module.js';
|
|
10
10
|
import type { ApiAuthClass, ApiKey } from './api-module.js';
|
|
11
11
|
import type { AuthProviderModule } from './auth-module.js';
|
|
12
|
-
import type { AuthStorage } from './auth-storage.js';
|
|
12
|
+
import type { AuthStorage, AuthTokenData, AuthTokenMetadata } from './auth-storage.js';
|
|
13
13
|
export type { Application, Request, Response, NextFunction, Router } from 'express';
|
|
14
14
|
export type { Multer } from 'multer';
|
|
15
15
|
export type { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
|
@@ -35,7 +35,7 @@ interface JwtDecodeResult<T> {
|
|
|
35
35
|
data?: T;
|
|
36
36
|
error?: string;
|
|
37
37
|
}
|
|
38
|
-
export interface ApiTokenData extends JwtPayload {
|
|
38
|
+
export interface ApiTokenData extends JwtPayload, AuthTokenMetadata {
|
|
39
39
|
uid: unknown;
|
|
40
40
|
iat?: number;
|
|
41
41
|
exp?: number;
|
|
@@ -46,6 +46,8 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
authToken?: AuthTokenData | null;
|
|
50
|
+
apiKey?: ApiKey | null;
|
|
49
51
|
clientInfo?: ClientInfo;
|
|
50
52
|
getClientInfo: () => ClientInfo;
|
|
51
53
|
getClientIp: () => string | null;
|
|
@@ -93,6 +95,7 @@ export interface ApiServerConf {
|
|
|
93
95
|
authApi: boolean;
|
|
94
96
|
devMode: boolean;
|
|
95
97
|
hydrateGetBody: boolean;
|
|
98
|
+
validateTokens: boolean;
|
|
96
99
|
}
|
|
97
100
|
export declare class ApiServer {
|
|
98
101
|
app: Application;
|
|
@@ -134,8 +137,14 @@ export declare class ApiServer {
|
|
|
134
137
|
start(): this;
|
|
135
138
|
private verifyJWT;
|
|
136
139
|
private authenticate;
|
|
140
|
+
private tryAuthenticateApiKey;
|
|
141
|
+
private requiresAuthToken;
|
|
142
|
+
private shouldValidateStoredToken;
|
|
143
|
+
private assertStoredAccessToken;
|
|
137
144
|
private handle_request;
|
|
138
145
|
api<T extends ApiModule<any>>(module: T): this;
|
|
139
146
|
dumpRequest(apiReq: ApiRequest): void;
|
|
147
|
+
private formatDebugValue;
|
|
148
|
+
dumpResponse(apiReq: ApiRequest, payload: unknown, status: number): void;
|
|
140
149
|
}
|
|
141
150
|
export default ApiServer;
|
|
@@ -317,10 +317,11 @@ function fillConfig(config) {
|
|
|
317
317
|
accessCookie: config.accessCookie ?? 'dat',
|
|
318
318
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
319
319
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
320
|
-
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60
|
|
320
|
+
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60,
|
|
321
321
|
authApi: config.authApi ?? false,
|
|
322
322
|
devMode: config.devMode ?? false,
|
|
323
|
-
hydrateGetBody: config.hydrateGetBody ?? true
|
|
323
|
+
hydrateGetBody: config.hydrateGetBody ?? true,
|
|
324
|
+
validateTokens: config.validateTokens ?? false
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
327
|
export class ApiServer {
|
|
@@ -529,37 +530,23 @@ export class ApiServer {
|
|
|
529
530
|
}
|
|
530
531
|
let token = null;
|
|
531
532
|
const authHeader = apiReq.req.headers.authorization;
|
|
533
|
+
const requiresAuthToken = this.requiresAuthToken(authType);
|
|
534
|
+
const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
|
|
535
|
+
if (apiKeyAuth) {
|
|
536
|
+
return apiKeyAuth;
|
|
537
|
+
}
|
|
532
538
|
if (authHeader?.startsWith('Bearer ')) {
|
|
533
539
|
token = authHeader.slice(7).trim();
|
|
534
540
|
}
|
|
535
|
-
else if (
|
|
541
|
+
else if (requiresAuthToken && !authHeader) {
|
|
536
542
|
throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
|
|
537
543
|
}
|
|
538
|
-
if (token) {
|
|
539
|
-
const m = token.match(/^apikey-(.+)$/);
|
|
540
|
-
if (m) {
|
|
541
|
-
const key = await this.getApiKey(m[1]);
|
|
542
|
-
if (key) {
|
|
543
|
-
apiReq.token = m[1];
|
|
544
|
-
return {
|
|
545
|
-
uid: key.uid,
|
|
546
|
-
domain: '',
|
|
547
|
-
fingerprint: '',
|
|
548
|
-
iat: 0,
|
|
549
|
-
exp: 0
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
544
|
if (!token || token === null) {
|
|
558
545
|
const access = apiReq.req.cookies?.dat;
|
|
559
546
|
if (access) {
|
|
560
547
|
token = access;
|
|
561
548
|
}
|
|
562
|
-
else if (
|
|
549
|
+
else if (requiresAuthToken) {
|
|
563
550
|
throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
|
|
564
551
|
}
|
|
565
552
|
}
|
|
@@ -575,24 +562,76 @@ export class ApiServer {
|
|
|
575
562
|
if (!tokenData) {
|
|
576
563
|
throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
|
|
577
564
|
}
|
|
565
|
+
if (this.shouldValidateStoredToken(authType)) {
|
|
566
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
567
|
+
}
|
|
578
568
|
apiReq.token = token;
|
|
579
569
|
return tokenData;
|
|
580
570
|
}
|
|
571
|
+
async tryAuthenticateApiKey(apiReq, authType, authHeader) {
|
|
572
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
573
|
+
if (authType === 'apikey') {
|
|
574
|
+
throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
const keyToken = authHeader.slice(7).trim();
|
|
579
|
+
if (!keyToken.startsWith('apikey-')) {
|
|
580
|
+
if (authType === 'apikey') {
|
|
581
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
const secret = keyToken.replace(/^apikey-/, '');
|
|
586
|
+
const key = await this.getApiKey(secret);
|
|
587
|
+
if (!key) {
|
|
588
|
+
throw new ApiError({ code: 401, message: 'Invalid API Key' });
|
|
589
|
+
}
|
|
590
|
+
apiReq.token = secret;
|
|
591
|
+
apiReq.apiKey = key;
|
|
592
|
+
return {
|
|
593
|
+
uid: key.uid,
|
|
594
|
+
domain: '',
|
|
595
|
+
fingerprint: '',
|
|
596
|
+
iat: 0,
|
|
597
|
+
exp: 0
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
requiresAuthToken(authType) {
|
|
601
|
+
return authType === 'yes' || authType === 'strict';
|
|
602
|
+
}
|
|
603
|
+
shouldValidateStoredToken(authType) {
|
|
604
|
+
return this.config.validateTokens || authType === 'strict';
|
|
605
|
+
}
|
|
606
|
+
async assertStoredAccessToken(apiReq, token, tokenData) {
|
|
607
|
+
if (typeof tokenData.uid !== 'string' && typeof tokenData.uid !== 'number') {
|
|
608
|
+
throw new ApiError({ code: 401, message: 'Authorization token is malformed' });
|
|
609
|
+
}
|
|
610
|
+
const userId = tokenData.uid;
|
|
611
|
+
const stored = await this.storageAdapter.getToken({
|
|
612
|
+
accessToken: token,
|
|
613
|
+
userId
|
|
614
|
+
});
|
|
615
|
+
if (!stored) {
|
|
616
|
+
throw new ApiError({ code: 401, message: 'Authorization token is no longer valid' });
|
|
617
|
+
}
|
|
618
|
+
apiReq.authToken = stored;
|
|
619
|
+
}
|
|
581
620
|
handle_request(handler, auth) {
|
|
582
621
|
return async (req, res, next) => {
|
|
583
622
|
void next;
|
|
623
|
+
const apiReq = {
|
|
624
|
+
server: this,
|
|
625
|
+
req,
|
|
626
|
+
res,
|
|
627
|
+
token: '',
|
|
628
|
+
tokenData: null,
|
|
629
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
630
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
631
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
632
|
+
};
|
|
633
|
+
this.currReq = apiReq;
|
|
584
634
|
try {
|
|
585
|
-
const apiReq = {
|
|
586
|
-
server: this,
|
|
587
|
-
req,
|
|
588
|
-
res,
|
|
589
|
-
token: '',
|
|
590
|
-
tokenData: null,
|
|
591
|
-
getClientInfo: () => ensureClientInfo(apiReq),
|
|
592
|
-
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
593
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
594
|
-
};
|
|
595
|
-
this.currReq = apiReq;
|
|
596
635
|
if (this.config.hydrateGetBody) {
|
|
597
636
|
hydrateGetBody(apiReq.req);
|
|
598
637
|
}
|
|
@@ -613,7 +652,11 @@ export class ApiServer {
|
|
|
613
652
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
614
653
|
}
|
|
615
654
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
616
|
-
|
|
655
|
+
const responsePayload = { code, message, data };
|
|
656
|
+
if (this.config.debug) {
|
|
657
|
+
this.dumpResponse(apiReq, responsePayload, code);
|
|
658
|
+
}
|
|
659
|
+
res.status(code).json(responsePayload);
|
|
617
660
|
}
|
|
618
661
|
catch (error) {
|
|
619
662
|
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
@@ -621,20 +664,28 @@ export class ApiServer {
|
|
|
621
664
|
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
622
665
|
? apiError.errors
|
|
623
666
|
: {};
|
|
624
|
-
|
|
667
|
+
const errorPayload = {
|
|
625
668
|
code: apiError.code,
|
|
626
669
|
message: apiError.message,
|
|
627
670
|
data: apiError.data ?? null,
|
|
628
671
|
errors: normalizedErrors
|
|
629
|
-
}
|
|
672
|
+
};
|
|
673
|
+
if (this.config.debug) {
|
|
674
|
+
this.dumpResponse(apiReq, errorPayload, apiError.code);
|
|
675
|
+
}
|
|
676
|
+
res.status(apiError.code).json(errorPayload);
|
|
630
677
|
}
|
|
631
678
|
else {
|
|
632
|
-
|
|
679
|
+
const errorPayload = {
|
|
633
680
|
code: 500,
|
|
634
681
|
message: this.guessExceptionText(error),
|
|
635
682
|
data: null,
|
|
636
683
|
errors: {}
|
|
637
|
-
}
|
|
684
|
+
};
|
|
685
|
+
if (this.config.debug) {
|
|
686
|
+
this.dumpResponse(apiReq, errorPayload, 500);
|
|
687
|
+
}
|
|
688
|
+
res.status(500).json(errorPayload);
|
|
638
689
|
}
|
|
639
690
|
}
|
|
640
691
|
};
|
|
@@ -672,5 +723,58 @@ export class ApiServer {
|
|
|
672
723
|
console.log('Headers:', req.headers);
|
|
673
724
|
console.log('------------------------');
|
|
674
725
|
}
|
|
726
|
+
formatDebugValue(value, maxLength = 50, seen = new WeakSet()) {
|
|
727
|
+
if (value === null || value === undefined) {
|
|
728
|
+
return value;
|
|
729
|
+
}
|
|
730
|
+
if (typeof value === 'string') {
|
|
731
|
+
return value.length <= maxLength
|
|
732
|
+
? value
|
|
733
|
+
: `${value.slice(0, maxLength)}… [truncated ${value.length - maxLength} chars]`;
|
|
734
|
+
}
|
|
735
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
736
|
+
return value;
|
|
737
|
+
}
|
|
738
|
+
if (typeof value === 'symbol') {
|
|
739
|
+
return value.toString();
|
|
740
|
+
}
|
|
741
|
+
if (value instanceof Date) {
|
|
742
|
+
return value.toISOString();
|
|
743
|
+
}
|
|
744
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
|
|
745
|
+
return `<Buffer length=${value.length}>`;
|
|
746
|
+
}
|
|
747
|
+
if (typeof value === 'function') {
|
|
748
|
+
return `[Function ${value.name || 'anonymous'}]`;
|
|
749
|
+
}
|
|
750
|
+
if (typeof value === 'object') {
|
|
751
|
+
const obj = value;
|
|
752
|
+
if (seen.has(obj)) {
|
|
753
|
+
return '[Circular]';
|
|
754
|
+
}
|
|
755
|
+
seen.add(obj);
|
|
756
|
+
if (Array.isArray(value)) {
|
|
757
|
+
const arr = value.map((item) => this.formatDebugValue(item, maxLength, seen));
|
|
758
|
+
seen.delete(obj);
|
|
759
|
+
return arr;
|
|
760
|
+
}
|
|
761
|
+
const recordValue = value;
|
|
762
|
+
const entries = Object.entries(recordValue).reduce((acc, [key, val]) => {
|
|
763
|
+
acc[key] = this.formatDebugValue(val, maxLength, seen);
|
|
764
|
+
return acc;
|
|
765
|
+
}, {});
|
|
766
|
+
seen.delete(obj);
|
|
767
|
+
return entries;
|
|
768
|
+
}
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
dumpResponse(apiReq, payload, status) {
|
|
772
|
+
const url = apiReq.req.originalUrl || apiReq.req.url;
|
|
773
|
+
console.log('--- Outgoing Response! ---');
|
|
774
|
+
console.log('URL:', url);
|
|
775
|
+
console.log('Status:', status);
|
|
776
|
+
console.log('Payload:', this.formatDebugValue(payload));
|
|
777
|
+
console.log('--------------------------');
|
|
778
|
+
}
|
|
675
779
|
}
|
|
676
780
|
export default ApiServer;
|