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