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

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 +81 -32
@@ -1,17 +1,17 @@
1
1
  import { TokenStore } from './base.js';
2
2
  function comparableUserId(value) {
3
- if (value === undefined || value === null) {
3
+ if (value === undefined) {
4
4
  return undefined;
5
5
  }
6
6
  return String(value);
7
7
  }
8
8
  function cloneToken(record) {
9
- // this.normalizeToken is not available in static context; caller passes through instance.
10
- // cloning handled via store instance methods.
11
- const normalized = record;
12
9
  return {
13
- ...normalized,
14
- scope: normalized.scope ? [...normalized.scope] : undefined
10
+ ...record,
11
+ scope: record.scope ? [...record.scope] : undefined,
12
+ expires: record.expires ? new Date(record.expires) : undefined,
13
+ issuedAt: record.issuedAt ? new Date(record.issuedAt) : undefined,
14
+ lastSeenAt: record.lastSeenAt ? new Date(record.lastSeenAt) : undefined
15
15
  };
16
16
  }
17
17
  function matchesQuery(record, query, includeExpired) {
@@ -45,17 +45,63 @@ function matchesQuery(record, query, includeExpired) {
45
45
  return true;
46
46
  }
47
47
  export class MemoryTokenStore extends TokenStore {
48
- constructor() {
49
- super(...arguments);
50
- this.tokens = [];
48
+ constructor(options = {}) {
49
+ super();
50
+ this.tokens = new Map();
51
+ this.tokensByUser = new Map();
52
+ this.maxTokens =
53
+ typeof options.maxTokens === 'number' && Number.isFinite(options.maxTokens) && options.maxTokens > 0
54
+ ? Math.floor(options.maxTokens)
55
+ : undefined;
56
+ }
57
+ indexToken(token) {
58
+ const userId = comparableUserId(token.userId);
59
+ if (!userId) {
60
+ return;
61
+ }
62
+ let userTokens = this.tokensByUser.get(userId);
63
+ if (!userTokens) {
64
+ userTokens = new Set();
65
+ this.tokensByUser.set(userId, userTokens);
66
+ }
67
+ userTokens.add(token.refreshToken);
68
+ }
69
+ unindexToken(token) {
70
+ const userId = comparableUserId(token.userId);
71
+ if (!userId) {
72
+ return;
73
+ }
74
+ const userTokens = this.tokensByUser.get(userId);
75
+ if (!userTokens) {
76
+ return;
77
+ }
78
+ userTokens.delete(token.refreshToken);
79
+ if (userTokens.size === 0) {
80
+ this.tokensByUser.delete(userId);
81
+ }
82
+ }
83
+ removeByRefreshToken(refreshToken) {
84
+ const existing = this.tokens.get(refreshToken);
85
+ if (!existing) {
86
+ return;
87
+ }
88
+ this.unindexToken(existing);
89
+ this.tokens.delete(refreshToken);
51
90
  }
52
91
  async save(record) {
53
92
  const stored = this.normalizeToken(record);
54
93
  const normalizedUserId = comparableUserId(stored.userId);
94
+ if (!normalizedUserId) {
95
+ throw new Error('userId is required');
96
+ }
55
97
  const domainProvided = record.domain !== undefined;
56
98
  const fingerprintProvided = record.fingerprint !== undefined;
57
- for (let index = this.tokens.length - 1; index >= 0; index -= 1) {
58
- const existing = this.tokens[index];
99
+ const userRefreshTokens = [...(this.tokensByUser.get(normalizedUserId) ?? [])];
100
+ for (const refreshToken of userRefreshTokens) {
101
+ const existing = this.tokens.get(refreshToken);
102
+ if (!existing) {
103
+ continue;
104
+ }
59
105
  if (comparableUserId(existing.userId) !== normalizedUserId) {
60
106
  continue;
61
107
  }
@@ -68,44 +114,52 @@ export class MemoryTokenStore extends TokenStore {
68
114
  if (fingerprintProvided && existing.fingerprint !== stored.fingerprint) {
69
115
  continue;
70
116
  }
71
- this.tokens.splice(index, 1);
117
+ this.removeByRefreshToken(existing.refreshToken);
72
118
  }
73
- this.tokens.push(stored);
119
+ this.removeByRefreshToken(stored.refreshToken);
120
+ this.tokens.set(stored.refreshToken, stored);
121
+ this.indexToken(stored);
122
+ this.enforceCapacity();
74
123
  }
75
124
  async get(query, opts) {
76
125
  if (!query.refreshToken && !query.accessToken && query.userId === undefined) {
77
126
  throw new Error('At least one token lookup field must be provided');
78
127
  }
79
128
  const includeExpired = opts?.includeExpired ?? false;
80
- const record = this.tokens.find((token) => matchesQuery(token, query, includeExpired));
81
- return record ? cloneToken(record) : null;
129
+ if (query.refreshToken) {
130
+ const record = this.tokens.get(query.refreshToken);
131
+ return record && matchesQuery(record, query, includeExpired) ? cloneToken(record) : null;
132
+ }
133
+ for (const token of this.tokens.values()) {
134
+ if (matchesQuery(token, query, includeExpired)) {
135
+ return cloneToken(token);
136
+ }
137
+ }
138
+ return null;
82
139
  }
83
140
  async delete(query) {
84
141
  if (!query.refreshToken && !query.accessToken && query.userId === undefined && !query.clientId) {
85
142
  return 0;
86
143
  }
87
144
  let removed = 0;
88
- for (let index = this.tokens.length - 1; index >= 0; index -= 1) {
89
- if (matchesQuery(this.tokens[index], query, true)) {
90
- this.tokens.splice(index, 1);
145
+ const refreshTokens = [...this.tokens.keys()];
146
+ for (const refreshToken of refreshTokens) {
147
+ const token = this.tokens.get(refreshToken);
148
+ if (token && matchesQuery(token, query, true)) {
149
+ this.removeByRefreshToken(refreshToken);
91
150
  removed += 1;
92
151
  }
93
152
  }
94
153
  return removed;
95
154
  }
96
155
  async update(params) {
97
- const token = this.tokens.find((record) => {
98
- if (record.refreshToken !== params.refreshToken) {
99
- return false;
100
- }
101
- if (params.clientId && record.clientId !== params.clientId) {
102
- return false;
103
- }
104
- return true;
105
- });
156
+ const token = this.tokens.get(params.refreshToken);
106
157
  if (!token) {
107
158
  return false;
108
159
  }
160
+ if (params.clientId && token.clientId !== params.clientId) {
161
+ return false;
162
+ }
109
163
  const merged = { ...token };
110
164
  const maybeAssign = (key) => {
111
165
  const value = params[key];
@@ -113,8 +167,12 @@ export class MemoryTokenStore extends TokenStore {
113
167
  merged[key] = value;
114
168
  }
115
169
  };
116
- maybeAssign('accessToken');
117
- maybeAssign('expires');
170
+ if (params.accessToken !== undefined && params.accessToken !== null) {
171
+ merged.accessToken = params.accessToken;
172
+ }
173
+ if (params.expires !== undefined && params.expires !== null) {
174
+ merged.expires = params.expires;
175
+ }
118
176
  maybeAssign('scope');
119
177
  maybeAssign('label');
120
178
  maybeAssign('domain');
@@ -125,16 +183,36 @@ export class MemoryTokenStore extends TokenStore {
125
183
  maybeAssign('os');
126
184
  maybeAssign('refreshTtlSeconds');
127
185
  maybeAssign('loginType');
128
- maybeAssign('issuedAt');
129
- maybeAssign('lastSeenAt');
186
+ if (params.issuedAt !== undefined && params.issuedAt !== null) {
187
+ merged.issuedAt = params.issuedAt;
188
+ }
189
+ if (params.lastSeenAt !== undefined && params.lastSeenAt !== null) {
190
+ merged.lastSeenAt = params.lastSeenAt;
191
+ }
130
192
  maybeAssign('sessionCookie');
131
193
  const normalized = this.normalizeToken(merged);
194
+ const previousUserId = token.userId;
195
+ const previousRefreshToken = token.refreshToken;
196
+ const userChanged = comparableUserId(previousUserId) !== comparableUserId(normalized.userId);
132
197
  Object.assign(token, normalized);
198
+ if (userChanged || previousRefreshToken !== token.refreshToken) {
199
+ this.unindexToken({ ...token, userId: previousUserId, refreshToken: previousRefreshToken });
200
+ this.indexToken(token);
201
+ if (previousRefreshToken !== token.refreshToken) {
202
+ this.tokens.delete(previousRefreshToken);
203
+ this.tokens.set(token.refreshToken, token);
204
+ }
205
+ }
133
206
  return true;
134
207
  }
135
208
  async list(userId, opts = {}) {
136
209
  const includeExpired = opts.includeExpired ?? false;
137
- const filtered = this.tokens.filter((token) => matchesQuery(token, { userId: comparableUserId(userId) }, includeExpired));
210
+ const normalizedUserId = comparableUserId(userId);
211
+ const userRefreshTokens = normalizedUserId ? [...(this.tokensByUser.get(normalizedUserId) ?? [])] : [];
212
+ const filtered = userRefreshTokens
213
+ .map((refreshToken) => this.tokens.get(refreshToken))
214
+ .filter((token) => Boolean(token))
215
+ .filter((token) => matchesQuery(token, { userId: normalizedUserId }, includeExpired));
138
216
  const offset = opts.offset ?? 0;
139
217
  const limit = opts.limit ?? filtered.length;
140
218
  return filtered.slice(offset, offset + limit).map(cloneToken);
@@ -142,4 +220,16 @@ export class MemoryTokenStore extends TokenStore {
142
220
  async close() {
143
221
  return;
144
222
  }
223
+ enforceCapacity() {
224
+ if (!this.maxTokens) {
225
+ return;
226
+ }
227
+ while (this.tokens.size > this.maxTokens) {
228
+ const oldestRefresh = this.tokens.keys().next().value;
229
+ if (!oldestRefresh) {
230
+ return;
231
+ }
232
+ this.removeByRefreshToken(oldestRefresh);
233
+ }
234
+ }
145
235
  }
@@ -27,8 +27,11 @@ export type TokenAttributes = InferAttributes<TokenModel>;
27
27
  export type TokenCreationAttributes = InferCreationAttributes<TokenModel>;
28
28
  export interface SequelizeTokenStoreOptions {
29
29
  sequelize: Sequelize;
30
+ tablePrefix?: string;
30
31
  tokenModel?: ModelStatic<TokenModel>;
31
- tokenModelFactory?: (sequelize: Sequelize) => ModelStatic<TokenModel>;
32
+ tokenModelFactory?: (sequelize: Sequelize, options?: {
33
+ tablePrefix?: string;
34
+ }) => ModelStatic<TokenModel>;
32
35
  }
33
36
  export declare class SequelizeTokenStore extends TokenStore {
34
37
  readonly Tokens: ModelStatic<TokenModel>;
@@ -49,10 +52,7 @@ export declare class SequelizeTokenStore extends TokenStore {
49
52
  close(): Promise<void>;
50
53
  private normalizeUserId;
51
54
  private resolveRealUserId;
52
- private encodeStringArray;
53
- private decodeStringArray;
54
55
  private encodeScope;
55
- private decodeScope;
56
56
  private toTokenRecord;
57
57
  }
58
58
  export {};
@@ -1,26 +1,17 @@
1
1
  import { DataTypes, Model, Op } from 'sequelize';
2
+ import { normalizeStringUserId } from '../auth-api/user-id.js';
3
+ import { applyTablePrefix, decodeStringArray, encodeStringArray, integerIdType, tableOptions } from '../sequelize-utils.js';
2
4
  import { TokenStore } from './base.js';
3
- const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
4
5
  class TokenModel extends Model {
5
6
  }
6
- function tokenTableOptions(sequelize) {
7
- const opts = {
8
- sequelize,
9
- tableName: 'jwttokens',
10
- timestamps: false
11
- };
12
- if (DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())) {
13
- opts.charset = 'utf8mb4';
14
- opts.collate = 'utf8mb4_unicode_ci';
15
- }
16
- return opts;
17
- }
18
- function initTokenModel(sequelize) {
7
+ function initTokenModel(sequelize, options = {}) {
8
+ const tableName = applyTablePrefix(options.tablePrefix, 'jwttokens');
9
+ const usePrefixedIndexNames = tableName !== 'jwttokens';
10
+ const accessIndexName = usePrefixedIndexNames ? `${tableName}_access_unique` : 'jwt_access_unique';
11
+ const refreshIndexName = usePrefixedIndexNames ? `${tableName}_refresh_unique` : 'jwt_refresh_unique';
19
12
  TokenModel.init({
20
13
  token_id: {
21
- type: DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())
22
- ? DataTypes.INTEGER.UNSIGNED
23
- : DataTypes.INTEGER,
14
+ type: integerIdType(sequelize),
24
15
  autoIncrement: true,
25
16
  allowNull: false,
26
17
  primaryKey: true
@@ -49,11 +40,11 @@ function initTokenModel(sequelize) {
49
40
  defaultValue: DataTypes.NOW
50
41
  },
51
42
  access: {
52
- type: DataTypes.STRING(512),
43
+ type: DataTypes.STRING(768),
53
44
  allowNull: false
54
45
  },
55
46
  refresh: {
56
- type: DataTypes.STRING(512),
47
+ type: DataTypes.STRING(768),
57
48
  allowNull: false
58
49
  },
59
50
  domain: {
@@ -117,10 +108,10 @@ function initTokenModel(sequelize) {
117
108
  defaultValue: '[]'
118
109
  }
119
110
  }, {
120
- ...tokenTableOptions(sequelize),
111
+ ...tableOptions(sequelize, 'jwttokens', options.tablePrefix, { timestamps: false }),
121
112
  indexes: [
122
- { name: 'jwt_access_unique', unique: true, fields: ['access'] },
123
- { name: 'jwt_refresh_unique', unique: true, fields: ['refresh'] }
113
+ { name: accessIndexName, unique: true, fields: ['access'] },
114
+ { name: refreshIndexName, unique: true, fields: ['refresh'] }
124
115
  ]
125
116
  });
126
117
  return TokenModel;
@@ -131,7 +122,11 @@ export class SequelizeTokenStore extends TokenStore {
131
122
  if (!options?.sequelize) {
132
123
  throw new Error('SequelizeTokenStore requires an initialised Sequelize instance');
133
124
  }
134
- this.Tokens = options.tokenModel ?? (options.tokenModelFactory ?? initTokenModel)(options.sequelize);
125
+ this.Tokens =
126
+ options.tokenModel ??
127
+ (options.tokenModelFactory ?? initTokenModel)(options.sequelize, {
128
+ tablePrefix: options.tablePrefix
129
+ });
135
130
  }
136
131
  async save(record) {
137
132
  const normalized = this.normalizeToken(record);
@@ -152,36 +147,50 @@ export class SequelizeTokenStore extends TokenStore {
152
147
  const lastSeenAt = normalized.lastSeenAt ?? issuedAt;
153
148
  const sessionCookie = normalized.sessionCookie ?? false;
154
149
  const removalWhere = { user_id: resolvedUserId };
155
- if (normalized.domain !== undefined && record.domain !== undefined) {
150
+ if (record.domain !== undefined) {
156
151
  removalWhere.domain = domain;
157
152
  }
158
- if (normalized.fingerprint !== undefined && record.fingerprint !== undefined) {
153
+ if (record.fingerprint !== undefined) {
159
154
  removalWhere.fingerprint = fingerprint;
160
155
  }
161
156
  if (normalized.clientId) {
162
157
  removalWhere.client_id = normalized.clientId;
163
158
  }
164
- await this.Tokens.destroy({ where: removalWhere });
165
- await this.Tokens.create({
166
- user_id: resolvedUserId,
167
- real_user_id: resolvedRealUserId,
168
- access: normalized.accessToken ?? '',
169
- refresh: normalized.refreshToken,
170
- expires: normalized.expires,
171
- issued_at: issuedAt,
172
- last_seen_at: lastSeenAt,
173
- domain,
174
- fingerprint,
175
- label,
176
- browser,
177
- device,
178
- ip,
179
- os,
180
- client_id: normalized.clientId ?? null,
181
- scope: this.encodeScope(normalized.scope),
182
- login_type: loginType,
183
- refresh_ttl_seconds: refreshTtlSeconds,
184
- session_cookie: sessionCookie
159
+ const sequelize = this.Tokens.sequelize;
160
+ if (!sequelize) {
161
+ throw new Error('Token model is not bound to a Sequelize instance');
162
+ }
163
+ await sequelize.transaction(async (transaction) => {
164
+ await this.Tokens.destroy({ where: removalWhere, transaction });
165
+ // Access/refresh columns are unique. Remove stale collisions before insert to avoid
166
+ // transient uniqueness failures during retries/rotation edge-cases.
167
+ await this.Tokens.destroy({
168
+ where: {
169
+ [Op.or]: [{ access: normalized.accessToken ?? '' }, { refresh: normalized.refreshToken }]
170
+ },
171
+ transaction
172
+ });
173
+ await this.Tokens.create({
174
+ user_id: resolvedUserId,
175
+ real_user_id: resolvedRealUserId,
176
+ access: normalized.accessToken ?? '',
177
+ refresh: normalized.refreshToken,
178
+ expires: normalized.expires,
179
+ issued_at: issuedAt,
180
+ last_seen_at: lastSeenAt,
181
+ domain,
182
+ fingerprint,
183
+ label,
184
+ browser,
185
+ device,
186
+ ip,
187
+ os,
188
+ client_id: normalized.clientId ?? null,
189
+ scope: this.encodeScope(normalized.scope),
190
+ login_type: loginType,
191
+ refresh_ttl_seconds: refreshTtlSeconds,
192
+ session_cookie: sessionCookie
193
+ }, { transaction });
185
194
  });
186
195
  }
187
196
  async get(query, opts) {
@@ -250,11 +259,11 @@ export class SequelizeTokenStore extends TokenStore {
250
259
  where.client_id = params.clientId;
251
260
  }
252
261
  const updates = {};
253
- if (params.accessToken !== undefined) {
254
- updates.access = params.accessToken ?? null;
262
+ if (params.accessToken !== undefined && params.accessToken !== null) {
263
+ updates.access = params.accessToken;
255
264
  }
256
- if (params.expires !== undefined) {
257
- updates.expires = params.expires ?? null;
265
+ if (params.expires !== undefined && params.expires !== null) {
266
+ updates.expires = params.expires;
258
267
  }
259
268
  if (params.scope !== undefined) {
260
269
  updates.scope = this.encodeScope(params.scope);
@@ -292,11 +301,11 @@ export class SequelizeTokenStore extends TokenStore {
292
301
  if (params.sessionCookie !== undefined) {
293
302
  updates.session_cookie = params.sessionCookie;
294
303
  }
295
- if (params.issuedAt !== undefined) {
296
- updates.issued_at = params.issuedAt ?? null;
304
+ if (params.issuedAt !== undefined && params.issuedAt !== null) {
305
+ updates.issued_at = params.issuedAt;
297
306
  }
298
- if (params.lastSeenAt !== undefined) {
299
- updates.last_seen_at = params.lastSeenAt ?? null;
307
+ if (params.lastSeenAt !== undefined && params.lastSeenAt !== null) {
308
+ updates.last_seen_at = params.lastSeenAt;
300
309
  }
301
310
  if (Object.keys(updates).length === 0) {
302
311
  return false;
@@ -321,10 +330,7 @@ export class SequelizeTokenStore extends TokenStore {
321
330
  return;
322
331
  }
323
332
  normalizeUserId(identifier) {
324
- if (identifier === undefined || identifier === null) {
325
- throw new Error(`Unable to normalise user identifier: ${identifier}`);
326
- }
327
- return String(identifier);
333
+ return normalizeStringUserId(identifier);
328
334
  }
329
335
  resolveRealUserId(ruid) {
330
336
  if (ruid === undefined || ruid === null) {
@@ -336,41 +342,17 @@ export class SequelizeTokenStore extends TokenStore {
336
342
  }
337
343
  return value;
338
344
  }
339
- encodeStringArray(values) {
340
- return JSON.stringify(values ?? []);
341
- }
342
- decodeStringArray(raw) {
343
- if (!raw) {
344
- return [];
345
- }
346
- try {
347
- const parsed = JSON.parse(raw);
348
- if (Array.isArray(parsed)) {
349
- return parsed.filter((entry) => typeof entry === 'string' && entry.length > 0);
350
- }
351
- }
352
- catch {
353
- // ignore malformed values
354
- }
355
- return raw
356
- .split(/\s+/)
357
- .map((entry) => entry.trim())
358
- .filter((entry) => entry.length > 0);
359
- }
360
345
  encodeScope(scope) {
361
346
  if (!scope || (Array.isArray(scope) && scope.length === 0)) {
362
347
  return '[]';
363
348
  }
364
349
  if (Array.isArray(scope)) {
365
- return this.encodeStringArray(scope);
350
+ return encodeStringArray(scope);
366
351
  }
367
- return this.encodeStringArray(scope.split(/\s+/).filter((entry) => entry.length > 0));
368
- }
369
- decodeScope(raw) {
370
- return this.decodeStringArray(raw);
352
+ return encodeStringArray(scope.split(/\s+/).filter((entry) => entry.length > 0));
371
353
  }
372
354
  toTokenRecord(model) {
373
- const scope = this.decodeScope(model.scope);
355
+ const scope = decodeStringArray(model.scope);
374
356
  const normalized = this.normalizeToken({
375
357
  userId: model.user_id,
376
358
  refreshToken: model.refresh,
@@ -9,14 +9,21 @@ export interface Token {
9
9
  status?: TokenStatus;
10
10
  ruid?: string;
11
11
  clientId?: string;
12
+ /**
13
+ * Optional session partition key. Token stores may use `domain` and `fingerprint`
14
+ * to replace previous sessions that match the same bucket.
15
+ */
12
16
  domain?: string;
17
+ /**
18
+ * Optional device/session fingerprint used together with `domain` for session bucketing.
19
+ */
13
20
  fingerprint?: string;
14
21
  label?: string;
15
22
  browser?: string;
16
23
  device?: string;
17
24
  ip?: string;
18
25
  os?: string;
19
- scope?: string | string[];
26
+ scope?: string[];
20
27
  loginType?: string;
21
28
  refreshTtlSeconds?: number;
22
29
  sessionCookie?: boolean;
@@ -9,6 +9,7 @@ export declare abstract class UserStore<User, PublicUser> {
9
9
  bcryptRounds?: number;
10
10
  bcryptPepper?: string;
11
11
  });
12
+ private applyPepper;
12
13
  protected hashPassword(plain: string): Promise<string>;
13
14
  verifyPassword(plain: string, hashed: string): Promise<boolean>;
14
15
  protected normalizeUserInput(input: Partial<CreateUserInput>): CreateUserInput;
@@ -1,3 +1,4 @@
1
+ import { createHmac } from 'node:crypto';
1
2
  import bcrypt from 'bcryptjs';
2
3
  export class UserStore {
3
4
  constructor(opts = {}) {
@@ -9,12 +10,18 @@ export class UserStore {
9
10
  this.bcryptPepper =
10
11
  typeof opts.bcryptPepper === 'string' && opts.bcryptPepper.length > 0 ? opts.bcryptPepper : undefined;
11
12
  }
13
+ applyPepper(plain) {
14
+ if (!this.bcryptPepper) {
15
+ return plain;
16
+ }
17
+ return createHmac('sha256', this.bcryptPepper).update(plain).digest('hex');
18
+ }
12
19
  async hashPassword(plain) {
13
- const candidate = this.bcryptPepper ? `${plain}${this.bcryptPepper}` : plain;
20
+ const candidate = this.applyPepper(plain);
14
21
  return bcrypt.hash(candidate, this.bcryptRounds);
15
22
  }
16
23
  async verifyPassword(plain, hashed) {
17
- const candidate = this.bcryptPepper ? `${plain}${this.bcryptPepper}` : plain;
24
+ const candidate = this.applyPepper(plain);
18
25
  return bcrypt.compare(candidate, hashed);
19
26
  }
20
27
  normalizeUserInput(input) {
@@ -29,8 +36,8 @@ export class UserStore {
29
36
  toPublic(user) {
30
37
  const mapped = this.toPublicUser(user);
31
38
  if (mapped && typeof mapped === 'object') {
32
- const { password: _password, ...rest } = mapped;
33
- void _password;
39
+ const rest = { ...mapped };
40
+ delete rest.password;
34
41
  return rest;
35
42
  }
36
43
  return mapped;
@@ -14,12 +14,14 @@ export interface MemoryUserStoreOptions<UserAttributes extends MemoryUserAttribu
14
14
  toPublic?: PublicUserMapper<UserAttributes, PublicUserShape>;
15
15
  userIdFactory?: () => number;
16
16
  startingUserId?: number;
17
+ maxUsers?: number;
17
18
  }
18
19
  export declare class MemoryUserStore<UserAttributes extends MemoryUserAttributes = MemoryUserAttributes, PublicUserShape extends Omit<UserAttributes, 'password'> = Omit<UserAttributes, 'password'>> extends UserStore<UserAttributes, PublicUserShape> {
19
20
  private readonly usersById;
20
21
  private readonly loginToId;
21
22
  private readonly emailToId;
22
23
  private readonly userIdFactory;
24
+ private readonly maxUsers?;
23
25
  private nextUserId;
24
26
  constructor(options?: MemoryUserStoreOptions<UserAttributes, PublicUserShape>);
25
27
  findUser(identifier: AuthIdentifier | string): Promise<UserAttributes | null>;
@@ -1,16 +1,8 @@
1
+ import { normalizeNumericUserId } from '../auth-api/user-id.js';
1
2
  import { UserStore } from './base.js';
2
3
  function cloneUser(user) {
3
4
  return { ...user };
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
- throw new Error(`Unable to normalise user identifier: ${identifier}`);
13
- }
14
6
  export class MemoryUserStore extends UserStore {
15
7
  constructor(options = {}) {
16
8
  super({
@@ -22,6 +14,10 @@ export class MemoryUserStore extends UserStore {
22
14
  this.loginToId = new Map();
23
15
  this.emailToId = new Map();
24
16
  this.nextUserId = Number.isFinite(options.startingUserId) ? Number(options.startingUserId) : 1;
17
+ this.maxUsers =
18
+ typeof options.maxUsers === 'number' && Number.isFinite(options.maxUsers) && options.maxUsers > 0
19
+ ? Math.floor(options.maxUsers)
20
+ : undefined;
25
21
  this.userIdFactory =
26
22
  options.userIdFactory ??
27
23
  (() => {
@@ -56,7 +52,7 @@ export class MemoryUserStore extends UserStore {
56
52
  }
57
53
  async findById(id) {
58
54
  try {
59
- const numeric = normalizeUserId(id);
55
+ const numeric = normalizeNumericUserId(id);
60
56
  const user = this.usersById.get(numeric);
61
57
  return user ? cloneUser(user) : null;
62
58
  }
@@ -74,6 +70,9 @@ export class MemoryUserStore extends UserStore {
74
70
  if (this.usersById.has(userId)) {
75
71
  throw new Error(`User ${userId} already exists`);
76
72
  }
73
+ if (this.maxUsers !== undefined && this.usersById.size >= this.maxUsers) {
74
+ throw new Error('MemoryUserStore maxUsers limit reached');
75
+ }
77
76
  if (this.loginToId.has(normalizedInput.login)) {
78
77
  throw new Error(`User with login ${normalizedInput.login} already exists`);
79
78
  }
@@ -10,15 +10,20 @@ export declare class AuthUserModel extends Model<InferAttributes<AuthUserModel>,
10
10
  }
11
11
  export type AuthUserAttributes = InferAttributes<AuthUserModel>;
12
12
  export type AuthUserCreationAttributes = InferCreationAttributes<AuthUserModel>;
13
- export declare function initAuthUserModel(sequelize: Sequelize): typeof AuthUserModel;
13
+ export declare function initAuthUserModel(sequelize: Sequelize, options?: {
14
+ tablePrefix?: string;
15
+ }): typeof AuthUserModel;
14
16
  export type GenericUserModel = Model<Record<string, unknown>, Record<string, unknown>>;
15
17
  export type GenericUserModelStatic = ModelStatic<GenericUserModel>;
16
18
  export interface SequelizeUserStoreOptions<UserAttributes extends AuthUserAttributes = AuthUserAttributes, PublicUserShape extends Omit<UserAttributes, 'password'> = Omit<UserAttributes, 'password'>> {
17
19
  bcryptRounds?: number;
18
20
  bcryptPepper?: string;
19
21
  sequelize: Sequelize;
22
+ tablePrefix?: string;
20
23
  userModel?: GenericUserModelStatic;
21
- userModelFactory?: (sequelize: Sequelize) => GenericUserModelStatic;
24
+ userModelFactory?: (sequelize: Sequelize, options?: {
25
+ tablePrefix?: string;
26
+ }) => GenericUserModelStatic;
22
27
  recordMapper?: (model: GenericUserModel) => UserAttributes;
23
28
  toPublic?: PublicUserMapper<UserAttributes, PublicUserShape>;
24
29
  }