@luckystack/login 0.1.9 → 0.2.1

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,561 +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, options) => {
173
- const [error, outcome] = 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 { ok: false, errorCode: preSessionCreateResult.signal.errorCode || "api.internalServerError" };
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 { ok: true };
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(
202
- (t) => t !== token && t !== options?.supersedeToken
203
- );
204
- let tokensToKick = [];
205
- if (effectivePerUser === "single") {
206
- tokensToKick = previousTokens;
207
- } else if (cap !== null && previousTokens.length + 1 > cap && sessionCfg.onConflict === "revokeOld") {
208
- const excess = previousTokens.length + 1 - cap;
209
- tokensToKick = previousTokens.slice(0, excess);
210
- }
211
- if (tokensToKick.length > 0) {
212
- const { logout: logout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
213
- await Promise.all(tokensToKick.map(async (previousToken) => {
214
- const sockets = io.sockets.adapter.rooms.get(previousToken);
215
- if (sockets) {
216
- getLogger2().debug(`Kicking previous session for user ${userId}`);
217
- if (sessionCfg.notifyOldDeviceOnRevoke) {
218
- io.to(previousToken).emit(socketEventNames2.sessionReplaced, JSON.stringify({
219
- reason: "session-replaced",
220
- userId
221
- }));
222
- }
223
- for (const socketId of sockets) {
224
- const socket = io.sockets.sockets.get(socketId);
225
- if (socket) {
226
- await logout2({ token: previousToken, socket, userId });
227
- }
228
- }
229
- } else {
230
- await adapter.delete(previousToken);
231
- await adapter.untrackActive(userId, previousToken);
232
- }
233
- }));
234
- }
235
- }
236
- if (io.sockets.adapter.rooms.has(token)) {
237
- io.to(token).emit(socketEventNames2.updateSession, JSON.stringify(data));
238
- }
239
- if (newUser) {
240
- await dispatchHook2("postSessionCreate", { token, user: data, persistent: !getProjectConfig().session.basedToken });
241
- }
242
- return { ok: true };
243
- }, void 0, { fn: "saveSession", token });
244
- if (error) {
245
- getLogger2().error("saveSession failed", error, { token });
246
- return { ok: false, errorCode: "api.internalServerError" };
247
- }
248
- return outcome ?? { ok: false, errorCode: "api.internalServerError" };
249
- };
250
- getSession = async (token) => {
251
- if (!token) return null;
252
- const [error, value] = await tryCatch3(async () => {
253
- const adapter = getSessionAdapter();
254
- const raw = await adapter.getRaw(token);
255
- if (!raw) return null;
256
- const parsed = parseSessionLayout(raw);
257
- const userId = parsed?.id ?? null;
258
- const newTtl = getSessionTtl();
259
- const oldTtl = await adapter.ttl(token);
260
- const preRefreshResult = await dispatchHook2("preSessionRefresh", { token, userId, oldTtl, newTtl });
261
- let applied = false;
262
- if (!preRefreshResult.stopped) {
263
- applied = await adapter.expire(token, newTtl);
264
- }
265
- await dispatchHook2("postSessionRefresh", {
266
- token,
267
- userId,
268
- oldTtl,
269
- newTtl,
270
- applied
271
- });
272
- if (!parsed) return null;
273
- const merged = { ...parsed, token };
274
- return merged;
275
- }, void 0, { fn: "getSession", token });
276
- if (error) {
277
- getLogger2().error("getSession failed", error, { token });
278
- return null;
279
- }
280
- return value;
281
- };
282
- deleteSession = async (token, options) => {
283
- if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === "1") {
284
- getLogger2().warn("[session] deleteSession invoked", {
285
- tokenPrefix: token.slice(0, 8),
286
- stack: new Error("deleteSession invoked").stack
287
- });
288
- }
289
- const [error, ok] = await tryCatch3(async () => {
290
- const adapter = getSessionAdapter();
291
- const raw = await adapter.getRaw(token);
292
- let resolvedUserId = null;
293
- if (raw) {
294
- resolvedUserId = parseSessionLayout(raw)?.id ?? null;
295
- }
296
- const preSessionDeleteResult = await dispatchHook2("preSessionDelete", { token, userId: resolvedUserId });
297
- if (preSessionDeleteResult.stopped) {
298
- getLogger2().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });
299
- return false;
300
- }
301
- if (raw && resolvedUserId) {
302
- const { getIoInstance } = await import("@luckystack/core");
303
- const ioInstance = getIoInstance();
304
- if (ioInstance && !options?.skipSocketLogout) {
305
- const { logout: logout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
306
- const sockets = ioInstance.sockets.adapter.rooms.get(token);
307
- if (sockets) {
308
- await Promise.all([...sockets].map(async (socketId) => {
309
- const socket = ioInstance.sockets.sockets.get(socketId);
310
- if (!socket) return;
311
- await logout2({
312
- token,
313
- socket,
314
- userId: resolvedUserId,
315
- skipSessionDelete: true
316
- });
317
- }));
318
- }
319
- }
320
- await adapter.untrackActive(resolvedUserId, token);
321
- }
322
- await adapter.delete(token);
323
- await dispatchHook2("postSessionDelete", { token, userId: resolvedUserId });
324
- return true;
325
- }, void 0, { fn: "deleteSession", token });
326
- if (error) {
327
- getLogger2().error("deleteSession failed", error, { token });
328
- return false;
329
- }
330
- return ok ?? false;
331
- };
332
- getAllSessions = async () => {
333
- const [error, sessions] = await tryCatch3(async () => {
334
- const adapter = getSessionAdapter();
335
- if (!adapter.listAll) {
336
- getLogger2().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll \u2014 returning empty`);
337
- return [];
338
- }
339
- const rows = await adapter.listAll();
340
- const parsedSessions = [];
341
- for (const { raw } of rows) {
342
- const parsed = parseSessionLayout(raw);
343
- if (parsed) parsedSessions.push(parsed);
344
- }
345
- return parsedSessions;
346
- }, void 0, { fn: "getAllSessions" });
347
- if (error) {
348
- getLogger2().error("getAllSessions failed", error);
349
- return [];
350
- }
351
- return sessions ?? [];
352
- };
353
- revokeUserSessions = async (userId, exceptToken) => {
354
- if (!userId) return 0;
355
- const adapter = getSessionAdapter();
356
- const tokens = await adapter.listActive(userId);
357
- const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
358
- await Promise.all(targets.map((token) => deleteSession(token)));
359
- return targets.length;
360
- };
361
- sessionKeyFor = (token) => formatKey2("-session", token);
362
- activeUsersKeyFor = (userId) => formatKey2("-activeUsers", userId);
363
- }
364
- });
365
-
366
- // src/index.ts
367
- init_session();
368
- init_sessionAdapter();
369
-
370
- // src/oauthProviders.ts
371
- import { tryCatch as tryCatch4 } from "@luckystack/core";
372
- var asOAuthUserData = (value) => {
373
- if (value && typeof value === "object") {
374
- return value;
375
- }
376
- return {};
46
+ var lockoutKey = (accountKey, requesterIp) => {
47
+ const base = `auth:${accountKey.trim().toLowerCase()}`;
48
+ return requesterIp ? `${base}:${requesterIp}` : base;
377
49
  };
378
- var isFullOAuthProvider = (provider) => provider.name !== "credentials";
379
- var mergeScopes = (defaults, extra) => {
380
- if (!extra || extra.length === 0) return defaults;
381
- 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 };
382
54
  };
383
- var requireString = (value, label) => {
384
- if (!value || value.length === 0) {
385
- throw new Error(`OAuth provider configuration error: ${label} is empty.`);
386
- }
387
- 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;
388
60
  };
389
- var credentialsProvider = () => ({ name: "credentials" });
390
- var googleProvider = (input) => ({
391
- name: "google",
392
- clientID: requireString(input.clientId, "google clientId"),
393
- clientSecret: requireString(input.clientSecret, "google clientSecret"),
394
- callbackURL: input.callbackUrl,
395
- authorizationURL: input.endpoints?.authorizationURL ?? "https://accounts.google.com/o/oauth2/v2/auth",
396
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://oauth2.googleapis.com/token",
397
- tokenExchangeMethod: "json",
398
- userInfoURL: input.endpoints?.userInfoURL ?? "https://www.googleapis.com/oauth2/v1/userinfo",
399
- scope: mergeScopes([
400
- "https://www.googleapis.com/auth/userinfo.profile",
401
- "https://www.googleapis.com/auth/userinfo.email"
402
- ], input.extraScopes),
403
- nameKey: "name",
404
- emailKey: "email",
405
- avatarKey: "picture",
406
- avatarCodeKey: "",
407
- extraSessionFields: input.extraSessionFields
408
- });
409
- var githubProvider = (input) => ({
410
- name: "github",
411
- clientID: requireString(input.clientId, "github clientId"),
412
- clientSecret: requireString(input.clientSecret, "github clientSecret"),
413
- callbackURL: input.callbackUrl,
414
- authorizationURL: input.endpoints?.authorizationURL ?? "https://github.com/login/oauth/authorize",
415
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://github.com/login/oauth/access_token",
416
- tokenExchangeMethod: "json",
417
- userInfoURL: input.endpoints?.userInfoURL ?? "https://api.github.com/user",
418
- scope: mergeScopes(["read:user", "user:email"], input.extraScopes),
419
- nameKey: "login",
420
- emailKey: "email",
421
- avatarKey: "avatar_url",
422
- avatarCodeKey: "",
423
- extraSessionFields: input.extraSessionFields,
424
- getEmail: async (accessToken) => {
425
- const fetchEmails = async () => {
426
- const response = await fetch("https://api.github.com/user/emails", {
427
- method: "GET",
428
- headers: {
429
- "Content-Type": "application/json",
430
- Accept: "application/json",
431
- Authorization: `Bearer ${accessToken}`
432
- }
433
- });
434
- if (!response.ok) return false;
435
- const emails2 = await response.json();
436
- if (!Array.isArray(emails2)) return false;
437
- return emails2;
438
- };
439
- const [error, emails] = await tryCatch4(fetchEmails);
440
- if (error || !emails) return false;
441
- let mainEmail;
442
- for (const entry of emails) {
443
- if (entry.primary && typeof entry.email === "string") mainEmail = entry.email;
444
- }
445
- mainEmail ??= emails[0]?.email;
446
- return mainEmail;
447
- }
448
- });
449
- var discordProvider = (input) => ({
450
- name: "discord",
451
- clientID: requireString(input.clientId, "discord clientId"),
452
- clientSecret: requireString(input.clientSecret, "discord clientSecret"),
453
- callbackURL: input.callbackUrl,
454
- authorizationURL: input.endpoints?.authorizationURL ?? "https://discord.com/oauth2/authorize",
455
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? "https://discord.com/api/oauth2/token",
456
- tokenExchangeMethod: "form",
457
- userInfoURL: input.endpoints?.userInfoURL ?? "https://discord.com/api/users/@me",
458
- scope: mergeScopes(["identify", "email"], input.extraScopes),
459
- nameKey: "username",
460
- emailKey: "email",
461
- avatarCodeKey: "avatar",
462
- extraSessionFields: input.extraSessionFields,
463
- getAvatar: ({ userData, avatarId }) => {
464
- if (!avatarId) return;
465
- const userId = typeof userData.id === "string" ? userData.id : "";
466
- if (!userId) return;
467
- const format = avatarId.startsWith("a_") ? "gif" : "png";
468
- 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
+ });
469
74
  }
470
- });
471
- var facebookProvider = (input) => {
472
- const v = input.apiVersion ?? "v18.0";
473
- return {
474
- name: "facebook",
475
- clientID: requireString(input.clientId, "facebook clientId"),
476
- clientSecret: requireString(input.clientSecret, "facebook clientSecret"),
477
- callbackURL: input.callbackUrl,
478
- authorizationURL: input.endpoints?.authorizationURL ?? `https://www.facebook.com/${v}/dialog/oauth`,
479
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://graph.facebook.com/${v}/oauth/access_token`,
480
- tokenExchangeMethod: "form",
481
- userInfoURL: input.endpoints?.userInfoURL ?? "https://graph.facebook.com/me?fields=id,name,email,picture.type(large)",
482
- scope: mergeScopes(["public_profile", "email"], input.extraScopes),
483
- nameKey: "name",
484
- emailKey: "email",
485
- avatarCodeKey: "",
486
- extraSessionFields: input.extraSessionFields,
487
- getAvatar: ({ userData }) => {
488
- const picture = asOAuthUserData(userData.picture);
489
- const pictureData = asOAuthUserData(picture.data);
490
- const url = pictureData.url;
491
- return typeof url === "string" ? url : void 0;
492
- }
493
- };
494
75
  };
495
- var microsoftProvider = (input) => {
496
- const tenant = input.tenant ?? "common";
497
- const oauthVersion = input.apiVersion ?? "v2.0";
498
- const graphVersion = input.graphApiVersion ?? "v1.0";
499
- return {
500
- name: "microsoft",
501
- clientID: requireString(input.clientId, "microsoft clientId"),
502
- clientSecret: requireString(input.clientSecret, "microsoft clientSecret"),
503
- callbackURL: input.callbackUrl,
504
- authorizationURL: input.endpoints?.authorizationURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/authorize`,
505
- tokenExchangeURL: input.endpoints?.tokenExchangeURL ?? `https://login.microsoftonline.com/${tenant}/oauth2/${oauthVersion}/token`,
506
- tokenExchangeMethod: "form",
507
- userInfoURL: input.endpoints?.userInfoURL ?? `https://graph.microsoft.com/${graphVersion}/me`,
508
- scope: mergeScopes(["openid", "profile", "email", "User.Read"], input.extraScopes),
509
- nameKey: "displayName",
510
- emailKey: "mail",
511
- avatarCodeKey: "id",
512
- extraSessionFields: input.extraSessionFields,
513
- //? Graph's /photo/$value requires bearer auth, so we can't store the URL —
514
- //? a browser <img> would 401. Fetch the bytes and inline as a data URL.
515
- getAvatar: async ({ avatarId, accessToken }) => {
516
- if (!avatarId) return;
517
- const fetchPhoto = async () => {
518
- const response = await fetch(
519
- `https://graph.microsoft.com/${graphVersion}/users/${avatarId}/photo/$value`,
520
- { method: "GET", headers: { Authorization: `Bearer ${accessToken}` } }
521
- );
522
- if (!response.ok) return;
523
- const contentType = response.headers.get("content-type") ?? "image/jpeg";
524
- const buffer = Buffer.from(await response.arrayBuffer());
525
- return `data:${contentType};base64,${buffer.toString("base64")}`;
526
- };
527
- const [error, dataUrl] = await tryCatch4(fetchPhoto);
528
- if (error) return;
529
- return dataUrl ?? void 0;
530
- },
531
- getEmail: async (accessToken) => {
532
- const fetchProfile = async () => {
533
- const response = await fetch(`https://graph.microsoft.com/${graphVersion}/me`, {
534
- method: "GET",
535
- headers: {
536
- Authorization: `Bearer ${accessToken}`,
537
- "Content-Type": "application/json"
538
- }
539
- });
540
- if (!response.ok) return false;
541
- const data = await response.json();
542
- if (!data || typeof data !== "object") return false;
543
- return data;
544
- };
545
- const [error, profile] = await tryCatch4(fetchProfile);
546
- if (error || !profile) return false;
547
- const record = asOAuthUserData(profile);
548
- const mail = typeof record.mail === "string" ? record.mail : "";
549
- const userPrincipalName = typeof record.userPrincipalName === "string" ? record.userPrincipalName : "";
550
- return mail || userPrincipalName || false;
551
- }
552
- };
76
+ var clearAuthFailures = async (accountKey, requesterIp) => {
77
+ const cfg = getAuthLockoutConfig();
78
+ if (!cfg || !accountKey) return;
79
+ await clearRateLimit(lockoutKey(accountKey, requesterIp));
553
80
  };
554
- var registeredProviders = [{ name: "credentials" }];
555
- var getOAuthProviders = () => registeredProviders;
556
- var registerOAuthProviders = (providers) => {
557
- registeredProviders = providers;
558
- 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
+ });
559
93
  };
560
94
 
561
95
  // src/redirectResolver.ts
@@ -567,53 +101,33 @@ var registerPostLoginRedirect = (resolver) => {
567
101
  var getPostLoginRedirect = () => activeResolver;
568
102
 
569
103
  // src/login.ts
570
- init_session();
571
104
  import { URLSearchParams } from "url";
572
- 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";
573
106
  import bcrypt from "bcryptjs";
574
- import { randomBytes as randomBytes2 } from "crypto";
107
+ import { randomBytes, createHash, timingSafeEqual } from "crypto";
575
108
  import validator from "validator";
576
- import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
577
109
 
578
- // src/userAdapter.ts
579
- import { prisma } from "@luckystack/core";
580
- var registeredAdapter = null;
581
- var registerUserAdapter = (adapter) => {
582
- registeredAdapter = adapter;
583
- return registeredAdapter;
584
- };
585
- var isUserAdapterRegistered = () => registeredAdapter !== null;
586
- var getPrismaUser = () => (
587
- // eslint-disable-next-line no-restricted-syntax -- structural Prisma boundary
588
- prisma.user
589
- );
590
- var defaultPrismaUserAdapter = () => {
591
- return {
592
- findByEmail: async ({ email, provider }) => getPrismaUser().findFirst({ where: { email, provider } }),
593
- findById: async (id) => getPrismaUser().findUnique({ where: { id } }),
594
- create: async (input) => getPrismaUser().create({
595
- data: {
596
- email: input.email,
597
- provider: input.provider,
598
- name: input.name,
599
- password: input.password ?? null,
600
- avatar: input.avatar ?? "",
601
- avatarFallback: input.avatarFallback,
602
- language: input.language
603
- }
604
- }),
605
- update: async (id, patch) => getPrismaUser().update({ where: { id }, data: patch })
606
- };
607
- };
608
- var cachedDefaultAdapter = null;
609
- var getUserAdapter = () => {
610
- if (registeredAdapter) return registeredAdapter;
611
- cachedDefaultAdapter ??= defaultPrismaUserAdapter();
612
- 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 });
613
127
  };
614
128
 
615
129
  // src/passwordPolicy.ts
616
- import { getProjectConfig as getProjectConfig2 } from "@luckystack/core";
130
+ import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
617
131
 
618
132
  // src/data/commonPasswords.ts
619
133
  var COMMON_PASSWORD_LIST = [
@@ -833,9 +347,12 @@ var UPPERCASE_REGEX = /[A-Z]/;
833
347
  var LOWERCASE_REGEX = /[a-z]/;
834
348
  var NUMBER_REGEX = /\d/;
835
349
  var SPECIAL_REGEX = /[^A-Za-z0-9]/;
350
+ var BCRYPT_MAX_BYTES = 72;
836
351
  var validatePassword = (password) => {
837
- const policy = getProjectConfig2().auth.passwordPolicy;
352
+ const policy = getProjectConfig3().auth.passwordPolicy;
838
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";
839
356
  if (password.length > policy.maxLength) return "login.passwordCharacterLimit";
840
357
  if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return "login.passwordRequiresUppercase";
841
358
  if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return "login.passwordRequiresLowercase";
@@ -851,37 +368,85 @@ var validatePassword = (password) => {
851
368
 
852
369
  // src/login.ts
853
370
  import path from "path";
854
- import { existsSync } from "fs";
371
+ import { promises as fsPromises } from "fs";
855
372
  var uploadsFolder = () => getUploadsDir();
856
- var isDevMode = () => getProjectConfig3().logging.devLogs;
373
+ var isDevMode = () => getProjectConfig4().logging.devLogs;
857
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
+ };
858
385
  var { escape, isEmail } = validator;
859
386
  var getOAuthStateKey = (providerName, state) => {
860
- return formatKey3("-oauth-state", `${providerName}:${state}`);
387
+ return formatKey("-oauth-state", `${providerName}:${state}`);
861
388
  };
862
- var createOAuthState = async (providerName) => {
863
- const state = randomBytes2(32).toString("hex");
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 };
396
+ };
397
+ var createOAuthState = async (providerName, options) => {
398
+ const state = randomBytes(32).toString("hex");
399
+ const nonce = randomBytes(32).toString("hex");
864
400
  const key = getOAuthStateKey(providerName, state);
865
- 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
+ );
866
414
  if (result !== "OK") {
867
415
  return null;
868
416
  }
869
- return state;
417
+ return {
418
+ state,
419
+ stateCookie: nonce,
420
+ ...pkce ? { codeChallenge: pkce.challenge } : {}
421
+ };
870
422
  };
871
- var consumeOAuthState = async (providerName, state) => {
872
- if (!state) {
873
- return false;
423
+ var consumeOAuthState = async (providerName, state, cookieNonce) => {
424
+ if (!state || !cookieNonce) {
425
+ return null;
874
426
  }
875
427
  const key = getOAuthStateKey(providerName, state);
876
- const txResult = await redisClient2.multi().get(key).del(key).exec();
428
+ const txResult = await redisClient.multi().get(key).del(key).exec();
877
429
  if (!txResult || txResult.length < 2) {
878
- return false;
430
+ return null;
879
431
  }
880
432
  const getResult = txResult[0];
881
433
  if (!getResult || getResult[0]) {
882
- return false;
434
+ return null;
435
+ }
436
+ const rawValue = getResult[1];
437
+ if (typeof rawValue !== "string") {
438
+ return null;
883
439
  }
884
- return getResult[1] === "1";
440
+ const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue));
441
+ if (parseErr || !entry || typeof entry.nonceHash !== "string") {
442
+ return null;
443
+ }
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;
885
450
  };
886
451
  var asRecord = (value) => {
887
452
  if (value && typeof value === "object") {
@@ -889,10 +454,26 @@ var asRecord = (value) => {
889
454
  }
890
455
  return {};
891
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
+ };
892
470
  var sanitizeUserForSession = (user) => {
893
471
  const { password: _password, ...safeUser } = user;
894
472
  return safeUser;
895
473
  };
474
+ var emitLoginFailed = (payload) => {
475
+ void dispatchHook("loginFailed", payload);
476
+ };
896
477
  var toReasonKey = (error, fallback = "api.internalServerError") => {
897
478
  if (typeof error === "string" && error.length > 0) {
898
479
  return error;
@@ -913,19 +494,26 @@ var normalizeCredentials = (params) => ({
913
494
  //? (regression introduced in pass-1 fix #9 which only un-escaped password).
914
495
  //? Email is normalized (lowercase + trim) and validated via `isEmail`;
915
496
  //? HTML-escaping it would make legacy raw rows unfindable.
916
- email: (params.email || "").trim().toLowerCase(),
917
- password: params.password || "",
497
+ email: (params.email ?? "").trim().toLowerCase(),
498
+ password: params.password ?? "",
918
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
919
505
  confirmPassword: params.confirmPassword || void 0
920
506
  });
921
- var validateCredentialsShape = (creds) => {
922
- const authLimits = getProjectConfig3().auth;
507
+ var validateCredentialsShape = (creds, mode) => {
508
+ const authLimits = getProjectConfig4().auth;
923
509
  if (!creds.email || !creds.password) return { status: false, reason: "login.empty" };
924
510
  if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: "login.emailCharacterLimit" };
925
511
  if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: "login.nameCharacterLimit" };
926
512
  if (!isEmail(creds.email)) return { status: false, reason: "login.invalidEmailFormat" };
927
- const passwordReason = validatePassword(creds.password);
928
- 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
+ }
929
517
  return null;
930
518
  };
931
519
  var registerWithCredentials = async ({
@@ -933,25 +521,36 @@ var registerWithCredentials = async ({
933
521
  password,
934
522
  name,
935
523
  confirmPassword
936
- }) => {
524
+ }, options) => {
937
525
  if (password !== confirmPassword) {
526
+ emitLoginFailed({ email, provider: "credentials", reason: "login.passwordNotMatch", stage: "register" });
938
527
  return { status: false, reason: "login.passwordNotMatch" };
939
528
  }
940
- const preRegisterResult = await dispatchHook3("preRegister", { email, provider: "credentials", name });
529
+ const preRegisterResult = await dispatchHook("preRegister", { email, provider: "credentials", name });
941
530
  if (preRegisterResult.stopped) {
531
+ emitLoginFailed({ email, provider: "credentials", reason: preRegisterResult.signal.errorCode, stage: "register" });
942
532
  return { status: false, reason: preRegisterResult.signal.errorCode };
943
533
  }
534
+ if (await isAccountLocked(email)) {
535
+ emitLoginFailed({ email, provider: "credentials", reason: "login.accountLocked", stage: "register" });
536
+ return { status: false, reason: "login.accountLocked" };
537
+ }
944
538
  const userAdapter = getUserAdapter();
945
- const [checkEmailError, checkEmailResponse] = await tryCatch5(
946
- () => userAdapter.findByEmail({ email, provider: "credentials" })
539
+ const [checkEmailError, checkEmailResponse] = await tryCatch(
540
+ () => resolveUserByEmail(userAdapter, { email, provider: "credentials" })
947
541
  );
948
542
  if (checkEmailError) {
949
543
  getLogger3().error("login: findByEmail failed during register", checkEmailError);
950
- 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" };
951
551
  }
952
- if (checkEmailResponse) return { status: false, reason: "login.emailExists" };
953
552
  const createNewUser = async () => {
954
- const salt = await genSalt(getProjectConfig3().auth.bcryptRounds);
553
+ const salt = await genSalt(getProjectConfig4().auth.bcryptRounds);
955
554
  const hashedPassword = await hash(password, salt);
956
555
  return await userAdapter.create({
957
556
  email,
@@ -959,61 +558,111 @@ var registerWithCredentials = async ({
959
558
  name,
960
559
  password: hashedPassword,
961
560
  avatar: "",
962
- avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
963
- language: getProjectConfig3().defaultLanguage
561
+ avatarFallback: generateAvatarFallbackColor(),
562
+ language: getProjectConfig4().defaultLanguage
964
563
  });
965
564
  };
966
- const [createNewUserError, createNewUserResponse] = await tryCatch5(createNewUser);
967
- if (createNewUserError) return { status: false, reason: toReasonKey(createNewUserError) };
968
- if (!createNewUserResponse) return { status: false, reason: "login.createUserFailed" };
969
- 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 });
970
597
  return {
971
598
  status: true,
972
599
  reason: "login.userCreated",
973
- session: sanitizeUserForSession(createNewUserResponse)
600
+ newToken,
601
+ session: newUser
974
602
  };
975
603
  };
976
604
  var loginWithCredentialsCore = async ({ email, password }, options) => {
977
- const preLoginResult = await dispatchHook3("preLogin", { email, provider: "credentials" });
605
+ const requesterIp = options?.requesterIp;
606
+ const preLoginResult = await dispatchHook("preLogin", { email, provider: "credentials" });
978
607
  if (preLoginResult.stopped) {
608
+ emitLoginFailed({ email, provider: "credentials", reason: preLoginResult.signal.errorCode, stage: "login", requesterIp });
979
609
  return { status: false, reason: preLoginResult.signal.errorCode };
980
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
+ }
981
615
  const userAdapter = getUserAdapter();
982
- const [findUserError, findUserResponse] = await tryCatch5(
983
- () => userAdapter.findByEmail({ email, provider: "credentials" })
616
+ const [findUserError, findUserResponse] = await tryCatch(
617
+ () => resolveUserByEmail(userAdapter, { email, provider: "credentials" })
984
618
  );
985
619
  if (findUserError) {
986
620
  getLogger3().error("login: findByEmail failed", findUserError);
987
- 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 };
988
624
  }
989
- if (!findUserResponse) return { status: false, reason: "login.userNotFound" };
990
- const [checkPasswordError, checkPasswordResponse] = await tryCatch5(
991
- () => compare(password, findUserResponse.password)
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" };
631
+ }
632
+ const [checkPasswordError, checkPasswordResponse] = await tryCatch(
633
+ () => compare(password, passwordHash)
992
634
  );
993
635
  if (checkPasswordError) {
994
636
  getLogger3().error("login: bcrypt compare failed", checkPasswordError);
995
- 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" };
996
644
  }
997
- if (!checkPasswordResponse) return { status: false, reason: "login.wrongPassword" };
998
- const newToken = randomBytes2(32).toString("hex");
645
+ const newToken = randomBytes(32).toString("hex");
999
646
  const previousLogin = findUserResponse.lastLogin ?? null;
1000
647
  const nowLogin = /* @__PURE__ */ new Date();
1001
- await tryCatch5(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
648
+ await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
1002
649
  const newUser = {
1003
650
  ...sanitizeUserForSession(findUserResponse),
1004
651
  token: newToken,
1005
652
  lastLogin: nowLogin,
1006
653
  previousLogin
1007
654
  };
1008
- const filePath = path.join(uploadsFolder(), `${newUser.id}.webp`);
1009
- if (existsSync(filePath)) {
1010
- newUser.avatar = `${newUser.id}.webp`;
655
+ const uploadedAvatar = await resolveUploadedAvatar(newUser.id);
656
+ if (uploadedAvatar) {
657
+ newUser.avatar = uploadedAvatar;
1011
658
  }
1012
659
  const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });
1013
660
  if (!saved.ok) {
661
+ emitLoginFailed({ email, userId: newUser.id, provider: "credentials", reason: saved.errorCode, stage: "login", requesterIp });
1014
662
  return { status: false, reason: saved.errorCode };
1015
663
  }
1016
- await dispatchHook3("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
664
+ await dispatchHook("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
665
+ void clearAuthFailures(email, requesterIp);
1017
666
  if (isDevMode()) {
1018
667
  getLogger3().debug(`credentials login success for user ${newUser.id}`);
1019
668
  }
@@ -1024,25 +673,35 @@ var loginWithCredentials = async (params, options) => {
1024
673
  if (isDevMode()) {
1025
674
  getLogger3().debug(`credentials auth attempt for ${creds.email || "unknown-email"}`);
1026
675
  }
1027
- const validationError = validateCredentialsShape(creds);
1028
- if (validationError) return validationError;
1029
- 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
+ }
1030
684
  return registerWithCredentials({
1031
685
  email: creds.email,
1032
686
  password: creds.password,
1033
- name: creds.name,
1034
- confirmPassword: creds.confirmPassword
1035
- });
687
+ name,
688
+ confirmPassword
689
+ }, options);
1036
690
  }
691
+ const validationError = validateCredentialsShape(creds, "login");
692
+ if (validationError) return validationError;
1037
693
  return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);
1038
694
  };
1039
695
  var isAllowedRedirectUrl = (url) => {
1040
696
  if (!URL.canParse(url, "http://placeholder")) return false;
697
+ if (url.includes("\\")) return false;
1041
698
  const parsed = new URL(url, "http://placeholder");
1042
699
  if (parsed.origin === "http://placeholder") {
1043
700
  return true;
1044
701
  }
1045
- 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 ?? [];
1046
705
  if (typeof allowed === "function") {
1047
706
  return allowed(parsed.origin);
1048
707
  }
@@ -1051,43 +710,52 @@ var isAllowedRedirectUrl = (url) => {
1051
710
  return new URL(origin).origin === parsed.origin;
1052
711
  });
1053
712
  };
1054
- var exchangeOAuthToken = async (provider, code) => {
713
+ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
1055
714
  const values = {
1056
715
  code,
1057
716
  client_id: provider.clientID,
1058
717
  client_secret: provider.clientSecret,
1059
718
  redirect_uri: provider.callbackURL,
1060
- grant_type: "authorization_code"
719
+ grant_type: "authorization_code",
720
+ ...codeVerifier ? { code_verifier: codeVerifier } : {}
1061
721
  };
1062
722
  const getToken = async () => {
1063
723
  if (provider.tokenExchangeMethod === "json") {
1064
- const response2 = await fetch(provider.tokenExchangeURL, {
724
+ const response3 = await fetch(provider.tokenExchangeURL, {
1065
725
  method: "POST",
1066
726
  headers: { "Content-Type": "application/json", "Accept": "application/json" },
1067
727
  body: JSON.stringify(values)
1068
728
  });
1069
- return await response2.json();
1070
- }
1071
- if (provider.tokenExchangeMethod === "form") {
1072
- const formParams = new URLSearchParams();
1073
- formParams.append("client_id", provider.clientID);
1074
- formParams.append("client_secret", provider.clientSecret);
1075
- formParams.append("code", values.code);
1076
- formParams.append("grant_type", "authorization_code");
1077
- formParams.append("redirect_uri", provider.callbackURL);
1078
- if (isDevMode()) {
1079
- 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;
1080
732
  }
1081
- const response2 = await fetch(provider.tokenExchangeURL, {
1082
- method: "POST",
1083
- headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
1084
- body: formParams.toString()
1085
- });
1086
- return await response2.json();
733
+ return await response3.json();
1087
734
  }
1088
- 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();
1089
757
  };
1090
- const [error, response] = await tryCatch5(getToken);
758
+ const [error, response] = await tryCatch(getToken);
1091
759
  if (error) {
1092
760
  getLogger3().error("oauth: token exchange failed", error);
1093
761
  return null;
@@ -1110,19 +778,33 @@ var fetchOAuthProfile = async (provider, accessToken) => {
1110
778
  "Authorization": `Bearer ${accessToken}`
1111
779
  }
1112
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
+ }
1113
785
  return await response2.json();
1114
786
  };
1115
- const [error, response] = await tryCatch5(getUserData);
787
+ const [error, response] = await tryCatch(getUserData);
1116
788
  if (error) {
1117
789
  getLogger3().error("oauth: userInfo fetch failed", error);
1118
790
  return null;
1119
791
  }
1120
792
  const userData = asRecord(response);
1121
- 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;
1122
795
  const emailValue = userData[provider.emailKey];
1123
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
+ }
1124
806
  const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : void 0;
1125
- 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 }) : "";
1126
808
  const avatar = typeof avatarValue === "string" ? avatarValue : "";
1127
809
  if (!email && provider.getEmail) {
1128
810
  const selectedEmail = await provider.getEmail(accessToken);
@@ -1131,33 +813,51 @@ var fetchOAuthProfile = async (provider, accessToken) => {
1131
813
  return null;
1132
814
  }
1133
815
  email = selectedEmail;
816
+ emailVerified = true;
817
+ }
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;
1134
825
  }
1135
- return { email, name, avatar };
826
+ return { email, name, avatar, emailVerified, rawUserData: userData };
1136
827
  };
1137
828
  var findOrCreateOAuthUser = async (provider, profile) => {
1138
- const { email, name, avatar } = profile;
829
+ const { email, name, avatar, emailVerified } = profile;
1139
830
  if (!email) return null;
1140
- const preLoginResult = await dispatchHook3("preLogin", { email, provider: provider.name });
831
+ const preLoginResult = await dispatchHook("preLogin", { email, provider: provider.name });
1141
832
  if (preLoginResult.stopped) {
1142
833
  getLogger3().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });
1143
834
  return null;
1144
835
  }
1145
836
  const userAdapter = getUserAdapter();
1146
- const [findError, findResponse] = await tryCatch5(
1147
- () => userAdapter.findByEmail({ email, provider: provider.name })
837
+ const [findError, findResponse] = await tryCatch(
838
+ () => resolveUserByEmail(userAdapter, { email, provider: provider.name })
1148
839
  );
1149
840
  if (findError) {
1150
841
  getLogger3().error("oauth: findByEmail failed", findError);
1151
842
  return null;
1152
843
  }
1153
844
  if (findResponse?.id) {
1154
- const filePath = path.join(uploadsFolder(), `${findResponse.id}.webp`);
1155
- if (existsSync(filePath)) {
1156
- 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;
1157
857
  }
1158
858
  const previousLogin = findResponse.lastLogin ?? null;
1159
859
  const nowLogin = /* @__PURE__ */ new Date();
1160
- await tryCatch5(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
860
+ await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
1161
861
  return {
1162
862
  user: {
1163
863
  ...sanitizeUserForSession(findResponse),
@@ -1168,7 +868,7 @@ var findOrCreateOAuthUser = async (provider, profile) => {
1168
868
  isNewUser: false
1169
869
  };
1170
870
  }
1171
- const preRegisterResult = await dispatchHook3("preRegister", { email, provider: provider.name, name });
871
+ const preRegisterResult = await dispatchHook("preRegister", { email, provider: provider.name, name });
1172
872
  if (preRegisterResult.stopped) {
1173
873
  getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
1174
874
  return null;
@@ -1178,10 +878,10 @@ var findOrCreateOAuthUser = async (provider, profile) => {
1178
878
  provider: provider.name,
1179
879
  name,
1180
880
  avatar,
1181
- avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
1182
- language: getProjectConfig3().defaultLanguage
881
+ avatarFallback: generateAvatarFallbackColor(),
882
+ language: getProjectConfig4().defaultLanguage
1183
883
  });
1184
- const [createError, createResponse] = await tryCatch5(createNewUser);
884
+ const [createError, createResponse] = await tryCatch(createNewUser);
1185
885
  if (createError) {
1186
886
  getLogger3().error("oauth: user create failed", createError);
1187
887
  return null;
@@ -1200,7 +900,7 @@ var resolvePostLoginRedirect = async ({
1200
900
  }) => {
1201
901
  const resolver = getPostLoginRedirect();
1202
902
  if (!resolver) return fallbackUrl;
1203
- const [resolverError, resolved] = await tryCatch5(() => resolver({
903
+ const [resolverError, resolved] = await tryCatch(() => resolver({
1204
904
  userId,
1205
905
  provider: providerName,
1206
906
  isNewUser,
@@ -1219,53 +919,84 @@ var resolvePostLoginRedirect = async ({
1219
919
  }
1220
920
  return fallbackUrl;
1221
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
+ };
1222
952
  var loginCallback = async (pathname, req, _res, options = {}) => {
1223
- const providerName = pathname.split("/")[3];
1224
- if (!providerName) return false;
1225
- const provider = getOAuthProviders().find((p) => p.name === providerName);
1226
- if (!provider || !req.url) return false;
1227
- if (!isFullOAuthProvider(provider)) return false;
1228
- 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] ?? "";
1229
957
  const params = new URLSearchParams(queryString);
1230
958
  const code = params.get("code");
1231
959
  const state = params.get("state");
1232
- const stateIsValid = await consumeOAuthState(provider.name, state || "");
1233
- if (!stateIsValid) {
1234
- 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" });
1235
965
  return false;
1236
966
  }
1237
967
  if (!code) {
1238
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" });
1239
975
  return false;
1240
976
  }
1241
- const accessToken = await exchangeOAuthToken(provider, code);
1242
- if (!accessToken) return false;
1243
977
  const profile = await fetchOAuthProfile(provider, accessToken);
1244
- if (!profile) return false;
978
+ if (!profile) {
979
+ emitLoginFailed({ provider: providerName, reason: "oauth.profileFetchFailed", stage: "oauth" });
980
+ return false;
981
+ }
1245
982
  const resolved = await findOrCreateOAuthUser(provider, profile);
1246
- if (!resolved) return false;
1247
- const newToken = randomBytes2(32).toString("hex");
1248
- resolved.user.token = newToken;
1249
- const extraSessionFields = provider.extraSessionFields;
1250
- if (extraSessionFields) {
1251
- const [extraErr, extra] = await tryCatch5(
1252
- async () => extraSessionFields({ userData: asOAuthUserData(profile), accessToken })
1253
- );
1254
- if (extraErr) {
1255
- getLogger3().warn(`[oauth:${providerName}] extraSessionFields hook threw \u2014 continuing without extras`, { err: extraErr });
1256
- } else if (extra) {
1257
- Object.assign(resolved.user, extra);
1258
- }
983
+ if (!resolved) {
984
+ emitLoginFailed({ provider: providerName, email: profile.email, reason: "oauth.userResolveFailed", stage: "oauth" });
985
+ return false;
1259
986
  }
987
+ const newToken = randomBytes(32).toString("hex");
988
+ resolved.user.token = newToken;
989
+ await applyExtraSessionFields(resolved.user, provider, profile, accessToken);
1260
990
  const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });
1261
991
  if (!saved.ok) {
1262
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" });
1263
994
  return false;
1264
995
  }
1265
996
  if (resolved.isNewUser) {
1266
- await dispatchHook3("postRegister", { userId: resolved.user.id, provider: providerName });
997
+ await dispatchHook("postRegister", { userId: resolved.user.id, provider: providerName });
1267
998
  }
1268
- await dispatchHook3("postLogin", {
999
+ await dispatchHook("postLogin", {
1269
1000
  userId: resolved.user.id,
1270
1001
  provider: providerName,
1271
1002
  isNewUser: resolved.isNewUser,
@@ -1274,7 +1005,7 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1274
1005
  if (isDevMode()) {
1275
1006
  getLogger3().debug(`oauth login success for user ${resolved.user.id}`);
1276
1007
  }
1277
- const fallbackUrl = options.defaultRedirectUrl ?? getProjectConfig3().loginRedirectUrl ?? "/";
1008
+ const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);
1278
1009
  const redirectUrl = await resolvePostLoginRedirect({
1279
1010
  fallbackUrl,
1280
1011
  userId: resolved.user.id,
@@ -1290,37 +1021,32 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1290
1021
  };
1291
1022
  };
1292
1023
 
1293
- // src/index.ts
1294
- init_logout();
1295
-
1296
1024
  // src/passwordReset.ts
1297
1025
  import bcrypt2 from "bcryptjs";
1298
- import { randomBytes as randomBytes3 } from "crypto";
1299
- import { getProjectConfig as getProjectConfig4, formatKey as formatKey4, redis as redis2 } from "@luckystack/core";
1300
- 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);
1301
1029
  var createPasswordResetToken = async (userId) => {
1302
- const token = randomBytes3(32).toString("hex");
1303
- const ttl = getProjectConfig4().auth.passwordResetTtlSeconds;
1304
- await redis2.set(tokenKey(token), userId, "EX", ttl);
1305
- 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;
1306
1040
  };
1307
1041
  var consumePasswordResetToken = async (token) => {
1308
- if (!token || typeof token !== "string") return null;
1309
- const key = tokenKey(token);
1310
- const txResult = await redis2.multi().get(key).del(key).exec();
1311
- if (!txResult || txResult.length === 0) return null;
1312
- const first = txResult[0];
1313
- if (!first) return null;
1314
- const [getErr, value] = first;
1315
- if (getErr) return null;
1316
- return typeof value === "string" && value.length > 0 ? value : null;
1042
+ return consumeOneTimeToken(PWRESET_NAMESPACE, token);
1317
1043
  };
1318
1044
  var updatePasswordHash = async (userId, plaintext) => {
1319
1045
  const reason = validatePassword(plaintext);
1320
1046
  if (reason) {
1321
1047
  throw new PasswordPolicyError(reason);
1322
1048
  }
1323
- const salt = await bcrypt2.genSalt(getProjectConfig4().auth.bcryptRounds);
1049
+ const salt = await bcrypt2.genSalt(getProjectConfig5().auth.bcryptRounds);
1324
1050
  const hashedPassword = await bcrypt2.hash(plaintext, salt);
1325
1051
  const patch = { password: hashedPassword };
1326
1052
  await getUserAdapter().update(userId, patch);
@@ -1338,37 +1064,44 @@ var verifyPassword = async (plaintext, hash2) => {
1338
1064
  };
1339
1065
 
1340
1066
  // src/forgotPassword.ts
1341
- 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
1342
1076
  var sendPasswordResetEmail = async ({ email, brand }) => {
1343
- const config = getProjectConfig5();
1344
- getLogger4().info("[forgotPassword] start", { email, forgotPasswordMode: config.auth.forgotPassword });
1077
+ const config = getProjectConfig6();
1078
+ getLogger4().info("[forgotPassword] start", { forgotPasswordMode: config.auth.forgotPassword });
1345
1079
  if (config.auth.forgotPassword !== "framework") {
1346
1080
  getLogger4().warn('[forgotPassword] auth.forgotPassword is not "framework" \u2014 nothing sent', { mode: config.auth.forgotPassword });
1347
1081
  return { ok: false, reason: "forgotPassword-not-framework" };
1348
1082
  }
1349
1083
  const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? "LuckyStack";
1350
- const emailModule = await // @ts-expect-error optional peer dep — installed only when forgotPassword === 'framework'
1351
- import("@luckystack/email").catch((error) => {
1084
+ const emailModule = await loadEmailModule().catch((error) => {
1352
1085
  getLogger4().warn("[forgotPassword] failed to load @luckystack/email \u2014 is it installed?", { error: String(error) });
1353
1086
  return null;
1354
1087
  });
1355
1088
  if (!emailModule) {
1356
1089
  return { ok: false, reason: "email-module-load-failed" };
1357
1090
  }
1358
- const { sendEmail, renderEmailLayout } = emailModule;
1091
+ const { sendEmail } = emailModule;
1359
1092
  const userAdapter = getUserAdapter();
1360
1093
  const user = await userAdapter.findByEmail({ email, provider: "credentials" });
1361
- 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 });
1362
1095
  if (!user) {
1363
- getLogger4().warn("[forgotPassword] no credentials account for this email \u2014 nothing sent. Use the email+password account you registered with.", { email });
1364
- 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", {
1365
1098
  email,
1366
1099
  matched: false
1367
1100
  });
1368
1101
  return { ok: true };
1369
1102
  }
1370
1103
  const token = await createPasswordResetToken(user.id);
1371
- void dispatchHook4("passwordResetRequested", {
1104
+ void dispatchHook2("passwordResetRequested", {
1372
1105
  email,
1373
1106
  matched: true,
1374
1107
  userId: user.id,
@@ -1378,20 +1111,10 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1378
1111
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1379
1112
  const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;
1380
1113
  const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);
1381
- const { html, text } = renderEmailLayout({
1382
- brand: resolvedBrand,
1383
- title: "Reset your password",
1384
- 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.`,
1385
- ctaLabel: "Reset password",
1386
- ctaUrl: resetUrl,
1387
- outro: `If you didn't request this, you can safely ignore this email \u2014 your password will stay the same. The link: ${resetUrl}`,
1388
- footer: `Sent by ${resolvedBrand}. If you have questions, reply to this email.`
1389
- });
1390
1114
  const result = await sendEmail({
1391
1115
  to: user.email,
1392
- subject: `Reset your ${resolvedBrand} password`,
1393
- html,
1394
- text,
1116
+ template: "password-reset",
1117
+ data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },
1395
1118
  adapterHint: "transactional"
1396
1119
  });
1397
1120
  if (result.ok) {
@@ -1403,69 +1126,66 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1403
1126
  };
1404
1127
 
1405
1128
  // src/emailChange.ts
1406
- import { randomBytes as randomBytes4 } from "crypto";
1407
- import { getProjectConfig as getProjectConfig6, formatKey as formatKey5, redis as redis3 } from "@luckystack/core";
1408
- 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);
1409
1132
  var createEmailChangeToken = async (userId, newEmail) => {
1410
- const token = randomBytes4(32).toString("hex");
1411
- const ttl = getProjectConfig6().auth.emailChangeTtlSeconds;
1412
- const payload = JSON.stringify({ userId, newEmail });
1413
- await redis3.set(tokenKey2(token), payload, "EX", ttl);
1414
- 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;
1415
1147
  };
1416
1148
  var consumeEmailChangeToken = async (token) => {
1417
- if (!token || typeof token !== "string") return null;
1418
- const key = tokenKey2(token);
1419
- const txResult = await redis3.multi().get(key).del(key).exec();
1420
- if (!txResult || txResult.length === 0) return null;
1421
- const first = txResult[0];
1422
- if (!first) return null;
1423
- const [getErr, value] = first;
1424
- if (getErr) return null;
1425
- if (typeof value !== "string" || value.length === 0) return null;
1426
- try {
1427
- const parsed = JSON.parse(value);
1428
- if (!parsed || typeof parsed !== "object" || typeof parsed.userId !== "string" || typeof parsed.newEmail !== "string") {
1429
- return null;
1430
- }
1431
- return parsed;
1432
- } catch {
1149
+ const parsed = await consumeOneTimeTokenJson(EMAIL_CHANGE_NAMESPACE, token);
1150
+ if (!parsed || typeof parsed !== "object" || typeof parsed.userId !== "string" || typeof parsed.newEmail !== "string") {
1433
1151
  return null;
1434
1152
  }
1153
+ return parsed;
1435
1154
  };
1436
1155
 
1437
1156
  // src/emailChangeNotification.ts
1438
- import { getProjectConfig as getProjectConfig7 } from "@luckystack/core";
1157
+ import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
1439
1158
  var sendEmailChangeConfirmation = async ({ userId, newEmail, userName, brand }) => {
1440
- const config = getProjectConfig7();
1159
+ const config = getProjectConfig8();
1441
1160
  const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? "LuckyStack";
1442
- const { sendEmail, renderEmailLayout } = await // @ts-expect-error optional peer dep — installed only when the consumer wires email sending
1443
- import("@luckystack/email");
1161
+ const { sendEmail } = await loadEmailModule();
1444
1162
  const token = await createEmailChangeToken(userId, newEmail);
1445
1163
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1446
1164
  const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;
1447
1165
  const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);
1448
- const { html, text } = renderEmailLayout({
1449
- brand: resolvedBrand,
1450
- title: "Confirm your new email address",
1451
- 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.`,
1452
- ctaLabel: "Confirm new email",
1453
- ctaUrl: confirmUrl,
1454
- outro: `If you didn't request this, you can safely ignore this email \u2014 nothing will change. The link: ${confirmUrl}`,
1455
- footer: `Sent by ${resolvedBrand}. If you have questions, reply to this email.`
1456
- });
1457
1166
  const result = await sendEmail({
1458
1167
  to: newEmail,
1459
- subject: `Confirm your new ${resolvedBrand} email address`,
1460
- html,
1461
- text,
1168
+ template: "email-change",
1169
+ data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },
1462
1170
  adapterHint: "transactional"
1463
1171
  });
1464
1172
  return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };
1465
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();
1466
1183
  export {
1184
+ OAUTH_STATE_COOKIE_NAME,
1467
1185
  PasswordPolicyError,
1468
1186
  activeUsersKeyFor,
1187
+ asOAuthUserData,
1188
+ clearAuthFailures,
1469
1189
  consumeEmailChangeToken,
1470
1190
  consumePasswordResetToken,
1471
1191
  createEmailChangeToken,
@@ -1481,9 +1201,11 @@ export {
1481
1201
  getPostLoginRedirect,
1482
1202
  getSession,
1483
1203
  getSessionAdapter,
1204
+ getSessionSanitizer,
1484
1205
  getUserAdapter,
1485
1206
  githubProvider,
1486
1207
  googleProvider,
1208
+ isAccountLocked,
1487
1209
  isFullOAuthProvider,
1488
1210
  isUserAdapterRegistered,
1489
1211
  loginCallback,
@@ -1491,10 +1213,13 @@ export {
1491
1213
  loginWithCredentialsCore,
1492
1214
  logout,
1493
1215
  microsoftProvider,
1216
+ recordAuthFailure,
1494
1217
  redisSessionAdapter,
1218
+ registerOAuthProviderFactory,
1495
1219
  registerOAuthProviders,
1496
1220
  registerPostLoginRedirect,
1497
1221
  registerSessionAdapter,
1222
+ registerSessionSanitizer,
1498
1223
  registerUserAdapter,
1499
1224
  registerWithCredentials,
1500
1225
  revokeUserSessions,