@technomoron/api-server-base 1.1.3 → 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,14 +121,16 @@ 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.
127
128
 
128
129
  Client IP Helpers
129
130
  -----------------
130
- Use getClientIp(req) to obtain the most likely client address, skipping loopback entries collected from proxy headers. Call getClientIpChain(req) when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' req.ip/req.ips and the underlying socket.
131
+ Call `apiReq.getClientInfo()` when you need the entire client fingerprint captured during request hydration. It returns the raw user-agent string plus derived browser/OS/device labels along with the computed `ip` and `ipchain`.
132
+
133
+ Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
131
134
 
132
135
  Extending the Base Classes
133
136
  --------------------------
@@ -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;
@@ -110,6 +110,111 @@ function extractForwardedHeader(header) {
110
110
  }
111
111
  return ips;
112
112
  }
113
+ function detectBrowser(userAgent) {
114
+ const browserMatchers = [
115
+ { label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
116
+ { label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
117
+ { label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
118
+ { label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
119
+ { label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
120
+ { label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
121
+ { label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
122
+ { label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
123
+ { label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
124
+ { label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
125
+ { label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
126
+ ];
127
+ for (const matcher of browserMatchers) {
128
+ const m = userAgent.match(matcher.pattern);
129
+ if (m) {
130
+ const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
131
+ return version ? `${matcher.label} ${version}` : matcher.label;
132
+ }
133
+ }
134
+ return '';
135
+ }
136
+ function detectOs(userAgent) {
137
+ const osMatchers = [
138
+ {
139
+ label: 'Windows',
140
+ pattern: /Windows NT ([\d.]+)/i,
141
+ transform: (match) => `Windows ${match[1]}`
142
+ },
143
+ {
144
+ label: 'iOS',
145
+ pattern: /iPhone OS ([\d_]+)/i,
146
+ transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
147
+ },
148
+ {
149
+ label: 'iPadOS',
150
+ pattern: /iPad; CPU OS ([\d_]+)/i,
151
+ transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
152
+ },
153
+ {
154
+ label: 'macOS',
155
+ pattern: /Mac OS X ([\d_]+)/i,
156
+ transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
157
+ },
158
+ {
159
+ label: 'Android',
160
+ pattern: /Android ([\d.]+)/i,
161
+ transform: (match) => `Android ${match[1]}`
162
+ },
163
+ {
164
+ label: 'ChromeOS',
165
+ pattern: /CrOS [^ ]+ ([\d.]+)/i,
166
+ transform: (match) => `ChromeOS ${match[1]}`
167
+ },
168
+ { label: 'Linux', pattern: /Linux/i },
169
+ { label: 'Unix', pattern: /X11/i }
170
+ ];
171
+ for (const matcher of osMatchers) {
172
+ const m = userAgent.match(matcher.pattern);
173
+ if (m) {
174
+ return matcher.transform ? matcher.transform(m) : matcher.label;
175
+ }
176
+ }
177
+ return '';
178
+ }
179
+ function detectDevice(userAgent, osLabel) {
180
+ if (/iPhone/i.test(userAgent)) {
181
+ return 'iPhone';
182
+ }
183
+ if (/iPad/i.test(userAgent)) {
184
+ return 'iPad';
185
+ }
186
+ if (/iPod/i.test(userAgent)) {
187
+ return 'iPod';
188
+ }
189
+ if (/Android/i.test(userAgent)) {
190
+ const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
191
+ if (match) {
192
+ return match[1];
193
+ }
194
+ return 'Android Device';
195
+ }
196
+ if (/Macintosh/i.test(userAgent)) {
197
+ return 'Mac';
198
+ }
199
+ if (/Windows/i.test(userAgent)) {
200
+ return 'PC';
201
+ }
202
+ if (/CrOS/i.test(userAgent)) {
203
+ return 'Chromebook';
204
+ }
205
+ return osLabel;
206
+ }
207
+ function parseClientAgent(userAgentHeader) {
208
+ const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
209
+ const ua = typeof raw === 'string' ? raw.trim() : '';
210
+ if (!ua) {
211
+ return { ua: '', browser: '', os: '', device: '' };
212
+ }
213
+ const os = detectOs(ua);
214
+ const browser = detectBrowser(ua);
215
+ const device = detectDevice(ua, os);
216
+ return { ua, browser, os, device };
217
+ }
113
218
  function isLoopbackAddress(ip) {
114
219
  if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
115
220
  return true;
@@ -122,6 +227,71 @@ function isLoopbackAddress(ip) {
122
227
  }
123
228
  return false;
124
229
  }
230
+ function collectClientIpChain(req) {
231
+ const seen = new Set();
232
+ const result = [];
233
+ const pushNormalized = (ip) => {
234
+ if (!ip || seen.has(ip)) {
235
+ return;
236
+ }
237
+ seen.add(ip);
238
+ result.push(ip);
239
+ };
240
+ for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
241
+ pushNormalized(ip);
242
+ }
243
+ for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
244
+ pushNormalized(ip);
245
+ }
246
+ const realIp = req.headers['x-real-ip'];
247
+ if (Array.isArray(realIp)) {
248
+ realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
249
+ }
250
+ else if (typeof realIp === 'string') {
251
+ pushNormalized(normalizeIpAddress(realIp));
252
+ }
253
+ if (Array.isArray(req.ips)) {
254
+ for (const ip of req.ips) {
255
+ pushNormalized(normalizeIpAddress(ip));
256
+ }
257
+ }
258
+ if (typeof req.ip === 'string') {
259
+ pushNormalized(normalizeIpAddress(req.ip));
260
+ }
261
+ const socketAddress = req.socket?.remoteAddress;
262
+ if (typeof socketAddress === 'string') {
263
+ pushNormalized(normalizeIpAddress(socketAddress));
264
+ }
265
+ const connectionAddress = req.connection?.remoteAddress;
266
+ if (typeof connectionAddress === 'string') {
267
+ pushNormalized(normalizeIpAddress(connectionAddress));
268
+ }
269
+ return result;
270
+ }
271
+ function selectClientIp(chain) {
272
+ for (const ip of chain) {
273
+ if (!isLoopbackAddress(ip)) {
274
+ return ip;
275
+ }
276
+ }
277
+ return chain[0] ?? null;
278
+ }
279
+ function buildClientInfo(req) {
280
+ const agent = parseClientAgent(req.headers['user-agent']);
281
+ const ipchain = collectClientIpChain(req);
282
+ const ip = selectClientIp(ipchain);
283
+ return {
284
+ ...agent,
285
+ ip,
286
+ ipchain
287
+ };
288
+ }
289
+ function ensureClientInfo(apiReq) {
290
+ if (!apiReq.clientInfo) {
291
+ apiReq.clientInfo = buildClientInfo(apiReq.req);
292
+ }
293
+ return apiReq.clientInfo;
294
+ }
125
295
  class ApiError extends Error {
126
296
  constructor({ code, message, data, errors }) {
127
297
  const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
@@ -158,7 +328,8 @@ function fillConfig(config) {
158
328
  refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60 * 1000,
159
329
  authApi: config.authApi ?? false,
160
330
  devMode: config.devMode ?? false,
161
- hydrateGetBody: config.hydrateGetBody ?? true
331
+ hydrateGetBody: config.hydrateGetBody ?? true,
332
+ validateTokens: config.validateTokens ?? false
162
333
  };
163
334
  }
164
335
  class ApiServer {
@@ -367,37 +538,23 @@ class ApiServer {
367
538
  }
368
539
  let token = null;
369
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
+ }
370
546
  if (authHeader?.startsWith('Bearer ')) {
371
547
  token = authHeader.slice(7).trim();
372
548
  }
373
- else if (authType === 'yes' && !authHeader) {
549
+ else if (requiresAuthToken && !authHeader) {
374
550
  throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
375
551
  }
376
- if (token) {
377
- const m = token.match(/^apikey-(.+)$/);
378
- if (m) {
379
- const key = await this.getApiKey(m[1]);
380
- if (key) {
381
- apiReq.token = m[1];
382
- return {
383
- uid: key.uid,
384
- domain: '',
385
- fingerprint: '',
386
- iat: 0,
387
- exp: 0
388
- };
389
- }
390
- else {
391
- throw new ApiError({ code: 401, message: 'Invalid API Key' });
392
- }
393
- }
394
- }
395
552
  if (!token || token === null) {
396
553
  const access = apiReq.req.cookies?.dat;
397
554
  if (access) {
398
555
  token = access;
399
556
  }
400
- else if (authType === 'yes') {
557
+ else if (requiresAuthToken) {
401
558
  throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
402
559
  }
403
560
  }
@@ -413,20 +570,76 @@ class ApiServer {
413
570
  if (!tokenData) {
414
571
  throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
415
572
  }
573
+ if (this.shouldValidateStoredToken(authType)) {
574
+ await this.assertStoredAccessToken(apiReq, token, tokenData);
575
+ }
416
576
  apiReq.token = token;
417
577
  return tokenData;
418
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
+ }
419
628
  handle_request(handler, auth) {
420
629
  return async (req, res, next) => {
421
630
  void next;
422
631
  try {
423
- const apiReq = (this.currReq = {
632
+ const apiReq = {
424
633
  server: this,
425
634
  req,
426
635
  res,
427
636
  token: '',
428
- tokenData: null
429
- });
637
+ tokenData: null,
638
+ getClientInfo: () => ensureClientInfo(apiReq),
639
+ getClientIp: () => ensureClientInfo(apiReq).ip,
640
+ getClientIpChain: () => ensureClientInfo(apiReq).ipchain
641
+ };
642
+ this.currReq = apiReq;
430
643
  if (this.config.hydrateGetBody) {
431
644
  hydrateGetBody(apiReq.req);
432
645
  }
@@ -473,56 +686,6 @@ class ApiServer {
473
686
  }
474
687
  };
475
688
  }
476
- getClientIp(req) {
477
- const chain = this.getClientIpChain(req);
478
- for (const ip of chain) {
479
- if (!isLoopbackAddress(ip)) {
480
- return ip;
481
- }
482
- }
483
- return chain[0] ?? null;
484
- }
485
- getClientIpChain(req) {
486
- const seen = new Set();
487
- const result = [];
488
- const pushNormalized = (ip) => {
489
- if (!ip || seen.has(ip)) {
490
- return;
491
- }
492
- seen.add(ip);
493
- result.push(ip);
494
- };
495
- for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
496
- pushNormalized(ip);
497
- }
498
- for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
499
- pushNormalized(ip);
500
- }
501
- const realIp = req.headers['x-real-ip'];
502
- if (Array.isArray(realIp)) {
503
- realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
504
- }
505
- else if (typeof realIp === 'string') {
506
- pushNormalized(normalizeIpAddress(realIp));
507
- }
508
- if (Array.isArray(req.ips)) {
509
- for (const ip of req.ips) {
510
- pushNormalized(normalizeIpAddress(ip));
511
- }
512
- }
513
- if (typeof req.ip === 'string') {
514
- pushNormalized(normalizeIpAddress(req.ip));
515
- }
516
- const socketAddress = req.socket?.remoteAddress;
517
- if (typeof socketAddress === 'string') {
518
- pushNormalized(normalizeIpAddress(socketAddress));
519
- }
520
- const connectionAddress = req.connection?.remoteAddress;
521
- if (typeof connectionAddress === 'string') {
522
- pushNormalized(normalizeIpAddress(connectionAddress));
523
- }
524
- return result;
525
- }
526
689
  api(module) {
527
690
  const router = express_1.default.Router();
528
691
  module.server = this;
@@ -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,22 @@ export interface ApiRequest {
46
46
  res: Response;
47
47
  tokenData?: ApiTokenData | null;
48
48
  token?: string;
49
+ authToken?: AuthTokenData | null;
50
+ apiKey?: ApiKey | null;
51
+ clientInfo?: ClientInfo;
52
+ getClientInfo: () => ClientInfo;
53
+ getClientIp: () => string | null;
54
+ getClientIpChain: () => string[];
55
+ }
56
+ export interface ClientAgentProfile {
57
+ ua: string;
58
+ browser: string;
59
+ os: string;
60
+ device: string;
61
+ }
62
+ export interface ClientInfo extends ClientAgentProfile {
63
+ ip: string | null;
64
+ ipchain: string[];
49
65
  }
50
66
  export { ApiModule } from './api-module.js';
51
67
  export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
@@ -79,6 +95,7 @@ export interface ApiServerConf {
79
95
  authApi: boolean;
80
96
  devMode: boolean;
81
97
  hydrateGetBody: boolean;
98
+ validateTokens: boolean;
82
99
  }
83
100
  export declare class ApiServer {
84
101
  app: Application;
@@ -120,9 +137,11 @@ export declare class ApiServer {
120
137
  start(): this;
121
138
  private verifyJWT;
122
139
  private authenticate;
140
+ private tryAuthenticateApiKey;
141
+ private requiresAuthToken;
142
+ private shouldValidateStoredToken;
143
+ private assertStoredAccessToken;
123
144
  private handle_request;
124
- getClientIp(req: RequestWithStuff): string | null;
125
- getClientIpChain(req: RequestWithStuff): string[];
126
145
  api<T extends ApiModule<any>>(module: T): this;
127
146
  dumpRequest(apiReq: ApiRequest): void;
128
147
  }
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
4
4
  domain?: string;
5
5
  fingerprint?: string;
6
6
  label?: string;
7
+ browser?: string;
8
+ device?: string;
9
+ ip?: string;
10
+ os?: string;
7
11
  scope?: string | string[];
8
12
  revokeSessions?: 'device' | 'domain' | 'client' | 'user';
9
13
  }
10
14
  export interface AuthTokenData extends AuthTokenMetadata {
11
15
  access: string;
12
16
  expires?: Date;
17
+ issuedAt?: Date;
18
+ lastSeenAt?: Date;
13
19
  refresh: string;
14
20
  userId: AuthIdentifier;
15
21
  }
@@ -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,22 @@ export interface ApiRequest {
46
46
  res: Response;
47
47
  tokenData?: ApiTokenData | null;
48
48
  token?: string;
49
+ authToken?: AuthTokenData | null;
50
+ apiKey?: ApiKey | null;
51
+ clientInfo?: ClientInfo;
52
+ getClientInfo: () => ClientInfo;
53
+ getClientIp: () => string | null;
54
+ getClientIpChain: () => string[];
55
+ }
56
+ export interface ClientAgentProfile {
57
+ ua: string;
58
+ browser: string;
59
+ os: string;
60
+ device: string;
61
+ }
62
+ export interface ClientInfo extends ClientAgentProfile {
63
+ ip: string | null;
64
+ ipchain: string[];
49
65
  }
50
66
  export { ApiModule } from './api-module.js';
51
67
  export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
@@ -79,6 +95,7 @@ export interface ApiServerConf {
79
95
  authApi: boolean;
80
96
  devMode: boolean;
81
97
  hydrateGetBody: boolean;
98
+ validateTokens: boolean;
82
99
  }
83
100
  export declare class ApiServer {
84
101
  app: Application;
@@ -120,9 +137,11 @@ export declare class ApiServer {
120
137
  start(): this;
121
138
  private verifyJWT;
122
139
  private authenticate;
140
+ private tryAuthenticateApiKey;
141
+ private requiresAuthToken;
142
+ private shouldValidateStoredToken;
143
+ private assertStoredAccessToken;
123
144
  private handle_request;
124
- getClientIp(req: RequestWithStuff): string | null;
125
- getClientIpChain(req: RequestWithStuff): string[];
126
145
  api<T extends ApiModule<any>>(module: T): this;
127
146
  dumpRequest(apiReq: ApiRequest): void;
128
147
  }
@@ -103,6 +103,111 @@ function extractForwardedHeader(header) {
103
103
  }
104
104
  return ips;
105
105
  }
106
+ function detectBrowser(userAgent) {
107
+ const browserMatchers = [
108
+ { label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
109
+ { label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
110
+ { label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
111
+ { label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
112
+ { label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
113
+ { label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
114
+ { label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
115
+ { label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
116
+ { label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
117
+ { label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
118
+ { label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
119
+ ];
120
+ for (const matcher of browserMatchers) {
121
+ const m = userAgent.match(matcher.pattern);
122
+ if (m) {
123
+ const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
124
+ return version ? `${matcher.label} ${version}` : matcher.label;
125
+ }
126
+ }
127
+ return '';
128
+ }
129
+ function detectOs(userAgent) {
130
+ const osMatchers = [
131
+ {
132
+ label: 'Windows',
133
+ pattern: /Windows NT ([\d.]+)/i,
134
+ transform: (match) => `Windows ${match[1]}`
135
+ },
136
+ {
137
+ label: 'iOS',
138
+ pattern: /iPhone OS ([\d_]+)/i,
139
+ transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
140
+ },
141
+ {
142
+ label: 'iPadOS',
143
+ pattern: /iPad; CPU OS ([\d_]+)/i,
144
+ transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
145
+ },
146
+ {
147
+ label: 'macOS',
148
+ pattern: /Mac OS X ([\d_]+)/i,
149
+ transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
150
+ },
151
+ {
152
+ label: 'Android',
153
+ pattern: /Android ([\d.]+)/i,
154
+ transform: (match) => `Android ${match[1]}`
155
+ },
156
+ {
157
+ label: 'ChromeOS',
158
+ pattern: /CrOS [^ ]+ ([\d.]+)/i,
159
+ transform: (match) => `ChromeOS ${match[1]}`
160
+ },
161
+ { label: 'Linux', pattern: /Linux/i },
162
+ { label: 'Unix', pattern: /X11/i }
163
+ ];
164
+ for (const matcher of osMatchers) {
165
+ const m = userAgent.match(matcher.pattern);
166
+ if (m) {
167
+ return matcher.transform ? matcher.transform(m) : matcher.label;
168
+ }
169
+ }
170
+ return '';
171
+ }
172
+ function detectDevice(userAgent, osLabel) {
173
+ if (/iPhone/i.test(userAgent)) {
174
+ return 'iPhone';
175
+ }
176
+ if (/iPad/i.test(userAgent)) {
177
+ return 'iPad';
178
+ }
179
+ if (/iPod/i.test(userAgent)) {
180
+ return 'iPod';
181
+ }
182
+ if (/Android/i.test(userAgent)) {
183
+ const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
184
+ if (match) {
185
+ return match[1];
186
+ }
187
+ return 'Android Device';
188
+ }
189
+ if (/Macintosh/i.test(userAgent)) {
190
+ return 'Mac';
191
+ }
192
+ if (/Windows/i.test(userAgent)) {
193
+ return 'PC';
194
+ }
195
+ if (/CrOS/i.test(userAgent)) {
196
+ return 'Chromebook';
197
+ }
198
+ return osLabel;
199
+ }
200
+ function parseClientAgent(userAgentHeader) {
201
+ const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
202
+ const ua = typeof raw === 'string' ? raw.trim() : '';
203
+ if (!ua) {
204
+ return { ua: '', browser: '', os: '', device: '' };
205
+ }
206
+ const os = detectOs(ua);
207
+ const browser = detectBrowser(ua);
208
+ const device = detectDevice(ua, os);
209
+ return { ua, browser, os, device };
210
+ }
106
211
  function isLoopbackAddress(ip) {
107
212
  if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
108
213
  return true;
@@ -115,6 +220,71 @@ function isLoopbackAddress(ip) {
115
220
  }
116
221
  return false;
117
222
  }
223
+ function collectClientIpChain(req) {
224
+ const seen = new Set();
225
+ const result = [];
226
+ const pushNormalized = (ip) => {
227
+ if (!ip || seen.has(ip)) {
228
+ return;
229
+ }
230
+ seen.add(ip);
231
+ result.push(ip);
232
+ };
233
+ for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
234
+ pushNormalized(ip);
235
+ }
236
+ for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
237
+ pushNormalized(ip);
238
+ }
239
+ const realIp = req.headers['x-real-ip'];
240
+ if (Array.isArray(realIp)) {
241
+ realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
242
+ }
243
+ else if (typeof realIp === 'string') {
244
+ pushNormalized(normalizeIpAddress(realIp));
245
+ }
246
+ if (Array.isArray(req.ips)) {
247
+ for (const ip of req.ips) {
248
+ pushNormalized(normalizeIpAddress(ip));
249
+ }
250
+ }
251
+ if (typeof req.ip === 'string') {
252
+ pushNormalized(normalizeIpAddress(req.ip));
253
+ }
254
+ const socketAddress = req.socket?.remoteAddress;
255
+ if (typeof socketAddress === 'string') {
256
+ pushNormalized(normalizeIpAddress(socketAddress));
257
+ }
258
+ const connectionAddress = req.connection?.remoteAddress;
259
+ if (typeof connectionAddress === 'string') {
260
+ pushNormalized(normalizeIpAddress(connectionAddress));
261
+ }
262
+ return result;
263
+ }
264
+ function selectClientIp(chain) {
265
+ for (const ip of chain) {
266
+ if (!isLoopbackAddress(ip)) {
267
+ return ip;
268
+ }
269
+ }
270
+ return chain[0] ?? null;
271
+ }
272
+ function buildClientInfo(req) {
273
+ const agent = parseClientAgent(req.headers['user-agent']);
274
+ const ipchain = collectClientIpChain(req);
275
+ const ip = selectClientIp(ipchain);
276
+ return {
277
+ ...agent,
278
+ ip,
279
+ ipchain
280
+ };
281
+ }
282
+ function ensureClientInfo(apiReq) {
283
+ if (!apiReq.clientInfo) {
284
+ apiReq.clientInfo = buildClientInfo(apiReq.req);
285
+ }
286
+ return apiReq.clientInfo;
287
+ }
118
288
  export class ApiError extends Error {
119
289
  constructor({ code, message, data, errors }) {
120
290
  const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
@@ -150,7 +320,8 @@ function fillConfig(config) {
150
320
  refreshExpiry: config.refreshExpiry ?? 30 * 24 * 60 * 60 * 1000,
151
321
  authApi: config.authApi ?? false,
152
322
  devMode: config.devMode ?? false,
153
- hydrateGetBody: config.hydrateGetBody ?? true
323
+ hydrateGetBody: config.hydrateGetBody ?? true,
324
+ validateTokens: config.validateTokens ?? false
154
325
  };
155
326
  }
156
327
  export class ApiServer {
@@ -359,37 +530,23 @@ export class ApiServer {
359
530
  }
360
531
  let token = null;
361
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
+ }
362
538
  if (authHeader?.startsWith('Bearer ')) {
363
539
  token = authHeader.slice(7).trim();
364
540
  }
365
- else if (authType === 'yes' && !authHeader) {
541
+ else if (requiresAuthToken && !authHeader) {
366
542
  throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
367
543
  }
368
- if (token) {
369
- const m = token.match(/^apikey-(.+)$/);
370
- if (m) {
371
- const key = await this.getApiKey(m[1]);
372
- if (key) {
373
- apiReq.token = m[1];
374
- return {
375
- uid: key.uid,
376
- domain: '',
377
- fingerprint: '',
378
- iat: 0,
379
- exp: 0
380
- };
381
- }
382
- else {
383
- throw new ApiError({ code: 401, message: 'Invalid API Key' });
384
- }
385
- }
386
- }
387
544
  if (!token || token === null) {
388
545
  const access = apiReq.req.cookies?.dat;
389
546
  if (access) {
390
547
  token = access;
391
548
  }
392
- else if (authType === 'yes') {
549
+ else if (requiresAuthToken) {
393
550
  throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
394
551
  }
395
552
  }
@@ -405,20 +562,76 @@ export class ApiServer {
405
562
  if (!tokenData) {
406
563
  throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
407
564
  }
565
+ if (this.shouldValidateStoredToken(authType)) {
566
+ await this.assertStoredAccessToken(apiReq, token, tokenData);
567
+ }
408
568
  apiReq.token = token;
409
569
  return tokenData;
410
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
+ }
411
620
  handle_request(handler, auth) {
412
621
  return async (req, res, next) => {
413
622
  void next;
414
623
  try {
415
- const apiReq = (this.currReq = {
624
+ const apiReq = {
416
625
  server: this,
417
626
  req,
418
627
  res,
419
628
  token: '',
420
- tokenData: null
421
- });
629
+ tokenData: null,
630
+ getClientInfo: () => ensureClientInfo(apiReq),
631
+ getClientIp: () => ensureClientInfo(apiReq).ip,
632
+ getClientIpChain: () => ensureClientInfo(apiReq).ipchain
633
+ };
634
+ this.currReq = apiReq;
422
635
  if (this.config.hydrateGetBody) {
423
636
  hydrateGetBody(apiReq.req);
424
637
  }
@@ -465,56 +678,6 @@ export class ApiServer {
465
678
  }
466
679
  };
467
680
  }
468
- getClientIp(req) {
469
- const chain = this.getClientIpChain(req);
470
- for (const ip of chain) {
471
- if (!isLoopbackAddress(ip)) {
472
- return ip;
473
- }
474
- }
475
- return chain[0] ?? null;
476
- }
477
- getClientIpChain(req) {
478
- const seen = new Set();
479
- const result = [];
480
- const pushNormalized = (ip) => {
481
- if (!ip || seen.has(ip)) {
482
- return;
483
- }
484
- seen.add(ip);
485
- result.push(ip);
486
- };
487
- for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
488
- pushNormalized(ip);
489
- }
490
- for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
491
- pushNormalized(ip);
492
- }
493
- const realIp = req.headers['x-real-ip'];
494
- if (Array.isArray(realIp)) {
495
- realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
496
- }
497
- else if (typeof realIp === 'string') {
498
- pushNormalized(normalizeIpAddress(realIp));
499
- }
500
- if (Array.isArray(req.ips)) {
501
- for (const ip of req.ips) {
502
- pushNormalized(normalizeIpAddress(ip));
503
- }
504
- }
505
- if (typeof req.ip === 'string') {
506
- pushNormalized(normalizeIpAddress(req.ip));
507
- }
508
- const socketAddress = req.socket?.remoteAddress;
509
- if (typeof socketAddress === 'string') {
510
- pushNormalized(normalizeIpAddress(socketAddress));
511
- }
512
- const connectionAddress = req.connection?.remoteAddress;
513
- if (typeof connectionAddress === 'string') {
514
- pushNormalized(normalizeIpAddress(connectionAddress));
515
- }
516
- return result;
517
- }
518
681
  api(module) {
519
682
  const router = express.Router();
520
683
  module.server = this;
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
4
4
  domain?: string;
5
5
  fingerprint?: string;
6
6
  label?: string;
7
+ browser?: string;
8
+ device?: string;
9
+ ip?: string;
10
+ os?: string;
7
11
  scope?: string | string[];
8
12
  revokeSessions?: 'device' | 'domain' | 'client' | 'user';
9
13
  }
10
14
  export interface AuthTokenData extends AuthTokenMetadata {
11
15
  access: string;
12
16
  expires?: Date;
17
+ issuedAt?: Date;
18
+ lastSeenAt?: Date;
13
19
  refresh: string;
14
20
  userId: AuthIdentifier;
15
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/api-server-base",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Api Server Skeleton / Base Class",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",