@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 +6 -3
- package/dist/cjs/api-module.d.ts +1 -1
- package/dist/cjs/api-server-base.cjs +238 -75
- package/dist/cjs/api-server-base.d.ts +22 -3
- package/dist/cjs/auth-storage.d.ts +6 -0
- package/dist/esm/api-module.d.ts +1 -1
- package/dist/esm/api-server-base.d.ts +22 -3
- package/dist/esm/api-server-base.js +238 -75
- package/dist/esm/auth-storage.d.ts +6 -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,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
|
|
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
|
-
|
|
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
|
--------------------------
|
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;
|
|
@@ -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 (
|
|
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 (
|
|
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 =
|
|
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
|
}
|
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,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 (
|
|
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 (
|
|
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 =
|
|
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
|
}
|