@technomoron/api-server-base 2.0.0-beta.18 → 2.0.0-beta.19
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/dist/cjs/api-module.cjs +9 -0
- package/dist/cjs/api-module.d.ts +4 -2
- package/dist/cjs/api-server-base.cjs +59 -37
- package/dist/cjs/api-server-base.d.ts +5 -0
- package/dist/cjs/auth-api/auth-module.d.ts +12 -1
- package/dist/cjs/auth-api/auth-module.js +42 -34
- package/dist/cjs/auth-api/mem-auth-store.js +2 -24
- package/dist/cjs/auth-api/sql-auth-store.js +4 -32
- package/dist/cjs/auth-api/user-id.d.ts +4 -0
- package/dist/cjs/auth-api/user-id.js +31 -0
- package/dist/cjs/auth-cookie-options.d.ts +11 -0
- package/dist/cjs/auth-cookie-options.js +57 -0
- package/dist/cjs/oauth/memory.js +2 -9
- package/dist/cjs/oauth/models.js +4 -15
- package/dist/cjs/oauth/sequelize.js +6 -22
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/config.js +26 -0
- package/dist/cjs/passkey/memory.js +2 -9
- package/dist/cjs/passkey/models.js +4 -15
- package/dist/cjs/passkey/sequelize.js +6 -22
- package/dist/cjs/sequelize-utils.d.ts +3 -0
- package/dist/cjs/sequelize-utils.js +17 -0
- package/dist/cjs/token/memory.d.ts +4 -0
- package/dist/cjs/token/memory.js +90 -25
- package/dist/cjs/token/sequelize.js +16 -22
- package/dist/cjs/token/types.d.ts +7 -0
- package/dist/cjs/user/memory.js +2 -9
- package/dist/cjs/user/sequelize.js +6 -22
- package/dist/esm/api-module.d.ts +4 -2
- package/dist/esm/api-module.js +9 -0
- package/dist/esm/api-server-base.d.ts +5 -0
- package/dist/esm/api-server-base.js +59 -37
- package/dist/esm/auth-api/auth-module.d.ts +12 -1
- package/dist/esm/auth-api/auth-module.js +42 -34
- package/dist/esm/auth-api/mem-auth-store.js +1 -23
- package/dist/esm/auth-api/sql-auth-store.js +2 -30
- package/dist/esm/auth-api/user-id.d.ts +4 -0
- package/dist/esm/auth-api/user-id.js +26 -0
- package/dist/esm/auth-cookie-options.d.ts +11 -0
- package/dist/esm/auth-cookie-options.js +54 -0
- package/dist/esm/oauth/memory.js +2 -9
- package/dist/esm/oauth/models.js +1 -12
- package/dist/esm/oauth/sequelize.js +3 -19
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.js +2 -9
- package/dist/esm/passkey/models.js +1 -12
- package/dist/esm/passkey/sequelize.js +3 -19
- package/dist/esm/sequelize-utils.d.ts +3 -0
- package/dist/esm/sequelize-utils.js +12 -0
- package/dist/esm/token/memory.d.ts +4 -0
- package/dist/esm/token/memory.js +90 -25
- package/dist/esm/token/sequelize.js +12 -18
- package/dist/esm/token/types.d.ts +7 -0
- package/dist/esm/user/memory.js +2 -9
- package/dist/esm/user/sequelize.js +3 -19
- package/docs/swagger/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -3,29 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.SequelizeUserStore = exports.AuthUserModel = void 0;
|
|
4
4
|
exports.initAuthUserModel = initAuthUserModel;
|
|
5
5
|
const sequelize_1 = require("sequelize");
|
|
6
|
+
const user_id_js_1 = require("../auth-api/user-id.js");
|
|
7
|
+
const sequelize_utils_js_1 = require("../sequelize-utils.js");
|
|
6
8
|
const base_js_1 = require("./base.js");
|
|
7
|
-
const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
|
|
8
|
-
function normalizeTablePrefix(prefix) {
|
|
9
|
-
if (!prefix) {
|
|
10
|
-
return undefined;
|
|
11
|
-
}
|
|
12
|
-
const trimmed = prefix.trim();
|
|
13
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
14
|
-
}
|
|
15
|
-
function applyTablePrefix(prefix, tableName) {
|
|
16
|
-
const normalized = normalizeTablePrefix(prefix);
|
|
17
|
-
return normalized ? `${normalized}${tableName}` : tableName;
|
|
18
|
-
}
|
|
19
9
|
function integerIdType(sequelize) {
|
|
20
|
-
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? sequelize_1.DataTypes.INTEGER.UNSIGNED : sequelize_1.DataTypes.INTEGER;
|
|
10
|
+
return sequelize_utils_js_1.DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? sequelize_1.DataTypes.INTEGER.UNSIGNED : sequelize_1.DataTypes.INTEGER;
|
|
21
11
|
}
|
|
22
12
|
function userTableOptions(sequelize, tablePrefix) {
|
|
23
13
|
const opts = {
|
|
24
14
|
sequelize,
|
|
25
|
-
tableName: applyTablePrefix(tablePrefix, 'users'),
|
|
15
|
+
tableName: (0, sequelize_utils_js_1.applyTablePrefix)(tablePrefix, 'users'),
|
|
26
16
|
timestamps: false
|
|
27
17
|
};
|
|
28
|
-
if (DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())) {
|
|
18
|
+
if (sequelize_utils_js_1.DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())) {
|
|
29
19
|
opts.charset = 'utf8mb4';
|
|
30
20
|
opts.collate = 'utf8mb4_unicode_ci';
|
|
31
21
|
}
|
|
@@ -183,13 +173,7 @@ class SequelizeUserStore extends base_js_1.UserStore {
|
|
|
183
173
|
};
|
|
184
174
|
}
|
|
185
175
|
normalizeUserId(identifier) {
|
|
186
|
-
|
|
187
|
-
return identifier;
|
|
188
|
-
}
|
|
189
|
-
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
190
|
-
return Number(identifier);
|
|
191
|
-
}
|
|
192
|
-
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
176
|
+
return (0, user_id_js_1.normalizeNumericUserId)(identifier);
|
|
193
177
|
}
|
|
194
178
|
}
|
|
195
179
|
exports.SequelizeUserStore = SequelizeUserStore;
|
package/dist/esm/api-module.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface ApiKey {
|
|
|
7
7
|
uid: unknown;
|
|
8
8
|
}
|
|
9
9
|
export type ApiRoute = {
|
|
10
|
-
method: 'get' | 'post' | 'put' | 'delete';
|
|
10
|
+
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
11
11
|
path: string;
|
|
12
12
|
handler: ApiHandler;
|
|
13
13
|
auth: {
|
|
@@ -16,7 +16,9 @@ export type ApiRoute = {
|
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
18
|
export declare class ApiModule<T = unknown> {
|
|
19
|
-
|
|
19
|
+
private _server?;
|
|
20
|
+
get server(): T;
|
|
21
|
+
set server(value: T);
|
|
20
22
|
namespace: string;
|
|
21
23
|
mountpath: string;
|
|
22
24
|
static defaultNamespace: string;
|
package/dist/esm/api-module.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export class ApiModule {
|
|
2
|
+
get server() {
|
|
3
|
+
if (this._server === undefined) {
|
|
4
|
+
throw new Error('ApiModule.server is not set. Mount the module with ApiServer.api(...) before using it.');
|
|
5
|
+
}
|
|
6
|
+
return this._server;
|
|
7
|
+
}
|
|
8
|
+
set server(value) {
|
|
9
|
+
this._server = value;
|
|
10
|
+
}
|
|
2
11
|
constructor(opts = {}) {
|
|
3
12
|
this.mountpath = '';
|
|
4
13
|
this.namespace = opts.namespace ?? this.constructor.defaultNamespace ?? '';
|
|
@@ -129,6 +129,7 @@ export interface ApiServerConf {
|
|
|
129
129
|
minClientVersion: string;
|
|
130
130
|
tokenStore?: TokenStore;
|
|
131
131
|
authStores?: ApiServerAuthStores;
|
|
132
|
+
onStartError?: (error: Error) => void;
|
|
132
133
|
}
|
|
133
134
|
export declare class ApiServer {
|
|
134
135
|
app: Application;
|
|
@@ -141,12 +142,14 @@ export declare class ApiServer {
|
|
|
141
142
|
private moduleAdapter;
|
|
142
143
|
private serverAuthAdapter;
|
|
143
144
|
private apiNotFoundHandler;
|
|
145
|
+
private apiErrorHandlerInstalled;
|
|
144
146
|
private tokenStoreAdapter;
|
|
145
147
|
private userStoreAdapter;
|
|
146
148
|
private passkeyServiceAdapter;
|
|
147
149
|
private oauthStoreAdapter;
|
|
148
150
|
private canImpersonateAdapter;
|
|
149
151
|
private readonly jwtHelper;
|
|
152
|
+
private currReqDeprecationWarned;
|
|
150
153
|
/**
|
|
151
154
|
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
152
155
|
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
@@ -225,8 +228,10 @@ export declare class ApiServer {
|
|
|
225
228
|
private installSwaggerHandler;
|
|
226
229
|
private normalizeApiBasePath;
|
|
227
230
|
private installApiNotFoundHandler;
|
|
231
|
+
private installApiErrorHandler;
|
|
228
232
|
private describeMissingEndpoint;
|
|
229
233
|
start(): this;
|
|
234
|
+
private internalServerErrorMessage;
|
|
230
235
|
private verifyJWT;
|
|
231
236
|
private jwtCookieOptions;
|
|
232
237
|
private setAccessCookie;
|
|
@@ -14,6 +14,7 @@ import express from 'express';
|
|
|
14
14
|
import multer from 'multer';
|
|
15
15
|
import { nullAuthModule } from './auth-api/module.js';
|
|
16
16
|
import { nullAuthAdapter } from './auth-api/storage.js';
|
|
17
|
+
import { buildAuthCookieOptions } from './auth-cookie-options.js';
|
|
17
18
|
import { TokenStore } from './token/base.js';
|
|
18
19
|
class JwtHelperStore extends TokenStore {
|
|
19
20
|
async save() {
|
|
@@ -70,6 +71,7 @@ function hydrateGetBody(req) {
|
|
|
70
71
|
req.body = { ...query };
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
74
|
+
// Keep explicit body fields authoritative when both query and body provide the same key.
|
|
73
75
|
req.body = { ...query, ...body };
|
|
74
76
|
}
|
|
75
77
|
function normalizeIpAddress(candidate) {
|
|
@@ -329,6 +331,17 @@ function isApiErrorLike(candidate) {
|
|
|
329
331
|
const maybeError = candidate;
|
|
330
332
|
return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
|
|
331
333
|
}
|
|
334
|
+
function asHttpStatus(error) {
|
|
335
|
+
if (!error || typeof error !== 'object') {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const maybe = error;
|
|
339
|
+
const status = typeof maybe.status === 'number' ? maybe.status : maybe.statusCode;
|
|
340
|
+
if (typeof status === 'number' && status >= 400 && status <= 599) {
|
|
341
|
+
return status;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
332
345
|
function fillConfig(config) {
|
|
333
346
|
return {
|
|
334
347
|
apiPort: config.apiPort ?? 3101,
|
|
@@ -361,7 +374,8 @@ function fillConfig(config) {
|
|
|
361
374
|
apiVersion: config.apiVersion ?? '',
|
|
362
375
|
minClientVersion: config.minClientVersion ?? '',
|
|
363
376
|
tokenStore: config.tokenStore,
|
|
364
|
-
authStores: config.authStores
|
|
377
|
+
authStores: config.authStores,
|
|
378
|
+
onStartError: config.onStartError
|
|
365
379
|
};
|
|
366
380
|
}
|
|
367
381
|
export class ApiServer {
|
|
@@ -374,17 +388,23 @@ export class ApiServer {
|
|
|
374
388
|
return null;
|
|
375
389
|
}
|
|
376
390
|
set currReq(_value) {
|
|
391
|
+
if (this.config.devMode && !this.currReqDeprecationWarned) {
|
|
392
|
+
this.currReqDeprecationWarned = true;
|
|
393
|
+
console.warn('[api-server-base] ApiServer.currReq is deprecated and always null. Use req.apiReq or res.locals.apiReq.');
|
|
394
|
+
}
|
|
377
395
|
void _value;
|
|
378
396
|
}
|
|
379
397
|
constructor(config = {}) {
|
|
380
398
|
this.finalized = false;
|
|
381
399
|
this.serverAuthAdapter = null;
|
|
382
400
|
this.apiNotFoundHandler = null;
|
|
401
|
+
this.apiErrorHandlerInstalled = false;
|
|
383
402
|
this.tokenStoreAdapter = null;
|
|
384
403
|
this.userStoreAdapter = null;
|
|
385
404
|
this.passkeyServiceAdapter = null;
|
|
386
405
|
this.oauthStoreAdapter = null;
|
|
387
406
|
this.canImpersonateAdapter = null;
|
|
407
|
+
this.currReqDeprecationWarned = false;
|
|
388
408
|
this.config = fillConfig(config);
|
|
389
409
|
this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
|
|
390
410
|
this.startedAt = Date.now();
|
|
@@ -431,6 +451,7 @@ export class ApiServer {
|
|
|
431
451
|
this.app.use(this.apiBasePath, this.apiRouter);
|
|
432
452
|
// addSwaggerUi(this.app);
|
|
433
453
|
this.installApiNotFoundHandler();
|
|
454
|
+
this.installApiErrorHandler();
|
|
434
455
|
}
|
|
435
456
|
assertNotFinalized(action) {
|
|
436
457
|
if (this.finalized) {
|
|
@@ -460,6 +481,7 @@ export class ApiServer {
|
|
|
460
481
|
}
|
|
461
482
|
finalize() {
|
|
462
483
|
this.installApiNotFoundHandler();
|
|
484
|
+
this.installApiErrorHandler();
|
|
463
485
|
this.finalized = true;
|
|
464
486
|
return this;
|
|
465
487
|
}
|
|
@@ -802,8 +824,12 @@ export class ApiServer {
|
|
|
802
824
|
const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
|
|
803
825
|
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
804
826
|
const path = resolved.startsWith('/') ? resolved : `/${resolved}`;
|
|
805
|
-
|
|
827
|
+
let specCache;
|
|
806
828
|
this.app.get(path, (_req, res) => {
|
|
829
|
+
if (specCache === undefined) {
|
|
830
|
+
specCache = this.loadSwaggerSpec();
|
|
831
|
+
}
|
|
832
|
+
const spec = specCache;
|
|
807
833
|
if (!spec) {
|
|
808
834
|
res.status(500).json({
|
|
809
835
|
success: false,
|
|
@@ -847,6 +873,13 @@ export class ApiServer {
|
|
|
847
873
|
};
|
|
848
874
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
849
875
|
}
|
|
876
|
+
installApiErrorHandler() {
|
|
877
|
+
if (this.apiErrorHandlerInstalled) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
this.apiErrorHandlerInstalled = true;
|
|
881
|
+
this.app.use(this.apiBasePath, this.expressErrorHandler());
|
|
882
|
+
}
|
|
850
883
|
describeMissingEndpoint(req) {
|
|
851
884
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
852
885
|
const target = req.originalUrl || req.url || this.apiBasePath;
|
|
@@ -881,10 +914,17 @@ export class ApiServer {
|
|
|
881
914
|
}
|
|
882
915
|
const err = new Error(message);
|
|
883
916
|
err.cause = error;
|
|
917
|
+
if (typeof this.config.onStartError === 'function') {
|
|
918
|
+
this.config.onStartError(err);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
884
921
|
throw err;
|
|
885
922
|
});
|
|
886
923
|
return this;
|
|
887
924
|
}
|
|
925
|
+
internalServerErrorMessage(error) {
|
|
926
|
+
return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
|
|
927
|
+
}
|
|
888
928
|
async verifyJWT(token) {
|
|
889
929
|
if (!this.config.accessSecret) {
|
|
890
930
|
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
|
|
@@ -899,39 +939,7 @@ export class ApiServer {
|
|
|
899
939
|
return { tokenData: result.data, error: undefined, expired: false };
|
|
900
940
|
}
|
|
901
941
|
jwtCookieOptions(apiReq) {
|
|
902
|
-
|
|
903
|
-
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
904
|
-
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
905
|
-
const origin = typeof referer === 'string' ? referer : '';
|
|
906
|
-
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
907
|
-
.split(',')[0]
|
|
908
|
-
.trim()
|
|
909
|
-
.toLowerCase();
|
|
910
|
-
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
911
|
-
const isLocalhost = origin.includes('localhost');
|
|
912
|
-
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
913
|
-
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
914
|
-
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
915
|
-
sameSite = 'lax';
|
|
916
|
-
}
|
|
917
|
-
let resolvedSecure = secure;
|
|
918
|
-
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
919
|
-
// Modern browsers reject SameSite=None cookies unless Secure is set.
|
|
920
|
-
resolvedSecure = true;
|
|
921
|
-
}
|
|
922
|
-
const options = {
|
|
923
|
-
httpOnly: conf.cookieHttpOnly ?? true,
|
|
924
|
-
secure: resolvedSecure,
|
|
925
|
-
sameSite,
|
|
926
|
-
domain: conf.cookieDomain || undefined,
|
|
927
|
-
path: conf.cookiePath || '/',
|
|
928
|
-
maxAge: undefined
|
|
929
|
-
};
|
|
930
|
-
if (conf.devMode && isLocalhost) {
|
|
931
|
-
// Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
|
|
932
|
-
options.domain = undefined;
|
|
933
|
-
}
|
|
934
|
-
return options;
|
|
942
|
+
return buildAuthCookieOptions(this.config, apiReq.req);
|
|
935
943
|
}
|
|
936
944
|
setAccessCookie(apiReq, accessToken, sessionCookie) {
|
|
937
945
|
const conf = this.config;
|
|
@@ -1277,10 +1285,21 @@ export class ApiServer {
|
|
|
1277
1285
|
res.status(apiError.code).json(errorPayload);
|
|
1278
1286
|
return;
|
|
1279
1287
|
}
|
|
1288
|
+
const status = asHttpStatus(error);
|
|
1289
|
+
if (status) {
|
|
1290
|
+
res.status(status).json({
|
|
1291
|
+
success: false,
|
|
1292
|
+
code: status,
|
|
1293
|
+
message: status === 400 ? 'Invalid request payload' : this.internalServerErrorMessage(error),
|
|
1294
|
+
data: null,
|
|
1295
|
+
errors: {}
|
|
1296
|
+
});
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1280
1299
|
const errorPayload = {
|
|
1281
1300
|
success: false,
|
|
1282
1301
|
code: 500,
|
|
1283
|
-
message: this.
|
|
1302
|
+
message: this.internalServerErrorMessage(error),
|
|
1284
1303
|
data: null,
|
|
1285
1304
|
errors: {}
|
|
1286
1305
|
};
|
|
@@ -1340,7 +1359,7 @@ export class ApiServer {
|
|
|
1340
1359
|
const errorPayload = {
|
|
1341
1360
|
success: false,
|
|
1342
1361
|
code: 500,
|
|
1343
|
-
message: this.
|
|
1362
|
+
message: this.internalServerErrorMessage(error),
|
|
1344
1363
|
data: null,
|
|
1345
1364
|
errors: {}
|
|
1346
1365
|
};
|
|
@@ -1381,6 +1400,9 @@ export class ApiServer {
|
|
|
1381
1400
|
case 'put':
|
|
1382
1401
|
router.put(r.path, handler);
|
|
1383
1402
|
break;
|
|
1403
|
+
case 'patch':
|
|
1404
|
+
router.patch(r.path, handler);
|
|
1405
|
+
break;
|
|
1384
1406
|
case 'delete':
|
|
1385
1407
|
router.delete(r.path, handler);
|
|
1386
1408
|
break;
|
|
@@ -10,10 +10,16 @@ interface CanImpersonateContext<UserEntity> {
|
|
|
10
10
|
targetUser: UserEntity;
|
|
11
11
|
effectiveUserId: AuthIdentifier;
|
|
12
12
|
}
|
|
13
|
+
type AuthRateLimitEndpoint = 'login' | 'passkey-challenge' | 'oauth-token';
|
|
13
14
|
interface AuthModuleOptions<UserEntity> {
|
|
14
15
|
namespace?: string;
|
|
15
16
|
defaultDomain?: string;
|
|
16
17
|
canImpersonate?: (context: CanImpersonateContext<UserEntity>) => Promise<boolean> | boolean;
|
|
18
|
+
rateLimit?: (context: {
|
|
19
|
+
apiReq: ApiRequest;
|
|
20
|
+
endpoint: AuthRateLimitEndpoint;
|
|
21
|
+
}) => Promise<void> | void;
|
|
22
|
+
allowInsecurePkcePlain?: boolean;
|
|
17
23
|
}
|
|
18
24
|
type TokenMetadata = Partial<Token> & {
|
|
19
25
|
sessionCookie?: boolean;
|
|
@@ -42,9 +48,12 @@ type AuthCapableServer<PublicUser> = ApiServer & {
|
|
|
42
48
|
};
|
|
43
49
|
export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<UserEntity> implements AuthProviderModule<UserEntity> {
|
|
44
50
|
static defaultNamespace: string;
|
|
45
|
-
server: AuthCapableServer<PublicUser>;
|
|
51
|
+
get server(): AuthCapableServer<PublicUser>;
|
|
52
|
+
set server(value: AuthCapableServer<PublicUser>);
|
|
46
53
|
private readonly defaultDomain?;
|
|
47
54
|
private readonly canImpersonateHook?;
|
|
55
|
+
private readonly rateLimitHook?;
|
|
56
|
+
private readonly allowInsecurePkcePlain;
|
|
48
57
|
constructor(options?: AuthModuleOptions<UserEntity>);
|
|
49
58
|
protected get storage(): AuthAdapter<UserEntity, PublicUser>;
|
|
50
59
|
protected canImpersonate(apiReq: ApiRequest, realUser: UserEntity, targetUser: UserEntity): Promise<boolean>;
|
|
@@ -100,6 +109,8 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
|
|
|
100
109
|
private hasOAuthStore;
|
|
101
110
|
private storageImplements;
|
|
102
111
|
private storageImplementsAll;
|
|
112
|
+
private applyRateLimit;
|
|
113
|
+
private resolvePkceChallengeMethod;
|
|
103
114
|
defineRoutes(): ApiRoute[];
|
|
104
115
|
}
|
|
105
116
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
3
3
|
import { ApiError } from '../api-server-base.js';
|
|
4
|
+
import { buildAuthCookieOptions } from '../auth-cookie-options.js';
|
|
4
5
|
import { BaseAuthModule } from './module.js';
|
|
5
6
|
import { BaseAuthAdapter } from './storage.js';
|
|
6
7
|
function isAuthIdentifier(value) {
|
|
@@ -30,10 +31,18 @@ function sha256Base64Url(value) {
|
|
|
30
31
|
return base64UrlEncode(hash);
|
|
31
32
|
}
|
|
32
33
|
class AuthModule extends BaseAuthModule {
|
|
34
|
+
get server() {
|
|
35
|
+
return super.server;
|
|
36
|
+
}
|
|
37
|
+
set server(value) {
|
|
38
|
+
super.server = value;
|
|
39
|
+
}
|
|
33
40
|
constructor(options = {}) {
|
|
34
41
|
super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
|
|
35
42
|
this.defaultDomain = options.defaultDomain;
|
|
36
43
|
this.canImpersonateHook = options.canImpersonate;
|
|
44
|
+
this.rateLimitHook = options.rateLimit;
|
|
45
|
+
this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? true;
|
|
37
46
|
}
|
|
38
47
|
get storage() {
|
|
39
48
|
return this.server.getAuthStorage();
|
|
@@ -234,37 +243,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
234
243
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
235
244
|
}
|
|
236
245
|
cookieOptions(apiReq) {
|
|
237
|
-
|
|
238
|
-
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
239
|
-
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
240
|
-
const origin = typeof referer === 'string' ? referer : '';
|
|
241
|
-
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
242
|
-
.split(',')[0]
|
|
243
|
-
.trim()
|
|
244
|
-
.toLowerCase();
|
|
245
|
-
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
246
|
-
const isLocalhost = origin.includes('localhost');
|
|
247
|
-
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
248
|
-
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
249
|
-
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
250
|
-
sameSite = 'lax';
|
|
251
|
-
}
|
|
252
|
-
let resolvedSecure = secure;
|
|
253
|
-
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
254
|
-
resolvedSecure = true;
|
|
255
|
-
}
|
|
256
|
-
const options = {
|
|
257
|
-
httpOnly: conf.cookieHttpOnly ?? true,
|
|
258
|
-
secure: resolvedSecure,
|
|
259
|
-
sameSite,
|
|
260
|
-
domain: conf.cookieDomain || undefined,
|
|
261
|
-
path: conf.cookiePath || '/',
|
|
262
|
-
maxAge: undefined
|
|
263
|
-
};
|
|
264
|
-
if (conf.devMode && isLocalhost) {
|
|
265
|
-
options.domain = undefined;
|
|
266
|
-
}
|
|
267
|
-
return options;
|
|
246
|
+
return buildAuthCookieOptions(this.server.config, apiReq.req);
|
|
268
247
|
}
|
|
269
248
|
setJwtCookies(apiReq, tokens, preferences = {}) {
|
|
270
249
|
const conf = this.server.config;
|
|
@@ -291,7 +270,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
291
270
|
async issueTokens(apiReq, user, metadata = {}) {
|
|
292
271
|
const conf = this.server.config;
|
|
293
272
|
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
294
|
-
const payload =
|
|
273
|
+
const payload = {
|
|
274
|
+
...this.buildTokenPayload(user, enrichedMetadata),
|
|
275
|
+
jti: randomUUID()
|
|
276
|
+
};
|
|
295
277
|
const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
296
278
|
if (!access.success || !access.token) {
|
|
297
279
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
@@ -461,6 +443,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
461
443
|
return undefined;
|
|
462
444
|
}
|
|
463
445
|
async postLogin(apiReq) {
|
|
446
|
+
await this.applyRateLimit(apiReq, 'login');
|
|
464
447
|
this.assertAuthReady();
|
|
465
448
|
const { login, password, ...metadata } = this.parseLoginBody(apiReq);
|
|
466
449
|
const user = await this.storage.getUser(login);
|
|
@@ -624,6 +607,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
624
607
|
];
|
|
625
608
|
}
|
|
626
609
|
async postPasskeyChallenge(apiReq) {
|
|
610
|
+
await this.applyRateLimit(apiReq, 'passkey-challenge');
|
|
627
611
|
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
628
612
|
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
629
613
|
}
|
|
@@ -761,7 +745,8 @@ class AuthModule extends BaseAuthModule {
|
|
|
761
745
|
async deleteImpersonation(apiReq) {
|
|
762
746
|
this.assertAuthReady();
|
|
763
747
|
const actor = await this.resolveActorContext(apiReq);
|
|
764
|
-
const
|
|
748
|
+
const query = (apiReq.req.query ?? {});
|
|
749
|
+
const metadata = this.buildImpersonationMetadata(query);
|
|
765
750
|
metadata.loginType = metadata.loginType ?? 'impersonation-end';
|
|
766
751
|
const tokens = await this.issueTokens(apiReq, actor.user, metadata);
|
|
767
752
|
const publicUser = this.storage.filterUser(actor.user);
|
|
@@ -833,6 +818,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
833
818
|
const state = toStringOrNull(body.state) ?? undefined;
|
|
834
819
|
const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
|
|
835
820
|
const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
|
|
821
|
+
const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
|
|
836
822
|
if (!clientId) {
|
|
837
823
|
throw new ApiError({ code: 400, message: 'clientId is required' });
|
|
838
824
|
}
|
|
@@ -852,7 +838,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
852
838
|
redirectUri,
|
|
853
839
|
scope: resolvedScope,
|
|
854
840
|
codeChallenge,
|
|
855
|
-
codeChallengeMethod:
|
|
841
|
+
codeChallengeMethod: resolvedCodeChallengeMethod,
|
|
856
842
|
expiresInSeconds: 300
|
|
857
843
|
});
|
|
858
844
|
const redirect = new URL(redirectUri);
|
|
@@ -863,6 +849,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
863
849
|
return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
|
|
864
850
|
}
|
|
865
851
|
async postOAuthToken(apiReq) {
|
|
852
|
+
await this.applyRateLimit(apiReq, 'oauth-token');
|
|
866
853
|
if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
|
|
867
854
|
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
868
855
|
}
|
|
@@ -916,6 +903,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
916
903
|
}
|
|
917
904
|
}
|
|
918
905
|
else if (record.codeChallengeMethod === 'plain') {
|
|
906
|
+
if (!this.allowInsecurePkcePlain) {
|
|
907
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
908
|
+
}
|
|
919
909
|
if (codeVerifier !== record.codeChallenge) {
|
|
920
910
|
throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
|
|
921
911
|
}
|
|
@@ -1116,6 +1106,24 @@ class AuthModule extends BaseAuthModule {
|
|
|
1116
1106
|
storageImplementsAll(keys) {
|
|
1117
1107
|
return keys.every((key) => this.storageImplements(key));
|
|
1118
1108
|
}
|
|
1109
|
+
async applyRateLimit(apiReq, endpoint) {
|
|
1110
|
+
if (!this.rateLimitHook) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
await this.rateLimitHook({ apiReq, endpoint });
|
|
1114
|
+
}
|
|
1115
|
+
resolvePkceChallengeMethod(value) {
|
|
1116
|
+
if (value === 'S256') {
|
|
1117
|
+
return 'S256';
|
|
1118
|
+
}
|
|
1119
|
+
if (value === 'plain') {
|
|
1120
|
+
if (!this.allowInsecurePkcePlain) {
|
|
1121
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
1122
|
+
}
|
|
1123
|
+
return 'plain';
|
|
1124
|
+
}
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1119
1127
|
defineRoutes() {
|
|
1120
1128
|
const routes = [];
|
|
1121
1129
|
const coreAuthSupported = this.storageImplementsAll([
|
|
@@ -1,31 +1,9 @@
|
|
|
1
1
|
import { MemoryOAuthStore } from '../oauth/memory.js';
|
|
2
|
+
import { normalizePasskeyConfig } from '../passkey/config.js';
|
|
2
3
|
import { MemoryPasskeyStore } from '../passkey/memory.js';
|
|
3
4
|
import { MemoryTokenStore } from '../token/memory.js';
|
|
4
5
|
import { MemoryUserStore } from '../user/memory.js';
|
|
5
6
|
import { CompositeAuthAdapter } from './compat-auth-storage.js';
|
|
6
|
-
const DEFAULT_PASSKEY_CONFIG = {
|
|
7
|
-
rpId: 'localhost',
|
|
8
|
-
rpName: 'API Server',
|
|
9
|
-
origins: ['http://localhost:5173'],
|
|
10
|
-
timeoutMs: 5 * 60 * 1000,
|
|
11
|
-
userVerification: 'preferred'
|
|
12
|
-
};
|
|
13
|
-
function isOriginString(origin) {
|
|
14
|
-
return typeof origin === 'string' && origin.trim().length > 0;
|
|
15
|
-
}
|
|
16
|
-
function normalizePasskeyConfig(config = {}) {
|
|
17
|
-
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
18
|
-
return {
|
|
19
|
-
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
20
|
-
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
21
|
-
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
22
|
-
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
23
|
-
? config.timeoutMs
|
|
24
|
-
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
25
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
26
|
-
debug: Boolean(config.debug)
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
7
|
export class MemAuthStore {
|
|
30
8
|
constructor(params = {}) {
|
|
31
9
|
this.userStore = new MemoryUserStore({
|
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
import { SequelizeOAuthStore } from '../oauth/sequelize.js';
|
|
2
|
+
import { normalizePasskeyConfig } from '../passkey/config.js';
|
|
2
3
|
import { SequelizePasskeyStore } from '../passkey/sequelize.js';
|
|
4
|
+
import { normalizeTablePrefix } from '../sequelize-utils.js';
|
|
3
5
|
import { SequelizeTokenStore } from '../token/sequelize.js';
|
|
4
6
|
import { SequelizeUserStore } from '../user/sequelize.js';
|
|
5
7
|
import { CompositeAuthAdapter } from './compat-auth-storage.js';
|
|
6
|
-
const DEFAULT_PASSKEY_CONFIG = {
|
|
7
|
-
rpId: 'localhost',
|
|
8
|
-
rpName: 'API Server',
|
|
9
|
-
origins: ['http://localhost:5173'],
|
|
10
|
-
timeoutMs: 5 * 60 * 1000,
|
|
11
|
-
userVerification: 'preferred'
|
|
12
|
-
};
|
|
13
|
-
function normalizeTablePrefix(prefix) {
|
|
14
|
-
if (!prefix) {
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
const trimmed = prefix.trim();
|
|
18
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
19
|
-
}
|
|
20
8
|
function resolveTablePrefix(...prefixes) {
|
|
21
9
|
for (const prefix of prefixes) {
|
|
22
10
|
const normalized = normalizeTablePrefix(prefix);
|
|
@@ -26,22 +14,6 @@ function resolveTablePrefix(...prefixes) {
|
|
|
26
14
|
}
|
|
27
15
|
return undefined;
|
|
28
16
|
}
|
|
29
|
-
function isOriginString(origin) {
|
|
30
|
-
return typeof origin === 'string' && origin.trim().length > 0;
|
|
31
|
-
}
|
|
32
|
-
function normalizePasskeyConfig(config = {}) {
|
|
33
|
-
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
34
|
-
return {
|
|
35
|
-
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
36
|
-
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
37
|
-
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
38
|
-
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
39
|
-
? config.timeoutMs
|
|
40
|
-
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
41
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
42
|
-
debug: Boolean(config.debug)
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
17
|
export class SqlAuthStore {
|
|
46
18
|
constructor(params) {
|
|
47
19
|
this.closed = false;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AuthIdentifier } from './types.js';
|
|
2
|
+
export declare function normalizeComparableUserId(identifier: AuthIdentifier): string;
|
|
3
|
+
export declare function normalizeNumericUserId(identifier: AuthIdentifier): number;
|
|
4
|
+
export declare function normalizeStringUserId(identifier: AuthIdentifier): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function normalizeComparableUserId(identifier) {
|
|
2
|
+
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
|
3
|
+
return String(identifier);
|
|
4
|
+
}
|
|
5
|
+
if (typeof identifier === 'string') {
|
|
6
|
+
const trimmed = identifier.trim();
|
|
7
|
+
if (trimmed.length === 0) {
|
|
8
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
9
|
+
}
|
|
10
|
+
if (/^\d+$/.test(trimmed)) {
|
|
11
|
+
return String(Number(trimmed));
|
|
12
|
+
}
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
16
|
+
}
|
|
17
|
+
export function normalizeNumericUserId(identifier) {
|
|
18
|
+
const normalized = normalizeComparableUserId(identifier);
|
|
19
|
+
if (/^\d+$/.test(normalized)) {
|
|
20
|
+
return Number(normalized);
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
23
|
+
}
|
|
24
|
+
export function normalizeStringUserId(identifier) {
|
|
25
|
+
return normalizeComparableUserId(identifier);
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import type { CookieOptions } from 'express-serve-static-core';
|
|
3
|
+
export interface AuthCookieConfig {
|
|
4
|
+
cookieSecure?: boolean | 'auto';
|
|
5
|
+
cookieSameSite?: 'lax' | 'strict' | 'none';
|
|
6
|
+
cookieHttpOnly?: boolean;
|
|
7
|
+
cookieDomain?: string;
|
|
8
|
+
cookiePath?: string;
|
|
9
|
+
devMode?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildAuthCookieOptions(config: AuthCookieConfig, req: Pick<Request, 'headers' | 'protocol'>): CookieOptions;
|