@lastshotlabs/bunshot 0.0.13 → 0.0.18

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 (123) hide show
  1. package/README.md +2816 -1747
  2. package/dist/adapters/memoryAuth.d.ts +7 -0
  3. package/dist/adapters/memoryAuth.js +177 -2
  4. package/dist/adapters/mongoAuth.js +94 -0
  5. package/dist/adapters/sqliteAuth.d.ts +9 -0
  6. package/dist/adapters/sqliteAuth.js +190 -2
  7. package/dist/app.d.ts +120 -2
  8. package/dist/app.js +104 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +24 -8
  12. package/dist/index.js +15 -5
  13. package/dist/lib/appConfig.d.ts +81 -0
  14. package/dist/lib/appConfig.js +30 -0
  15. package/dist/lib/authAdapter.d.ts +54 -0
  16. package/dist/lib/authRateLimit.d.ts +2 -0
  17. package/dist/lib/authRateLimit.js +4 -0
  18. package/dist/lib/clientIp.d.ts +14 -0
  19. package/dist/lib/clientIp.js +52 -0
  20. package/dist/lib/constants.d.ts +4 -0
  21. package/dist/lib/constants.js +4 -0
  22. package/dist/lib/context.d.ts +2 -0
  23. package/dist/lib/createDtoMapper.d.ts +33 -0
  24. package/dist/lib/createDtoMapper.js +69 -0
  25. package/dist/lib/crypto.d.ts +11 -0
  26. package/dist/lib/crypto.js +22 -0
  27. package/dist/lib/emailVerification.d.ts +4 -0
  28. package/dist/lib/emailVerification.js +20 -12
  29. package/dist/lib/jwt.d.ts +1 -1
  30. package/dist/lib/jwt.js +19 -6
  31. package/dist/lib/mfaChallenge.d.ts +42 -0
  32. package/dist/lib/mfaChallenge.js +293 -0
  33. package/dist/lib/oauth.d.ts +14 -1
  34. package/dist/lib/oauth.js +19 -1
  35. package/dist/lib/oauthCode.d.ts +15 -0
  36. package/dist/lib/oauthCode.js +90 -0
  37. package/dist/lib/queue.d.ts +33 -0
  38. package/dist/lib/queue.js +98 -0
  39. package/dist/lib/resetPassword.js +12 -16
  40. package/dist/lib/roles.d.ts +4 -0
  41. package/dist/lib/roles.js +27 -0
  42. package/dist/lib/session.d.ts +12 -0
  43. package/dist/lib/session.js +165 -5
  44. package/dist/lib/tenant.d.ts +15 -0
  45. package/dist/lib/tenant.js +65 -0
  46. package/dist/lib/ws.js +5 -1
  47. package/dist/lib/zodToMongoose.d.ts +38 -0
  48. package/dist/lib/zodToMongoose.js +84 -0
  49. package/dist/middleware/bearerAuth.js +4 -3
  50. package/dist/middleware/botProtection.js +2 -2
  51. package/dist/middleware/cacheResponse.d.ts +1 -0
  52. package/dist/middleware/cacheResponse.js +18 -3
  53. package/dist/middleware/cors.d.ts +2 -0
  54. package/dist/middleware/cors.js +22 -8
  55. package/dist/middleware/csrf.d.ts +18 -0
  56. package/dist/middleware/csrf.js +115 -0
  57. package/dist/middleware/rateLimit.d.ts +2 -1
  58. package/dist/middleware/rateLimit.js +7 -5
  59. package/dist/middleware/requireRole.d.ts +14 -3
  60. package/dist/middleware/requireRole.js +46 -6
  61. package/dist/middleware/tenant.d.ts +5 -0
  62. package/dist/middleware/tenant.js +116 -0
  63. package/dist/models/AuthUser.d.ts +17 -0
  64. package/dist/models/AuthUser.js +17 -0
  65. package/dist/models/TenantRole.d.ts +15 -0
  66. package/dist/models/TenantRole.js +23 -0
  67. package/dist/routes/auth.d.ts +5 -3
  68. package/dist/routes/auth.js +173 -30
  69. package/dist/routes/jobs.d.ts +2 -0
  70. package/dist/routes/jobs.js +270 -0
  71. package/dist/routes/mfa.d.ts +5 -0
  72. package/dist/routes/mfa.js +616 -0
  73. package/dist/routes/oauth.js +378 -23
  74. package/dist/schemas/auth.d.ts +2 -0
  75. package/dist/schemas/auth.js +22 -1
  76. package/dist/server.d.ts +6 -0
  77. package/dist/server.js +19 -3
  78. package/dist/services/auth.d.ts +18 -5
  79. package/dist/services/auth.js +112 -18
  80. package/dist/services/mfa.d.ts +84 -0
  81. package/dist/services/mfa.js +543 -0
  82. package/dist/ws/index.js +3 -2
  83. package/docs/sections/adding-middleware/full.md +35 -0
  84. package/docs/sections/adding-models/full.md +125 -0
  85. package/docs/sections/adding-models/overview.md +13 -0
  86. package/docs/sections/adding-routes/full.md +182 -0
  87. package/docs/sections/adding-routes/overview.md +23 -0
  88. package/docs/sections/auth-flow/full.md +634 -0
  89. package/docs/sections/auth-flow/overview.md +10 -0
  90. package/docs/sections/cli/full.md +30 -0
  91. package/docs/sections/configuration/full.md +155 -0
  92. package/docs/sections/configuration/overview.md +17 -0
  93. package/docs/sections/configuration-example/full.md +117 -0
  94. package/docs/sections/configuration-example/overview.md +30 -0
  95. package/docs/sections/documentation/full.md +171 -0
  96. package/docs/sections/environment-variables/full.md +55 -0
  97. package/docs/sections/exports/full.md +92 -0
  98. package/docs/sections/extending-context/full.md +59 -0
  99. package/docs/sections/header.md +3 -0
  100. package/docs/sections/installation/full.md +6 -0
  101. package/docs/sections/jobs/full.md +140 -0
  102. package/docs/sections/jobs/overview.md +15 -0
  103. package/docs/sections/mongodb-connections/full.md +45 -0
  104. package/docs/sections/mongodb-connections/overview.md +7 -0
  105. package/docs/sections/multi-tenancy/full.md +66 -0
  106. package/docs/sections/multi-tenancy/overview.md +15 -0
  107. package/docs/sections/oauth/full.md +189 -0
  108. package/docs/sections/oauth/overview.md +16 -0
  109. package/docs/sections/package-development/full.md +7 -0
  110. package/docs/sections/peer-dependencies/full.md +47 -0
  111. package/docs/sections/quick-start/full.md +43 -0
  112. package/docs/sections/response-caching/full.md +117 -0
  113. package/docs/sections/response-caching/overview.md +13 -0
  114. package/docs/sections/roles/full.md +136 -0
  115. package/docs/sections/roles/overview.md +12 -0
  116. package/docs/sections/running-without-redis/full.md +16 -0
  117. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  118. package/docs/sections/stack/full.md +10 -0
  119. package/docs/sections/websocket/full.md +101 -0
  120. package/docs/sections/websocket/overview.md +5 -0
  121. package/docs/sections/websocket-rooms/full.md +97 -0
  122. package/docs/sections/websocket-rooms/overview.md +5 -0
  123. package/package.json +30 -9
@@ -10,6 +10,10 @@ export declare const memoryGetUserSessions: (userId: string) => SessionInfo[];
10
10
  export declare const memoryGetActiveSessionCount: (userId: string) => number;
11
11
  export declare const memoryEvictOldestSession: (userId: string) => void;
12
12
  export declare const memoryUpdateSessionLastActive: (sessionId: string) => void;
13
+ export declare const memorySetRefreshToken: (sessionId: string, refreshToken: string) => void;
14
+ import type { RefreshResult } from "../lib/session";
15
+ export declare const memoryGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
16
+ export declare const memoryRotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => void;
13
17
  export declare const memoryStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
14
18
  export declare const memoryConsumeOAuthState: (state: string) => {
15
19
  codeVerifier?: string;
@@ -30,3 +34,6 @@ export declare const memoryConsumeResetToken: (hash: string) => {
30
34
  userId: string;
31
35
  email: string;
32
36
  } | null;
37
+ import type { OAuthCodePayload } from "../lib/oauthCode";
38
+ export declare const memoryStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
39
+ export declare const memoryConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;
@@ -1,23 +1,33 @@
1
1
  import { HttpError } from "../lib/HttpError";
2
2
  import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
3
+ import { clearMemoryRateLimitStore } from "../lib/authRateLimit";
4
+ import { clearMemoryMfaChallenges } from "../lib/mfaChallenge";
3
5
  const _users = new Map();
4
6
  const _byEmail = new Map();
5
7
  const _sessions = new Map(); // sessionId → session
6
8
  const _userSessionIds = new Map(); // userId → Set<sessionId>
9
+ const _refreshTokenIndex = new Map(); // refreshToken → sessionId
7
10
  const _oauthStates = new Map();
8
11
  const _cache = new Map();
9
12
  const _verificationTokens = new Map();
10
13
  const _resetTokens = new Map();
14
+ const _oauthCodes = new Map();
15
+ const _tenantRoles = new Map(); // "userId:tenantId" → roles
11
16
  /** Reset all in-memory state. Useful for test isolation. */
12
17
  export const clearMemoryStore = () => {
13
18
  _users.clear();
14
19
  _byEmail.clear();
15
20
  _sessions.clear();
16
21
  _userSessionIds.clear();
22
+ _refreshTokenIndex.clear();
23
+ _tenantRoles.clear();
17
24
  _oauthStates.clear();
25
+ _oauthCodes.clear();
18
26
  _cache.clear();
19
27
  _verificationTokens.clear();
20
28
  _resetTokens.clear();
29
+ clearMemoryRateLimitStore();
30
+ clearMemoryMfaChallenges();
21
31
  };
22
32
  // ---------------------------------------------------------------------------
23
33
  // Auth adapter
@@ -37,7 +47,7 @@ export const memoryAuthAdapter = {
37
47
  if (_byEmail.has(normalised))
38
48
  throw new HttpError(409, "Email already registered");
39
49
  const id = crypto.randomUUID();
40
- const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false };
50
+ const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [], webauthnCredentials: [] };
41
51
  _users.set(id, user);
42
52
  _byEmail.set(normalised, id);
43
53
  return { id };
@@ -63,7 +73,7 @@ export const memoryAuthAdapter = {
63
73
  }
64
74
  const id = crypto.randomUUID();
65
75
  const email = profile.email ? profile.email.toLowerCase() : null;
66
- const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false };
76
+ const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [], webauthnCredentials: [] };
67
77
  _users.set(id, user);
68
78
  if (email)
69
79
  _byEmail.set(email, id);
@@ -132,6 +142,106 @@ export const memoryAuthAdapter = {
132
142
  async getEmailVerified(userId) {
133
143
  return _users.get(userId)?.emailVerified ?? false;
134
144
  },
145
+ async deleteUser(userId) {
146
+ const user = _users.get(userId);
147
+ if (user?.email)
148
+ _byEmail.delete(user.email);
149
+ _users.delete(userId);
150
+ },
151
+ async hasPassword(userId) {
152
+ return !!_users.get(userId)?.passwordHash;
153
+ },
154
+ async setMfaSecret(userId, secret) {
155
+ const user = _users.get(userId);
156
+ if (user)
157
+ user.mfaSecret = secret;
158
+ },
159
+ async getMfaSecret(userId) {
160
+ return _users.get(userId)?.mfaSecret ?? null;
161
+ },
162
+ async isMfaEnabled(userId) {
163
+ return _users.get(userId)?.mfaEnabled ?? false;
164
+ },
165
+ async setMfaEnabled(userId, enabled) {
166
+ const user = _users.get(userId);
167
+ if (user)
168
+ user.mfaEnabled = enabled;
169
+ },
170
+ async setRecoveryCodes(userId, codes) {
171
+ const user = _users.get(userId);
172
+ if (user)
173
+ user.recoveryCodes = [...codes];
174
+ },
175
+ async getRecoveryCodes(userId) {
176
+ return _users.get(userId)?.recoveryCodes ?? [];
177
+ },
178
+ async removeRecoveryCode(userId, code) {
179
+ const user = _users.get(userId);
180
+ if (user)
181
+ user.recoveryCodes = user.recoveryCodes.filter((c) => c !== code);
182
+ },
183
+ async getMfaMethods(userId) {
184
+ const user = _users.get(userId);
185
+ if (!user)
186
+ return [];
187
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
188
+ if (user.mfaMethods.length === 0 && user.mfaEnabled)
189
+ return ["totp"];
190
+ return [...user.mfaMethods];
191
+ },
192
+ async setMfaMethods(userId, methods) {
193
+ const user = _users.get(userId);
194
+ if (user)
195
+ user.mfaMethods = [...methods];
196
+ },
197
+ async getWebAuthnCredentials(userId) {
198
+ return [...(_users.get(userId)?.webauthnCredentials ?? [])];
199
+ },
200
+ async addWebAuthnCredential(userId, credential) {
201
+ const user = _users.get(userId);
202
+ if (user)
203
+ user.webauthnCredentials.push({ ...credential });
204
+ },
205
+ async removeWebAuthnCredential(userId, credentialId) {
206
+ const user = _users.get(userId);
207
+ if (user)
208
+ user.webauthnCredentials = user.webauthnCredentials.filter((c) => c.credentialId !== credentialId);
209
+ },
210
+ async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
211
+ const user = _users.get(userId);
212
+ if (!user)
213
+ return;
214
+ const cred = user.webauthnCredentials.find((c) => c.credentialId === credentialId);
215
+ if (cred)
216
+ cred.signCount = signCount;
217
+ },
218
+ async findUserByWebAuthnCredentialId(credentialId) {
219
+ for (const user of _users.values()) {
220
+ if (user.webauthnCredentials.some((c) => c.credentialId === credentialId))
221
+ return user.id;
222
+ }
223
+ return null;
224
+ },
225
+ async getTenantRoles(userId, tenantId) {
226
+ return _tenantRoles.get(`${userId}:${tenantId}`) ?? [];
227
+ },
228
+ async setTenantRoles(userId, tenantId, roles) {
229
+ _tenantRoles.set(`${userId}:${tenantId}`, [...roles]);
230
+ },
231
+ async addTenantRole(userId, tenantId, role) {
232
+ const key = `${userId}:${tenantId}`;
233
+ const current = _tenantRoles.get(key) ?? [];
234
+ if (!current.includes(role)) {
235
+ _tenantRoles.set(key, [...current, role]);
236
+ }
237
+ },
238
+ async removeTenantRole(userId, tenantId, role) {
239
+ const key = `${userId}:${tenantId}`;
240
+ const current = _tenantRoles.get(key);
241
+ if (current) {
242
+ _tenantRoles.set(key, current.filter((r) => r !== role));
243
+ }
244
+ },
135
245
  };
136
246
  // ---------------------------------------------------------------------------
137
247
  // Session helpers (used by src/lib/session.ts)
@@ -160,8 +270,16 @@ export const memoryDeleteSession = (sessionId) => {
160
270
  const entry = _sessions.get(sessionId);
161
271
  if (!entry)
162
272
  return;
273
+ // Clean up refresh token reverse-lookup keys
274
+ if (entry.refreshToken)
275
+ _refreshTokenIndex.delete(entry.refreshToken);
276
+ if (entry.prevRefreshToken)
277
+ _refreshTokenIndex.delete(entry.prevRefreshToken);
163
278
  if (getPersistSessionMetadata()) {
164
279
  entry.token = null;
280
+ entry.refreshToken = null;
281
+ entry.prevRefreshToken = null;
282
+ entry.prevTokenExpiresAt = null;
165
283
  }
166
284
  else {
167
285
  _sessions.delete(sessionId);
@@ -231,6 +349,51 @@ export const memoryUpdateSessionLastActive = (sessionId) => {
231
349
  if (entry)
232
350
  entry.lastActiveAt = Date.now();
233
351
  };
352
+ export const memorySetRefreshToken = (sessionId, refreshToken) => {
353
+ const entry = _sessions.get(sessionId);
354
+ if (!entry)
355
+ return;
356
+ entry.refreshToken = refreshToken;
357
+ _refreshTokenIndex.set(refreshToken, sessionId);
358
+ };
359
+ import { getRotationGraceSeconds } from "../lib/appConfig";
360
+ export const memoryGetSessionByRefreshToken = (refreshToken) => {
361
+ const sessionId = _refreshTokenIndex.get(refreshToken);
362
+ if (!sessionId)
363
+ return null;
364
+ const entry = _sessions.get(sessionId);
365
+ if (!entry)
366
+ return null;
367
+ // Current refresh token matches
368
+ if (entry.refreshToken === refreshToken) {
369
+ return { sessionId: entry.sessionId, userId: entry.userId, newRefreshToken: refreshToken };
370
+ }
371
+ // Check grace window
372
+ if (entry.prevRefreshToken === refreshToken && entry.prevTokenExpiresAt && entry.prevTokenExpiresAt > Date.now()) {
373
+ return { sessionId: entry.sessionId, userId: entry.userId, newRefreshToken: entry.refreshToken };
374
+ }
375
+ // Grace window expired — theft detected, invalidate session
376
+ if (entry.prevRefreshToken === refreshToken) {
377
+ memoryDeleteSession(sessionId);
378
+ return null;
379
+ }
380
+ return null;
381
+ };
382
+ export const memoryRotateRefreshToken = (sessionId, newRefreshToken, newAccessToken) => {
383
+ const entry = _sessions.get(sessionId);
384
+ if (!entry)
385
+ return;
386
+ const graceSeconds = getRotationGraceSeconds();
387
+ // Move current to prev
388
+ const oldRefreshToken = entry.refreshToken;
389
+ entry.prevRefreshToken = oldRefreshToken;
390
+ entry.prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
391
+ entry.refreshToken = newRefreshToken;
392
+ entry.token = newAccessToken;
393
+ // Update reverse-lookup index
394
+ _refreshTokenIndex.set(newRefreshToken, sessionId);
395
+ // Old token stays in index during grace window — cleaned up on next lookup or session delete
396
+ };
234
397
  // ---------------------------------------------------------------------------
235
398
  // OAuth state helpers (used by src/lib/oauth.ts)
236
399
  // ---------------------------------------------------------------------------
@@ -313,3 +476,15 @@ export const memoryConsumeResetToken = (hash) => {
313
476
  _resetTokens.delete(hash);
314
477
  return { userId: entry.userId, email: entry.email };
315
478
  };
479
+ export const memoryStoreOAuthCode = (hash, payload, ttlSeconds) => {
480
+ _oauthCodes.set(hash, { ...payload, expiresAt: Date.now() + ttlSeconds * 1000 });
481
+ };
482
+ export const memoryConsumeOAuthCode = (hash) => {
483
+ const entry = _oauthCodes.get(hash);
484
+ if (!entry || entry.expiresAt <= Date.now()) {
485
+ _oauthCodes.delete(hash);
486
+ return null;
487
+ }
488
+ _oauthCodes.delete(hash);
489
+ return { token: entry.token, userId: entry.userId, email: entry.email, refreshToken: entry.refreshToken };
490
+ };
@@ -1,4 +1,5 @@
1
1
  import { AuthUser } from "../models/AuthUser";
2
+ import { TenantRole } from "../models/TenantRole";
2
3
  import { HttpError } from "../lib/HttpError";
3
4
  export const mongoAuthAdapter = {
4
5
  async findByEmail(email) {
@@ -90,4 +91,97 @@ export const mongoAuthAdapter = {
90
91
  const user = await AuthUser.findById(userId, "emailVerified").lean();
91
92
  return user?.emailVerified ?? false;
92
93
  },
94
+ async deleteUser(userId) {
95
+ await AuthUser.findByIdAndDelete(userId);
96
+ },
97
+ async hasPassword(userId) {
98
+ const user = await AuthUser.findById(userId, "password").lean();
99
+ return !!user?.password;
100
+ },
101
+ async setMfaSecret(userId, secret) {
102
+ await AuthUser.findByIdAndUpdate(userId, { mfaSecret: secret });
103
+ },
104
+ async getMfaSecret(userId) {
105
+ const user = await AuthUser.findById(userId, "mfaSecret").lean();
106
+ return user?.mfaSecret ?? null;
107
+ },
108
+ async isMfaEnabled(userId) {
109
+ const user = await AuthUser.findById(userId, "mfaEnabled").lean();
110
+ return user?.mfaEnabled ?? false;
111
+ },
112
+ async setMfaEnabled(userId, enabled) {
113
+ await AuthUser.findByIdAndUpdate(userId, { mfaEnabled: enabled });
114
+ },
115
+ async setRecoveryCodes(userId, codes) {
116
+ await AuthUser.findByIdAndUpdate(userId, { recoveryCodes: codes });
117
+ },
118
+ async getRecoveryCodes(userId) {
119
+ const user = await AuthUser.findById(userId, "recoveryCodes").lean();
120
+ return user?.recoveryCodes ?? [];
121
+ },
122
+ async removeRecoveryCode(userId, code) {
123
+ await AuthUser.findByIdAndUpdate(userId, { $pull: { recoveryCodes: code } });
124
+ },
125
+ async getMfaMethods(userId) {
126
+ const user = await AuthUser.findById(userId, "mfaMethods mfaEnabled").lean();
127
+ const methods = user?.mfaMethods ?? [];
128
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
129
+ if (methods.length === 0 && user?.mfaEnabled)
130
+ return ["totp"];
131
+ return methods;
132
+ },
133
+ async setMfaMethods(userId, methods) {
134
+ await AuthUser.findByIdAndUpdate(userId, { mfaMethods: methods });
135
+ },
136
+ async getWebAuthnCredentials(userId) {
137
+ const user = await AuthUser.findById(userId, "webauthnCredentials").lean();
138
+ const creds = user?.webauthnCredentials ?? [];
139
+ return creds.map((c) => ({
140
+ credentialId: c.credentialId,
141
+ publicKey: c.publicKey,
142
+ signCount: c.signCount,
143
+ transports: c.transports,
144
+ name: c.name,
145
+ createdAt: c.createdAt instanceof Date ? c.createdAt.getTime() : c.createdAt,
146
+ }));
147
+ },
148
+ async addWebAuthnCredential(userId, credential) {
149
+ await AuthUser.findByIdAndUpdate(userId, {
150
+ $push: {
151
+ webauthnCredentials: {
152
+ credentialId: credential.credentialId,
153
+ publicKey: credential.publicKey,
154
+ signCount: credential.signCount,
155
+ transports: credential.transports,
156
+ name: credential.name,
157
+ createdAt: new Date(credential.createdAt),
158
+ },
159
+ },
160
+ });
161
+ },
162
+ async removeWebAuthnCredential(userId, credentialId) {
163
+ await AuthUser.findByIdAndUpdate(userId, {
164
+ $pull: { webauthnCredentials: { credentialId } },
165
+ });
166
+ },
167
+ async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
168
+ await AuthUser.findOneAndUpdate({ _id: userId, "webauthnCredentials.credentialId": credentialId }, { $set: { "webauthnCredentials.$.signCount": signCount } });
169
+ },
170
+ async findUserByWebAuthnCredentialId(credentialId) {
171
+ const user = await AuthUser.findOne({ "webauthnCredentials.credentialId": credentialId }, "_id").lean();
172
+ return user ? String(user._id) : null;
173
+ },
174
+ async getTenantRoles(userId, tenantId) {
175
+ const doc = await TenantRole.findOne({ userId, tenantId }, "roles").lean();
176
+ return doc?.roles ?? [];
177
+ },
178
+ async setTenantRoles(userId, tenantId, roles) {
179
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $set: { roles } }, { upsert: true });
180
+ },
181
+ async addTenantRole(userId, tenantId, role) {
182
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $addToSet: { roles: role } }, { upsert: true });
183
+ },
184
+ async removeTenantRole(userId, tenantId, role) {
185
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $pull: { roles: role } });
186
+ },
93
187
  };
@@ -1,5 +1,7 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import type { AuthAdapter } from "../lib/authAdapter";
2
3
  export declare const setSqliteDb: (path: string) => void;
4
+ export declare function getDb(): Database;
3
5
  export declare const sqliteAuthAdapter: AuthAdapter;
4
6
  import type { SessionMetadata, SessionInfo } from "../lib/session";
5
7
  export declare const sqliteCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
@@ -9,6 +11,10 @@ export declare const sqliteGetUserSessions: (userId: string) => SessionInfo[];
9
11
  export declare const sqliteGetActiveSessionCount: (userId: string) => number;
10
12
  export declare const sqliteEvictOldestSession: (userId: string) => void;
11
13
  export declare const sqliteUpdateSessionLastActive: (sessionId: string) => void;
14
+ import type { RefreshResult } from "../lib/session";
15
+ export declare const sqliteSetRefreshToken: (sessionId: string, refreshToken: string) => void;
16
+ export declare const sqliteGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
17
+ export declare const sqliteRotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => void;
12
18
  export declare const sqliteStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
13
19
  export declare const sqliteConsumeOAuthState: (state: string) => {
14
20
  codeVerifier?: string;
@@ -30,4 +36,7 @@ export declare const sqliteConsumeResetToken: (hash: string) => {
30
36
  userId: string;
31
37
  email: string;
32
38
  } | null;
39
+ import type { OAuthCodePayload } from "../lib/oauthCode";
40
+ export declare const sqliteStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
41
+ export declare const sqliteConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;
33
42
  export declare const startSqliteCleanup: (intervalMs?: number) => ReturnType<typeof setInterval>;
@@ -10,7 +10,7 @@ export const setSqliteDb = (path) => {
10
10
  _db.run("PRAGMA foreign_keys = ON");
11
11
  initSchema(_db);
12
12
  };
13
- function getDb() {
13
+ export function getDb() {
14
14
  if (!_db)
15
15
  throw new Error("SQLite not initialized — call setSqliteDb(path) before using sqliteAuthAdapter or sessionStore: 'sqlite'");
16
16
  return _db;
@@ -32,6 +32,23 @@ function initSchema(db) {
32
32
  db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
33
33
  }
34
34
  catch { /* already exists */ }
35
+ // Add MFA columns to pre-existing databases
36
+ try {
37
+ db.run("ALTER TABLE users ADD COLUMN mfaSecret TEXT");
38
+ }
39
+ catch { /* already exists */ }
40
+ try {
41
+ db.run("ALTER TABLE users ADD COLUMN mfaEnabled INTEGER NOT NULL DEFAULT 0");
42
+ }
43
+ catch { /* already exists */ }
44
+ try {
45
+ db.run("ALTER TABLE users ADD COLUMN recoveryCodes TEXT NOT NULL DEFAULT '[]'");
46
+ }
47
+ catch { /* already exists */ }
48
+ try {
49
+ db.run("ALTER TABLE users ADD COLUMN mfaMethods TEXT NOT NULL DEFAULT '[]'");
50
+ }
51
+ catch { /* already exists */ }
35
52
  // Migrate legacy sessions table (userId PK) to new multi-session schema (sessionId PK)
36
53
  try {
37
54
  db.run("ALTER TABLE sessions RENAME TO sessions_legacy");
@@ -48,6 +65,20 @@ function initSchema(db) {
48
65
  userAgent TEXT
49
66
  )`);
50
67
  db.run("CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId)");
68
+ // Add refresh token columns to pre-existing databases
69
+ try {
70
+ db.run("ALTER TABLE sessions ADD COLUMN refreshToken TEXT");
71
+ }
72
+ catch { /* already exists */ }
73
+ try {
74
+ db.run("ALTER TABLE sessions ADD COLUMN prevRefreshToken TEXT");
75
+ }
76
+ catch { /* already exists */ }
77
+ try {
78
+ db.run("ALTER TABLE sessions ADD COLUMN prevTokenExpiresAt INTEGER");
79
+ }
80
+ catch { /* already exists */ }
81
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_refreshToken ON sessions(refreshToken) WHERE refreshToken IS NOT NULL");
51
82
  db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
52
83
  state TEXT PRIMARY KEY,
53
84
  codeVerifier TEXT,
@@ -71,6 +102,31 @@ function initSchema(db) {
71
102
  email TEXT NOT NULL,
72
103
  expiresAt INTEGER NOT NULL
73
104
  )`);
105
+ db.run(`CREATE TABLE IF NOT EXISTS tenant_roles (
106
+ userId TEXT NOT NULL,
107
+ tenantId TEXT NOT NULL,
108
+ role TEXT NOT NULL,
109
+ PRIMARY KEY (userId, tenantId, role)
110
+ )`);
111
+ db.run("CREATE INDEX IF NOT EXISTS idx_tenant_roles_tenant ON tenant_roles(tenantId)");
112
+ db.run(`CREATE TABLE IF NOT EXISTS webauthn_credentials (
113
+ credentialId TEXT PRIMARY KEY,
114
+ userId TEXT NOT NULL,
115
+ publicKey TEXT NOT NULL,
116
+ signCount INTEGER NOT NULL DEFAULT 0,
117
+ transports TEXT NOT NULL DEFAULT '[]',
118
+ name TEXT,
119
+ createdAt INTEGER NOT NULL
120
+ )`);
121
+ db.run("CREATE INDEX IF NOT EXISTS idx_webauthn_userId ON webauthn_credentials(userId)");
122
+ db.run(`CREATE TABLE IF NOT EXISTS oauth_codes (
123
+ codeHash TEXT PRIMARY KEY,
124
+ token TEXT NOT NULL,
125
+ userId TEXT NOT NULL,
126
+ email TEXT,
127
+ refreshToken TEXT,
128
+ expiresAt INTEGER NOT NULL
129
+ )`);
74
130
  }
75
131
  // ---------------------------------------------------------------------------
76
132
  // Auth adapter
@@ -177,6 +233,98 @@ export const sqliteAuthAdapter = {
177
233
  const row = getDb().query("SELECT emailVerified FROM users WHERE id = ?").get(userId);
178
234
  return row?.emailVerified === 1;
179
235
  },
236
+ async deleteUser(userId) {
237
+ getDb().run("DELETE FROM users WHERE id = ?", [userId]);
238
+ },
239
+ async hasPassword(userId) {
240
+ const row = getDb().query("SELECT passwordHash FROM users WHERE id = ?").get(userId);
241
+ return !!row?.passwordHash;
242
+ },
243
+ async setMfaSecret(userId, secret) {
244
+ getDb().run("UPDATE users SET mfaSecret = ? WHERE id = ?", [secret, userId]);
245
+ },
246
+ async getMfaSecret(userId) {
247
+ const row = getDb().query("SELECT mfaSecret FROM users WHERE id = ?").get(userId);
248
+ return row?.mfaSecret ?? null;
249
+ },
250
+ async isMfaEnabled(userId) {
251
+ const row = getDb().query("SELECT mfaEnabled FROM users WHERE id = ?").get(userId);
252
+ return row?.mfaEnabled === 1;
253
+ },
254
+ async setMfaEnabled(userId, enabled) {
255
+ getDb().run("UPDATE users SET mfaEnabled = ? WHERE id = ?", [enabled ? 1 : 0, userId]);
256
+ },
257
+ async setRecoveryCodes(userId, codes) {
258
+ getDb().run("UPDATE users SET recoveryCodes = ? WHERE id = ?", [JSON.stringify(codes), userId]);
259
+ },
260
+ async getRecoveryCodes(userId) {
261
+ const row = getDb().query("SELECT recoveryCodes FROM users WHERE id = ?").get(userId);
262
+ return row?.recoveryCodes ? JSON.parse(row.recoveryCodes) : [];
263
+ },
264
+ async removeRecoveryCode(userId, code) {
265
+ const current = await sqliteAuthAdapter.getRecoveryCodes(userId);
266
+ const idx = current.indexOf(code);
267
+ if (idx !== -1) {
268
+ current.splice(idx, 1);
269
+ await sqliteAuthAdapter.setRecoveryCodes(userId, current);
270
+ }
271
+ },
272
+ async getMfaMethods(userId) {
273
+ const row = getDb().query("SELECT mfaMethods, mfaEnabled FROM users WHERE id = ?").get(userId);
274
+ const methods = row?.mfaMethods ? JSON.parse(row.mfaMethods) : [];
275
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
276
+ if (methods.length === 0 && row?.mfaEnabled === 1)
277
+ return ["totp"];
278
+ return methods;
279
+ },
280
+ async setMfaMethods(userId, methods) {
281
+ getDb().run("UPDATE users SET mfaMethods = ? WHERE id = ?", [JSON.stringify(methods), userId]);
282
+ },
283
+ async getWebAuthnCredentials(userId) {
284
+ const rows = getDb().query("SELECT credentialId, publicKey, signCount, transports, name, createdAt FROM webauthn_credentials WHERE userId = ?").all(userId);
285
+ return rows.map((r) => ({
286
+ credentialId: r.credentialId,
287
+ publicKey: r.publicKey,
288
+ signCount: r.signCount,
289
+ transports: JSON.parse(r.transports),
290
+ name: r.name ?? undefined,
291
+ createdAt: r.createdAt,
292
+ }));
293
+ },
294
+ async addWebAuthnCredential(userId, credential) {
295
+ getDb().run("INSERT INTO webauthn_credentials (credentialId, userId, publicKey, signCount, transports, name, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)", [credential.credentialId, userId, credential.publicKey, credential.signCount, JSON.stringify(credential.transports ?? []), credential.name ?? null, credential.createdAt]);
296
+ },
297
+ async removeWebAuthnCredential(userId, credentialId) {
298
+ getDb().run("DELETE FROM webauthn_credentials WHERE credentialId = ? AND userId = ?", [credentialId, userId]);
299
+ },
300
+ async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
301
+ getDb().run("UPDATE webauthn_credentials SET signCount = ? WHERE credentialId = ? AND userId = ?", [signCount, credentialId, userId]);
302
+ },
303
+ async findUserByWebAuthnCredentialId(credentialId) {
304
+ const row = getDb().query("SELECT userId FROM webauthn_credentials WHERE credentialId = ?").get(credentialId);
305
+ return row?.userId ?? null;
306
+ },
307
+ async getTenantRoles(userId, tenantId) {
308
+ const rows = getDb().query("SELECT role FROM tenant_roles WHERE userId = ? AND tenantId = ?").all(userId, tenantId);
309
+ return rows.map((r) => r.role);
310
+ },
311
+ async setTenantRoles(userId, tenantId, roles) {
312
+ const db = getDb();
313
+ db.run("DELETE FROM tenant_roles WHERE userId = ? AND tenantId = ?", [userId, tenantId]);
314
+ const stmt = db.prepare("INSERT INTO tenant_roles (userId, tenantId, role) VALUES (?, ?, ?)");
315
+ for (const role of roles) {
316
+ stmt.run(userId, tenantId, role);
317
+ }
318
+ },
319
+ async addTenantRole(userId, tenantId, role) {
320
+ try {
321
+ getDb().run("INSERT INTO tenant_roles (userId, tenantId, role) VALUES (?, ?, ?)", [userId, tenantId, role]);
322
+ }
323
+ catch { /* already exists */ }
324
+ },
325
+ async removeTenantRole(userId, tenantId, role) {
326
+ getDb().run("DELETE FROM tenant_roles WHERE userId = ? AND tenantId = ? AND role = ?", [userId, tenantId, role]);
327
+ },
180
328
  };
181
329
  import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
182
330
  const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
@@ -193,7 +341,7 @@ export const sqliteGetSession = (sessionId) => {
193
341
  };
194
342
  export const sqliteDeleteSession = (sessionId) => {
195
343
  if (getPersistSessionMetadata()) {
196
- getDb().run("UPDATE sessions SET token = NULL WHERE sessionId = ?", [sessionId]);
344
+ getDb().run("UPDATE sessions SET token = NULL, refreshToken = NULL, prevRefreshToken = NULL, prevTokenExpiresAt = NULL WHERE sessionId = ?", [sessionId]);
197
345
  }
198
346
  else {
199
347
  getDb().run("DELETE FROM sessions WHERE sessionId = ?", [sessionId]);
@@ -236,6 +384,35 @@ export const sqliteEvictOldestSession = (userId) => {
236
384
  export const sqliteUpdateSessionLastActive = (sessionId) => {
237
385
  getDb().run("UPDATE sessions SET lastActiveAt = ? WHERE sessionId = ?", [Date.now(), sessionId]);
238
386
  };
387
+ import { getRotationGraceSeconds } from "../lib/appConfig";
388
+ export const sqliteSetRefreshToken = (sessionId, refreshToken) => {
389
+ getDb().run("UPDATE sessions SET refreshToken = ? WHERE sessionId = ?", [refreshToken, sessionId]);
390
+ };
391
+ export const sqliteGetSessionByRefreshToken = (refreshToken) => {
392
+ const db = getDb();
393
+ // Check current refresh token
394
+ let row = db.query("SELECT sessionId, userId, refreshToken FROM sessions WHERE refreshToken = ?").get(refreshToken);
395
+ if (row) {
396
+ return { sessionId: row.sessionId, userId: row.userId, newRefreshToken: refreshToken };
397
+ }
398
+ // Check previous refresh token (grace window)
399
+ row = db.query("SELECT sessionId, userId, refreshToken, prevTokenExpiresAt FROM sessions WHERE prevRefreshToken = ?").get(refreshToken);
400
+ if (!row)
401
+ return null;
402
+ const prevExpiry = row.prevTokenExpiresAt;
403
+ if (prevExpiry && prevExpiry > Date.now()) {
404
+ // Within grace window — return current refresh token
405
+ return { sessionId: row.sessionId, userId: row.userId, newRefreshToken: row.refreshToken };
406
+ }
407
+ // Grace window expired — theft detected, invalidate session
408
+ sqliteDeleteSession(row.sessionId);
409
+ return null;
410
+ };
411
+ export const sqliteRotateRefreshToken = (sessionId, newRefreshToken, newAccessToken) => {
412
+ const graceSeconds = getRotationGraceSeconds();
413
+ const prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
414
+ getDb().run("UPDATE sessions SET prevRefreshToken = refreshToken, prevTokenExpiresAt = ?, refreshToken = ?, token = ? WHERE sessionId = ?", [prevTokenExpiresAt, newRefreshToken, newAccessToken, sessionId]);
415
+ };
239
416
  // ---------------------------------------------------------------------------
240
417
  // OAuth state helpers (used by src/lib/oauth.ts)
241
418
  // ---------------------------------------------------------------------------
@@ -298,6 +475,16 @@ export const sqliteConsumeResetToken = (hash) => {
298
475
  const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
299
476
  return row ?? null;
300
477
  };
478
+ export const sqliteStoreOAuthCode = (hash, payload, ttlSeconds) => {
479
+ const expiresAt = Date.now() + ttlSeconds * 1000;
480
+ getDb().run("INSERT INTO oauth_codes (codeHash, token, userId, email, refreshToken, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", [hash, payload.token, payload.userId, payload.email ?? null, payload.refreshToken ?? null, expiresAt]);
481
+ };
482
+ export const sqliteConsumeOAuthCode = (hash) => {
483
+ const row = getDb().query("DELETE FROM oauth_codes WHERE codeHash = ? AND expiresAt > ? RETURNING token, userId, email, refreshToken").get(hash, Date.now());
484
+ if (!row)
485
+ return null;
486
+ return { token: row.token, userId: row.userId, email: row.email ?? undefined, refreshToken: row.refreshToken ?? undefined };
487
+ };
301
488
  // ---------------------------------------------------------------------------
302
489
  // Optional periodic cleanup of expired rows
303
490
  // ---------------------------------------------------------------------------
@@ -316,5 +503,6 @@ export const startSqliteCleanup = (intervalMs = 3_600_000) => {
316
503
  db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
317
504
  db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
318
505
  db.run("DELETE FROM password_resets WHERE expiresAt <= ?", [now]);
506
+ db.run("DELETE FROM oauth_codes WHERE expiresAt <= ?", [now]);
319
507
  }, intervalMs);
320
508
  };