@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 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, maybe, or yes. Bearer tokens and the dat cookie are accepted. API key tokens prefixed with apikey- delegate to getApiKey.
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.
@@ -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 (authType === 'yes' && !authHeader) {
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 (authType === 'yes') {
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;
@@ -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 (authType === 'yes' && !authHeader) {
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 (authType === 'yes') {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/api-server-base",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Api Server Skeleton / Base Class",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",