@luckystack/login 0.1.8 → 0.2.0

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.
package/dist/index.js CHANGED
@@ -1,556 +1,95 @@
1
- var __defProp = Object.defineProperty;
2
- var __getOwnPropNames = Object.getOwnPropertyNames;
3
- var __esm = (fn, res) => function __init() {
4
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
- };
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
-
11
- // src/sessionAdapter.ts
12
- import { redis, tryCatch, formatKey } from "@luckystack/core";
13
- var sessionKey, activeUsersKey, redisSessionAdapter, activeAdapter, registerSessionAdapter, getSessionAdapter;
14
- var init_sessionAdapter = __esm({
15
- "src/sessionAdapter.ts"() {
16
- "use strict";
17
- sessionKey = (token) => formatKey("-session", token);
18
- activeUsersKey = (userId) => formatKey("-activeUsers", userId);
19
- redisSessionAdapter = {
20
- name: "redis",
21
- async getRaw(token) {
22
- const [error, value] = await tryCatch(async () => await redis.get(sessionKey(token)));
23
- if (error) return null;
24
- return value ?? null;
25
- },
26
- async setRaw(token, value, ttlSeconds) {
27
- const key = sessionKey(token);
28
- await redis.set(key, value);
29
- await redis.expire(key, ttlSeconds);
30
- },
31
- async delete(token) {
32
- await redis.del(sessionKey(token));
33
- },
34
- async expire(token, ttlSeconds) {
35
- const result = await redis.expire(sessionKey(token), ttlSeconds);
36
- return result === 1;
37
- },
38
- async ttl(token) {
39
- const [error, value] = await tryCatch(async () => await redis.ttl(sessionKey(token)));
40
- if (error) return null;
41
- return typeof value === "number" ? value : null;
42
- },
43
- async trackActive(userId, token, ttlSeconds) {
44
- const key = activeUsersKey(userId);
45
- await redis.sadd(key, token);
46
- await redis.expire(key, ttlSeconds);
47
- },
48
- async untrackActive(userId, token) {
49
- await redis.srem(activeUsersKey(userId), token);
50
- },
51
- async listActive(userId) {
52
- const [error, value] = await tryCatch(async () => await redis.smembers(activeUsersKey(userId)));
53
- if (error || !Array.isArray(value)) return [];
54
- return value;
55
- },
56
- async listAll() {
57
- const pattern = `${formatKey("-session", "")}:*`;
58
- const collected = [];
59
- let cursor = "0";
60
- do {
61
- const [scanError, scanResponse] = await tryCatch(
62
- async () => await redis.scan(cursor, "MATCH", pattern, "COUNT", 100)
63
- );
64
- if (scanError || !scanResponse) break;
65
- cursor = scanResponse[0];
66
- const keys = scanResponse[1];
67
- if (Array.isArray(keys) && keys.length > 0) {
68
- const values = await Promise.all(keys.map((k) => redis.get(k)));
69
- for (const [idx, k] of keys.entries()) {
70
- const raw = values[idx];
71
- if (raw) {
72
- collected.push({
73
- token: k.replace(`${formatKey("-session", "")}:`, ""),
74
- raw
75
- });
76
- }
77
- }
78
- }
79
- } while (cursor !== "0");
80
- return collected;
81
- }
82
- };
83
- activeAdapter = redisSessionAdapter;
84
- registerSessionAdapter = (adapter) => {
85
- activeAdapter = adapter;
86
- };
87
- getSessionAdapter = () => activeAdapter;
88
- }
89
- });
1
+ import {
2
+ asOAuthUserData,
3
+ credentialsProvider,
4
+ defaultPrismaUserAdapter,
5
+ discordProvider,
6
+ facebookProvider,
7
+ getOAuthProviders,
8
+ getUserAdapter,
9
+ githubProvider,
10
+ googleProvider,
11
+ isFullOAuthProvider,
12
+ isUserAdapterRegistered,
13
+ microsoftProvider,
14
+ registerOAuthProviderFactory,
15
+ registerOAuthProviders,
16
+ registerUserAdapter
17
+ } from "./chunk-22NFCMM7.js";
18
+ import {
19
+ activeUsersKeyFor,
20
+ deleteSession,
21
+ getAllSessions,
22
+ getSession,
23
+ getSessionAdapter,
24
+ getSessionSanitizer,
25
+ logout,
26
+ redisSessionAdapter,
27
+ registerSessionAdapter,
28
+ registerSessionSanitizer,
29
+ revokeUserSessions,
30
+ saveSession,
31
+ sessionKeyFor
32
+ } from "./chunk-NXXBZT3I.js";
90
33
 
91
- // src/logout.ts
92
- var logout_exports = {};
93
- __export(logout_exports, {
94
- logout: () => logout
95
- });
96
- import { redis as redisClient, tryCatch as tryCatch2, socketEventNames, dispatchHook, getLogger } from "@luckystack/core";
97
- var logout;
98
- var init_logout = __esm({
99
- "src/logout.ts"() {
100
- "use strict";
101
- init_session();
102
- logout = async ({ token, socket, userId, skipSessionDelete }) => {
103
- const [error, result] = await tryCatch2(async () => {
104
- if (!socket) {
105
- getLogger().warn("logout: invalid socket");
106
- return;
107
- }
108
- if (!token) {
109
- getLogger().warn("logout: no token provided");
110
- return;
111
- }
112
- const preLogoutResult = await dispatchHook("preLogout", { userId, token });
113
- if (preLogoutResult.stopped) {
114
- getLogger().warn("logout: aborted by preLogout hook", { errorCode: preLogoutResult.signal.errorCode });
115
- socket.emit(socketEventNames.logout, "error");
116
- return;
117
- }
118
- getLogger().debug(`logout: user ${userId ?? "?"}`, { token });
119
- if (!skipSessionDelete) {
120
- await deleteSession(token);
121
- }
122
- if (userId) {
123
- await redisClient.srem(activeUsersKeyFor(userId), token);
124
- }
125
- socket.leave(token);
126
- await dispatchHook("postLogout", { userId, token });
127
- return true;
128
- });
129
- if (error) {
130
- if (socket) {
131
- socket.emit(socketEventNames.logout, "error");
132
- }
133
- } else if (result && socket) {
134
- if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === "1") {
135
- getLogger().warn("[session] logout success \u2014 emitting logout to socket", {
136
- tokenPrefix: token ? token.slice(0, 8) : null,
137
- userId,
138
- socketId: socket.id,
139
- stack: new Error("logout invoked").stack
140
- });
141
- }
142
- socket.emit(socketEventNames.logout, "success");
143
- }
144
- };
145
- }
146
- });
34
+ // src/index.ts
35
+ import { registerSessionProvider } from "@luckystack/core";
147
36
 
148
- // src/session.ts
149
- import { randomBytes } from "crypto";
37
+ // src/authLockout.ts
150
38
  import {
151
- socketEventNames as socketEventNames2,
152
- dispatchHook as dispatchHook2,
153
- getCsrfConfig,
154
- getLogger as getLogger2,
155
39
  getProjectConfig,
156
- formatKey as formatKey2,
157
- tryCatch as tryCatch3
40
+ checkRateLimit,
41
+ getRateLimitStatus,
42
+ clearRateLimit,
43
+ getLogger,
44
+ registerHook
158
45
  } from "@luckystack/core";
159
- var getSessionTtl, isSessionLayout, parseSessionLayout, saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor;
160
- var init_session = __esm({
161
- "src/session.ts"() {
162
- "use strict";
163
- init_sessionAdapter();
164
- getSessionTtl = () => 60 * 60 * 24 * getProjectConfig().session.expiryDays;
165
- isSessionLayout = (value) => {
166
- return value !== null && typeof value === "object" && "id" in value;
167
- };
168
- parseSessionLayout = (value) => {
169
- const parsed = JSON.parse(value);
170
- return isSessionLayout(parsed) ? parsed : null;
171
- };
172
- saveSession = async (token, data, newUser) => {
173
- const [error] = await tryCatch3(async () => {
174
- const adapter = getSessionAdapter();
175
- if (newUser) {
176
- const preSessionCreateResult = await dispatchHook2("preSessionCreate", {
177
- token,
178
- user: data,
179
- persistent: !getProjectConfig().session.basedToken
180
- });
181
- if (preSessionCreateResult.stopped) {
182
- getLogger2().warn(`session create aborted by preSessionCreate hook`, { errorCode: preSessionCreateResult.signal.errorCode });
183
- return;
184
- }
185
- }
186
- data.csrfToken ??= randomBytes(getCsrfConfig().tokenLength).toString("hex");
187
- const ttl = getSessionTtl();
188
- await adapter.setRaw(token, JSON.stringify(data), ttl);
189
- const userId = data.id;
190
- if (userId) {
191
- await adapter.trackActive(userId, token, ttl);
192
- }
193
- const { getIoInstance } = await import("@luckystack/core");
194
- const io = getIoInstance();
195
- if (!io) return;
196
- if (newUser && userId) {
197
- const sessionCfg = getProjectConfig().session;
198
- const effectivePerUser = sessionCfg.allowMultiple ? "multiple" : sessionCfg.perUser;
199
- const cap = sessionCfg.maxConcurrentPerUser;
200
- const allTokens = await adapter.listActive(userId);
201
- const previousTokens = allTokens.filter((t) => t !== token);
202
- let tokensToKick = [];
203
- if (effectivePerUser === "single") {
204
- tokensToKick = previousTokens;
205
- } else if (cap !== null && previousTokens.length + 1 > cap && sessionCfg.onConflict === "revokeOld") {
206
- const excess = previousTokens.length + 1 - cap;
207
- tokensToKick = previousTokens.slice(0, excess);
208
- }
209
- if (tokensToKick.length > 0) {
210
- const { logout: logout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
211
- await Promise.all(tokensToKick.map(async (previousToken) => {
212
- const sockets = io.sockets.adapter.rooms.get(previousToken);
213
- if (sockets) {
214
- getLogger2().debug(`Kicking previous session for user ${userId}`);
215
- if (sessionCfg.notifyOldDeviceOnRevoke) {
216
- io.to(previousToken).emit(socketEventNames2.sessionReplaced, JSON.stringify({
217
- reason: "session-replaced",
218
- userId
219
- }));
220
- }
221
- for (const socketId of sockets) {
222
- const socket = io.sockets.sockets.get(socketId);
223
- if (socket) {
224
- await logout2({ token: previousToken, socket, userId });
225
- }
226
- }
227
- } else {
228
- await adapter.delete(previousToken);
229
- await adapter.untrackActive(userId, previousToken);
230
- }
231
- }));
232
- }
233
- }
234
- if (io.sockets.adapter.rooms.has(token)) {
235
- io.to(token).emit(socketEventNames2.updateSession, JSON.stringify(data));
236
- }
237
- if (newUser) {
238
- await dispatchHook2("postSessionCreate", { token, user: data, persistent: !getProjectConfig().session.basedToken });
239
- }
240
- }, void 0, { fn: "saveSession", token });
241
- if (error) {
242
- getLogger2().error("saveSession failed", error, { token });
243
- }
244
- };
245
- getSession = async (token) => {
246
- if (!token) return null;
247
- const [error, value] = await tryCatch3(async () => {
248
- const adapter = getSessionAdapter();
249
- const raw = await adapter.getRaw(token);
250
- if (!raw) return null;
251
- const parsed = parseSessionLayout(raw);
252
- const userId = parsed?.id ?? null;
253
- const newTtl = getSessionTtl();
254
- const oldTtl = await adapter.ttl(token);
255
- const preRefreshResult = await dispatchHook2("preSessionRefresh", { token, userId, oldTtl, newTtl });
256
- let applied = false;
257
- if (!preRefreshResult.stopped) {
258
- applied = await adapter.expire(token, newTtl);
259
- }
260
- await dispatchHook2("postSessionRefresh", {
261
- token,
262
- userId,
263
- oldTtl,
264
- newTtl,
265
- applied
266
- });
267
- if (!parsed) return null;
268
- const merged = { ...parsed, token };
269
- return merged;
270
- }, void 0, { fn: "getSession", token });
271
- if (error) {
272
- getLogger2().error("getSession failed", error, { token });
273
- return null;
274
- }
275
- return value;
276
- };
277
- deleteSession = async (token) => {
278
- if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === "1") {
279
- getLogger2().warn("[session] deleteSession invoked", {
280
- tokenPrefix: token.slice(0, 8),
281
- stack: new Error("deleteSession invoked").stack
282
- });
283
- }
284
- const [error, ok] = await tryCatch3(async () => {
285
- const adapter = getSessionAdapter();
286
- const raw = await adapter.getRaw(token);
287
- let resolvedUserId = null;
288
- if (raw) {
289
- resolvedUserId = parseSessionLayout(raw)?.id ?? null;
290
- }
291
- const preSessionDeleteResult = await dispatchHook2("preSessionDelete", { token, userId: resolvedUserId });
292
- if (preSessionDeleteResult.stopped) {
293
- getLogger2().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });
294
- return false;
295
- }
296
- if (raw && resolvedUserId) {
297
- const { getIoInstance } = await import("@luckystack/core");
298
- const ioInstance = getIoInstance();
299
- if (ioInstance) {
300
- const { logout: logout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
301
- const sockets = ioInstance.sockets.adapter.rooms.get(token);
302
- if (sockets) {
303
- await Promise.all([...sockets].map(async (socketId) => {
304
- const socket = ioInstance.sockets.sockets.get(socketId);
305
- if (!socket) return;
306
- await logout2({
307
- token,
308
- socket,
309
- userId: resolvedUserId,
310
- skipSessionDelete: true
311
- });
312
- }));
313
- }
314
- }
315
- await adapter.untrackActive(resolvedUserId, token);
316
- }
317
- await adapter.delete(token);
318
- await dispatchHook2("postSessionDelete", { token, userId: resolvedUserId });
319
- return true;
320
- }, void 0, { fn: "deleteSession", token });
321
- if (error) {
322
- getLogger2().error("deleteSession failed", error, { token });
323
- return false;
324
- }
325
- return ok ?? false;
326
- };
327
- getAllSessions = async () => {
328
- const [error, sessions] = await tryCatch3(async () => {
329
- const adapter = getSessionAdapter();
330
- if (!adapter.listAll) {
331
- getLogger2().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll \u2014 returning empty`);
332
- return [];
333
- }
334
- const rows = await adapter.listAll();
335
- const parsedSessions = [];
336
- for (const { raw } of rows) {
337
- const parsed = parseSessionLayout(raw);
338
- if (parsed) parsedSessions.push(parsed);
339
- }
340
- return parsedSessions;
341
- }, void 0, { fn: "getAllSessions" });
342
- if (error) {
343
- getLogger2().error("getAllSessions failed", error);
344
- return [];
345
- }
346
- return sessions ?? [];
347
- };
348
- revokeUserSessions = async (userId, exceptToken) => {
349
- if (!userId) return 0;
350
- const adapter = getSessionAdapter();
351
- const tokens = await adapter.listActive(userId);
352
- const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
353
- await Promise.all(targets.map((token) => deleteSession(token)));
354
- return targets.length;
355
- };
356
- sessionKeyFor = (token) => formatKey2("-session", token);
357
- activeUsersKeyFor = (userId) => formatKey2("-activeUsers", userId);
358
- }
359
- });
360
-
361
- // src/index.ts
362
- init_session();
363
- init_sessionAdapter();
364
-
365
- // src/oauthProviders.ts
366
- import { tryCatch as tryCatch4 } from "@luckystack/core";
367
- var asOAuthUserData = (value) => {
368
- if (value && typeof value === "object") {
369
- return value;
370
- }
371
- return {};
46
+ var lockoutKey = (accountKey, requesterIp) => {
47
+ const base = `auth:${accountKey.trim().toLowerCase()}`;
48
+ return requesterIp ? `${base}:${requesterIp}` : base;
372
49
  };
373
- var isFullOAuthProvider = (provider) => provider.name !== "credentials";
374
- var mergeScopes = (defaults, extra) => {
375
- if (!extra || extra.length === 0) return defaults;
376
- return [.../* @__PURE__ */ new Set([...defaults, ...extra])];
50
+ var getAuthLockoutConfig = () => {
51
+ const auth = getProjectConfig().rateLimiting.auth;
52
+ if (!auth.enabled) return null;
53
+ return { maxAttempts: auth.maxAttempts, windowMs: auth.windowMs };
377
54
  };
378
- var requireString = (value, label) => {
379
- if (!value || value.length === 0) {
380
- throw new Error(`OAuth provider configuration error: ${label} is empty.`);
381
- }
382
- return value;
55
+ var isAccountLocked = async (accountKey, requesterIp) => {
56
+ const cfg = getAuthLockoutConfig();
57
+ if (!cfg || !accountKey) return false;
58
+ const { remaining } = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);
59
+ return remaining <= 0;
383
60
  };
384
- var credentialsProvider = () => ({ name: "credentials" });
385
- var googleProvider = (input) => ({
386
- name: "google",
387
- clientID: requireString(input.clientId, "google clientId"),
388
- clientSecret: requireString(input.clientSecret, "google clientSecret"),
389
- callbackURL: input.callbackUrl,
390
- authorizationURL: input.endpoints?.authorizationURL ?? "https://accounts.google.com/o/oauth2/v2/auth",
391
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://oauth2.googleapis.com/token",
392
- tokenExchangeMethod: "json",
393
- userInfoURL: input.endpoints?.userInfoURL ?? "https://www.googleapis.com/oauth2/v1/userinfo",
394
- scope: mergeScopes([
395
- "https://www.googleapis.com/auth/userinfo.profile",
396
- "https://www.googleapis.com/auth/userinfo.email"
397
- ], input.extraScopes),
398
- nameKey: "name",
399
- emailKey: "email",
400
- avatarKey: "picture",
401
- avatarCodeKey: "",
402
- extraSessionFields: input.extraSessionFields
403
- });
404
- var githubProvider = (input) => ({
405
- name: "github",
406
- clientID: requireString(input.clientId, "github clientId"),
407
- clientSecret: requireString(input.clientSecret, "github clientSecret"),
408
- callbackURL: input.callbackUrl,
409
- authorizationURL: input.endpoints?.authorizationURL ?? "https://github.com/login/oauth/authorize",
410
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://github.com/login/oauth/access_token",
411
- tokenExchangeMethod: "json",
412
- userInfoURL: input.endpoints?.userInfoURL ?? "https://api.github.com/user",
413
- scope: mergeScopes(["read:user", "user:email"], input.extraScopes),
414
- nameKey: "login",
415
- emailKey: "email",
416
- avatarKey: "avatar_url",
417
- avatarCodeKey: "",
418
- extraSessionFields: input.extraSessionFields,
419
- getEmail: async (accessToken) => {
420
- const fetchEmails = async () => {
421
- const response = await fetch("https://api.github.com/user/emails", {
422
- method: "GET",
423
- headers: {
424
- "Content-Type": "application/json",
425
- Accept: "application/json",
426
- Authorization: `Bearer ${accessToken}`
427
- }
428
- });
429
- if (!response.ok) return false;
430
- const emails2 = await response.json();
431
- if (!Array.isArray(emails2)) return false;
432
- return emails2;
433
- };
434
- const [error, emails] = await tryCatch4(fetchEmails);
435
- if (error || !emails) return false;
436
- let mainEmail;
437
- for (const entry of emails) {
438
- if (entry.primary && typeof entry.email === "string") mainEmail = entry.email;
439
- }
440
- mainEmail ??= emails[0]?.email;
441
- return mainEmail;
442
- }
443
- });
444
- var discordProvider = (input) => ({
445
- name: "discord",
446
- clientID: requireString(input.clientId, "discord clientId"),
447
- clientSecret: requireString(input.clientSecret, "discord clientSecret"),
448
- callbackURL: input.callbackUrl,
449
- authorizationURL: input.endpoints?.authorizationURL ?? "https://discord.com/oauth2/authorize",
450
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://discord.com/api/oauth2/token",
451
- tokenExchangeMethod: "form",
452
- userInfoURL: input.endpoints?.userInfoURL ?? "https://discord.com/api/users/@me",
453
- scope: mergeScopes(["identify", "email"], input.extraScopes),
454
- nameKey: "username",
455
- emailKey: "email",
456
- avatarCodeKey: "avatar",
457
- extraSessionFields: input.extraSessionFields,
458
- getAvatar: ({ userData, avatarId }) => {
459
- if (!avatarId) return;
460
- const userId = typeof userData.id === "string" ? userData.id : "";
461
- if (!userId) return;
462
- const format = avatarId.startsWith("a_") ? "gif" : "png";
463
- return `https://cdn.discordapp.com/avatars/${userId}/${avatarId}.${format}`;
61
+ var recordAuthFailure = async (accountKey, requesterIp) => {
62
+ const cfg = getAuthLockoutConfig();
63
+ if (!cfg || !accountKey) return;
64
+ const { allowed, remaining } = await checkRateLimit({
65
+ key: lockoutKey(accountKey, requesterIp),
66
+ limit: cfg.maxAttempts,
67
+ windowMs: cfg.windowMs
68
+ });
69
+ if (!allowed) {
70
+ getLogger().warn("[authLockout] account temporarily locked after repeated failures", {
71
+ remaining,
72
+ maxAttempts: cfg.maxAttempts
73
+ });
464
74
  }
465
- });
466
- var facebookProvider = (input) => {
467
- const v = input.apiVersion ?? "v18.0";
468
- return {
469
- name: "facebook",
470
- clientID: requireString(input.clientId, "facebook clientId"),
471
- clientSecret: requireString(input.clientSecret, "facebook clientSecret"),
472
- callbackURL: input.callbackUrl,
473
- authorizationURL: input.endpoints?.authorizationURL ?? `https://www.facebook.com/${v}/dialog/oauth`,
474
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://graph.facebook.com/${v}/oauth/access_token`,
475
- tokenExchangeMethod: "form",
476
- userInfoURL: input.endpoints?.userInfoURL ?? "https://graph.facebook.com/me?fields=id,name,email,picture.type(large)",
477
- scope: mergeScopes(["public_profile", "email"], input.extraScopes),
478
- nameKey: "name",
479
- emailKey: "email",
480
- avatarCodeKey: "",
481
- extraSessionFields: input.extraSessionFields,
482
- getAvatar: ({ userData }) => {
483
- const picture = asOAuthUserData(userData.picture);
484
- const pictureData = asOAuthUserData(picture.data);
485
- const url = pictureData.url;
486
- return typeof url === "string" ? url : void 0;
487
- }
488
- };
489
75
  };
490
- var microsoftProvider = (input) => {
491
- const tenant = input.tenant ?? "common";
492
- const oauthVersion = input.apiVersion ?? "v2.0";
493
- const graphVersion = input.graphApiVersion ?? "v1.0";
494
- return {
495
- name: "microsoft",
496
- clientID: requireString(input.clientId, "microsoft clientId"),
497
- clientSecret: requireString(input.clientSecret, "microsoft clientSecret"),
498
- callbackURL: input.callbackUrl,
499
- authorizationURL: input.endpoints?.authorizationURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/authorize`,
500
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/token`,
501
- tokenExchangeMethod: "form",
502
- userInfoURL: input.endpoints?.userInfoURL ?? `https://graph.microsoft.com/${graphVersion}/me`,
503
- scope: mergeScopes(["openid", "profile", "email", "User.Read"], input.extraScopes),
504
- nameKey: "displayName",
505
- emailKey: "mail",
506
- avatarCodeKey: "id",
507
- extraSessionFields: input.extraSessionFields,
508
- //? Graph's /photo/$value requires bearer auth, so we can't store the URL —
509
- //? a browser <img> would 401. Fetch the bytes and inline as a data URL.
510
- getAvatar: async ({ avatarId, accessToken }) => {
511
- if (!avatarId) return;
512
- const fetchPhoto = async () => {
513
- const response = await fetch(
514
- `https://graph.microsoft.com/${graphVersion}/users/${avatarId}/photo/$value`,
515
- { method: "GET", headers: { Authorization: `Bearer ${accessToken}` } }
516
- );
517
- if (!response.ok) return;
518
- const contentType = response.headers.get("content-type") ?? "image/jpeg";
519
- const buffer = Buffer.from(await response.arrayBuffer());
520
- return `data:${contentType};base64,${buffer.toString("base64")}`;
521
- };
522
- const [error, dataUrl] = await tryCatch4(fetchPhoto);
523
- if (error) return;
524
- return dataUrl ?? void 0;
525
- },
526
- getEmail: async (accessToken) => {
527
- const fetchProfile = async () => {
528
- const response = await fetch(`https://graph.microsoft.com/${graphVersion}/me`, {
529
- method: "GET",
530
- headers: {
531
- Authorization: `Bearer ${accessToken}`,
532
- "Content-Type": "application/json"
533
- }
534
- });
535
- if (!response.ok) return false;
536
- const data = await response.json();
537
- if (!data || typeof data !== "object") return false;
538
- return data;
539
- };
540
- const [error, profile] = await tryCatch4(fetchProfile);
541
- if (error || !profile) return false;
542
- const record = asOAuthUserData(profile);
543
- const mail = typeof record.mail === "string" ? record.mail : "";
544
- const userPrincipalName = typeof record.userPrincipalName === "string" ? record.userPrincipalName : "";
545
- return mail || userPrincipalName || false;
546
- }
547
- };
76
+ var clearAuthFailures = async (accountKey, requesterIp) => {
77
+ const cfg = getAuthLockoutConfig();
78
+ if (!cfg || !accountKey) return;
79
+ await clearRateLimit(lockoutKey(accountKey, requesterIp));
548
80
  };
549
- var registeredProviders = [{ name: "credentials" }];
550
- var getOAuthProviders = () => registeredProviders;
551
- var registerOAuthProviders = (providers) => {
552
- registeredProviders = providers;
553
- return registeredProviders;
81
+ var COUNTING_REASONS = /* @__PURE__ */ new Set([
82
+ "login.wrongPassword"
83
+ ]);
84
+ var lockoutHookRegistered = false;
85
+ var registerAuthLockoutHook = () => {
86
+ if (lockoutHookRegistered) return;
87
+ lockoutHookRegistered = true;
88
+ registerHook("loginFailed", async ({ email, provider, reason, stage, requesterIp }) => {
89
+ if (stage !== "login" || provider !== "credentials") return;
90
+ if (!email || !COUNTING_REASONS.has(reason)) return;
91
+ await recordAuthFailure(email, requesterIp);
92
+ });
554
93
  };
555
94
 
556
95
  // src/redirectResolver.ts
@@ -562,53 +101,33 @@ var registerPostLoginRedirect = (resolver) => {
562
101
  var getPostLoginRedirect = () => activeResolver;
563
102
 
564
103
  // src/login.ts
565
- init_session();
566
104
  import { URLSearchParams } from "url";
567
- import { tryCatch as tryCatch5, redis as redisClient2, getUploadsDir, dispatchHook as dispatchHook3, getLogger as getLogger3, formatKey as formatKey3 } from "@luckystack/core";
105
+ import { tryCatch, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue } from "@luckystack/core";
568
106
  import bcrypt from "bcryptjs";
569
- import { randomBytes as randomBytes2 } from "crypto";
107
+ import { randomBytes, createHash, timingSafeEqual } from "crypto";
570
108
  import validator from "validator";
571
- import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
572
109
 
573
- // src/userAdapter.ts
574
- import { prisma } from "@luckystack/core";
575
- var registeredAdapter = null;
576
- var registerUserAdapter = (adapter) => {
577
- registeredAdapter = adapter;
578
- return registeredAdapter;
579
- };
580
- var isUserAdapterRegistered = () => registeredAdapter !== null;
581
- var getPrismaUser = () => (
582
- // eslint-disable-next-line no-restricted-syntax -- structural Prisma boundary
583
- prisma.user
584
- );
585
- var defaultPrismaUserAdapter = () => {
586
- return {
587
- findByEmail: async ({ email, provider }) => getPrismaUser().findFirst({ where: { email, provider } }),
588
- findById: async (id) => getPrismaUser().findUnique({ where: { id } }),
589
- create: async (input) => getPrismaUser().create({
590
- data: {
591
- email: input.email,
592
- provider: input.provider,
593
- name: input.name,
594
- password: input.password ?? null,
595
- avatar: input.avatar ?? "",
596
- avatarFallback: input.avatarFallback,
597
- language: input.language
598
- }
599
- }),
600
- update: async (id, patch) => getPrismaUser().update({ where: { id }, data: patch })
601
- };
602
- };
603
- var cachedDefaultAdapter = null;
604
- var getUserAdapter = () => {
605
- if (registeredAdapter) return registeredAdapter;
606
- cachedDefaultAdapter ??= defaultPrismaUserAdapter();
607
- return cachedDefaultAdapter;
110
+ // src/accountStrategy.ts
111
+ import { getProjectConfig as getProjectConfig2, getLogger as getLogger2 } from "@luckystack/core";
112
+ var warnedMissingAnyProvider = false;
113
+ var resolveUserByEmail = async (adapter, { email, provider }) => {
114
+ const strategy = getProjectConfig2().auth.providerAccountStrategy;
115
+ if (strategy === "unified") {
116
+ if (adapter.findByEmailAnyProvider) {
117
+ return adapter.findByEmailAnyProvider({ email });
118
+ }
119
+ if (!warnedMissingAnyProvider) {
120
+ warnedMissingAnyProvider = true;
121
+ getLogger2().warn(
122
+ '[login] auth.providerAccountStrategy is "unified" but the registered UserAdapter does not implement findByEmailAnyProvider \u2014 falling back to provider-scoped lookup (duplicate accounts per provider are still possible). Implement findByEmailAnyProvider on your adapter to enable unified accounts.'
123
+ );
124
+ }
125
+ }
126
+ return adapter.findByEmail({ email, provider });
608
127
  };
609
128
 
610
129
  // src/passwordPolicy.ts
611
- import { getProjectConfig as getProjectConfig2 } from "@luckystack/core";
130
+ import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
612
131
 
613
132
  // src/data/commonPasswords.ts
614
133
  var COMMON_PASSWORD_LIST = [
@@ -828,9 +347,12 @@ var UPPERCASE_REGEX = /[A-Z]/;
828
347
  var LOWERCASE_REGEX = /[a-z]/;
829
348
  var NUMBER_REGEX = /\d/;
830
349
  var SPECIAL_REGEX = /[^A-Za-z0-9]/;
350
+ var BCRYPT_MAX_BYTES = 72;
831
351
  var validatePassword = (password) => {
832
- const policy = getProjectConfig2().auth.passwordPolicy;
352
+ const policy = getProjectConfig3().auth.passwordPolicy;
833
353
  if (password.length < policy.minLength) return "login.passwordCharacterMinimum";
354
+ const byteLength = Buffer.byteLength(password, "utf8");
355
+ if (byteLength > BCRYPT_MAX_BYTES) return "login.passwordCharacterLimit";
834
356
  if (password.length > policy.maxLength) return "login.passwordCharacterLimit";
835
357
  if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return "login.passwordRequiresUppercase";
836
358
  if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return "login.passwordRequiresLowercase";
@@ -846,37 +368,85 @@ var validatePassword = (password) => {
846
368
 
847
369
  // src/login.ts
848
370
  import path from "path";
849
- import { existsSync } from "fs";
371
+ import { promises as fsPromises } from "fs";
850
372
  var uploadsFolder = () => getUploadsDir();
851
- var isDevMode = () => getProjectConfig3().logging.devLogs;
373
+ var isDevMode = () => getProjectConfig4().logging.devLogs;
852
374
  var { compare, genSalt, hash } = bcrypt;
375
+ var dummyHashCache = /* @__PURE__ */ new Map();
376
+ var getDummyBcryptHash = async () => {
377
+ const rounds = getProjectConfig4().auth.bcryptRounds;
378
+ const cached = dummyHashCache.get(rounds);
379
+ if (cached) return cached;
380
+ const salt = await genSalt(rounds);
381
+ const generated = await hash(randomBytes(16).toString("hex"), salt);
382
+ dummyHashCache.set(rounds, generated);
383
+ return generated;
384
+ };
853
385
  var { escape, isEmail } = validator;
854
386
  var getOAuthStateKey = (providerName, state) => {
855
- return formatKey3("-oauth-state", `${providerName}:${state}`);
387
+ return formatKey("-oauth-state", `${providerName}:${state}`);
388
+ };
389
+ var OAUTH_STATE_COOKIE_NAME = "ls-oauth-state";
390
+ var hashStateNonce = (nonce) => createHash("sha256").update(nonce).digest("hex");
391
+ var base64Url = (buf) => buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
392
+ var generatePkcePair = () => {
393
+ const verifier = base64Url(randomBytes(32));
394
+ const challenge = base64Url(createHash("sha256").update(verifier).digest());
395
+ return { verifier, challenge };
856
396
  };
857
- var createOAuthState = async (providerName) => {
858
- const state = randomBytes2(32).toString("hex");
397
+ var createOAuthState = async (providerName, options) => {
398
+ const state = randomBytes(32).toString("hex");
399
+ const nonce = randomBytes(32).toString("hex");
859
400
  const key = getOAuthStateKey(providerName, state);
860
- const result = await redisClient2.set(key, "1", "EX", getProjectConfig3().auth.oauthStateTtlSeconds, "NX");
401
+ const pkce = options?.usePkce ? generatePkcePair() : null;
402
+ const entry = {
403
+ nonceHash: hashStateNonce(nonce),
404
+ ...pkce ? { codeVerifier: pkce.verifier } : {},
405
+ ...options?.returnUrl ? { returnUrl: options.returnUrl } : {}
406
+ };
407
+ const result = await redisClient.set(
408
+ key,
409
+ JSON.stringify(entry),
410
+ "EX",
411
+ getProjectConfig4().auth.oauthStateTtlSeconds,
412
+ "NX"
413
+ );
861
414
  if (result !== "OK") {
862
415
  return null;
863
416
  }
864
- return state;
417
+ return {
418
+ state,
419
+ stateCookie: nonce,
420
+ ...pkce ? { codeChallenge: pkce.challenge } : {}
421
+ };
865
422
  };
866
- var consumeOAuthState = async (providerName, state) => {
867
- if (!state) {
868
- return false;
423
+ var consumeOAuthState = async (providerName, state, cookieNonce) => {
424
+ if (!state || !cookieNonce) {
425
+ return null;
869
426
  }
870
427
  const key = getOAuthStateKey(providerName, state);
871
- const txResult = await redisClient2.multi().get(key).del(key).exec();
428
+ const txResult = await redisClient.multi().get(key).del(key).exec();
872
429
  if (!txResult || txResult.length < 2) {
873
- return false;
430
+ return null;
874
431
  }
875
432
  const getResult = txResult[0];
876
433
  if (!getResult || getResult[0]) {
877
- return false;
434
+ return null;
435
+ }
436
+ const rawValue = getResult[1];
437
+ if (typeof rawValue !== "string") {
438
+ return null;
439
+ }
440
+ const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue));
441
+ if (parseErr || !entry || typeof entry.nonceHash !== "string") {
442
+ return null;
878
443
  }
879
- return getResult[1] === "1";
444
+ const incomingHash = Buffer.from(hashStateNonce(cookieNonce));
445
+ const storedHash = Buffer.from(entry.nonceHash);
446
+ if (incomingHash.length !== storedHash.length || !timingSafeEqual(incomingHash, storedHash)) {
447
+ return null;
448
+ }
449
+ return entry;
880
450
  };
881
451
  var asRecord = (value) => {
882
452
  if (value && typeof value === "object") {
@@ -884,10 +454,26 @@ var asRecord = (value) => {
884
454
  }
885
455
  return {};
886
456
  };
457
+ var readStringField = (record, key, fallback = "") => {
458
+ const value = record[key];
459
+ if (typeof value === "string") return value;
460
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
461
+ if (typeof value === "boolean") return String(value);
462
+ return fallback;
463
+ };
464
+ var generateAvatarFallbackColor = () => `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`;
465
+ var resolveUploadedAvatar = async (userId) => {
466
+ const filePath = path.join(uploadsFolder(), `${userId}.webp`);
467
+ const [, avatarStat] = await tryCatch(() => fsPromises.stat(filePath));
468
+ return avatarStat ? `${userId}.webp` : null;
469
+ };
887
470
  var sanitizeUserForSession = (user) => {
888
471
  const { password: _password, ...safeUser } = user;
889
472
  return safeUser;
890
473
  };
474
+ var emitLoginFailed = (payload) => {
475
+ void dispatchHook("loginFailed", payload);
476
+ };
891
477
  var toReasonKey = (error, fallback = "api.internalServerError") => {
892
478
  if (typeof error === "string" && error.length > 0) {
893
479
  return error;
@@ -908,19 +494,26 @@ var normalizeCredentials = (params) => ({
908
494
  //? (regression introduced in pass-1 fix #9 which only un-escaped password).
909
495
  //? Email is normalized (lowercase + trim) and validated via `isEmail`;
910
496
  //? HTML-escaping it would make legacy raw rows unfindable.
911
- email: (params.email || "").trim().toLowerCase(),
912
- password: params.password || "",
497
+ email: (params.email ?? "").trim().toLowerCase(),
498
+ password: params.password ?? "",
913
499
  name: params.name ? escape(params.name) : void 0,
500
+ //? Empty-string confirmPassword must collapse to `undefined` so the
501
+ //? register-vs-login dispatcher's truthiness check treats a blank field as
502
+ //? "absent". `??` would keep the empty string (only nullish collapses), so the
503
+ //? falsy-coercing `||` is deliberate here.
504
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional empty-string → undefined collapse
914
505
  confirmPassword: params.confirmPassword || void 0
915
506
  });
916
- var validateCredentialsShape = (creds) => {
917
- const authLimits = getProjectConfig3().auth;
507
+ var validateCredentialsShape = (creds, mode) => {
508
+ const authLimits = getProjectConfig4().auth;
918
509
  if (!creds.email || !creds.password) return { status: false, reason: "login.empty" };
919
510
  if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: "login.emailCharacterLimit" };
920
511
  if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: "login.nameCharacterLimit" };
921
512
  if (!isEmail(creds.email)) return { status: false, reason: "login.invalidEmailFormat" };
922
- const passwordReason = validatePassword(creds.password);
923
- if (passwordReason) return { status: false, reason: passwordReason };
513
+ if (mode === "register") {
514
+ const passwordReason = validatePassword(creds.password);
515
+ if (passwordReason) return { status: false, reason: passwordReason };
516
+ }
924
517
  return null;
925
518
  };
926
519
  var registerWithCredentials = async ({
@@ -928,25 +521,36 @@ var registerWithCredentials = async ({
928
521
  password,
929
522
  name,
930
523
  confirmPassword
931
- }) => {
524
+ }, options) => {
932
525
  if (password !== confirmPassword) {
526
+ emitLoginFailed({ email, provider: "credentials", reason: "login.passwordNotMatch", stage: "register" });
933
527
  return { status: false, reason: "login.passwordNotMatch" };
934
528
  }
935
- const preRegisterResult = await dispatchHook3("preRegister", { email, provider: "credentials", name });
529
+ const preRegisterResult = await dispatchHook("preRegister", { email, provider: "credentials", name });
936
530
  if (preRegisterResult.stopped) {
531
+ emitLoginFailed({ email, provider: "credentials", reason: preRegisterResult.signal.errorCode, stage: "register" });
937
532
  return { status: false, reason: preRegisterResult.signal.errorCode };
938
533
  }
534
+ if (await isAccountLocked(email)) {
535
+ emitLoginFailed({ email, provider: "credentials", reason: "login.accountLocked", stage: "register" });
536
+ return { status: false, reason: "login.accountLocked" };
537
+ }
939
538
  const userAdapter = getUserAdapter();
940
- const [checkEmailError, checkEmailResponse] = await tryCatch5(
941
- () => userAdapter.findByEmail({ email, provider: "credentials" })
539
+ const [checkEmailError, checkEmailResponse] = await tryCatch(
540
+ () => resolveUserByEmail(userAdapter, { email, provider: "credentials" })
942
541
  );
943
542
  if (checkEmailError) {
944
543
  getLogger3().error("login: findByEmail failed during register", checkEmailError);
945
- return { status: false, reason: toReasonKey(checkEmailError) };
544
+ const reason = toReasonKey(checkEmailError);
545
+ emitLoginFailed({ email, provider: "credentials", reason, stage: "register" });
546
+ return { status: false, reason };
547
+ }
548
+ if (checkEmailResponse) {
549
+ emitLoginFailed({ email, provider: "credentials", reason: "login.emailExists", stage: "register" });
550
+ return { status: false, reason: "login.emailExists" };
946
551
  }
947
- if (checkEmailResponse) return { status: false, reason: "login.emailExists" };
948
552
  const createNewUser = async () => {
949
- const salt = await genSalt(getProjectConfig3().auth.bcryptRounds);
553
+ const salt = await genSalt(getProjectConfig4().auth.bcryptRounds);
950
554
  const hashedPassword = await hash(password, salt);
951
555
  return await userAdapter.create({
952
556
  email,
@@ -954,87 +558,150 @@ var registerWithCredentials = async ({
954
558
  name,
955
559
  password: hashedPassword,
956
560
  avatar: "",
957
- avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
958
- language: getProjectConfig3().defaultLanguage
561
+ avatarFallback: generateAvatarFallbackColor(),
562
+ language: getProjectConfig4().defaultLanguage
959
563
  });
960
564
  };
961
- const [createNewUserError, createNewUserResponse] = await tryCatch5(createNewUser);
962
- if (createNewUserError) return { status: false, reason: toReasonKey(createNewUserError) };
963
- if (!createNewUserResponse) return { status: false, reason: "login.createUserFailed" };
964
- await dispatchHook3("postRegister", { userId: createNewUserResponse.id, provider: "credentials" });
565
+ const [createNewUserError, createNewUserResponse] = await tryCatch(createNewUser);
566
+ if (createNewUserError) {
567
+ const errorMessage = createNewUserError instanceof Error ? createNewUserError.message : String(createNewUserError);
568
+ const isUniqueViolation = errorMessage.includes("Unique constraint") || errorMessage.includes("unique constraint") || // Prisma error code P2002 covers all DB engines
569
+ createNewUserError.code === "P2002";
570
+ if (isUniqueViolation) {
571
+ emitLoginFailed({ email, provider: "credentials", reason: "login.emailExists", stage: "register" });
572
+ return { status: false, reason: "login.emailExists" };
573
+ }
574
+ const reason = toReasonKey(createNewUserError);
575
+ emitLoginFailed({ email, provider: "credentials", reason, stage: "register" });
576
+ return { status: false, reason };
577
+ }
578
+ if (!createNewUserResponse) {
579
+ emitLoginFailed({ email, provider: "credentials", reason: "login.createUserFailed", stage: "register" });
580
+ return { status: false, reason: "login.createUserFailed" };
581
+ }
582
+ await dispatchHook("postRegister", { userId: createNewUserResponse.id, provider: "credentials" });
583
+ const newToken = randomBytes(32).toString("hex");
584
+ const nowLogin = /* @__PURE__ */ new Date();
585
+ const newUser = {
586
+ ...sanitizeUserForSession(createNewUserResponse),
587
+ token: newToken,
588
+ lastLogin: nowLogin,
589
+ previousLogin: null
590
+ };
591
+ const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });
592
+ if (!saved.ok) {
593
+ emitLoginFailed({ email, userId: newUser.id, provider: "credentials", reason: saved.errorCode, stage: "register" });
594
+ return { status: false, reason: saved.errorCode };
595
+ }
596
+ await dispatchHook("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: true, token: newToken });
965
597
  return {
966
598
  status: true,
967
599
  reason: "login.userCreated",
968
- session: sanitizeUserForSession(createNewUserResponse)
600
+ newToken,
601
+ session: newUser
969
602
  };
970
603
  };
971
- var loginWithCredentialsCore = async ({ email, password }) => {
972
- const preLoginResult = await dispatchHook3("preLogin", { email, provider: "credentials" });
604
+ var loginWithCredentialsCore = async ({ email, password }, options) => {
605
+ const requesterIp = options?.requesterIp;
606
+ const preLoginResult = await dispatchHook("preLogin", { email, provider: "credentials" });
973
607
  if (preLoginResult.stopped) {
608
+ emitLoginFailed({ email, provider: "credentials", reason: preLoginResult.signal.errorCode, stage: "login", requesterIp });
974
609
  return { status: false, reason: preLoginResult.signal.errorCode };
975
610
  }
611
+ if (await isAccountLocked(email, requesterIp)) {
612
+ emitLoginFailed({ email, provider: "credentials", reason: "login.accountLocked", stage: "login", requesterIp });
613
+ return { status: false, reason: "login.accountLocked" };
614
+ }
976
615
  const userAdapter = getUserAdapter();
977
- const [findUserError, findUserResponse] = await tryCatch5(
978
- () => userAdapter.findByEmail({ email, provider: "credentials" })
616
+ const [findUserError, findUserResponse] = await tryCatch(
617
+ () => resolveUserByEmail(userAdapter, { email, provider: "credentials" })
979
618
  );
980
619
  if (findUserError) {
981
620
  getLogger3().error("login: findByEmail failed", findUserError);
982
- return { status: false, reason: toReasonKey(findUserError) };
621
+ const reason = toReasonKey(findUserError);
622
+ emitLoginFailed({ email, provider: "credentials", reason, stage: "login", requesterIp });
623
+ return { status: false, reason };
624
+ }
625
+ const passwordHash = findUserResponse?.password ?? null;
626
+ if (!findUserResponse || !passwordHash) {
627
+ const dummyHash = await getDummyBcryptHash();
628
+ await tryCatch(() => compare(password, dummyHash));
629
+ emitLoginFailed({ email, userId: findUserResponse?.id, provider: "credentials", reason: "login.wrongPassword", stage: "login", requesterIp });
630
+ return { status: false, reason: "login.wrongPassword" };
983
631
  }
984
- if (!findUserResponse) return { status: false, reason: "login.userNotFound" };
985
- const [checkPasswordError, checkPasswordResponse] = await tryCatch5(
986
- () => compare(password, findUserResponse.password)
632
+ const [checkPasswordError, checkPasswordResponse] = await tryCatch(
633
+ () => compare(password, passwordHash)
987
634
  );
988
635
  if (checkPasswordError) {
989
636
  getLogger3().error("login: bcrypt compare failed", checkPasswordError);
990
- return { status: false, reason: checkPasswordError };
637
+ const reason = toReasonKey(checkPasswordError);
638
+ emitLoginFailed({ email, userId: findUserResponse.id, provider: "credentials", reason, stage: "login", requesterIp });
639
+ return { status: false, reason };
640
+ }
641
+ if (!checkPasswordResponse) {
642
+ emitLoginFailed({ email, userId: findUserResponse.id, provider: "credentials", reason: "login.wrongPassword", stage: "login", requesterIp });
643
+ return { status: false, reason: "login.wrongPassword" };
991
644
  }
992
- if (!checkPasswordResponse) return { status: false, reason: "login.wrongPassword" };
993
- const newToken = randomBytes2(32).toString("hex");
645
+ const newToken = randomBytes(32).toString("hex");
994
646
  const previousLogin = findUserResponse.lastLogin ?? null;
995
647
  const nowLogin = /* @__PURE__ */ new Date();
996
- await tryCatch5(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
648
+ await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
997
649
  const newUser = {
998
650
  ...sanitizeUserForSession(findUserResponse),
999
651
  token: newToken,
1000
652
  lastLogin: nowLogin,
1001
653
  previousLogin
1002
654
  };
1003
- const filePath = path.join(uploadsFolder(), `${newUser.id}.webp`);
1004
- if (existsSync(filePath)) {
1005
- newUser.avatar = `${newUser.id}.webp`;
655
+ const uploadedAvatar = await resolveUploadedAvatar(newUser.id);
656
+ if (uploadedAvatar) {
657
+ newUser.avatar = uploadedAvatar;
1006
658
  }
1007
- await saveSession(newToken, newUser, true);
1008
- await dispatchHook3("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
659
+ const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });
660
+ if (!saved.ok) {
661
+ emitLoginFailed({ email, userId: newUser.id, provider: "credentials", reason: saved.errorCode, stage: "login", requesterIp });
662
+ return { status: false, reason: saved.errorCode };
663
+ }
664
+ await dispatchHook("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
665
+ void clearAuthFailures(email, requesterIp);
1009
666
  if (isDevMode()) {
1010
667
  getLogger3().debug(`credentials login success for user ${newUser.id}`);
1011
668
  }
1012
669
  return { status: true, reason: "login.loggedIn", newToken, session: newUser };
1013
670
  };
1014
- var loginWithCredentials = async (params) => {
671
+ var loginWithCredentials = async (params, options) => {
1015
672
  const creds = normalizeCredentials(params);
1016
673
  if (isDevMode()) {
1017
674
  getLogger3().debug(`credentials auth attempt for ${creds.email || "unknown-email"}`);
1018
675
  }
1019
- const validationError = validateCredentialsShape(creds);
1020
- if (validationError) return validationError;
1021
- if (creds.name && creds.confirmPassword) {
676
+ const { name, confirmPassword } = creds;
677
+ if (name && confirmPassword) {
678
+ const validationError2 = validateCredentialsShape(creds, "register");
679
+ if (validationError2) return validationError2;
680
+ if (!getProjectConfig4().auth.allowRegistration) {
681
+ emitLoginFailed({ email: creds.email, provider: "credentials", reason: "auth.registrationDisabled", stage: "register" });
682
+ return { status: false, reason: "auth.registrationDisabled" };
683
+ }
1022
684
  return registerWithCredentials({
1023
685
  email: creds.email,
1024
686
  password: creds.password,
1025
- name: creds.name,
1026
- confirmPassword: creds.confirmPassword
1027
- });
687
+ name,
688
+ confirmPassword
689
+ }, options);
1028
690
  }
1029
- return loginWithCredentialsCore({ email: creds.email, password: creds.password });
691
+ const validationError = validateCredentialsShape(creds, "login");
692
+ if (validationError) return validationError;
693
+ return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);
1030
694
  };
1031
695
  var isAllowedRedirectUrl = (url) => {
1032
696
  if (!URL.canParse(url, "http://placeholder")) return false;
697
+ if (url.includes("\\")) return false;
1033
698
  const parsed = new URL(url, "http://placeholder");
1034
699
  if (parsed.origin === "http://placeholder") {
1035
700
  return true;
1036
701
  }
1037
- const allowed = getProjectConfig3().http.cors.allowedOrigins ?? [];
702
+ const cfg = getProjectConfig4();
703
+ if (cfg.http.cors.allowLocalhost && parsed.hostname === "localhost") return true;
704
+ const allowed = cfg.http.cors.allowedOrigins ?? [];
1038
705
  if (typeof allowed === "function") {
1039
706
  return allowed(parsed.origin);
1040
707
  }
@@ -1043,43 +710,52 @@ var isAllowedRedirectUrl = (url) => {
1043
710
  return new URL(origin).origin === parsed.origin;
1044
711
  });
1045
712
  };
1046
- var exchangeOAuthToken = async (provider, code) => {
713
+ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
1047
714
  const values = {
1048
715
  code,
1049
716
  client_id: provider.clientID,
1050
717
  client_secret: provider.clientSecret,
1051
718
  redirect_uri: provider.callbackURL,
1052
- grant_type: "authorization_code"
719
+ grant_type: "authorization_code",
720
+ ...codeVerifier ? { code_verifier: codeVerifier } : {}
1053
721
  };
1054
722
  const getToken = async () => {
1055
723
  if (provider.tokenExchangeMethod === "json") {
1056
- const response2 = await fetch(provider.tokenExchangeURL, {
724
+ const response3 = await fetch(provider.tokenExchangeURL, {
1057
725
  method: "POST",
1058
726
  headers: { "Content-Type": "application/json", "Accept": "application/json" },
1059
727
  body: JSON.stringify(values)
1060
728
  });
1061
- return await response2.json();
1062
- }
1063
- if (provider.tokenExchangeMethod === "form") {
1064
- const formParams = new URLSearchParams();
1065
- formParams.append("client_id", provider.clientID);
1066
- formParams.append("client_secret", provider.clientSecret);
1067
- formParams.append("code", values.code);
1068
- formParams.append("grant_type", "authorization_code");
1069
- formParams.append("redirect_uri", provider.callbackURL);
1070
- if (isDevMode()) {
1071
- getLogger3().debug("oauth: token-exchange form params", { params: formParams.toString() });
729
+ if (!response3.ok) {
730
+ getLogger3().warn("oauth: token exchange returned non-OK status", { provider: provider.name, status: response3.status });
731
+ return null;
1072
732
  }
1073
- const response2 = await fetch(provider.tokenExchangeURL, {
1074
- method: "POST",
1075
- headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
1076
- body: formParams.toString()
1077
- });
1078
- return await response2.json();
733
+ return await response3.json();
1079
734
  }
1080
- return null;
735
+ const formParams = new URLSearchParams();
736
+ formParams.append("client_id", provider.clientID);
737
+ formParams.append("client_secret", provider.clientSecret);
738
+ formParams.append("code", values.code);
739
+ formParams.append("grant_type", "authorization_code");
740
+ formParams.append("redirect_uri", provider.callbackURL);
741
+ if (codeVerifier) {
742
+ formParams.append("code_verifier", codeVerifier);
743
+ }
744
+ if (isDevMode()) {
745
+ getLogger3().debug("oauth: token-exchange form params", { paramNames: [...formParams.keys()] });
746
+ }
747
+ const response2 = await fetch(provider.tokenExchangeURL, {
748
+ method: "POST",
749
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
750
+ body: formParams.toString()
751
+ });
752
+ if (!response2.ok) {
753
+ getLogger3().warn("oauth: token exchange returned non-OK status", { provider: provider.name, status: response2.status });
754
+ return null;
755
+ }
756
+ return await response2.json();
1081
757
  };
1082
- const [error, response] = await tryCatch5(getToken);
758
+ const [error, response] = await tryCatch(getToken);
1083
759
  if (error) {
1084
760
  getLogger3().error("oauth: token exchange failed", error);
1085
761
  return null;
@@ -1102,19 +778,33 @@ var fetchOAuthProfile = async (provider, accessToken) => {
1102
778
  "Authorization": `Bearer ${accessToken}`
1103
779
  }
1104
780
  });
781
+ if (!response2.ok) {
782
+ getLogger3().warn("oauth: userInfo fetch returned non-OK status", { provider: provider.name, status: response2.status });
783
+ return null;
784
+ }
1105
785
  return await response2.json();
1106
786
  };
1107
- const [error, response] = await tryCatch5(getUserData);
787
+ const [error, response] = await tryCatch(getUserData);
1108
788
  if (error) {
1109
789
  getLogger3().error("oauth: userInfo fetch failed", error);
1110
790
  return null;
1111
791
  }
1112
792
  const userData = asRecord(response);
1113
- const name = String(userData[provider.nameKey] || "didnt find a name");
793
+ const resolvedName = readStringField(userData, provider.nameKey);
794
+ const name = resolvedName === "" ? "didnt find a name" : resolvedName;
1114
795
  const emailValue = userData[provider.emailKey];
1115
796
  let email = typeof emailValue === "string" ? emailValue : void 0;
797
+ let emailVerified = false;
798
+ if (email && provider.emailVerifiedKey) {
799
+ const verifiedFlag = userData[provider.emailVerifiedKey];
800
+ if (verifiedFlag === false) {
801
+ getLogger3().warn("oauth: provider email is not verified \u2014 rejecting", { provider: provider.name });
802
+ return null;
803
+ }
804
+ if (verifiedFlag !== void 0) emailVerified = true;
805
+ }
1116
806
  const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : void 0;
1117
- const avatarValue = provider?.avatarKey ? String(userData[provider.avatarKey] || "") : provider.getAvatar ? await provider.getAvatar({ userData, avatarId: typeof avatarId === "string" ? avatarId : "", accessToken }) : "";
807
+ const avatarValue = provider.avatarKey ? readStringField(userData, provider.avatarKey) : provider.getAvatar ? await provider.getAvatar({ userData, avatarId: typeof avatarId === "string" ? avatarId : "", accessToken }) : "";
1118
808
  const avatar = typeof avatarValue === "string" ? avatarValue : "";
1119
809
  if (!email && provider.getEmail) {
1120
810
  const selectedEmail = await provider.getEmail(accessToken);
@@ -1123,33 +813,51 @@ var fetchOAuthProfile = async (provider, accessToken) => {
1123
813
  return null;
1124
814
  }
1125
815
  email = selectedEmail;
816
+ emailVerified = true;
1126
817
  }
1127
- return { email, name, avatar };
818
+ if (email !== void 0) {
819
+ const normalizedEmail = email.trim().toLowerCase();
820
+ if (!isEmail(normalizedEmail)) {
821
+ getLogger3().warn("oauth: provider returned a malformed email", { provider: provider.name });
822
+ return null;
823
+ }
824
+ email = normalizedEmail;
825
+ }
826
+ return { email, name, avatar, emailVerified, rawUserData: userData };
1128
827
  };
1129
828
  var findOrCreateOAuthUser = async (provider, profile) => {
1130
- const { email, name, avatar } = profile;
829
+ const { email, name, avatar, emailVerified } = profile;
1131
830
  if (!email) return null;
1132
- const preLoginResult = await dispatchHook3("preLogin", { email, provider: provider.name });
831
+ const preLoginResult = await dispatchHook("preLogin", { email, provider: provider.name });
1133
832
  if (preLoginResult.stopped) {
1134
833
  getLogger3().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });
1135
834
  return null;
1136
835
  }
1137
836
  const userAdapter = getUserAdapter();
1138
- const [findError, findResponse] = await tryCatch5(
1139
- () => userAdapter.findByEmail({ email, provider: provider.name })
837
+ const [findError, findResponse] = await tryCatch(
838
+ () => resolveUserByEmail(userAdapter, { email, provider: provider.name })
1140
839
  );
1141
840
  if (findError) {
1142
841
  getLogger3().error("oauth: findByEmail failed", findError);
1143
842
  return null;
1144
843
  }
1145
844
  if (findResponse?.id) {
1146
- const filePath = path.join(uploadsFolder(), `${findResponse.id}.webp`);
1147
- if (existsSync(filePath)) {
1148
- findResponse.avatar = `${findResponse.id}.webp`;
845
+ const existingProvider = findResponse.provider ?? null;
846
+ const isCrossProviderLink = existingProvider !== null && existingProvider !== provider.name;
847
+ if (isCrossProviderLink && !emailVerified) {
848
+ getLogger3().warn(
849
+ "oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)",
850
+ { provider: provider.name, existingProvider }
851
+ );
852
+ return null;
853
+ }
854
+ const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);
855
+ if (uploadedAvatar) {
856
+ findResponse.avatar = uploadedAvatar;
1149
857
  }
1150
858
  const previousLogin = findResponse.lastLogin ?? null;
1151
859
  const nowLogin = /* @__PURE__ */ new Date();
1152
- await tryCatch5(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
860
+ await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
1153
861
  return {
1154
862
  user: {
1155
863
  ...sanitizeUserForSession(findResponse),
@@ -1160,7 +868,7 @@ var findOrCreateOAuthUser = async (provider, profile) => {
1160
868
  isNewUser: false
1161
869
  };
1162
870
  }
1163
- const preRegisterResult = await dispatchHook3("preRegister", { email, provider: provider.name, name });
871
+ const preRegisterResult = await dispatchHook("preRegister", { email, provider: provider.name, name });
1164
872
  if (preRegisterResult.stopped) {
1165
873
  getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
1166
874
  return null;
@@ -1170,10 +878,10 @@ var findOrCreateOAuthUser = async (provider, profile) => {
1170
878
  provider: provider.name,
1171
879
  name,
1172
880
  avatar,
1173
- avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
1174
- language: getProjectConfig3().defaultLanguage
881
+ avatarFallback: generateAvatarFallbackColor(),
882
+ language: getProjectConfig4().defaultLanguage
1175
883
  });
1176
- const [createError, createResponse] = await tryCatch5(createNewUser);
884
+ const [createError, createResponse] = await tryCatch(createNewUser);
1177
885
  if (createError) {
1178
886
  getLogger3().error("oauth: user create failed", createError);
1179
887
  return null;
@@ -1192,7 +900,7 @@ var resolvePostLoginRedirect = async ({
1192
900
  }) => {
1193
901
  const resolver = getPostLoginRedirect();
1194
902
  if (!resolver) return fallbackUrl;
1195
- const [resolverError, resolved] = await tryCatch5(() => resolver({
903
+ const [resolverError, resolved] = await tryCatch(() => resolver({
1196
904
  userId,
1197
905
  provider: providerName,
1198
906
  isNewUser,
@@ -1211,49 +919,84 @@ var resolvePostLoginRedirect = async ({
1211
919
  }
1212
920
  return fallbackUrl;
1213
921
  };
922
+ var resolveProviderFromCallback = (pathname, url) => {
923
+ const rawProviderSegment = pathname.split("/")[3];
924
+ if (!rawProviderSegment || !url) return null;
925
+ const found = getOAuthProviders().find((p) => p.name === rawProviderSegment);
926
+ if (!found || !isFullOAuthProvider(found)) return null;
927
+ return { provider: found, providerName: found.name };
928
+ };
929
+ var applyExtraSessionFields = async (user, provider, profile, accessToken) => {
930
+ const { extraSessionFields } = provider;
931
+ if (!extraSessionFields) return;
932
+ const [extraErr, extra] = await tryCatch(
933
+ async () => extraSessionFields({ userData: profile.rawUserData, accessToken })
934
+ );
935
+ if (extraErr) {
936
+ getLogger3().warn(`[oauth:${provider.name}] extraSessionFields hook threw \u2014 continuing without extras`, { err: extraErr });
937
+ return;
938
+ }
939
+ if (!extra) return;
940
+ const { id, token, csrfToken, password, ...safeExtra } = extra;
941
+ if (id !== void 0 || token !== void 0 || csrfToken !== void 0 || password !== void 0) {
942
+ getLogger3().warn(
943
+ `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) \u2014 dropped`
944
+ );
945
+ }
946
+ Object.assign(user, safeExtra);
947
+ };
948
+ var resolveOAuthFallbackUrl = (stateEntry, options) => {
949
+ const stateReturnUrl = stateEntry.returnUrl && isAllowedRedirectUrl(stateEntry.returnUrl) ? stateEntry.returnUrl : void 0;
950
+ return stateReturnUrl ?? options.defaultRedirectUrl ?? getProjectConfig4().loginRedirectUrl ?? "/";
951
+ };
1214
952
  var loginCallback = async (pathname, req, _res, options = {}) => {
1215
- const providerName = pathname.split("/")[3];
1216
- if (!providerName) return false;
1217
- const provider = getOAuthProviders().find((p) => p.name === providerName);
1218
- if (!provider || !req.url) return false;
1219
- if (!isFullOAuthProvider(provider)) return false;
1220
- const queryString = req.url.split("?")[1] ?? "";
953
+ const providerResolution = resolveProviderFromCallback(pathname, req.url);
954
+ if (!providerResolution) return false;
955
+ const { provider, providerName } = providerResolution;
956
+ const queryString = (req.url ?? "").split("?")[1] ?? "";
1221
957
  const params = new URLSearchParams(queryString);
1222
958
  const code = params.get("code");
1223
959
  const state = params.get("state");
1224
- const stateIsValid = await consumeOAuthState(provider.name, state || "");
1225
- if (!stateIsValid) {
1226
- getLogger3().warn("oauth: invalid or missing state", { provider: provider.name });
960
+ const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? "";
961
+ const stateEntry = await consumeOAuthState(provider.name, state ?? "", cookieNonce);
962
+ if (!stateEntry) {
963
+ getLogger3().warn("oauth: invalid, missing, or unbound state", { provider: provider.name });
964
+ emitLoginFailed({ provider: providerName, reason: "oauth.invalidState", stage: "oauth" });
1227
965
  return false;
1228
966
  }
1229
967
  if (!code) {
1230
968
  getLogger3().warn("oauth: no code provided in callback url", { provider: provider.name });
969
+ emitLoginFailed({ provider: providerName, reason: "oauth.noCode", stage: "oauth" });
970
+ return false;
971
+ }
972
+ const accessToken = await exchangeOAuthToken(provider, code, stateEntry.codeVerifier);
973
+ if (!accessToken) {
974
+ emitLoginFailed({ provider: providerName, reason: "oauth.tokenExchangeFailed", stage: "oauth" });
1231
975
  return false;
1232
976
  }
1233
- const accessToken = await exchangeOAuthToken(provider, code);
1234
- if (!accessToken) return false;
1235
977
  const profile = await fetchOAuthProfile(provider, accessToken);
1236
- if (!profile) return false;
978
+ if (!profile) {
979
+ emitLoginFailed({ provider: providerName, reason: "oauth.profileFetchFailed", stage: "oauth" });
980
+ return false;
981
+ }
1237
982
  const resolved = await findOrCreateOAuthUser(provider, profile);
1238
- if (!resolved) return false;
1239
- const newToken = randomBytes2(32).toString("hex");
983
+ if (!resolved) {
984
+ emitLoginFailed({ provider: providerName, email: profile.email, reason: "oauth.userResolveFailed", stage: "oauth" });
985
+ return false;
986
+ }
987
+ const newToken = randomBytes(32).toString("hex");
1240
988
  resolved.user.token = newToken;
1241
- const extraSessionFields = provider.extraSessionFields;
1242
- if (extraSessionFields) {
1243
- const [extraErr, extra] = await tryCatch5(
1244
- async () => extraSessionFields({ userData: asOAuthUserData(profile), accessToken })
1245
- );
1246
- if (extraErr) {
1247
- getLogger3().warn(`[oauth:${providerName}] extraSessionFields hook threw \u2014 continuing without extras`, { err: extraErr });
1248
- } else if (extra) {
1249
- Object.assign(resolved.user, extra);
1250
- }
989
+ await applyExtraSessionFields(resolved.user, provider, profile, accessToken);
990
+ const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });
991
+ if (!saved.ok) {
992
+ getLogger3().error(`[oauth:${providerName}] saveSession failed \u2014 aborting callback`, { errorCode: saved.errorCode });
993
+ emitLoginFailed({ provider: providerName, userId: resolved.user.id, reason: saved.errorCode, stage: "oauth" });
994
+ return false;
1251
995
  }
1252
- await saveSession(newToken, resolved.user, true);
1253
996
  if (resolved.isNewUser) {
1254
- await dispatchHook3("postRegister", { userId: resolved.user.id, provider: providerName });
997
+ await dispatchHook("postRegister", { userId: resolved.user.id, provider: providerName });
1255
998
  }
1256
- await dispatchHook3("postLogin", {
999
+ await dispatchHook("postLogin", {
1257
1000
  userId: resolved.user.id,
1258
1001
  provider: providerName,
1259
1002
  isNewUser: resolved.isNewUser,
@@ -1262,7 +1005,7 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1262
1005
  if (isDevMode()) {
1263
1006
  getLogger3().debug(`oauth login success for user ${resolved.user.id}`);
1264
1007
  }
1265
- const fallbackUrl = options.defaultRedirectUrl ?? getProjectConfig3().loginRedirectUrl ?? "/";
1008
+ const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);
1266
1009
  const redirectUrl = await resolvePostLoginRedirect({
1267
1010
  fallbackUrl,
1268
1011
  userId: resolved.user.id,
@@ -1278,37 +1021,32 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1278
1021
  };
1279
1022
  };
1280
1023
 
1281
- // src/index.ts
1282
- init_logout();
1283
-
1284
1024
  // src/passwordReset.ts
1285
1025
  import bcrypt2 from "bcryptjs";
1286
- import { randomBytes as randomBytes3 } from "crypto";
1287
- import { getProjectConfig as getProjectConfig4, formatKey as formatKey4, redis as redis2 } from "@luckystack/core";
1288
- var tokenKey = (token) => formatKey4("-pwreset", token);
1026
+ import { getProjectConfig as getProjectConfig5, issueOneTimeToken, consumeOneTimeToken, formatKey as formatKey2, redis, oneTimeTokenKey } from "@luckystack/core";
1027
+ var PWRESET_NAMESPACE = "-pwreset";
1028
+ var pwresetUserKey = (userId) => formatKey2("-pwreset-user", userId);
1289
1029
  var createPasswordResetToken = async (userId) => {
1290
- const token = randomBytes3(32).toString("hex");
1291
- const ttl = getProjectConfig4().auth.passwordResetTtlSeconds;
1292
- await redis2.set(tokenKey(token), userId, "EX", ttl);
1293
- return token;
1030
+ const ttl = getProjectConfig5().auth.passwordResetTtlSeconds;
1031
+ const priorKey = await redis.get(pwresetUserKey(userId));
1032
+ if (priorKey) {
1033
+ await redis.del(priorKey);
1034
+ }
1035
+ const handle = issueOneTimeToken(PWRESET_NAMESPACE, ttl, userId);
1036
+ await handle.store();
1037
+ const newHashKey = oneTimeTokenKey(PWRESET_NAMESPACE, handle.token);
1038
+ await redis.set(pwresetUserKey(userId), newHashKey, "EX", ttl);
1039
+ return handle.token;
1294
1040
  };
1295
1041
  var consumePasswordResetToken = async (token) => {
1296
- if (!token || typeof token !== "string") return null;
1297
- const key = tokenKey(token);
1298
- const txResult = await redis2.multi().get(key).del(key).exec();
1299
- if (!txResult || txResult.length === 0) return null;
1300
- const first = txResult[0];
1301
- if (!first) return null;
1302
- const [getErr, value] = first;
1303
- if (getErr) return null;
1304
- return typeof value === "string" && value.length > 0 ? value : null;
1042
+ return consumeOneTimeToken(PWRESET_NAMESPACE, token);
1305
1043
  };
1306
1044
  var updatePasswordHash = async (userId, plaintext) => {
1307
1045
  const reason = validatePassword(plaintext);
1308
1046
  if (reason) {
1309
1047
  throw new PasswordPolicyError(reason);
1310
1048
  }
1311
- const salt = await bcrypt2.genSalt(getProjectConfig4().auth.bcryptRounds);
1049
+ const salt = await bcrypt2.genSalt(getProjectConfig5().auth.bcryptRounds);
1312
1050
  const hashedPassword = await bcrypt2.hash(plaintext, salt);
1313
1051
  const patch = { password: hashedPassword };
1314
1052
  await getUserAdapter().update(userId, patch);
@@ -1326,37 +1064,44 @@ var verifyPassword = async (plaintext, hash2) => {
1326
1064
  };
1327
1065
 
1328
1066
  // src/forgotPassword.ts
1329
- import { getProjectConfig as getProjectConfig5, dispatchHook as dispatchHook4, getLogger as getLogger4 } from "@luckystack/core";
1067
+ import { getProjectConfig as getProjectConfig6, dispatchHook as dispatchHook2, getLogger as getLogger4 } from "@luckystack/core";
1068
+
1069
+ // src/emailModuleLoader.ts
1070
+ var loadEmailModule = () => (
1071
+ // @ts-expect-error optional peer dep — installed only when the consumer wires email sending
1072
+ import("@luckystack/email")
1073
+ );
1074
+
1075
+ // src/forgotPassword.ts
1330
1076
  var sendPasswordResetEmail = async ({ email, brand }) => {
1331
- const config = getProjectConfig5();
1332
- getLogger4().info("[forgotPassword] start", { email, forgotPasswordMode: config.auth.forgotPassword });
1077
+ const config = getProjectConfig6();
1078
+ getLogger4().info("[forgotPassword] start", { forgotPasswordMode: config.auth.forgotPassword });
1333
1079
  if (config.auth.forgotPassword !== "framework") {
1334
1080
  getLogger4().warn('[forgotPassword] auth.forgotPassword is not "framework" \u2014 nothing sent', { mode: config.auth.forgotPassword });
1335
1081
  return { ok: false, reason: "forgotPassword-not-framework" };
1336
1082
  }
1337
1083
  const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? "LuckyStack";
1338
- const emailModule = await // @ts-expect-error optional peer dep — installed only when forgotPassword === 'framework'
1339
- import("@luckystack/email").catch((error) => {
1084
+ const emailModule = await loadEmailModule().catch((error) => {
1340
1085
  getLogger4().warn("[forgotPassword] failed to load @luckystack/email \u2014 is it installed?", { error: String(error) });
1341
1086
  return null;
1342
1087
  });
1343
1088
  if (!emailModule) {
1344
1089
  return { ok: false, reason: "email-module-load-failed" };
1345
1090
  }
1346
- const { sendEmail, renderEmailLayout } = emailModule;
1091
+ const { sendEmail } = emailModule;
1347
1092
  const userAdapter = getUserAdapter();
1348
1093
  const user = await userAdapter.findByEmail({ email, provider: "credentials" });
1349
- getLogger4().info("[forgotPassword] credentials user lookup", { email, found: Boolean(user), userId: user?.id ?? null });
1094
+ getLogger4().info("[forgotPassword] credentials user lookup", { found: Boolean(user), userId: user?.id ?? null });
1350
1095
  if (!user) {
1351
- getLogger4().warn("[forgotPassword] no credentials account for this email \u2014 nothing sent. Use the email+password account you registered with.", { email });
1352
- void dispatchHook4("passwordResetRequested", {
1096
+ getLogger4().warn("[forgotPassword] no credentials account for this email \u2014 nothing sent. Use the email+password account you registered with.");
1097
+ void dispatchHook2("passwordResetRequested", {
1353
1098
  email,
1354
1099
  matched: false
1355
1100
  });
1356
1101
  return { ok: true };
1357
1102
  }
1358
1103
  const token = await createPasswordResetToken(user.id);
1359
- void dispatchHook4("passwordResetRequested", {
1104
+ void dispatchHook2("passwordResetRequested", {
1360
1105
  email,
1361
1106
  matched: true,
1362
1107
  userId: user.id,
@@ -1366,20 +1111,10 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1366
1111
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1367
1112
  const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;
1368
1113
  const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);
1369
- const { html, text } = renderEmailLayout({
1370
- brand: resolvedBrand,
1371
- title: "Reset your password",
1372
- intro: `Hi ${user.name ?? "there"}, we received a request to reset the password on your ${resolvedBrand} account. Click the button below to choose a new one. The link expires in ${String(ttlMinutes)} minutes.`,
1373
- ctaLabel: "Reset password",
1374
- ctaUrl: resetUrl,
1375
- outro: `If you didn't request this, you can safely ignore this email \u2014 your password will stay the same. The link: ${resetUrl}`,
1376
- footer: `Sent by ${resolvedBrand}. If you have questions, reply to this email.`
1377
- });
1378
1114
  const result = await sendEmail({
1379
1115
  to: user.email,
1380
- subject: `Reset your ${resolvedBrand} password`,
1381
- html,
1382
- text,
1116
+ template: "password-reset",
1117
+ data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },
1383
1118
  adapterHint: "transactional"
1384
1119
  });
1385
1120
  if (result.ok) {
@@ -1391,69 +1126,66 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1391
1126
  };
1392
1127
 
1393
1128
  // src/emailChange.ts
1394
- import { randomBytes as randomBytes4 } from "crypto";
1395
- import { getProjectConfig as getProjectConfig6, formatKey as formatKey5, redis as redis3 } from "@luckystack/core";
1396
- var tokenKey2 = (token) => formatKey5("-email-change", token);
1129
+ import { getProjectConfig as getProjectConfig7, issueOneTimeToken as issueOneTimeToken2, consumeOneTimeTokenJson, formatKey as formatKey3, redis as redis2, oneTimeTokenKey as oneTimeTokenKey2 } from "@luckystack/core";
1130
+ var EMAIL_CHANGE_NAMESPACE = "-email-change";
1131
+ var emailChangeUserKey = (userId) => formatKey3("-email-change-user", userId);
1397
1132
  var createEmailChangeToken = async (userId, newEmail) => {
1398
- const token = randomBytes4(32).toString("hex");
1399
- const ttl = getProjectConfig6().auth.emailChangeTtlSeconds;
1400
- const payload = JSON.stringify({ userId, newEmail });
1401
- await redis3.set(tokenKey2(token), payload, "EX", ttl);
1402
- return token;
1133
+ const ttl = getProjectConfig7().auth.emailChangeTtlSeconds;
1134
+ const priorKey = await redis2.get(emailChangeUserKey(userId));
1135
+ if (priorKey) {
1136
+ await redis2.del(priorKey);
1137
+ }
1138
+ const handle = issueOneTimeToken2(
1139
+ EMAIL_CHANGE_NAMESPACE,
1140
+ ttl,
1141
+ { userId, newEmail }
1142
+ );
1143
+ await handle.store();
1144
+ const newHashKey = oneTimeTokenKey2(EMAIL_CHANGE_NAMESPACE, handle.token);
1145
+ await redis2.set(emailChangeUserKey(userId), newHashKey, "EX", ttl);
1146
+ return handle.token;
1403
1147
  };
1404
1148
  var consumeEmailChangeToken = async (token) => {
1405
- if (!token || typeof token !== "string") return null;
1406
- const key = tokenKey2(token);
1407
- const txResult = await redis3.multi().get(key).del(key).exec();
1408
- if (!txResult || txResult.length === 0) return null;
1409
- const first = txResult[0];
1410
- if (!first) return null;
1411
- const [getErr, value] = first;
1412
- if (getErr) return null;
1413
- if (typeof value !== "string" || value.length === 0) return null;
1414
- try {
1415
- const parsed = JSON.parse(value);
1416
- if (!parsed || typeof parsed !== "object" || typeof parsed.userId !== "string" || typeof parsed.newEmail !== "string") {
1417
- return null;
1418
- }
1419
- return parsed;
1420
- } catch {
1149
+ const parsed = await consumeOneTimeTokenJson(EMAIL_CHANGE_NAMESPACE, token);
1150
+ if (!parsed || typeof parsed !== "object" || typeof parsed.userId !== "string" || typeof parsed.newEmail !== "string") {
1421
1151
  return null;
1422
1152
  }
1153
+ return parsed;
1423
1154
  };
1424
1155
 
1425
1156
  // src/emailChangeNotification.ts
1426
- import { getProjectConfig as getProjectConfig7 } from "@luckystack/core";
1157
+ import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
1427
1158
  var sendEmailChangeConfirmation = async ({ userId, newEmail, userName, brand }) => {
1428
- const config = getProjectConfig7();
1159
+ const config = getProjectConfig8();
1429
1160
  const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? "LuckyStack";
1430
- const { sendEmail, renderEmailLayout } = await // @ts-expect-error optional peer dep — installed only when the consumer wires email sending
1431
- import("@luckystack/email");
1161
+ const { sendEmail } = await loadEmailModule();
1432
1162
  const token = await createEmailChangeToken(userId, newEmail);
1433
1163
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1434
1164
  const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;
1435
1165
  const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);
1436
- const { html, text } = renderEmailLayout({
1437
- brand: resolvedBrand,
1438
- title: "Confirm your new email address",
1439
- intro: `Hi ${userName ?? "there"}, you (or someone using your ${resolvedBrand} account) asked to change the account email to this address. Click the button below to confirm \u2014 the link expires in ${String(ttlMinutes)} minutes. If you confirm, all of your active sessions will be signed out as a security precaution.`,
1440
- ctaLabel: "Confirm new email",
1441
- ctaUrl: confirmUrl,
1442
- outro: `If you didn't request this, you can safely ignore this email \u2014 nothing will change. The link: ${confirmUrl}`,
1443
- footer: `Sent by ${resolvedBrand}. If you have questions, reply to this email.`
1444
- });
1445
1166
  const result = await sendEmail({
1446
1167
  to: newEmail,
1447
- subject: `Confirm your new ${resolvedBrand} email address`,
1448
- html,
1449
- text,
1168
+ template: "email-change",
1169
+ data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },
1450
1170
  adapterHint: "transactional"
1451
1171
  });
1452
1172
  return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };
1453
1173
  };
1174
+
1175
+ // src/index.ts
1176
+ registerSessionProvider({
1177
+ getSession,
1178
+ saveSession,
1179
+ deleteSession,
1180
+ logout: ({ token, socket, userId, skipSessionDelete }) => logout({ token, socket, userId: userId ?? null, skipSessionDelete })
1181
+ });
1182
+ registerAuthLockoutHook();
1454
1183
  export {
1184
+ OAUTH_STATE_COOKIE_NAME,
1455
1185
  PasswordPolicyError,
1456
1186
  activeUsersKeyFor,
1187
+ asOAuthUserData,
1188
+ clearAuthFailures,
1457
1189
  consumeEmailChangeToken,
1458
1190
  consumePasswordResetToken,
1459
1191
  createEmailChangeToken,
@@ -1469,9 +1201,11 @@ export {
1469
1201
  getPostLoginRedirect,
1470
1202
  getSession,
1471
1203
  getSessionAdapter,
1204
+ getSessionSanitizer,
1472
1205
  getUserAdapter,
1473
1206
  githubProvider,
1474
1207
  googleProvider,
1208
+ isAccountLocked,
1475
1209
  isFullOAuthProvider,
1476
1210
  isUserAdapterRegistered,
1477
1211
  loginCallback,
@@ -1479,10 +1213,13 @@ export {
1479
1213
  loginWithCredentialsCore,
1480
1214
  logout,
1481
1215
  microsoftProvider,
1216
+ recordAuthFailure,
1482
1217
  redisSessionAdapter,
1218
+ registerOAuthProviderFactory,
1483
1219
  registerOAuthProviders,
1484
1220
  registerPostLoginRedirect,
1485
1221
  registerSessionAdapter,
1222
+ registerSessionSanitizer,
1486
1223
  registerUserAdapter,
1487
1224
  registerWithCredentials,
1488
1225
  revokeUserSessions,