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