@technomoron/api-server-base 1.1.4 → 1.1.5
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 +61 -22
- package/dist/cjs/api-server-base.d.ts +8 -1
- package/dist/esm/api-module.d.ts +1 -1
- package/dist/esm/api-server-base.d.ts +8 -1
- package/dist/esm/api-server-base.js +61 -22
- 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;
|
|
@@ -328,7 +328,8 @@ function fillConfig(config) {
|
|
|
328
328
|
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60 * 1000,
|
|
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,9 +570,61 @@ 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;
|
|
@@ -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 } 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';
|
|
@@ -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,6 +137,10 @@ 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;
|
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 } 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';
|
|
@@ -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,6 +137,10 @@ 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;
|
|
@@ -320,7 +320,8 @@ function fillConfig(config) {
|
|
|
320
320
|
refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60 * 1000,
|
|
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,9 +562,61 @@ 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;
|