@luckystack/login 0.1.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 ADDED
@@ -0,0 +1,1497 @@
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
+ });
90
+
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
+ });
147
+
148
+ // src/session.ts
149
+ import { randomBytes } from "crypto";
150
+ import {
151
+ socketEventNames as socketEventNames2,
152
+ dispatchHook as dispatchHook2,
153
+ getCsrfConfig,
154
+ getLogger as getLogger2,
155
+ getProjectConfig,
156
+ formatKey as formatKey2,
157
+ tryCatch as tryCatch3
158
+ } 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 {};
372
+ };
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])];
377
+ };
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;
383
+ };
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}`;
464
+ }
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
+ };
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
+ };
548
+ };
549
+ var registeredProviders = [{ name: "credentials" }];
550
+ var getOAuthProviders = () => registeredProviders;
551
+ var registerOAuthProviders = (providers) => {
552
+ registeredProviders = providers;
553
+ return registeredProviders;
554
+ };
555
+
556
+ // src/redirectResolver.ts
557
+ var activeResolver = null;
558
+ var registerPostLoginRedirect = (resolver) => {
559
+ activeResolver = resolver;
560
+ return resolver;
561
+ };
562
+ var getPostLoginRedirect = () => activeResolver;
563
+
564
+ // src/login.ts
565
+ init_session();
566
+ 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";
568
+ import bcrypt from "bcryptjs";
569
+ import { randomBytes as randomBytes2 } from "crypto";
570
+ import validator from "validator";
571
+ import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
572
+
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;
608
+ };
609
+
610
+ // src/passwordPolicy.ts
611
+ import { getProjectConfig as getProjectConfig2 } from "@luckystack/core";
612
+
613
+ // src/data/commonPasswords.ts
614
+ var COMMON_PASSWORD_LIST = [
615
+ "123456",
616
+ "password",
617
+ "12345678",
618
+ "qwerty",
619
+ "123456789",
620
+ "12345",
621
+ "1234",
622
+ "111111",
623
+ "1234567",
624
+ "dragon",
625
+ "123123",
626
+ "baseball",
627
+ "abc123",
628
+ "football",
629
+ "monkey",
630
+ "letmein",
631
+ "696969",
632
+ "shadow",
633
+ "master",
634
+ "666666",
635
+ "qwertyuiop",
636
+ "123321",
637
+ "mustang",
638
+ "1234567890",
639
+ "michael",
640
+ "654321",
641
+ "pussy",
642
+ "superman",
643
+ "1qaz2wsx",
644
+ "7777777",
645
+ "fuckyou",
646
+ "121212",
647
+ "000000",
648
+ "qazwsx",
649
+ "123qwe",
650
+ "killer",
651
+ "trustno1",
652
+ "jordan",
653
+ "jennifer",
654
+ "zxcvbnm",
655
+ "asdfgh",
656
+ "hunter",
657
+ "buster",
658
+ "soccer",
659
+ "harley",
660
+ "batman",
661
+ "andrew",
662
+ "tigger",
663
+ "sunshine",
664
+ "iloveyou",
665
+ "fuckme",
666
+ "2000",
667
+ "charlie",
668
+ "robert",
669
+ "thomas",
670
+ "hockey",
671
+ "ranger",
672
+ "daniel",
673
+ "starwars",
674
+ "klaster",
675
+ "112233",
676
+ "george",
677
+ "asshole",
678
+ "computer",
679
+ "michelle",
680
+ "jessica",
681
+ "pepper",
682
+ "1111",
683
+ "zxcvbn",
684
+ "555555",
685
+ "11111111",
686
+ "131313",
687
+ "freedom",
688
+ "777777",
689
+ "pass",
690
+ "fuck",
691
+ "maggie",
692
+ "159753",
693
+ "aaaaaa",
694
+ "ginger",
695
+ "princess",
696
+ "joshua",
697
+ "cheese",
698
+ "amanda",
699
+ "summer",
700
+ "love",
701
+ "ashley",
702
+ "6969",
703
+ "nicole",
704
+ "chelsea",
705
+ "biteme",
706
+ "matthew",
707
+ "access",
708
+ "yankees",
709
+ "987654321",
710
+ "dallas",
711
+ "austin",
712
+ "thunder",
713
+ "taylor",
714
+ "matrix",
715
+ "william",
716
+ "corvette",
717
+ "hello",
718
+ "martin",
719
+ "heather",
720
+ "secret",
721
+ "fucker",
722
+ "merlin",
723
+ "diamond",
724
+ "1234qwer",
725
+ "gfhjkm",
726
+ "hammer",
727
+ "silver",
728
+ "richard",
729
+ "samantha",
730
+ "iceman",
731
+ "tigers",
732
+ "purple",
733
+ "andrea",
734
+ "horney",
735
+ "dakota",
736
+ "aaaaa",
737
+ "player",
738
+ "sucker",
739
+ "mercedes",
740
+ "whatever",
741
+ "orange",
742
+ "qweqwe",
743
+ "mickey",
744
+ "panties",
745
+ "hannah",
746
+ "fucking",
747
+ "paul",
748
+ "pakistan",
749
+ "patrick",
750
+ "martha",
751
+ "morgan",
752
+ "iwantu",
753
+ "lakers",
754
+ "rachel",
755
+ "slayer",
756
+ "scott",
757
+ "2112",
758
+ "fish",
759
+ "porn",
760
+ "matt",
761
+ "qwert",
762
+ "cookie",
763
+ "eagle1",
764
+ "london",
765
+ "phoenix",
766
+ "chicago",
767
+ "badboy",
768
+ "braves",
769
+ "yankee",
770
+ "lover",
771
+ "barney",
772
+ "edward",
773
+ "raiders",
774
+ "green",
775
+ "maverick",
776
+ "rangers",
777
+ "joseph",
778
+ "maddog",
779
+ "mike",
780
+ "jasmine",
781
+ "arsenal",
782
+ "beach",
783
+ "tiger",
784
+ "snoopy",
785
+ "natasha",
786
+ "matthews",
787
+ "jackson",
788
+ "cumshot",
789
+ "chevy",
790
+ "gateway",
791
+ "gators",
792
+ "angel",
793
+ "junior",
794
+ "samson",
795
+ "6666",
796
+ "amateur",
797
+ "gemini",
798
+ "apples",
799
+ "august",
800
+ "biscuit",
801
+ "5150",
802
+ "apollo",
803
+ "parker",
804
+ "qwerty123",
805
+ "rabbit",
806
+ "angela",
807
+ "love123",
808
+ "rocket",
809
+ "scooby",
810
+ "beaver",
811
+ "oliver",
812
+ "walter",
813
+ "blowme",
814
+ "spider",
815
+ "4321",
816
+ "cumshot1",
817
+ "lucky",
818
+ "helpme",
819
+ "jackie",
820
+ "monica"
821
+ ];
822
+ var COMMON_PASSWORDS = new Set(
823
+ COMMON_PASSWORD_LIST.map((p) => p.toLowerCase())
824
+ );
825
+
826
+ // src/passwordPolicy.ts
827
+ var UPPERCASE_REGEX = /[A-Z]/;
828
+ var LOWERCASE_REGEX = /[a-z]/;
829
+ var NUMBER_REGEX = /\d/;
830
+ var SPECIAL_REGEX = /[^A-Za-z0-9]/;
831
+ var validatePassword = (password) => {
832
+ const policy = getProjectConfig2().auth.passwordPolicy;
833
+ if (password.length < policy.minLength) return "login.passwordCharacterMinimum";
834
+ if (password.length > policy.maxLength) return "login.passwordCharacterLimit";
835
+ if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return "login.passwordRequiresUppercase";
836
+ if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return "login.passwordRequiresLowercase";
837
+ if (policy.requireNumber && !NUMBER_REGEX.test(password)) return "login.passwordRequiresNumber";
838
+ if (policy.requireSpecial && !SPECIAL_REGEX.test(password)) return "login.passwordRequiresSpecial";
839
+ if (policy.forbidCommon && COMMON_PASSWORDS.has(password.toLowerCase())) return "login.passwordTooCommon";
840
+ if (policy.customValidator) {
841
+ const customReason = policy.customValidator(password);
842
+ if (customReason) return customReason;
843
+ }
844
+ return null;
845
+ };
846
+
847
+ // src/login.ts
848
+ import path from "path";
849
+ import { existsSync } from "fs";
850
+ var uploadsFolder = () => getUploadsDir();
851
+ var isDevMode = () => getProjectConfig3().logging.devLogs;
852
+ var { compare, genSalt, hash } = bcrypt;
853
+ var { escape, isEmail } = validator;
854
+ var getOAuthStateKey = (providerName, state) => {
855
+ return formatKey3("-oauth-state", `${providerName}:${state}`);
856
+ };
857
+ var createOAuthState = async (providerName) => {
858
+ const state = randomBytes2(32).toString("hex");
859
+ const key = getOAuthStateKey(providerName, state);
860
+ const result = await redisClient2.set(key, "1", "EX", getProjectConfig3().auth.oauthStateTtlSeconds, "NX");
861
+ if (result !== "OK") {
862
+ return null;
863
+ }
864
+ return state;
865
+ };
866
+ var consumeOAuthState = async (providerName, state) => {
867
+ if (!state) {
868
+ return false;
869
+ }
870
+ const key = getOAuthStateKey(providerName, state);
871
+ const txResult = await redisClient2.multi().get(key).del(key).exec();
872
+ if (!txResult || txResult.length < 2) {
873
+ return false;
874
+ }
875
+ const getResult = txResult[0];
876
+ if (!getResult || getResult[0]) {
877
+ return false;
878
+ }
879
+ return getResult[1] === "1";
880
+ };
881
+ var asRecord = (value) => {
882
+ if (value && typeof value === "object") {
883
+ return value;
884
+ }
885
+ return {};
886
+ };
887
+ var sanitizeUserForSession = (user) => {
888
+ const { password: _password, ...safeUser } = user;
889
+ return safeUser;
890
+ };
891
+ var toReasonKey = (error, fallback = "api.internalServerError") => {
892
+ if (typeof error === "string" && error.length > 0) {
893
+ return error;
894
+ }
895
+ if (error instanceof Error && error.message) {
896
+ const message = error.message.toLowerCase();
897
+ if (message.includes("authentication failed") || message.includes("scram failure")) {
898
+ return "api.internalServerError";
899
+ }
900
+ }
901
+ return fallback;
902
+ };
903
+ var normalizeCredentials = (params) => ({
904
+ //? Don't HTML-escape password OR confirmPassword before bcrypt — neither
905
+ //? reaches HTML, and they're memory-compared for equality during register.
906
+ //? Escaping confirmPassword while leaving password raw made any password
907
+ //? containing `& < > " '` fail the `password !== confirmPassword` check
908
+ //? (regression introduced in pass-1 fix #9 which only un-escaped password).
909
+ //? Email is normalized (lowercase + trim) and validated via `isEmail`;
910
+ //? HTML-escaping it would make legacy raw rows unfindable.
911
+ email: (params.email || "").trim().toLowerCase(),
912
+ password: params.password || "",
913
+ name: params.name ? escape(params.name) : void 0,
914
+ confirmPassword: params.confirmPassword || void 0
915
+ });
916
+ var validateCredentialsShape = (creds) => {
917
+ const authLimits = getProjectConfig3().auth;
918
+ if (!creds.email || !creds.password) return { status: false, reason: "login.empty" };
919
+ if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: "login.emailCharacterLimit" };
920
+ if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: "login.nameCharacterLimit" };
921
+ if (!isEmail(creds.email)) return { status: false, reason: "login.invalidEmailFormat" };
922
+ const passwordReason = validatePassword(creds.password);
923
+ if (passwordReason) return { status: false, reason: passwordReason };
924
+ return null;
925
+ };
926
+ var registerWithCredentials = async ({
927
+ email,
928
+ password,
929
+ name,
930
+ confirmPassword
931
+ }) => {
932
+ if (password !== confirmPassword) {
933
+ return { status: false, reason: "login.passwordNotMatch" };
934
+ }
935
+ const preRegisterResult = await dispatchHook3("preRegister", { email, provider: "credentials", name });
936
+ if (preRegisterResult.stopped) {
937
+ return { status: false, reason: preRegisterResult.signal.errorCode };
938
+ }
939
+ const userAdapter = getUserAdapter();
940
+ const [checkEmailError, checkEmailResponse] = await tryCatch5(
941
+ () => userAdapter.findByEmail({ email, provider: "credentials" })
942
+ );
943
+ if (checkEmailError) {
944
+ getLogger3().error("login: findByEmail failed during register", checkEmailError);
945
+ return { status: false, reason: toReasonKey(checkEmailError) };
946
+ }
947
+ if (checkEmailResponse) return { status: false, reason: "login.emailExists" };
948
+ const createNewUser = async () => {
949
+ const salt = await genSalt(getProjectConfig3().auth.bcryptRounds);
950
+ const hashedPassword = await hash(password, salt);
951
+ return await userAdapter.create({
952
+ email,
953
+ provider: "credentials",
954
+ name,
955
+ password: hashedPassword,
956
+ avatar: "",
957
+ avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
958
+ language: getProjectConfig3().defaultLanguage
959
+ });
960
+ };
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" });
965
+ return {
966
+ status: true,
967
+ reason: "login.userCreated",
968
+ session: sanitizeUserForSession(createNewUserResponse)
969
+ };
970
+ };
971
+ var loginWithCredentialsCore = async ({ email, password }) => {
972
+ const preLoginResult = await dispatchHook3("preLogin", { email, provider: "credentials" });
973
+ if (preLoginResult.stopped) {
974
+ return { status: false, reason: preLoginResult.signal.errorCode };
975
+ }
976
+ const userAdapter = getUserAdapter();
977
+ const [findUserError, findUserResponse] = await tryCatch5(
978
+ () => userAdapter.findByEmail({ email, provider: "credentials" })
979
+ );
980
+ if (findUserError) {
981
+ getLogger3().error("login: findByEmail failed", findUserError);
982
+ return { status: false, reason: toReasonKey(findUserError) };
983
+ }
984
+ if (!findUserResponse) return { status: false, reason: "login.userNotFound" };
985
+ const [checkPasswordError, checkPasswordResponse] = await tryCatch5(
986
+ () => compare(password, findUserResponse.password)
987
+ );
988
+ if (checkPasswordError) {
989
+ getLogger3().error("login: bcrypt compare failed", checkPasswordError);
990
+ return { status: false, reason: checkPasswordError };
991
+ }
992
+ if (!checkPasswordResponse) return { status: false, reason: "login.wrongPassword" };
993
+ const newToken = randomBytes2(32).toString("hex");
994
+ const previousLogin = findUserResponse.lastLogin ?? null;
995
+ const nowLogin = /* @__PURE__ */ new Date();
996
+ await tryCatch5(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
997
+ const newUser = {
998
+ ...sanitizeUserForSession(findUserResponse),
999
+ token: newToken,
1000
+ lastLogin: nowLogin,
1001
+ previousLogin
1002
+ };
1003
+ const filePath = path.join(uploadsFolder(), `${newUser.id}.webp`);
1004
+ if (existsSync(filePath)) {
1005
+ newUser.avatar = `${newUser.id}.webp`;
1006
+ }
1007
+ await saveSession(newToken, newUser, true);
1008
+ await dispatchHook3("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
1009
+ if (isDevMode()) {
1010
+ getLogger3().debug(`credentials login success for user ${newUser.id}`);
1011
+ }
1012
+ return { status: true, reason: "login.loggedIn", newToken, session: newUser };
1013
+ };
1014
+ var loginWithCredentials = async (params) => {
1015
+ const creds = normalizeCredentials(params);
1016
+ if (isDevMode()) {
1017
+ getLogger3().debug(`credentials auth attempt for ${creds.email || "unknown-email"}`);
1018
+ }
1019
+ const validationError = validateCredentialsShape(creds);
1020
+ if (validationError) return validationError;
1021
+ if (creds.name && creds.confirmPassword) {
1022
+ return registerWithCredentials({
1023
+ email: creds.email,
1024
+ password: creds.password,
1025
+ name: creds.name,
1026
+ confirmPassword: creds.confirmPassword
1027
+ });
1028
+ }
1029
+ return loginWithCredentialsCore({ email: creds.email, password: creds.password });
1030
+ };
1031
+ var isAllowedRedirectUrl = (url) => {
1032
+ if (!URL.canParse(url, "http://placeholder")) return false;
1033
+ const parsed = new URL(url, "http://placeholder");
1034
+ if (parsed.origin === "http://placeholder") {
1035
+ return true;
1036
+ }
1037
+ const allowed = getProjectConfig3().http.cors.allowedOrigins ?? [];
1038
+ if (typeof allowed === "function") {
1039
+ return allowed(parsed.origin);
1040
+ }
1041
+ return allowed.some((origin) => {
1042
+ if (!URL.canParse(origin)) return false;
1043
+ return new URL(origin).origin === parsed.origin;
1044
+ });
1045
+ };
1046
+ var exchangeOAuthToken = async (provider, code) => {
1047
+ const values = {
1048
+ code,
1049
+ client_id: provider.clientID,
1050
+ client_secret: provider.clientSecret,
1051
+ redirect_uri: provider.callbackURL,
1052
+ grant_type: "authorization_code"
1053
+ };
1054
+ const getToken = async () => {
1055
+ if (provider.tokenExchangeMethod === "json") {
1056
+ const response2 = await fetch(provider.tokenExchangeURL, {
1057
+ method: "POST",
1058
+ headers: { "Content-Type": "application/json", "Accept": "application/json" },
1059
+ body: JSON.stringify(values)
1060
+ });
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() });
1072
+ }
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();
1079
+ }
1080
+ return null;
1081
+ };
1082
+ const [error, response] = await tryCatch5(getToken);
1083
+ if (error) {
1084
+ getLogger3().error("oauth: token exchange failed", error);
1085
+ return null;
1086
+ }
1087
+ const tokenData = asRecord(response);
1088
+ const accessToken = typeof tokenData.access_token === "string" ? tokenData.access_token : "";
1089
+ if (!accessToken) {
1090
+ getLogger3().warn("oauth: no access_token in token response", { provider: provider.name });
1091
+ return null;
1092
+ }
1093
+ return accessToken;
1094
+ };
1095
+ var fetchOAuthProfile = async (provider, accessToken) => {
1096
+ const getUserData = async () => {
1097
+ const response2 = await fetch(provider.userInfoURL, {
1098
+ method: "GET",
1099
+ headers: {
1100
+ "Content-Type": "application/json",
1101
+ "Accept": "application/json",
1102
+ "Authorization": `Bearer ${accessToken}`
1103
+ }
1104
+ });
1105
+ return await response2.json();
1106
+ };
1107
+ const [error, response] = await tryCatch5(getUserData);
1108
+ if (error) {
1109
+ getLogger3().error("oauth: userInfo fetch failed", error);
1110
+ return null;
1111
+ }
1112
+ const userData = asRecord(response);
1113
+ const name = String(userData[provider.nameKey] || "didnt find a name");
1114
+ const emailValue = userData[provider.emailKey];
1115
+ let email = typeof emailValue === "string" ? emailValue : void 0;
1116
+ 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 }) : "";
1118
+ const avatar = typeof avatarValue === "string" ? avatarValue : "";
1119
+ if (!email && provider.getEmail) {
1120
+ const selectedEmail = await provider.getEmail(accessToken);
1121
+ if (!selectedEmail) {
1122
+ getLogger3().warn("oauth: no email found via provider.getEmail", { provider: provider.name });
1123
+ return null;
1124
+ }
1125
+ email = selectedEmail;
1126
+ }
1127
+ return { email, name, avatar };
1128
+ };
1129
+ var findOrCreateOAuthUser = async (provider, profile) => {
1130
+ const { email, name, avatar } = profile;
1131
+ if (!email) return null;
1132
+ const preLoginResult = await dispatchHook3("preLogin", { email, provider: provider.name });
1133
+ if (preLoginResult.stopped) {
1134
+ getLogger3().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });
1135
+ return null;
1136
+ }
1137
+ const userAdapter = getUserAdapter();
1138
+ const [findError, findResponse] = await tryCatch5(
1139
+ () => userAdapter.findByEmail({ email, provider: provider.name })
1140
+ );
1141
+ if (findError) {
1142
+ getLogger3().error("oauth: findByEmail failed", findError);
1143
+ return null;
1144
+ }
1145
+ if (findResponse?.id) {
1146
+ const filePath = path.join(uploadsFolder(), `${findResponse.id}.webp`);
1147
+ if (existsSync(filePath)) {
1148
+ findResponse.avatar = `${findResponse.id}.webp`;
1149
+ }
1150
+ const previousLogin = findResponse.lastLogin ?? null;
1151
+ const nowLogin = /* @__PURE__ */ new Date();
1152
+ await tryCatch5(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
1153
+ return {
1154
+ user: {
1155
+ ...sanitizeUserForSession(findResponse),
1156
+ token: "",
1157
+ lastLogin: nowLogin,
1158
+ previousLogin
1159
+ },
1160
+ isNewUser: false
1161
+ };
1162
+ }
1163
+ const preRegisterResult = await dispatchHook3("preRegister", { email, provider: provider.name, name });
1164
+ if (preRegisterResult.stopped) {
1165
+ getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
1166
+ return null;
1167
+ }
1168
+ const createNewUser = async () => userAdapter.create({
1169
+ email,
1170
+ provider: provider.name,
1171
+ name,
1172
+ avatar,
1173
+ avatarFallback: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`,
1174
+ language: getProjectConfig3().defaultLanguage
1175
+ });
1176
+ const [createError, createResponse] = await tryCatch5(createNewUser);
1177
+ if (createError) {
1178
+ getLogger3().error("oauth: user create failed", createError);
1179
+ return null;
1180
+ }
1181
+ if (!createResponse) return null;
1182
+ return {
1183
+ user: { ...sanitizeUserForSession(createResponse), token: "" },
1184
+ isNewUser: true
1185
+ };
1186
+ };
1187
+ var resolvePostLoginRedirect = async ({
1188
+ fallbackUrl,
1189
+ userId,
1190
+ providerName,
1191
+ isNewUser
1192
+ }) => {
1193
+ const resolver = getPostLoginRedirect();
1194
+ if (!resolver) return fallbackUrl;
1195
+ const [resolverError, resolved] = await tryCatch5(() => resolver({
1196
+ userId,
1197
+ provider: providerName,
1198
+ isNewUser,
1199
+ defaultUrl: fallbackUrl
1200
+ }));
1201
+ if (resolverError) {
1202
+ getLogger3().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });
1203
+ return fallbackUrl;
1204
+ }
1205
+ if (resolved && isAllowedRedirectUrl(resolved)) return resolved;
1206
+ if (resolved) {
1207
+ getLogger3().warn(
1208
+ `[oauth] postLoginRedirect returned a URL not in allowed origins \u2014 falling back`,
1209
+ { resolved, fallbackUrl }
1210
+ );
1211
+ }
1212
+ return fallbackUrl;
1213
+ };
1214
+ 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] ?? "";
1221
+ const params = new URLSearchParams(queryString);
1222
+ const code = params.get("code");
1223
+ 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 });
1227
+ return false;
1228
+ }
1229
+ if (!code) {
1230
+ getLogger3().warn("oauth: no code provided in callback url", { provider: provider.name });
1231
+ return false;
1232
+ }
1233
+ const accessToken = await exchangeOAuthToken(provider, code);
1234
+ if (!accessToken) return false;
1235
+ const profile = await fetchOAuthProfile(provider, accessToken);
1236
+ if (!profile) return false;
1237
+ const resolved = await findOrCreateOAuthUser(provider, profile);
1238
+ if (!resolved) return false;
1239
+ const newToken = randomBytes2(32).toString("hex");
1240
+ 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
+ }
1251
+ }
1252
+ await saveSession(newToken, resolved.user, true);
1253
+ if (resolved.isNewUser) {
1254
+ await dispatchHook3("postRegister", { userId: resolved.user.id, provider: providerName });
1255
+ }
1256
+ await dispatchHook3("postLogin", {
1257
+ userId: resolved.user.id,
1258
+ provider: providerName,
1259
+ isNewUser: resolved.isNewUser,
1260
+ token: newToken
1261
+ });
1262
+ if (isDevMode()) {
1263
+ getLogger3().debug(`oauth login success for user ${resolved.user.id}`);
1264
+ }
1265
+ const fallbackUrl = options.defaultRedirectUrl ?? getProjectConfig3().loginRedirectUrl ?? "/";
1266
+ const redirectUrl = await resolvePostLoginRedirect({
1267
+ fallbackUrl,
1268
+ userId: resolved.user.id,
1269
+ providerName,
1270
+ isNewUser: resolved.isNewUser
1271
+ });
1272
+ return {
1273
+ token: newToken,
1274
+ redirectUrl,
1275
+ userId: resolved.user.id,
1276
+ provider: providerName,
1277
+ isNewUser: resolved.isNewUser
1278
+ };
1279
+ };
1280
+
1281
+ // src/index.ts
1282
+ init_logout();
1283
+
1284
+ // src/passwordReset.ts
1285
+ 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);
1289
+ 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;
1294
+ };
1295
+ 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;
1305
+ };
1306
+ var updatePasswordHash = async (userId, plaintext) => {
1307
+ const reason = validatePassword(plaintext);
1308
+ if (reason) {
1309
+ throw new PasswordPolicyError(reason);
1310
+ }
1311
+ const salt = await bcrypt2.genSalt(getProjectConfig4().auth.bcryptRounds);
1312
+ const hashedPassword = await bcrypt2.hash(plaintext, salt);
1313
+ const patch = { password: hashedPassword };
1314
+ await getUserAdapter().update(userId, patch);
1315
+ };
1316
+ var PasswordPolicyError = class extends Error {
1317
+ errorCode;
1318
+ constructor(errorCode) {
1319
+ super(`Password policy violation: ${errorCode}`);
1320
+ this.name = "PasswordPolicyError";
1321
+ this.errorCode = errorCode;
1322
+ }
1323
+ };
1324
+ var verifyPassword = async (plaintext, hash2) => {
1325
+ return bcrypt2.compare(plaintext, hash2);
1326
+ };
1327
+
1328
+ // src/forgotPassword.ts
1329
+ import { getProjectConfig as getProjectConfig5, dispatchHook as dispatchHook4, getLogger as getLogger4 } from "@luckystack/core";
1330
+ var sendPasswordResetEmail = async ({ email, brand }) => {
1331
+ const config = getProjectConfig5();
1332
+ getLogger4().info("[forgotPassword] start", { email, forgotPasswordMode: config.auth.forgotPassword });
1333
+ if (config.auth.forgotPassword !== "framework") {
1334
+ getLogger4().warn('[forgotPassword] auth.forgotPassword is not "framework" \u2014 nothing sent', { mode: config.auth.forgotPassword });
1335
+ return { ok: false, reason: "forgotPassword-not-framework" };
1336
+ }
1337
+ 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) => {
1340
+ getLogger4().warn("[forgotPassword] failed to load @luckystack/email \u2014 is it installed?", { error: String(error) });
1341
+ return null;
1342
+ });
1343
+ if (!emailModule) {
1344
+ return { ok: false, reason: "email-module-load-failed" };
1345
+ }
1346
+ const { sendEmail, renderEmailLayout } = emailModule;
1347
+ const userAdapter = getUserAdapter();
1348
+ const user = await userAdapter.findByEmail({ email, provider: "credentials" });
1349
+ getLogger4().info("[forgotPassword] credentials user lookup", { email, found: Boolean(user), userId: user?.id ?? null });
1350
+ 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", {
1353
+ email,
1354
+ matched: false
1355
+ });
1356
+ return { ok: true };
1357
+ }
1358
+ const token = await createPasswordResetToken(user.id);
1359
+ void dispatchHook4("passwordResetRequested", {
1360
+ email,
1361
+ matched: true,
1362
+ userId: user.id,
1363
+ token,
1364
+ ttlSeconds: config.auth.passwordResetTtlSeconds
1365
+ });
1366
+ const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1367
+ const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;
1368
+ 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
+ const result = await sendEmail({
1379
+ to: user.email,
1380
+ subject: `Reset your ${resolvedBrand} password`,
1381
+ html,
1382
+ text,
1383
+ adapterHint: "transactional"
1384
+ });
1385
+ if (result.ok) {
1386
+ getLogger4().info("[forgotPassword] reset email dispatched", { userId: user.id, to: user.email });
1387
+ } else {
1388
+ getLogger4().warn("[forgotPassword] reset email send FAILED", { userId: user.id, reason: result.reason });
1389
+ }
1390
+ return result.ok ? { ok: true } : { ok: false, reason: result.reason };
1391
+ };
1392
+
1393
+ // 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);
1397
+ 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;
1403
+ };
1404
+ 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 {
1421
+ return null;
1422
+ }
1423
+ };
1424
+
1425
+ // src/emailChangeNotification.ts
1426
+ import { getProjectConfig as getProjectConfig7 } from "@luckystack/core";
1427
+ var sendEmailChangeConfirmation = async ({ userId, newEmail, userName, brand }) => {
1428
+ const config = getProjectConfig7();
1429
+ 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");
1432
+ const token = await createEmailChangeToken(userId, newEmail);
1433
+ const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1434
+ const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;
1435
+ 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
+ const result = await sendEmail({
1446
+ to: newEmail,
1447
+ subject: `Confirm your new ${resolvedBrand} email address`,
1448
+ html,
1449
+ text,
1450
+ adapterHint: "transactional"
1451
+ });
1452
+ return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };
1453
+ };
1454
+ export {
1455
+ PasswordPolicyError,
1456
+ activeUsersKeyFor,
1457
+ consumeEmailChangeToken,
1458
+ consumePasswordResetToken,
1459
+ createEmailChangeToken,
1460
+ createOAuthState,
1461
+ createPasswordResetToken,
1462
+ credentialsProvider,
1463
+ defaultPrismaUserAdapter,
1464
+ deleteSession,
1465
+ discordProvider,
1466
+ facebookProvider,
1467
+ getAllSessions,
1468
+ getOAuthProviders,
1469
+ getPostLoginRedirect,
1470
+ getSession,
1471
+ getSessionAdapter,
1472
+ getUserAdapter,
1473
+ githubProvider,
1474
+ googleProvider,
1475
+ isFullOAuthProvider,
1476
+ isUserAdapterRegistered,
1477
+ loginCallback,
1478
+ loginWithCredentials,
1479
+ loginWithCredentialsCore,
1480
+ logout,
1481
+ microsoftProvider,
1482
+ redisSessionAdapter,
1483
+ registerOAuthProviders,
1484
+ registerPostLoginRedirect,
1485
+ registerSessionAdapter,
1486
+ registerUserAdapter,
1487
+ registerWithCredentials,
1488
+ revokeUserSessions,
1489
+ saveSession,
1490
+ sendEmailChangeConfirmation,
1491
+ sendPasswordResetEmail,
1492
+ sessionKeyFor,
1493
+ updatePasswordHash,
1494
+ validatePassword,
1495
+ verifyPassword
1496
+ };
1497
+ //# sourceMappingURL=index.js.map