@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.
Files changed (58) hide show
  1. package/dist/cjs/api-module.cjs +9 -0
  2. package/dist/cjs/api-module.d.ts +4 -2
  3. package/dist/cjs/api-server-base.cjs +59 -37
  4. package/dist/cjs/api-server-base.d.ts +5 -0
  5. package/dist/cjs/auth-api/auth-module.d.ts +12 -1
  6. package/dist/cjs/auth-api/auth-module.js +42 -34
  7. package/dist/cjs/auth-api/mem-auth-store.js +2 -24
  8. package/dist/cjs/auth-api/sql-auth-store.js +4 -32
  9. package/dist/cjs/auth-api/user-id.d.ts +4 -0
  10. package/dist/cjs/auth-api/user-id.js +31 -0
  11. package/dist/cjs/auth-cookie-options.d.ts +11 -0
  12. package/dist/cjs/auth-cookie-options.js +57 -0
  13. package/dist/cjs/oauth/memory.js +2 -9
  14. package/dist/cjs/oauth/models.js +4 -15
  15. package/dist/cjs/oauth/sequelize.js +6 -22
  16. package/dist/cjs/passkey/config.d.ts +2 -0
  17. package/dist/cjs/passkey/config.js +26 -0
  18. package/dist/cjs/passkey/memory.js +2 -9
  19. package/dist/cjs/passkey/models.js +4 -15
  20. package/dist/cjs/passkey/sequelize.js +6 -22
  21. package/dist/cjs/sequelize-utils.d.ts +3 -0
  22. package/dist/cjs/sequelize-utils.js +17 -0
  23. package/dist/cjs/token/memory.d.ts +4 -0
  24. package/dist/cjs/token/memory.js +90 -25
  25. package/dist/cjs/token/sequelize.js +16 -22
  26. package/dist/cjs/token/types.d.ts +7 -0
  27. package/dist/cjs/user/memory.js +2 -9
  28. package/dist/cjs/user/sequelize.js +6 -22
  29. package/dist/esm/api-module.d.ts +4 -2
  30. package/dist/esm/api-module.js +9 -0
  31. package/dist/esm/api-server-base.d.ts +5 -0
  32. package/dist/esm/api-server-base.js +59 -37
  33. package/dist/esm/auth-api/auth-module.d.ts +12 -1
  34. package/dist/esm/auth-api/auth-module.js +42 -34
  35. package/dist/esm/auth-api/mem-auth-store.js +1 -23
  36. package/dist/esm/auth-api/sql-auth-store.js +2 -30
  37. package/dist/esm/auth-api/user-id.d.ts +4 -0
  38. package/dist/esm/auth-api/user-id.js +26 -0
  39. package/dist/esm/auth-cookie-options.d.ts +11 -0
  40. package/dist/esm/auth-cookie-options.js +54 -0
  41. package/dist/esm/oauth/memory.js +2 -9
  42. package/dist/esm/oauth/models.js +1 -12
  43. package/dist/esm/oauth/sequelize.js +3 -19
  44. package/dist/esm/passkey/config.d.ts +2 -0
  45. package/dist/esm/passkey/config.js +23 -0
  46. package/dist/esm/passkey/memory.js +2 -9
  47. package/dist/esm/passkey/models.js +1 -12
  48. package/dist/esm/passkey/sequelize.js +3 -19
  49. package/dist/esm/sequelize-utils.d.ts +3 -0
  50. package/dist/esm/sequelize-utils.js +12 -0
  51. package/dist/esm/token/memory.d.ts +4 -0
  52. package/dist/esm/token/memory.js +90 -25
  53. package/dist/esm/token/sequelize.js +12 -18
  54. package/dist/esm/token/types.d.ts +7 -0
  55. package/dist/esm/user/memory.js +2 -9
  56. package/dist/esm/user/sequelize.js +3 -19
  57. package/docs/swagger/openapi.json +1 -1
  58. 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
- if (typeof identifier === 'number' && Number.isFinite(identifier)) {
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;
@@ -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
- server: T;
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;
@@ -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
- const spec = this.loadSwaggerSpec();
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
- const conf = this.config;
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.guessExceptionText(error),
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.guessExceptionText(error),
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
- const conf = this.server.config;
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 = this.buildTokenPayload(user, enrichedMetadata);
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 metadata = this.buildImpersonationMetadata((apiReq.req.body ?? {}));
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: codeChallengeMethod === 'S256' ? 'S256' : codeChallengeMethod === 'plain' ? 'plain' : undefined,
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;