@technomoron/api-server-base 2.0.0-beta.2 → 2.0.0-beta.21

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 (115) hide show
  1. package/README.txt +81 -28
  2. package/dist/cjs/api-module.cjs +9 -0
  3. package/dist/cjs/api-module.d.ts +7 -4
  4. package/dist/cjs/api-server-base.cjs +607 -99
  5. package/dist/cjs/api-server-base.d.ts +80 -23
  6. package/dist/cjs/auth-api/auth-module.d.ts +23 -3
  7. package/dist/cjs/auth-api/auth-module.js +320 -124
  8. package/dist/cjs/auth-api/compat-auth-storage.d.ts +7 -5
  9. package/dist/cjs/auth-api/compat-auth-storage.js +15 -3
  10. package/dist/cjs/auth-api/mem-auth-store.d.ts +5 -3
  11. package/dist/cjs/auth-api/mem-auth-store.js +14 -28
  12. package/dist/cjs/auth-api/module.d.ts +1 -1
  13. package/dist/cjs/auth-api/sql-auth-store.d.ts +16 -4
  14. package/dist/cjs/auth-api/sql-auth-store.js +43 -30
  15. package/dist/cjs/auth-api/storage.d.ts +6 -4
  16. package/dist/cjs/auth-api/storage.js +15 -5
  17. package/dist/cjs/auth-api/types.d.ts +7 -2
  18. package/dist/cjs/auth-api/user-id.d.ts +5 -0
  19. package/dist/cjs/auth-api/user-id.js +38 -0
  20. package/dist/cjs/auth-cookie-options.d.ts +11 -0
  21. package/dist/cjs/auth-cookie-options.js +66 -0
  22. package/dist/cjs/index.cjs +4 -14
  23. package/dist/cjs/index.d.ts +4 -9
  24. package/dist/cjs/oauth/memory.d.ts +6 -0
  25. package/dist/cjs/oauth/memory.js +44 -11
  26. package/dist/cjs/oauth/models.d.ts +7 -2
  27. package/dist/cjs/oauth/models.js +10 -21
  28. package/dist/cjs/oauth/sequelize.d.ts +10 -48
  29. package/dist/cjs/oauth/sequelize.js +44 -99
  30. package/dist/cjs/oauth/types.d.ts +1 -0
  31. package/dist/cjs/passkey/base.d.ts +2 -0
  32. package/dist/cjs/passkey/config.d.ts +2 -0
  33. package/dist/cjs/passkey/config.js +26 -0
  34. package/dist/cjs/passkey/memory.d.ts +8 -0
  35. package/dist/cjs/passkey/memory.js +57 -16
  36. package/dist/cjs/passkey/models.d.ts +13 -4
  37. package/dist/cjs/passkey/models.js +41 -14
  38. package/dist/cjs/passkey/sequelize.d.ts +13 -25
  39. package/dist/cjs/passkey/sequelize.js +68 -153
  40. package/dist/cjs/passkey/service.d.ts +6 -2
  41. package/dist/cjs/passkey/service.js +205 -27
  42. package/dist/cjs/passkey/types.d.ts +18 -9
  43. package/dist/cjs/sequelize-utils.d.ts +8 -0
  44. package/dist/cjs/sequelize-utils.js +57 -0
  45. package/dist/cjs/token/base.d.ts +2 -1
  46. package/dist/cjs/token/base.js +3 -1
  47. package/dist/cjs/token/memory.d.ts +10 -0
  48. package/dist/cjs/token/memory.js +122 -32
  49. package/dist/cjs/token/sequelize.d.ts +4 -4
  50. package/dist/cjs/token/sequelize.js +67 -85
  51. package/dist/cjs/token/types.d.ts +8 -1
  52. package/dist/cjs/user/base.d.ts +1 -0
  53. package/dist/cjs/user/base.js +11 -4
  54. package/dist/cjs/user/memory.d.ts +2 -0
  55. package/dist/cjs/user/memory.js +9 -10
  56. package/dist/cjs/user/sequelize.d.ts +7 -2
  57. package/dist/cjs/user/sequelize.js +19 -32
  58. package/dist/esm/api-module.d.ts +7 -4
  59. package/dist/esm/api-module.js +9 -0
  60. package/dist/esm/api-server-base.d.ts +80 -23
  61. package/dist/esm/api-server-base.js +608 -100
  62. package/dist/esm/auth-api/auth-module.d.ts +23 -3
  63. package/dist/esm/auth-api/auth-module.js +321 -125
  64. package/dist/esm/auth-api/compat-auth-storage.d.ts +7 -5
  65. package/dist/esm/auth-api/compat-auth-storage.js +13 -1
  66. package/dist/esm/auth-api/mem-auth-store.d.ts +5 -3
  67. package/dist/esm/auth-api/mem-auth-store.js +14 -28
  68. package/dist/esm/auth-api/module.d.ts +1 -1
  69. package/dist/esm/auth-api/sql-auth-store.d.ts +16 -4
  70. package/dist/esm/auth-api/sql-auth-store.js +43 -30
  71. package/dist/esm/auth-api/storage.d.ts +6 -4
  72. package/dist/esm/auth-api/storage.js +13 -3
  73. package/dist/esm/auth-api/types.d.ts +7 -2
  74. package/dist/esm/auth-api/user-id.d.ts +5 -0
  75. package/dist/esm/auth-api/user-id.js +32 -0
  76. package/dist/esm/auth-cookie-options.d.ts +11 -0
  77. package/dist/esm/auth-cookie-options.js +63 -0
  78. package/dist/esm/index.d.ts +4 -9
  79. package/dist/esm/index.js +2 -7
  80. package/dist/esm/oauth/memory.d.ts +6 -0
  81. package/dist/esm/oauth/memory.js +44 -11
  82. package/dist/esm/oauth/models.d.ts +7 -2
  83. package/dist/esm/oauth/models.js +6 -19
  84. package/dist/esm/oauth/sequelize.d.ts +10 -48
  85. package/dist/esm/oauth/sequelize.js +32 -87
  86. package/dist/esm/oauth/types.d.ts +1 -0
  87. package/dist/esm/passkey/base.d.ts +2 -0
  88. package/dist/esm/passkey/config.d.ts +2 -0
  89. package/dist/esm/passkey/config.js +23 -0
  90. package/dist/esm/passkey/memory.d.ts +8 -0
  91. package/dist/esm/passkey/memory.js +57 -16
  92. package/dist/esm/passkey/models.d.ts +13 -4
  93. package/dist/esm/passkey/models.js +39 -12
  94. package/dist/esm/passkey/sequelize.d.ts +13 -25
  95. package/dist/esm/passkey/sequelize.js +69 -154
  96. package/dist/esm/passkey/service.d.ts +6 -2
  97. package/dist/esm/passkey/service.js +173 -28
  98. package/dist/esm/passkey/types.d.ts +18 -9
  99. package/dist/esm/sequelize-utils.d.ts +8 -0
  100. package/dist/esm/sequelize-utils.js +48 -0
  101. package/dist/esm/token/base.d.ts +2 -1
  102. package/dist/esm/token/base.js +3 -1
  103. package/dist/esm/token/memory.d.ts +10 -0
  104. package/dist/esm/token/memory.js +122 -32
  105. package/dist/esm/token/sequelize.d.ts +4 -4
  106. package/dist/esm/token/sequelize.js +67 -85
  107. package/dist/esm/token/types.d.ts +8 -1
  108. package/dist/esm/user/base.d.ts +1 -0
  109. package/dist/esm/user/base.js +11 -4
  110. package/dist/esm/user/memory.d.ts +2 -0
  111. package/dist/esm/user/memory.js +9 -10
  112. package/dist/esm/user/sequelize.d.ts +7 -2
  113. package/dist/esm/user/sequelize.js +19 -32
  114. package/docs/swagger/openapi.json +1876 -0
  115. package/package.json +84 -34
@@ -1,6 +1,9 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isoBase64URL } from '@simplewebauthn/server/helpers';
2
3
  import { ApiError } from '../api-server-base.js';
4
+ import { buildAuthCookieOptions } from '../auth-cookie-options.js';
3
5
  import { BaseAuthModule } from './module.js';
6
+ import { BaseAuthAdapter } from './storage.js';
4
7
  function isAuthIdentifier(value) {
5
8
  return typeof value === 'string' || typeof value === 'number';
6
9
  }
@@ -28,10 +31,18 @@ function sha256Base64Url(value) {
28
31
  return base64UrlEncode(hash);
29
32
  }
30
33
  class AuthModule extends BaseAuthModule {
34
+ get server() {
35
+ return super.server;
36
+ }
37
+ set server(value) {
38
+ super.server = value;
39
+ }
31
40
  constructor(options = {}) {
32
41
  super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
33
42
  this.defaultDomain = options.defaultDomain;
34
43
  this.canImpersonateHook = options.canImpersonate;
44
+ this.rateLimitHook = options.rateLimit;
45
+ this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? true;
35
46
  }
36
47
  get storage() {
37
48
  return this.server.getAuthStorage();
@@ -72,9 +83,21 @@ class AuthModule extends BaseAuthModule {
72
83
  }
73
84
  buildTokenMetadata(metadata = {}) {
74
85
  const scope = metadata.scope;
86
+ const domain = metadata.domain ?? this.defaultDomain ?? '';
87
+ let fingerprint = metadata.fingerprint ?? metadata.clientId ?? '';
88
+ if (typeof fingerprint === 'string') {
89
+ fingerprint = fingerprint.trim();
90
+ }
91
+ else {
92
+ fingerprint = '';
93
+ }
94
+ // Avoid every client sharing the empty-string fingerprint which collapses sessions into one bucket.
95
+ if (!fingerprint) {
96
+ fingerprint = `srv-${randomUUID()}`;
97
+ }
75
98
  return {
76
- domain: metadata.domain ?? this.defaultDomain ?? '',
77
- fingerprint: metadata.fingerprint ?? metadata.clientId ?? '',
99
+ domain,
100
+ fingerprint,
78
101
  label: metadata.label ?? (Array.isArray(scope) ? scope.join(' ') : typeof scope === 'string' ? scope : ''),
79
102
  clientId: metadata.clientId,
80
103
  ruid: metadata.ruid,
@@ -132,6 +155,9 @@ class AuthModule extends BaseAuthModule {
132
155
  return candidate ? {} : { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
133
156
  }
134
157
  if (typeof candidate === 'number') {
158
+ if (candidate === 0) {
159
+ return { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
160
+ }
135
161
  const ttl = this.normalizeRefreshTtlSeconds(candidate);
136
162
  return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
137
163
  }
@@ -181,30 +207,46 @@ class AuthModule extends BaseAuthModule {
181
207
  }
182
208
  return prefs;
183
209
  }
184
- cookieOptions(apiReq) {
185
- const conf = this.server.config;
186
- const forwarded = apiReq.req.headers['x-forwarded-proto'];
187
- const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
188
- const origin = typeof referer === 'string' ? referer : '';
189
- const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
190
- const isLocalhost = origin.includes('localhost');
191
- const options = {
192
- httpOnly: true,
193
- secure: true,
194
- sameSite: 'strict',
195
- domain: conf.cookieDomain || undefined,
196
- path: '/',
197
- maxAge: undefined
198
- };
199
- if (conf.devMode) {
200
- options.secure = isHttps;
201
- options.httpOnly = false;
202
- options.sameSite = 'lax';
203
- if (isLocalhost) {
204
- options.domain = undefined;
210
+ validateCredentialId(apiReq) {
211
+ const paramId = toStringOrNull(apiReq.req.params?.credentialId);
212
+ const bodyId = toStringOrNull(apiReq.req.body?.credentialId);
213
+ const credentialId = paramId ?? bodyId;
214
+ if (!credentialId) {
215
+ throw new ApiError({ code: 400, message: 'credentialId is required' });
216
+ }
217
+ try {
218
+ isoBase64URL.toBuffer(credentialId);
219
+ }
220
+ catch {
221
+ throw new ApiError({ code: 400, message: 'Invalid credentialId' });
222
+ }
223
+ return credentialId;
224
+ }
225
+ normalizeCredentialId(value) {
226
+ if (Buffer.isBuffer(value)) {
227
+ return value;
228
+ }
229
+ try {
230
+ return Buffer.from(isoBase64URL.toBuffer(value));
231
+ }
232
+ catch {
233
+ try {
234
+ return Buffer.from(value, 'base64');
235
+ }
236
+ catch {
237
+ return Buffer.from(value);
205
238
  }
206
239
  }
207
- return options;
240
+ }
241
+ toIsoDate(value) {
242
+ if (!value) {
243
+ return undefined;
244
+ }
245
+ const date = value instanceof Date ? value : new Date(value);
246
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
247
+ }
248
+ cookieOptions(apiReq) {
249
+ return buildAuthCookieOptions(this.server.config, apiReq.req);
208
250
  }
209
251
  setJwtCookies(apiReq, tokens, preferences = {}) {
210
252
  const conf = this.server.config;
@@ -231,7 +273,10 @@ class AuthModule extends BaseAuthModule {
231
273
  async issueTokens(apiReq, user, metadata = {}) {
232
274
  const conf = this.server.config;
233
275
  const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
234
- const payload = this.buildTokenPayload(user, enrichedMetadata);
276
+ const payload = {
277
+ ...this.buildTokenPayload(user, enrichedMetadata),
278
+ jti: randomUUID()
279
+ };
235
280
  const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
236
281
  if (!access.success || !access.token) {
237
282
  throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
@@ -401,23 +446,16 @@ class AuthModule extends BaseAuthModule {
401
446
  return undefined;
402
447
  }
403
448
  async postLogin(apiReq) {
449
+ await this.applyRateLimit(apiReq, 'login');
404
450
  this.assertAuthReady();
405
451
  const { login, password, ...metadata } = this.parseLoginBody(apiReq);
406
452
  const user = await this.storage.getUser(login);
407
- if (!user) {
408
- throw new ApiError({
409
- code: 400,
410
- message: 'Invalid credentials',
411
- errors: { login: 'Unknown user' }
412
- });
413
- }
414
- const hash = this.storage.getUserPasswordHash(user);
415
- const verified = await this.storage.verifyPassword(password, hash);
416
- if (!verified) {
453
+ const hash = user ? this.storage.getUserPasswordHash(user) : '';
454
+ const verified = user ? await this.storage.verifyPassword(password, hash) : false;
455
+ if (!user || !verified) {
417
456
  throw new ApiError({
418
457
  code: 400,
419
- message: 'Invalid credentials',
420
- errors: { password: 'Wrong password' }
458
+ message: 'Invalid credentials'
421
459
  });
422
460
  }
423
461
  const pair = await this.issueTokens(apiReq, user, metadata);
@@ -459,6 +497,7 @@ class AuthModule extends BaseAuthModule {
459
497
  refreshTtlSeconds: sessionPrefs.refreshTtlSeconds ?? stored.refreshTtlSeconds,
460
498
  sessionCookie: sessionPrefs.sessionCookie ?? stored.sessionCookie
461
499
  };
500
+ await this.storage.deleteToken({ refreshToken: providedToken });
462
501
  const pair = await this.issueTokens(apiReq, user, metadata);
463
502
  const publicUser = this.storage.filterUser(user);
464
503
  return [200, { ...pair, user: publicUser }];
@@ -498,10 +537,39 @@ class AuthModule extends BaseAuthModule {
498
537
  apiReq.req.cookies[conf.accessCookie].trim().length > 0);
499
538
  const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
500
539
  if (shouldRefresh) {
501
- const access = this.server.jwtSign(this.buildTokenPayload(user, stored), conf.accessSecret, conf.accessExpiry);
540
+ const updateToken = this.storage.updateToken;
541
+ if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
542
+ throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
543
+ }
544
+ // Sign a new access token without embedding stored token secrets into the JWT payload.
545
+ const metadata = {
546
+ ruid: stored.ruid,
547
+ domain: stored.domain,
548
+ fingerprint: stored.fingerprint,
549
+ label: stored.label,
550
+ clientId: stored.clientId,
551
+ scope: stored.scope,
552
+ browser: stored.browser,
553
+ device: stored.device,
554
+ ip: stored.ip,
555
+ os: stored.os,
556
+ loginType: stored.loginType,
557
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
558
+ sessionCookie: stored.sessionCookie
559
+ };
560
+ const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
561
+ const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
502
562
  if (!access.success || !access.token) {
503
563
  throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
504
564
  }
565
+ const updated = await updateToken.call(this.storage, {
566
+ refreshToken,
567
+ accessToken: access.token,
568
+ lastSeenAt: new Date()
569
+ });
570
+ if (!updated) {
571
+ throw new ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
572
+ }
505
573
  const cookiePrefs = this.mergeSessionPreferences({
506
574
  sessionCookie: stored.sessionCookie,
507
575
  refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
@@ -511,9 +579,31 @@ class AuthModule extends BaseAuthModule {
511
579
  : cookiePrefs.refreshTtlSeconds;
512
580
  this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
513
581
  }
514
- return [200, this.storage.filterUser(user)];
582
+ const tokenClaims = verify.data;
583
+ const effectiveUserId = this.storage.getUserId(user);
584
+ const effectiveId = String(effectiveUserId);
585
+ const rawRealId = stored.ruid ?? tokenClaims.ruid;
586
+ const normalizedRealId = rawRealId === undefined || rawRealId === null ? null : String(rawRealId).trim() || null;
587
+ const isImpersonating = normalizedRealId !== null && normalizedRealId !== effectiveId;
588
+ let realUser;
589
+ let realUserId;
590
+ if (isImpersonating && normalizedRealId !== null) {
591
+ const realUserEntity = await this.getUserOrThrow(normalizedRealId, 'Real user not found');
592
+ realUser = this.storage.filterUser(realUserEntity);
593
+ realUserId = this.storage.getUserId(realUserEntity);
594
+ }
595
+ return [
596
+ 200,
597
+ {
598
+ user: this.storage.filterUser(user),
599
+ isImpersonating,
600
+ realUser,
601
+ realUserId
602
+ }
603
+ ];
515
604
  }
516
605
  async postPasskeyChallenge(apiReq) {
606
+ await this.applyRateLimit(apiReq, 'passkey-challenge');
517
607
  if (typeof this.storage.createPasskeyChallenge !== 'function') {
518
608
  throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
519
609
  }
@@ -525,15 +615,7 @@ class AuthModule extends BaseAuthModule {
525
615
  const params = {
526
616
  action,
527
617
  login: toStringOrNull(body.login) ?? undefined,
528
- userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
529
- userAgent: toStringOrNull(body.userAgent) ?? undefined,
530
- domain: toStringOrNull(body.domain) ?? undefined,
531
- fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
532
- label: toStringOrNull(body.label) ?? undefined,
533
- browser: toStringOrNull(body.browser) ?? undefined,
534
- device: toStringOrNull(body.device) ?? undefined,
535
- ip: toStringOrNull(body.ip) ?? undefined,
536
- os: toStringOrNull(body.os) ?? undefined
618
+ userId: isAuthIdentifier(body.userId) ? body.userId : undefined
537
619
  };
538
620
  const challenge = await this.storage.createPasskeyChallenge(params);
539
621
  return [200, challenge];
@@ -549,18 +631,25 @@ class AuthModule extends BaseAuthModule {
549
631
  if (!expectedChallenge || typeof response !== 'object' || response === null) {
550
632
  throw new ApiError({ code: 400, message: 'Malformed passkey verification payload' });
551
633
  }
552
- const params = {
553
- expectedChallenge,
554
- response: response,
555
- login: toStringOrNull(body.login) ?? undefined,
556
- userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
634
+ const rawMetadata = {
557
635
  domain: toStringOrNull(body.domain) ?? undefined,
558
636
  fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
559
637
  label: toStringOrNull(body.label) ?? undefined,
560
638
  browser: toStringOrNull(body.browser) ?? undefined,
561
639
  device: toStringOrNull(body.device) ?? undefined,
562
640
  ip: toStringOrNull(body.ip) ?? undefined,
563
- os: toStringOrNull(body.os) ?? undefined,
641
+ os: toStringOrNull(body.os) ?? undefined
642
+ };
643
+ const clientInfo = apiReq.getClientInfo();
644
+ const userAgent = toStringOrNull(body.userAgent) ?? (clientInfo.ua ? clientInfo.ua : null);
645
+ const requestMetadata = this.enrichTokenMetadata(apiReq, rawMetadata);
646
+ const params = {
647
+ expectedChallenge,
648
+ response: response,
649
+ login: toStringOrNull(body.login) ?? undefined,
650
+ userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
651
+ userAgent: userAgent ?? undefined,
652
+ ...requestMetadata,
564
653
  ...sessionPrefs
565
654
  };
566
655
  const result = await this.storage.verifyPasskeyResponse(params);
@@ -596,6 +685,44 @@ class AuthModule extends BaseAuthModule {
596
685
  const publicUser = this.storage.filterUser(user);
597
686
  return [200, { ...tokens, user: publicUser }];
598
687
  }
688
+ async getPasskeys(apiReq) {
689
+ if (typeof this.storage.listUserCredentials !== 'function') {
690
+ throw new ApiError({ code: 501, message: 'Passkey credential listing is not configured' });
691
+ }
692
+ const { userId } = await this.resolveActorContext(apiReq);
693
+ const credentials = await this.storage.listUserCredentials(userId);
694
+ const safeCredentials = credentials.map((credential) => {
695
+ const bufferId = this.normalizeCredentialId(credential.credentialId);
696
+ return {
697
+ id: isoBase64URL.fromBuffer(new Uint8Array(bufferId)),
698
+ transports: credential.transports,
699
+ backedUp: credential.backedUp,
700
+ deviceType: credential.deviceType,
701
+ createdAt: this.toIsoDate(credential.createdAt),
702
+ updatedAt: this.toIsoDate(credential.updatedAt)
703
+ };
704
+ });
705
+ return [200, { credentials: safeCredentials }];
706
+ }
707
+ async deletePasskey(apiReq) {
708
+ if (typeof this.storage.listUserCredentials !== 'function' ||
709
+ typeof this.storage.deletePasskeyCredential !== 'function') {
710
+ throw new ApiError({ code: 501, message: 'Passkey credential management is not configured' });
711
+ }
712
+ const { userId } = await this.resolveActorContext(apiReq);
713
+ const credentialId = this.validateCredentialId(apiReq);
714
+ const bufferId = Buffer.from(isoBase64URL.toBuffer(credentialId));
715
+ const credentials = await this.storage.listUserCredentials(userId);
716
+ const owns = credentials.some((credential) => {
717
+ const candidateId = this.normalizeCredentialId(credential.credentialId);
718
+ return isoBase64URL.fromBuffer(new Uint8Array(candidateId)) === credentialId;
719
+ });
720
+ if (!owns) {
721
+ throw new ApiError({ code: 404, message: 'Passkey not found' });
722
+ }
723
+ const deleted = await this.storage.deletePasskeyCredential(bufferId);
724
+ return [200, { deleted }];
725
+ }
599
726
  async postImpersonation(apiReq) {
600
727
  this.assertAuthReady();
601
728
  const { targetIdentifier, metadata } = this.parseImpersonationRequest(apiReq);
@@ -614,7 +741,8 @@ class AuthModule extends BaseAuthModule {
614
741
  async deleteImpersonation(apiReq) {
615
742
  this.assertAuthReady();
616
743
  const actor = await this.resolveActorContext(apiReq);
617
- const metadata = this.buildImpersonationMetadata((apiReq.req.body ?? {}));
744
+ const query = (apiReq.req.query ?? {});
745
+ const metadata = this.buildImpersonationMetadata(query);
618
746
  metadata.loginType = metadata.loginType ?? 'impersonation-end';
619
747
  const tokens = await this.issueTokens(apiReq, actor.user, metadata);
620
748
  const publicUser = this.storage.filterUser(actor.user);
@@ -686,6 +814,7 @@ class AuthModule extends BaseAuthModule {
686
814
  const state = toStringOrNull(body.state) ?? undefined;
687
815
  const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
688
816
  const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
817
+ const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
689
818
  if (!clientId) {
690
819
  throw new ApiError({ code: 400, message: 'clientId is required' });
691
820
  }
@@ -705,7 +834,7 @@ class AuthModule extends BaseAuthModule {
705
834
  redirectUri,
706
835
  scope: resolvedScope,
707
836
  codeChallenge,
708
- codeChallengeMethod: codeChallengeMethod === 'S256' ? 'S256' : codeChallengeMethod === 'plain' ? 'plain' : undefined,
837
+ codeChallengeMethod: resolvedCodeChallengeMethod,
709
838
  expiresInSeconds: 300
710
839
  });
711
840
  const redirect = new URL(redirectUri);
@@ -716,6 +845,7 @@ class AuthModule extends BaseAuthModule {
716
845
  return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
717
846
  }
718
847
  async postOAuthToken(apiReq) {
848
+ await this.applyRateLimit(apiReq, 'oauth-token');
719
849
  if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
720
850
  throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
721
851
  }
@@ -769,12 +899,15 @@ class AuthModule extends BaseAuthModule {
769
899
  }
770
900
  }
771
901
  else if (record.codeChallengeMethod === 'plain') {
902
+ if (!this.allowInsecurePkcePlain) {
903
+ throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
904
+ }
772
905
  if (codeVerifier !== record.codeChallenge) {
773
906
  throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
774
907
  }
775
908
  }
776
909
  }
777
- else if (!clientSecretProvided && client.clientSecret) {
910
+ else if (!clientSecretProvided && (client.hasSecret ?? Boolean(client.clientSecret))) {
778
911
  throw new ApiError({ code: 400, message: 'Client authentication required when no PKCE challenge present' });
779
912
  }
780
913
  const user = await this.getUserOrThrow(record.userId, 'User not found');
@@ -809,6 +942,7 @@ class AuthModule extends BaseAuthModule {
809
942
  throw new ApiError({ code: 400, message: 'Refresh token issued to another client' });
810
943
  }
811
944
  const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
945
+ await this.storage.deleteToken({ refreshToken });
812
946
  const tokens = await this.issueTokens(apiReq, user, {
813
947
  clientId: client.clientId,
814
948
  scope: stored.scope,
@@ -817,11 +951,7 @@ class AuthModule extends BaseAuthModule {
817
951
  loginType: stored.loginType ?? 'oauth'
818
952
  });
819
953
  this.clearOAuthCookies(apiReq);
820
- const scope = Array.isArray(stored.scope)
821
- ? stored.scope
822
- : typeof stored.scope === 'string'
823
- ? stored.scope.split(/\s+/).filter((entry) => entry.length > 0)
824
- : [];
954
+ const scope = Array.isArray(stored.scope) ? stored.scope : [];
825
955
  return [200, this.buildTokenResponse(tokens, client, scope)];
826
956
  }
827
957
  clearOAuthCookies(apiReq) {
@@ -884,19 +1014,16 @@ class AuthModule extends BaseAuthModule {
884
1014
  if (!client) {
885
1015
  throw new ApiError({ code: 400, message: 'Unknown client_id' });
886
1016
  }
887
- const requiresSecret = !!client.clientSecret;
1017
+ const requiresSecret = client.hasSecret ?? Boolean(client.clientSecret);
888
1018
  if (requiresSecret) {
889
1019
  if (!secretProvided) {
890
1020
  throw new ApiError({ code: 400, message: 'Client authentication is required' });
891
1021
  }
892
- let valid = false;
893
- if (this.storage.verifyClientSecret) {
894
- const verifySecret = this.storage.verifyClientSecret.bind(this.storage);
895
- valid = await verifySecret(client, clientSecret);
896
- }
897
- else {
898
- valid = client.clientSecret === clientSecret;
1022
+ const verifySecret = this.storage.verifyClientSecret;
1023
+ if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
1024
+ throw new ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
899
1025
  }
1026
+ const valid = await verifySecret.call(this.storage, client, clientSecret);
900
1027
  if (!valid) {
901
1028
  throw new ApiError({ code: 401, message: 'Invalid client credentials' });
902
1029
  }
@@ -923,63 +1050,118 @@ class AuthModule extends BaseAuthModule {
923
1050
  const password = toStringOrNull(body.password);
924
1051
  if (login && password) {
925
1052
  const user = await this.storage.getUser(login);
926
- if (!user) {
927
- throw new ApiError({ code: 400, message: 'Invalid credentials', errors: { login: 'Unknown user' } });
928
- }
929
- const hash = this.storage.getUserPasswordHash(user);
930
- const verified = await this.storage.verifyPassword(password, hash);
931
- if (!verified) {
932
- throw new ApiError({
933
- code: 400,
934
- message: 'Invalid credentials',
935
- errors: { password: 'Wrong password' }
936
- });
1053
+ const hash = user ? this.storage.getUserPasswordHash(user) : '';
1054
+ const verified = user ? await this.storage.verifyPassword(password, hash) : false;
1055
+ if (!user || !verified) {
1056
+ throw new ApiError({ code: 400, message: 'Invalid credentials' });
937
1057
  }
938
1058
  return user;
939
1059
  }
940
1060
  throw new ApiError({ code: 401, message: 'Authorization requires user authentication' });
941
1061
  }
942
- defineRoutes() {
943
- const routes = [
944
- {
945
- method: 'post',
946
- path: '/v1/login',
947
- handler: (req) => this.postLogin(req),
948
- auth: { type: 'none', req: 'any' }
949
- },
950
- {
951
- method: 'post',
952
- path: '/v1/refresh',
953
- handler: (req) => this.postRefresh(req),
954
- auth: { type: 'none', req: 'any' }
955
- },
956
- {
957
- method: 'post',
958
- path: '/v1/logout',
959
- handler: (req) => this.postLogout(req),
960
- auth: { type: 'maybe', req: 'any' }
961
- },
962
- {
963
- method: 'post',
964
- path: '/v1/whoami',
965
- handler: (req) => this.postWhoAmI(req),
966
- auth: { type: 'maybe', req: 'any' }
967
- },
968
- {
969
- method: 'post',
970
- path: '/v1/impersonations',
971
- handler: (req) => this.postImpersonation(req),
972
- auth: { type: 'strict', req: 'any' }
973
- },
974
- {
975
- method: 'delete',
976
- path: '/v1/impersonations',
977
- handler: (req) => this.deleteImpersonation(req),
978
- auth: { type: 'strict', req: 'any' }
1062
+ hasPasskeyService() {
1063
+ const storageHints = this.storage;
1064
+ if (storageHints.passkeyService || storageHints.passkeyStore) {
1065
+ return true;
1066
+ }
1067
+ if (storageHints.adapter?.passkeyService || storageHints.adapter?.passkeyStore) {
1068
+ return true;
1069
+ }
1070
+ const serverHints = this.server;
1071
+ return !!serverHints.passkeyServiceAdapter;
1072
+ }
1073
+ hasOAuthStore() {
1074
+ const storageHints = this.storage;
1075
+ if (storageHints.oauthStore) {
1076
+ return true;
1077
+ }
1078
+ if (storageHints.adapter?.oauthStore) {
1079
+ return true;
1080
+ }
1081
+ const serverHints = this.server;
1082
+ return !!serverHints.oauthStoreAdapter;
1083
+ }
1084
+ storageImplements(key) {
1085
+ const candidate = this.storage[key];
1086
+ if (typeof candidate !== 'function') {
1087
+ return false;
1088
+ }
1089
+ const baseImpl = BaseAuthAdapter.prototype[key];
1090
+ return candidate !== baseImpl;
1091
+ }
1092
+ storageImplementsAll(keys) {
1093
+ return keys.every((key) => this.storageImplements(key));
1094
+ }
1095
+ async applyRateLimit(apiReq, endpoint) {
1096
+ if (!this.rateLimitHook) {
1097
+ return;
1098
+ }
1099
+ await this.rateLimitHook({ apiReq, endpoint });
1100
+ }
1101
+ resolvePkceChallengeMethod(value) {
1102
+ if (value === 'S256') {
1103
+ return 'S256';
1104
+ }
1105
+ if (value === 'plain') {
1106
+ if (!this.allowInsecurePkcePlain) {
1107
+ throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
979
1108
  }
980
- ];
981
- const storage = this.storage;
982
- const passkeysSupported = typeof storage.createPasskeyChallenge === 'function' && typeof storage.verifyPasskeyResponse === 'function';
1109
+ return 'plain';
1110
+ }
1111
+ return undefined;
1112
+ }
1113
+ defineRoutes() {
1114
+ const routes = [];
1115
+ const coreAuthSupported = this.storageImplementsAll([
1116
+ 'getUser',
1117
+ 'getUserPasswordHash',
1118
+ 'getUserId',
1119
+ 'verifyPassword',
1120
+ 'filterUser',
1121
+ 'storeToken',
1122
+ 'getToken',
1123
+ 'deleteToken'
1124
+ ]);
1125
+ if (!coreAuthSupported) {
1126
+ return routes;
1127
+ }
1128
+ routes.push({
1129
+ method: 'post',
1130
+ path: '/v1/login',
1131
+ handler: (req) => this.postLogin(req),
1132
+ auth: { type: 'none', req: 'any' }
1133
+ }, {
1134
+ method: 'post',
1135
+ path: '/v1/refresh',
1136
+ handler: (req) => this.postRefresh(req),
1137
+ auth: { type: 'none', req: 'any' }
1138
+ }, {
1139
+ method: 'post',
1140
+ path: '/v1/logout',
1141
+ handler: (req) => this.postLogout(req),
1142
+ auth: { type: 'maybe', req: 'any' }
1143
+ }, {
1144
+ method: 'post',
1145
+ path: '/v1/whoami',
1146
+ handler: (req) => this.postWhoAmI(req),
1147
+ auth: { type: 'maybe', req: 'any' }
1148
+ }, {
1149
+ method: 'post',
1150
+ path: '/v1/impersonations',
1151
+ handler: (req) => this.postImpersonation(req),
1152
+ auth: { type: 'strict', req: 'any' }
1153
+ }, {
1154
+ method: 'delete',
1155
+ path: '/v1/impersonations',
1156
+ handler: (req) => this.deleteImpersonation(req),
1157
+ auth: { type: 'strict', req: 'any' }
1158
+ });
1159
+ const passkeysSupported = this.hasPasskeyService() &&
1160
+ this.storageImplements('createPasskeyChallenge') &&
1161
+ this.storageImplements('verifyPasskeyResponse');
1162
+ const passkeyCredentialsSupported = passkeysSupported &&
1163
+ this.storageImplements('listUserCredentials') &&
1164
+ this.storageImplements('deletePasskeyCredential');
983
1165
  if (passkeysSupported) {
984
1166
  routes.push({
985
1167
  method: 'post',
@@ -992,6 +1174,19 @@ class AuthModule extends BaseAuthModule {
992
1174
  handler: (req) => this.postPasskeyVerify(req),
993
1175
  auth: { type: 'none', req: 'any' }
994
1176
  });
1177
+ if (passkeyCredentialsSupported) {
1178
+ routes.push({
1179
+ method: 'get',
1180
+ path: '/v1/passkeys',
1181
+ handler: (req) => this.getPasskeys(req),
1182
+ auth: { type: 'strict', req: 'any' }
1183
+ }, {
1184
+ method: 'delete',
1185
+ path: '/v1/passkeys/:credentialId',
1186
+ handler: (req) => this.deletePasskey(req),
1187
+ auth: { type: 'strict', req: 'any' }
1188
+ });
1189
+ }
995
1190
  }
996
1191
  const externalOAuthSupported = typeof this.server.initiateOAuth === 'function' && typeof this.server.completeOAuth === 'function';
997
1192
  if (externalOAuthSupported) {
@@ -1007,9 +1202,10 @@ class AuthModule extends BaseAuthModule {
1007
1202
  auth: { type: 'none', req: 'any' }
1008
1203
  });
1009
1204
  }
1010
- const oauthStorageSupported = typeof storage.getClient === 'function' &&
1011
- typeof storage.createAuthCode === 'function' &&
1012
- typeof storage.consumeAuthCode === 'function';
1205
+ const oauthStorageSupported = this.hasOAuthStore() &&
1206
+ this.storageImplements('getClient') &&
1207
+ this.storageImplements('createAuthCode') &&
1208
+ this.storageImplements('consumeAuthCode');
1013
1209
  if (oauthStorageSupported) {
1014
1210
  routes.push({
1015
1211
  method: 'post',