@technomoron/api-server-base 2.0.0-beta.17 → 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 (63) hide show
  1. package/README.txt +48 -35
  2. package/dist/cjs/api-module.cjs +9 -0
  3. package/dist/cjs/api-module.d.ts +4 -2
  4. package/dist/cjs/api-server-base.cjs +178 -57
  5. package/dist/cjs/api-server-base.d.ts +31 -2
  6. package/dist/cjs/auth-api/auth-module.d.ts +12 -1
  7. package/dist/cjs/auth-api/auth-module.js +77 -35
  8. package/dist/cjs/auth-api/mem-auth-store.js +2 -23
  9. package/dist/cjs/auth-api/sql-auth-store.js +4 -31
  10. package/dist/cjs/auth-api/user-id.d.ts +4 -0
  11. package/dist/cjs/auth-api/user-id.js +31 -0
  12. package/dist/cjs/auth-cookie-options.d.ts +11 -0
  13. package/dist/cjs/auth-cookie-options.js +57 -0
  14. package/dist/cjs/oauth/memory.js +4 -10
  15. package/dist/cjs/oauth/models.js +4 -15
  16. package/dist/cjs/oauth/sequelize.js +8 -23
  17. package/dist/cjs/passkey/config.d.ts +2 -0
  18. package/dist/cjs/passkey/config.js +26 -0
  19. package/dist/cjs/passkey/memory.js +2 -9
  20. package/dist/cjs/passkey/models.js +4 -15
  21. package/dist/cjs/passkey/sequelize.js +6 -22
  22. package/dist/cjs/passkey/service.js +1 -1
  23. package/dist/cjs/passkey/types.d.ts +5 -0
  24. package/dist/cjs/sequelize-utils.d.ts +3 -0
  25. package/dist/cjs/sequelize-utils.js +17 -0
  26. package/dist/cjs/token/memory.d.ts +4 -0
  27. package/dist/cjs/token/memory.js +90 -25
  28. package/dist/cjs/token/sequelize.js +16 -22
  29. package/dist/cjs/token/types.d.ts +7 -0
  30. package/dist/cjs/user/memory.js +2 -9
  31. package/dist/cjs/user/sequelize.js +6 -22
  32. package/dist/esm/api-module.d.ts +4 -2
  33. package/dist/esm/api-module.js +9 -0
  34. package/dist/esm/api-server-base.d.ts +31 -2
  35. package/dist/esm/api-server-base.js +178 -57
  36. package/dist/esm/auth-api/auth-module.d.ts +12 -1
  37. package/dist/esm/auth-api/auth-module.js +77 -35
  38. package/dist/esm/auth-api/mem-auth-store.js +1 -22
  39. package/dist/esm/auth-api/sql-auth-store.js +2 -29
  40. package/dist/esm/auth-api/user-id.d.ts +4 -0
  41. package/dist/esm/auth-api/user-id.js +26 -0
  42. package/dist/esm/auth-cookie-options.d.ts +11 -0
  43. package/dist/esm/auth-cookie-options.js +54 -0
  44. package/dist/esm/oauth/memory.js +4 -10
  45. package/dist/esm/oauth/models.js +1 -12
  46. package/dist/esm/oauth/sequelize.js +5 -20
  47. package/dist/esm/passkey/config.d.ts +2 -0
  48. package/dist/esm/passkey/config.js +23 -0
  49. package/dist/esm/passkey/memory.js +2 -9
  50. package/dist/esm/passkey/models.js +1 -12
  51. package/dist/esm/passkey/sequelize.js +3 -19
  52. package/dist/esm/passkey/service.js +1 -1
  53. package/dist/esm/passkey/types.d.ts +5 -0
  54. package/dist/esm/sequelize-utils.d.ts +3 -0
  55. package/dist/esm/sequelize-utils.js +12 -0
  56. package/dist/esm/token/memory.d.ts +4 -0
  57. package/dist/esm/token/memory.js +90 -25
  58. package/dist/esm/token/sequelize.js +12 -18
  59. package/dist/esm/token/types.d.ts +7 -0
  60. package/dist/esm/user/memory.js +2 -9
  61. package/dist/esm/user/sequelize.js +3 -19
  62. package/docs/swagger/openapi.json +11 -145
  63. package/package.json +12 -12
@@ -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,29 +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 isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
242
- const isLocalhost = origin.includes('localhost');
243
- const options = {
244
- httpOnly: true,
245
- secure: true,
246
- sameSite: 'strict',
247
- domain: conf.cookieDomain || undefined,
248
- path: '/',
249
- maxAge: undefined
250
- };
251
- if (conf.devMode) {
252
- options.secure = isHttps;
253
- options.httpOnly = false;
254
- options.sameSite = 'lax';
255
- if (isLocalhost) {
256
- options.domain = undefined;
257
- }
258
- }
259
- return options;
246
+ return buildAuthCookieOptions(this.server.config, apiReq.req);
260
247
  }
261
248
  setJwtCookies(apiReq, tokens, preferences = {}) {
262
249
  const conf = this.server.config;
@@ -283,7 +270,10 @@ class AuthModule extends BaseAuthModule {
283
270
  async issueTokens(apiReq, user, metadata = {}) {
284
271
  const conf = this.server.config;
285
272
  const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
286
- const payload = this.buildTokenPayload(user, enrichedMetadata);
273
+ const payload = {
274
+ ...this.buildTokenPayload(user, enrichedMetadata),
275
+ jti: randomUUID()
276
+ };
287
277
  const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
288
278
  if (!access.success || !access.token) {
289
279
  throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
@@ -453,6 +443,7 @@ class AuthModule extends BaseAuthModule {
453
443
  return undefined;
454
444
  }
455
445
  async postLogin(apiReq) {
446
+ await this.applyRateLimit(apiReq, 'login');
456
447
  this.assertAuthReady();
457
448
  const { login, password, ...metadata } = this.parseLoginBody(apiReq);
458
449
  const user = await this.storage.getUser(login);
@@ -550,10 +541,39 @@ class AuthModule extends BaseAuthModule {
550
541
  apiReq.req.cookies[conf.accessCookie].trim().length > 0);
551
542
  const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
552
543
  if (shouldRefresh) {
553
- const access = this.server.jwtSign(this.buildTokenPayload(user, stored), conf.accessSecret, conf.accessExpiry);
544
+ const updateToken = this.storage.updateToken;
545
+ if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
546
+ throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
547
+ }
548
+ // Sign a new access token without embedding stored token secrets into the JWT payload.
549
+ const metadata = {
550
+ ruid: stored.ruid,
551
+ domain: stored.domain,
552
+ fingerprint: stored.fingerprint,
553
+ label: stored.label,
554
+ clientId: stored.clientId,
555
+ scope: stored.scope,
556
+ browser: stored.browser,
557
+ device: stored.device,
558
+ ip: stored.ip,
559
+ os: stored.os,
560
+ loginType: stored.loginType,
561
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
562
+ sessionCookie: stored.sessionCookie
563
+ };
564
+ const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
565
+ const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
554
566
  if (!access.success || !access.token) {
555
567
  throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
556
568
  }
569
+ const updated = await updateToken.call(this.storage, {
570
+ refreshToken,
571
+ accessToken: access.token,
572
+ lastSeenAt: new Date()
573
+ });
574
+ if (!updated) {
575
+ throw new ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
576
+ }
557
577
  const cookiePrefs = this.mergeSessionPreferences({
558
578
  sessionCookie: stored.sessionCookie,
559
579
  refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
@@ -587,6 +607,7 @@ class AuthModule extends BaseAuthModule {
587
607
  ];
588
608
  }
589
609
  async postPasskeyChallenge(apiReq) {
610
+ await this.applyRateLimit(apiReq, 'passkey-challenge');
590
611
  if (typeof this.storage.createPasskeyChallenge !== 'function') {
591
612
  throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
592
613
  }
@@ -724,7 +745,8 @@ class AuthModule extends BaseAuthModule {
724
745
  async deleteImpersonation(apiReq) {
725
746
  this.assertAuthReady();
726
747
  const actor = await this.resolveActorContext(apiReq);
727
- const metadata = this.buildImpersonationMetadata((apiReq.req.body ?? {}));
748
+ const query = (apiReq.req.query ?? {});
749
+ const metadata = this.buildImpersonationMetadata(query);
728
750
  metadata.loginType = metadata.loginType ?? 'impersonation-end';
729
751
  const tokens = await this.issueTokens(apiReq, actor.user, metadata);
730
752
  const publicUser = this.storage.filterUser(actor.user);
@@ -796,6 +818,7 @@ class AuthModule extends BaseAuthModule {
796
818
  const state = toStringOrNull(body.state) ?? undefined;
797
819
  const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
798
820
  const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
821
+ const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
799
822
  if (!clientId) {
800
823
  throw new ApiError({ code: 400, message: 'clientId is required' });
801
824
  }
@@ -815,7 +838,7 @@ class AuthModule extends BaseAuthModule {
815
838
  redirectUri,
816
839
  scope: resolvedScope,
817
840
  codeChallenge,
818
- codeChallengeMethod: codeChallengeMethod === 'S256' ? 'S256' : codeChallengeMethod === 'plain' ? 'plain' : undefined,
841
+ codeChallengeMethod: resolvedCodeChallengeMethod,
819
842
  expiresInSeconds: 300
820
843
  });
821
844
  const redirect = new URL(redirectUri);
@@ -826,6 +849,7 @@ class AuthModule extends BaseAuthModule {
826
849
  return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
827
850
  }
828
851
  async postOAuthToken(apiReq) {
852
+ await this.applyRateLimit(apiReq, 'oauth-token');
829
853
  if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
830
854
  throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
831
855
  }
@@ -879,6 +903,9 @@ class AuthModule extends BaseAuthModule {
879
903
  }
880
904
  }
881
905
  else if (record.codeChallengeMethod === 'plain') {
906
+ if (!this.allowInsecurePkcePlain) {
907
+ throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
908
+ }
882
909
  if (codeVerifier !== record.codeChallenge) {
883
910
  throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
884
911
  }
@@ -999,14 +1026,11 @@ class AuthModule extends BaseAuthModule {
999
1026
  if (!secretProvided) {
1000
1027
  throw new ApiError({ code: 400, message: 'Client authentication is required' });
1001
1028
  }
1002
- let valid = false;
1003
- if (this.storage.verifyClientSecret) {
1004
- const verifySecret = this.storage.verifyClientSecret.bind(this.storage);
1005
- valid = await verifySecret(client, clientSecret);
1006
- }
1007
- else {
1008
- valid = client.clientSecret === clientSecret;
1029
+ const verifySecret = this.storage.verifyClientSecret;
1030
+ if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
1031
+ throw new ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
1009
1032
  }
1033
+ const valid = await verifySecret.call(this.storage, client, clientSecret);
1010
1034
  if (!valid) {
1011
1035
  throw new ApiError({ code: 401, message: 'Invalid client credentials' });
1012
1036
  }
@@ -1082,6 +1106,24 @@ class AuthModule extends BaseAuthModule {
1082
1106
  storageImplementsAll(keys) {
1083
1107
  return keys.every((key) => this.storageImplements(key));
1084
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
+ }
1085
1127
  defineRoutes() {
1086
1128
  const routes = [];
1087
1129
  const coreAuthSupported = this.storageImplementsAll([
@@ -1154,7 +1196,7 @@ class AuthModule extends BaseAuthModule {
1154
1196
  auth: { type: 'strict', req: 'any' }
1155
1197
  }, {
1156
1198
  method: 'delete',
1157
- path: '/v1/passkeys/:credentialId?',
1199
+ path: '/v1/passkeys/:credentialId',
1158
1200
  handler: (req) => this.deletePasskey(req),
1159
1201
  auth: { type: 'strict', req: 'any' }
1160
1202
  });
@@ -1,30 +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
- };
27
- }
28
7
  export class MemAuthStore {
29
8
  constructor(params = {}) {
30
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,21 +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
- };
43
- }
44
17
  export class SqlAuthStore {
45
18
  constructor(params) {
46
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;
@@ -0,0 +1,54 @@
1
+ function firstHeaderValue(value) {
2
+ if (typeof value === 'string') {
3
+ return value;
4
+ }
5
+ if (Array.isArray(value)) {
6
+ return value[0] ?? '';
7
+ }
8
+ return '';
9
+ }
10
+ function resolveOriginHostname(origin) {
11
+ try {
12
+ const url = new URL(origin);
13
+ const hostname = url.hostname.trim().toLowerCase();
14
+ return hostname.length > 0 ? hostname : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function isLocalhostOrigin(origin) {
21
+ const hostname = resolveOriginHostname(origin);
22
+ if (!hostname) {
23
+ return false;
24
+ }
25
+ return hostname === 'localhost' || hostname.endsWith('.localhost');
26
+ }
27
+ export function buildAuthCookieOptions(config, req) {
28
+ const forwardedProto = firstHeaderValue(req.headers['x-forwarded-proto']).split(',')[0].trim().toLowerCase();
29
+ const isHttps = forwardedProto === 'https' || req.protocol === 'https';
30
+ const origin = firstHeaderValue(req.headers.origin ?? req.headers.referer);
31
+ const secure = config.cookieSecure === true ? true : config.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
32
+ let sameSite = config.cookieSameSite ?? 'lax';
33
+ if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
34
+ sameSite = 'lax';
35
+ }
36
+ let resolvedSecure = secure;
37
+ if (sameSite === 'none' && resolvedSecure !== true) {
38
+ // Modern browsers reject SameSite=None cookies unless Secure is set.
39
+ resolvedSecure = true;
40
+ }
41
+ const options = {
42
+ httpOnly: config.cookieHttpOnly ?? true,
43
+ secure: resolvedSecure,
44
+ sameSite,
45
+ domain: config.cookieDomain || undefined,
46
+ path: config.cookiePath || '/',
47
+ maxAge: undefined
48
+ };
49
+ if (config.devMode && isLocalhostOrigin(origin)) {
50
+ // Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
51
+ options.domain = undefined;
52
+ }
53
+ return options;
54
+ }
@@ -1,4 +1,5 @@
1
1
  import bcrypt from 'bcryptjs';
2
+ import { normalizeNumericUserId } from '../auth-api/user-id.js';
2
3
  import { OAuthStore } from './base.js';
3
4
  function cloneClient(client) {
4
5
  if (!client) {
@@ -6,7 +7,8 @@ function cloneClient(client) {
6
7
  }
7
8
  return {
8
9
  clientId: client.clientId,
9
- clientSecret: client.clientSecret,
10
+ // clientSecret is stored hashed; do not return the hash.
11
+ clientSecret: client.clientSecret ? '__stored__' : undefined,
10
12
  name: client.name,
11
13
  redirectUris: [...client.redirectUris],
12
14
  scope: client.scope ? [...client.scope] : undefined,
@@ -22,15 +24,7 @@ function cloneCode(code) {
22
24
  metadata: code.metadata ? { ...code.metadata } : undefined
23
25
  };
24
26
  }
25
- function normalizeUserId(identifier) {
26
- if (typeof identifier === 'number' && Number.isFinite(identifier)) {
27
- return identifier;
28
- }
29
- if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
30
- return Number(identifier);
31
- }
32
- throw new Error(`Unable to normalise user identifier: ${identifier}`);
33
- }
27
+ const normalizeUserId = normalizeNumericUserId;
34
28
  export class MemoryOAuthStore extends OAuthStore {
35
29
  constructor(options = {}) {
36
30
  super();
@@ -1,16 +1,5 @@
1
1
  import { DataTypes, Model } from 'sequelize';
2
- const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
3
- function normalizeTablePrefix(prefix) {
4
- if (!prefix) {
5
- return undefined;
6
- }
7
- const trimmed = prefix.trim();
8
- return trimmed.length > 0 ? trimmed : undefined;
9
- }
10
- function applyTablePrefix(prefix, tableName) {
11
- const normalized = normalizeTablePrefix(prefix);
12
- return normalized ? `${normalized}${tableName}` : tableName;
13
- }
2
+ import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
14
3
  function integerIdType(sequelize) {
15
4
  return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
16
5
  }
@@ -1,18 +1,8 @@
1
1
  import bcrypt from 'bcryptjs';
2
2
  import { DataTypes, Model } from 'sequelize';
3
+ import { normalizeNumericUserId } from '../auth-api/user-id.js';
4
+ import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
3
5
  import { OAuthStore } from './base.js';
4
- const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
5
- function normalizeTablePrefix(prefix) {
6
- if (!prefix) {
7
- return undefined;
8
- }
9
- const trimmed = prefix.trim();
10
- return trimmed.length > 0 ? trimmed : undefined;
11
- }
12
- function applyTablePrefix(prefix, tableName) {
13
- const normalized = normalizeTablePrefix(prefix);
14
- return normalized ? `${normalized}${tableName}` : tableName;
15
- }
16
6
  function integerIdType(sequelize) {
17
7
  return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
18
8
  }
@@ -101,13 +91,7 @@ function parseMetadata(raw) {
101
91
  return undefined;
102
92
  }
103
93
  function normalizeUserId(identifier) {
104
- if (typeof identifier === 'number' && Number.isFinite(identifier)) {
105
- return identifier;
106
- }
107
- if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
108
- return Number(identifier);
109
- }
110
- throw new Error(`Unable to normalise user identifier: ${identifier}`);
94
+ return normalizeNumericUserId(identifier);
111
95
  }
112
96
  export class SequelizeOAuthStore extends OAuthStore {
113
97
  constructor(options) {
@@ -194,7 +178,8 @@ export class SequelizeOAuthStore extends OAuthStore {
194
178
  toOAuthClient(model) {
195
179
  return {
196
180
  clientId: model.client_id,
197
- clientSecret: model.client_secret,
181
+ // client_secret is stored hashed; do not return the hash.
182
+ clientSecret: model.client_secret ? '__stored__' : undefined,
198
183
  name: model.name ?? undefined,
199
184
  redirectUris: decodeStringArray(model.redirect_uris),
200
185
  scope: decodeStringArray(model.scope),
@@ -0,0 +1,2 @@
1
+ import type { PasskeyServiceConfig } from './types.js';
2
+ export declare function normalizePasskeyConfig(config?: Partial<PasskeyServiceConfig>): PasskeyServiceConfig;
@@ -0,0 +1,23 @@
1
+ const DEFAULT_PASSKEY_CONFIG = {
2
+ rpId: 'localhost',
3
+ rpName: 'API Server',
4
+ origins: ['http://localhost:5173'],
5
+ timeoutMs: 5 * 60 * 1000,
6
+ userVerification: 'preferred'
7
+ };
8
+ function isOriginString(origin) {
9
+ return typeof origin === 'string' && origin.trim().length > 0;
10
+ }
11
+ export function normalizePasskeyConfig(config = {}) {
12
+ const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
13
+ return {
14
+ rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
15
+ rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
16
+ origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
17
+ timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
18
+ ? config.timeoutMs
19
+ : DEFAULT_PASSKEY_CONFIG.timeoutMs,
20
+ userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
21
+ debug: Boolean(config.debug)
22
+ };
23
+ }
@@ -1,16 +1,9 @@
1
+ import { normalizeComparableUserId } from '../auth-api/user-id.js';
1
2
  import { PasskeyStore } from './base.js';
2
3
  function encodeCredentialId(value) {
3
4
  return Buffer.isBuffer(value) ? value.toString('base64') : value;
4
5
  }
5
- function normalizeUserId(identifier) {
6
- if (typeof identifier === 'number' && Number.isFinite(identifier)) {
7
- return identifier;
8
- }
9
- if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
10
- return Number(identifier);
11
- }
12
- return identifier;
13
- }
6
+ const normalizeUserId = normalizeComparableUserId;
14
7
  function cloneCredential(record) {
15
8
  return {
16
9
  ...record,
@@ -1,16 +1,5 @@
1
1
  import { DataTypes, Model } from 'sequelize';
2
- const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
3
- function normalizeTablePrefix(prefix) {
4
- if (!prefix) {
5
- return undefined;
6
- }
7
- const trimmed = prefix.trim();
8
- return trimmed.length > 0 ? trimmed : undefined;
9
- }
10
- function applyTablePrefix(prefix, tableName) {
11
- const normalized = normalizeTablePrefix(prefix);
12
- return normalized ? `${normalized}${tableName}` : tableName;
13
- }
2
+ import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
14
3
  function integerIdType(sequelize) {
15
4
  return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
16
5
  }
@@ -1,17 +1,7 @@
1
1
  import { DataTypes, Model, Op } from 'sequelize';
2
+ import { normalizeNumericUserId } from '../auth-api/user-id.js';
3
+ import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
2
4
  import { PasskeyStore } from './base.js';
3
- const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
4
- function normalizeTablePrefix(prefix) {
5
- if (!prefix) {
6
- return undefined;
7
- }
8
- const trimmed = prefix.trim();
9
- return trimmed.length > 0 ? trimmed : undefined;
10
- }
11
- function applyTablePrefix(prefix, tableName) {
12
- const normalized = normalizeTablePrefix(prefix);
13
- return normalized ? `${normalized}${tableName}` : tableName;
14
- }
15
5
  function integerIdType(sequelize) {
16
6
  return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
17
7
  }
@@ -19,13 +9,7 @@ function encodeCredentialId(value) {
19
9
  return Buffer.isBuffer(value) ? value.toString('base64') : value;
20
10
  }
21
11
  function normalizeUserId(identifier) {
22
- if (typeof identifier === 'number' && Number.isFinite(identifier)) {
23
- return identifier;
24
- }
25
- if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
26
- return Number(identifier);
27
- }
28
- throw new Error(`Unable to normalise user identifier: ${identifier}`);
12
+ return normalizeNumericUserId(identifier);
29
13
  }
30
14
  class PasskeyCredentialModel extends Model {
31
15
  }
@@ -232,7 +232,7 @@ export class PasskeyService {
232
232
  }
233
233
  }
234
234
  const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
235
- if (this.logger?.warn) {
235
+ if (this.config.debug && this.logger?.warn) {
236
236
  const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
237
237
  const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
238
238
  this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
@@ -8,6 +8,11 @@ export interface PasskeyServiceConfig {
8
8
  origins: string[];
9
9
  timeoutMs: number;
10
10
  userVerification?: 'preferred' | 'required' | 'discouraged';
11
+ /**
12
+ * When enabled, PasskeyService emits additional diagnostic logs during registration/authentication.
13
+ * Defaults to false.
14
+ */
15
+ debug?: boolean;
11
16
  }
12
17
  export interface PasskeyChallengeRecord {
13
18
  challenge: string;
@@ -0,0 +1,3 @@
1
+ export declare const DIALECTS_SUPPORTING_UNSIGNED: Set<string>;
2
+ export declare function normalizeTablePrefix(prefix?: string): string | undefined;
3
+ export declare function applyTablePrefix(prefix: string | undefined, tableName: string): string;
@@ -0,0 +1,12 @@
1
+ export const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
2
+ export function normalizeTablePrefix(prefix) {
3
+ if (!prefix) {
4
+ return undefined;
5
+ }
6
+ const trimmed = prefix.trim();
7
+ return trimmed.length > 0 ? trimmed : undefined;
8
+ }
9
+ export function applyTablePrefix(prefix, tableName) {
10
+ const normalized = normalizeTablePrefix(prefix);
11
+ return normalized ? `${normalized}${tableName}` : tableName;
12
+ }
@@ -2,6 +2,10 @@ import { TokenStore } from './base.js';
2
2
  import type { Token } from './types.js';
3
3
  export declare class MemoryTokenStore extends TokenStore {
4
4
  private readonly tokens;
5
+ private readonly tokensByUser;
6
+ private indexToken;
7
+ private unindexToken;
8
+ private removeByRefreshToken;
5
9
  save(record: Token): Promise<void>;
6
10
  get(query: Partial<Token>, opts?: {
7
11
  includeExpired?: boolean;