@jskit-ai/auth-provider-local-core 0.1.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/package.descriptor.mjs +102 -0
- package/package.json +18 -0
- package/src/server/lib/fileBackend.js +496 -0
- package/src/server/lib/index.js +2 -0
- package/src/server/lib/passwords.js +44 -0
- package/src/server/lib/service.js +689 -0
- package/src/server/lib/tokens.js +57 -0
- package/src/server/providers/AuthLocalServiceProvider.js +194 -0
- package/src/server/providers/AuthProviderServiceProvider.js +9 -0
- package/test/providerRuntime.test.js +505 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import {
|
|
3
|
+
AUTH_PASSWORD_MAX_LENGTH,
|
|
4
|
+
AUTH_PASSWORD_MIN_LENGTH
|
|
5
|
+
} from "@jskit-ai/auth-core/shared/authConstraints";
|
|
6
|
+
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
7
|
+
import { buildSecurityStatusFromAuthMethodsStatus } from "@jskit-ai/auth-core/shared/authSecurityStatus";
|
|
8
|
+
import { normalizeAuthActor, normalizeAuthResult } from "@jskit-ai/auth-core/server/authActor";
|
|
9
|
+
import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
|
|
10
|
+
import { throwUnsupportedAuthOperation } from "@jskit-ai/auth-core/server/unsupportedOperation";
|
|
11
|
+
import { hashPassword, verifyPassword } from "./passwords.js";
|
|
12
|
+
import { randomToken, sha256Base64url, signToken, verifySignedToken } from "./tokens.js";
|
|
13
|
+
|
|
14
|
+
const ACCESS_TOKEN_COOKIE = "jskit_local_access_token";
|
|
15
|
+
const REFRESH_TOKEN_COOKIE = "jskit_local_refresh_token";
|
|
16
|
+
const RECOVERY_TOKEN_COOKIE = "jskit_local_recovery_token";
|
|
17
|
+
const PROVIDER_ID = "local";
|
|
18
|
+
const ACCESS_TTL_SECONDS = 15 * 60;
|
|
19
|
+
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
20
|
+
const RECOVERY_SESSION_TTL_SECONDS = 15 * 60;
|
|
21
|
+
const RECOVERY_TOKEN_TTL_SECONDS = 60 * 60;
|
|
22
|
+
|
|
23
|
+
function nowSeconds() {
|
|
24
|
+
return Math.floor(Date.now() / 1000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isoFromNow(seconds) {
|
|
28
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isExpiredIso(value) {
|
|
32
|
+
return Date.parse(String(value || "")) <= Date.now();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNormalSession(session) {
|
|
36
|
+
return (session?.purpose || "normal") === "normal";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeDisplayName(value, email) {
|
|
40
|
+
const displayName = String(value || "").trim();
|
|
41
|
+
if (displayName) {
|
|
42
|
+
return displayName;
|
|
43
|
+
}
|
|
44
|
+
return email.split("@")[0] || "User";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createId(prefix) {
|
|
48
|
+
return `${prefix}_${randomToken(18)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeRequestCookies(request) {
|
|
52
|
+
return request && request.cookies && typeof request.cookies === "object" ? request.cookies : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cookieOptions(isProduction, maxAge) {
|
|
56
|
+
return {
|
|
57
|
+
path: "/",
|
|
58
|
+
httpOnly: true,
|
|
59
|
+
sameSite: "lax",
|
|
60
|
+
secure: isProduction,
|
|
61
|
+
maxAge
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearCookieOptions(isProduction) {
|
|
66
|
+
return [
|
|
67
|
+
{ path: "/", httpOnly: true, sameSite: "lax", secure: isProduction, maxAge: 0 },
|
|
68
|
+
{ path: "/api", httpOnly: true, sameSite: "lax", secure: isProduction, maxAge: 0 }
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildProfile(user) {
|
|
73
|
+
return {
|
|
74
|
+
id: user.id,
|
|
75
|
+
authProvider: PROVIDER_ID,
|
|
76
|
+
authProviderUserSid: user.id,
|
|
77
|
+
email: user.email,
|
|
78
|
+
displayName: user.displayName,
|
|
79
|
+
profileSource: "auth-provider"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildActor(user, profile = null) {
|
|
84
|
+
const profileSource = profile ? "users" : "auth-provider";
|
|
85
|
+
return normalizeAuthActor({
|
|
86
|
+
provider: PROVIDER_ID,
|
|
87
|
+
providerUserId: user.id,
|
|
88
|
+
email: user.email,
|
|
89
|
+
displayName: user.displayName,
|
|
90
|
+
appUserId: profile?.id || null,
|
|
91
|
+
profileSource
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function buildAuthPayload({ user, session, profileProjector }) {
|
|
96
|
+
let profile = buildProfile(user);
|
|
97
|
+
if (profileProjector && typeof profileProjector.syncIdentityProfile === "function") {
|
|
98
|
+
profile = await profileProjector.syncIdentityProfile(profile);
|
|
99
|
+
if (!profile || typeof profile !== "object") {
|
|
100
|
+
throw new Error("auth.profile.projector.syncIdentityProfile() must return a profile object.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return normalizeAuthResult({
|
|
104
|
+
profile,
|
|
105
|
+
actor: buildActor(user, profileProjector ? profile : null),
|
|
106
|
+
session
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildAccessToken({ user, session, secret, ttlSeconds = ACCESS_TTL_SECONDS }) {
|
|
111
|
+
const issuedAt = nowSeconds();
|
|
112
|
+
return signToken(
|
|
113
|
+
{
|
|
114
|
+
iss: "jskit-auth-local",
|
|
115
|
+
aud: "authenticated",
|
|
116
|
+
sub: user.id,
|
|
117
|
+
sid: session.id,
|
|
118
|
+
purpose: session.purpose || "normal",
|
|
119
|
+
email: user.email,
|
|
120
|
+
iat: issuedAt,
|
|
121
|
+
exp: issuedAt + ttlSeconds,
|
|
122
|
+
jti: randomToken(12)
|
|
123
|
+
},
|
|
124
|
+
secret
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildSessionPayload({ user, session, refreshToken, secret }) {
|
|
129
|
+
const accessToken = buildAccessToken({ user, session, secret });
|
|
130
|
+
return {
|
|
131
|
+
access_token: accessToken,
|
|
132
|
+
refresh_token: refreshToken,
|
|
133
|
+
expires_in: ACCESS_TTL_SECONDS,
|
|
134
|
+
token_type: "bearer",
|
|
135
|
+
purpose: session.purpose || "normal"
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validatePasswordInput(password) {
|
|
140
|
+
const normalizedPassword = String(password || "");
|
|
141
|
+
if (
|
|
142
|
+
normalizedPassword.length < AUTH_PASSWORD_MIN_LENGTH ||
|
|
143
|
+
normalizedPassword.length > AUTH_PASSWORD_MAX_LENGTH
|
|
144
|
+
) {
|
|
145
|
+
throw new AppError(400, "Password must be at least 8 characters.", {
|
|
146
|
+
details: {
|
|
147
|
+
fieldErrors: {
|
|
148
|
+
password: "Password must be at least 8 characters."
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateEmailInput(email) {
|
|
156
|
+
const normalized = normalizeEmail(email);
|
|
157
|
+
if (!normalized || !normalized.includes("@")) {
|
|
158
|
+
throw new AppError(400, "Enter a valid email address.", {
|
|
159
|
+
details: {
|
|
160
|
+
fieldErrors: {
|
|
161
|
+
email: "Enter a valid email address."
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return normalized;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function maybeSendRecoveryEmail(config, recoveryUrl, email) {
|
|
170
|
+
if (!config.smtpConfigured) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const nodemailer = await import("nodemailer");
|
|
174
|
+
const transport = nodemailer.createTransport({
|
|
175
|
+
host: config.smtp.host,
|
|
176
|
+
port: config.smtp.port,
|
|
177
|
+
secure: config.smtp.secure,
|
|
178
|
+
auth: config.smtp.user
|
|
179
|
+
? {
|
|
180
|
+
user: config.smtp.user,
|
|
181
|
+
pass: config.smtp.password
|
|
182
|
+
}
|
|
183
|
+
: undefined
|
|
184
|
+
});
|
|
185
|
+
await transport.sendMail({
|
|
186
|
+
from: config.smtp.from,
|
|
187
|
+
replyTo: config.smtp.replyTo || undefined,
|
|
188
|
+
to: email,
|
|
189
|
+
subject: "Reset your password",
|
|
190
|
+
text: `Open this link to reset your password:\n\n${recoveryUrl}\n`
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function createLocalAuthService({ backend, config, profileProjector = null }) {
|
|
195
|
+
if (!backend || typeof backend.withTransaction !== "function") {
|
|
196
|
+
throw new Error("Local auth requires auth.local.backend with withTransaction().");
|
|
197
|
+
}
|
|
198
|
+
if (!config?.sessionSecret) {
|
|
199
|
+
throw new Error("Local auth requires a session secret.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const isProduction = config.nodeEnv === "production";
|
|
203
|
+
const profileProjectionEnabled = typeof profileProjector?.syncIdentityProfile === "function";
|
|
204
|
+
const recoveryDelivery = config.smtpConfigured
|
|
205
|
+
? "smtp"
|
|
206
|
+
: isProduction
|
|
207
|
+
? "disabled"
|
|
208
|
+
: config.recoveryDevOutput === "response"
|
|
209
|
+
? "dev-response"
|
|
210
|
+
: "dev-log";
|
|
211
|
+
const recoveryEnabled = recoveryDelivery !== "disabled";
|
|
212
|
+
const capabilities = normalizeAuthCapabilities({
|
|
213
|
+
provider: {
|
|
214
|
+
id: PROVIDER_ID,
|
|
215
|
+
label: "Local"
|
|
216
|
+
},
|
|
217
|
+
features: {
|
|
218
|
+
password: {
|
|
219
|
+
login: true,
|
|
220
|
+
register: true,
|
|
221
|
+
change: true,
|
|
222
|
+
methodToggle: false
|
|
223
|
+
},
|
|
224
|
+
passwordRecovery: {
|
|
225
|
+
request: recoveryEnabled,
|
|
226
|
+
complete: recoveryEnabled,
|
|
227
|
+
delivery: recoveryDelivery
|
|
228
|
+
},
|
|
229
|
+
otp: {
|
|
230
|
+
login: false
|
|
231
|
+
},
|
|
232
|
+
oauthLogin: {
|
|
233
|
+
enabled: false,
|
|
234
|
+
providers: []
|
|
235
|
+
},
|
|
236
|
+
emailConfirmation: false,
|
|
237
|
+
profileUpdate: true,
|
|
238
|
+
providerLinking: {
|
|
239
|
+
start: false,
|
|
240
|
+
unlink: false
|
|
241
|
+
},
|
|
242
|
+
securityStatus: true,
|
|
243
|
+
signOutOtherSessions: true,
|
|
244
|
+
appProfileProjection: profileProjectionEnabled
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
async function createSessionForUser(tx, user, { purpose = "normal", ttlSeconds = REFRESH_TTL_SECONDS } = {}) {
|
|
249
|
+
const refreshToken = randomToken(36);
|
|
250
|
+
const session = await tx.sessions.create({
|
|
251
|
+
id: createId("ses"),
|
|
252
|
+
userId: user.id,
|
|
253
|
+
tokenHash: sha256Base64url(refreshToken),
|
|
254
|
+
purpose,
|
|
255
|
+
expiresAt: isoFromNow(ttlSeconds)
|
|
256
|
+
});
|
|
257
|
+
return buildSessionPayload({
|
|
258
|
+
user,
|
|
259
|
+
session,
|
|
260
|
+
refreshToken,
|
|
261
|
+
secret: config.sessionSecret
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function resolveValidSessionByRefreshToken(refreshToken) {
|
|
266
|
+
const tokenHash = sha256Base64url(refreshToken);
|
|
267
|
+
return backend.withTransaction(async (tx) => {
|
|
268
|
+
const session = await tx.sessions.findByTokenHash(tokenHash);
|
|
269
|
+
if (!session || session.revokedAt || isExpiredIso(session.expiresAt)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const user = await tx.users.findById(session.userId);
|
|
273
|
+
if (!user || user.disabled) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
user,
|
|
278
|
+
session
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function register(input = {}) {
|
|
284
|
+
const email = validateEmailInput(input.email);
|
|
285
|
+
validatePasswordInput(input.password);
|
|
286
|
+
const displayName = normalizeDisplayName(input.displayName, email);
|
|
287
|
+
const password = await hashPassword(input.password);
|
|
288
|
+
const result = await backend.withTransaction(async (tx) => {
|
|
289
|
+
const existing = await tx.users.findByEmail(email);
|
|
290
|
+
if (existing) {
|
|
291
|
+
throw new AppError(409, "An account already exists for this email.");
|
|
292
|
+
}
|
|
293
|
+
const user = await tx.users.create({
|
|
294
|
+
id: createId("usr"),
|
|
295
|
+
email,
|
|
296
|
+
displayName,
|
|
297
|
+
password
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
user,
|
|
301
|
+
session: await createSessionForUser(tx, user)
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
...(await buildAuthPayload({ user: result.user, session: result.session, profileProjector })),
|
|
306
|
+
requiresEmailConfirmation: false
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function login(input = {}) {
|
|
311
|
+
const email = validateEmailInput(input.email);
|
|
312
|
+
const password = String(input.password || "");
|
|
313
|
+
return backend.withTransaction(async (tx) => {
|
|
314
|
+
const user = await tx.users.findByEmail(email);
|
|
315
|
+
if (!user || user.disabled || !(await verifyPassword(password, user.password))) {
|
|
316
|
+
throw new AppError(401, "Invalid email or password.");
|
|
317
|
+
}
|
|
318
|
+
const session = await createSessionForUser(tx, user);
|
|
319
|
+
return buildAuthPayload({ user, session, profileProjector });
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function authenticateRequest(request) {
|
|
324
|
+
const cookies = safeRequestCookies(request);
|
|
325
|
+
const accessToken = String(cookies[ACCESS_TOKEN_COOKIE] || "").trim();
|
|
326
|
+
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "").trim();
|
|
327
|
+
|
|
328
|
+
if (accessToken) {
|
|
329
|
+
const payload = verifySignedToken(accessToken, config.sessionSecret);
|
|
330
|
+
if (payload?.sid && payload?.sub) {
|
|
331
|
+
const resolved = await backend.withTransaction(async (tx) => {
|
|
332
|
+
const session = await tx.sessions.findById(String(payload.sid));
|
|
333
|
+
if (!session || session.revokedAt || isExpiredIso(session.expiresAt)) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const user = await tx.users.findById(String(payload.sub));
|
|
337
|
+
if (!user || user.disabled) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return { user, session };
|
|
341
|
+
});
|
|
342
|
+
if (resolved && isNormalSession(resolved.session)) {
|
|
343
|
+
return {
|
|
344
|
+
authenticated: true,
|
|
345
|
+
...(await buildAuthPayload({ user: resolved.user, session: null, profileProjector })),
|
|
346
|
+
session: null,
|
|
347
|
+
sessionPurpose: resolved.session.purpose || "normal",
|
|
348
|
+
clearSession: false,
|
|
349
|
+
transientFailure: false
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!refreshToken) {
|
|
356
|
+
return {
|
|
357
|
+
authenticated: false,
|
|
358
|
+
clearSession: Boolean(accessToken),
|
|
359
|
+
session: null,
|
|
360
|
+
transientFailure: false
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const resolved = await resolveValidSessionByRefreshToken(refreshToken);
|
|
365
|
+
if (!resolved) {
|
|
366
|
+
return {
|
|
367
|
+
authenticated: false,
|
|
368
|
+
clearSession: true,
|
|
369
|
+
session: null,
|
|
370
|
+
transientFailure: false
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!isNormalSession(resolved.session)) {
|
|
375
|
+
return {
|
|
376
|
+
authenticated: false,
|
|
377
|
+
clearSession: false,
|
|
378
|
+
session: null,
|
|
379
|
+
transientFailure: false
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
authenticated: true,
|
|
385
|
+
...(await buildAuthPayload({ user: resolved.user, session: null, profileProjector })),
|
|
386
|
+
session: buildSessionPayload({
|
|
387
|
+
user: resolved.user,
|
|
388
|
+
session: resolved.session,
|
|
389
|
+
refreshToken,
|
|
390
|
+
secret: config.sessionSecret
|
|
391
|
+
}),
|
|
392
|
+
sessionPurpose: resolved.session.purpose || "normal",
|
|
393
|
+
clearSession: false,
|
|
394
|
+
transientFailure: false
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writeSessionCookies(reply, session) {
|
|
399
|
+
if (!reply || !session) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const accessToken = String(session.access_token || "");
|
|
403
|
+
const refreshToken = String(session.refresh_token || "");
|
|
404
|
+
const purpose = String(session.purpose || "normal");
|
|
405
|
+
const isRecoverySession = purpose === "recovery";
|
|
406
|
+
const accessMaxAge = Number(session.expires_in || ACCESS_TTL_SECONDS);
|
|
407
|
+
const refreshMaxAge = isRecoverySession ? accessMaxAge : REFRESH_TTL_SECONDS;
|
|
408
|
+
if (accessToken) {
|
|
409
|
+
reply.setCookie(
|
|
410
|
+
isRecoverySession ? RECOVERY_TOKEN_COOKIE : ACCESS_TOKEN_COOKIE,
|
|
411
|
+
accessToken,
|
|
412
|
+
cookieOptions(isProduction, accessMaxAge)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (refreshToken) {
|
|
416
|
+
reply.setCookie(REFRESH_TOKEN_COOKIE, refreshToken, cookieOptions(isProduction, refreshMaxAge));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function clearSessionCookies(reply) {
|
|
421
|
+
if (!reply) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
for (const options of clearCookieOptions(isProduction)) {
|
|
425
|
+
reply.clearCookie(ACCESS_TOKEN_COOKIE, options);
|
|
426
|
+
reply.clearCookie(REFRESH_TOKEN_COOKIE, options);
|
|
427
|
+
reply.clearCookie(RECOVERY_TOKEN_COOKIE, options);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function logout(request) {
|
|
432
|
+
const cookies = safeRequestCookies(request);
|
|
433
|
+
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "").trim();
|
|
434
|
+
if (!refreshToken) {
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
clearSession: true
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
await backend.withTransaction(async (tx) => {
|
|
441
|
+
const session = await tx.sessions.findByTokenHash(sha256Base64url(refreshToken));
|
|
442
|
+
if (session) {
|
|
443
|
+
await tx.sessions.revoke(session.id);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
ok: true,
|
|
448
|
+
clearSession: true
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function requestPasswordReset(input = {}) {
|
|
453
|
+
if (!recoveryEnabled) {
|
|
454
|
+
throw new AppError(503, "Password recovery is not configured.");
|
|
455
|
+
}
|
|
456
|
+
const email = validateEmailInput(input.email);
|
|
457
|
+
let recoveryUrl = "";
|
|
458
|
+
await backend.withTransaction(async (tx) => {
|
|
459
|
+
const user = await tx.users.findByEmail(email);
|
|
460
|
+
if (!user || user.disabled) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const token = randomToken(32);
|
|
464
|
+
recoveryUrl = `${config.appPublicUrl.replace(/\/$/, "")}/auth/reset-password?token=${encodeURIComponent(token)}&type=recovery`;
|
|
465
|
+
await tx.recovery.create({
|
|
466
|
+
id: createId("rec"),
|
|
467
|
+
userId: user.id,
|
|
468
|
+
tokenHash: sha256Base64url(token),
|
|
469
|
+
expiresAt: isoFromNow(RECOVERY_TOKEN_TTL_SECONDS)
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
if (recoveryUrl) {
|
|
473
|
+
await maybeSendRecoveryEmail(config, recoveryUrl, email);
|
|
474
|
+
if (!config.smtpConfigured && config.recoveryDevOutput === "log" && config.logger?.info) {
|
|
475
|
+
config.logger.info({ recoveryUrl, email }, "Local auth password recovery URL created.");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
ok: true,
|
|
480
|
+
message: "If an account exists for that email, password reset instructions have been sent.",
|
|
481
|
+
...(recoveryUrl && !config.smtpConfigured && config.recoveryDevOutput === "response" ? { recoveryUrl } : {})
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function completePasswordRecovery(input = {}) {
|
|
486
|
+
if (!recoveryEnabled) {
|
|
487
|
+
throw new AppError(503, "Password recovery is not configured.");
|
|
488
|
+
}
|
|
489
|
+
const token = String(input.code || input.token || "").trim();
|
|
490
|
+
const tokenHash = String(input.tokenHash || "").trim() || (token ? sha256Base64url(token) : "");
|
|
491
|
+
if (!tokenHash) {
|
|
492
|
+
throw new AppError(400, "Recovery token is required.");
|
|
493
|
+
}
|
|
494
|
+
return backend.withTransaction(async (tx) => {
|
|
495
|
+
const recovery = await tx.recovery.findByTokenHash(tokenHash);
|
|
496
|
+
if (!recovery || recovery.usedAt || isExpiredIso(recovery.expiresAt)) {
|
|
497
|
+
throw new AppError(401, "Recovery token is invalid or expired.");
|
|
498
|
+
}
|
|
499
|
+
await tx.recovery.consume(recovery.id);
|
|
500
|
+
const user = await tx.users.findById(recovery.userId);
|
|
501
|
+
if (!user || user.disabled) {
|
|
502
|
+
throw new AppError(401, "Recovery token is invalid or expired.");
|
|
503
|
+
}
|
|
504
|
+
const session = await createSessionForUser(tx, user, {
|
|
505
|
+
purpose: "recovery",
|
|
506
|
+
ttlSeconds: RECOVERY_SESSION_TTL_SECONDS
|
|
507
|
+
});
|
|
508
|
+
return buildAuthPayload({ user, session, profileProjector });
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function resetPassword(request, input = {}) {
|
|
513
|
+
validatePasswordInput(input.password);
|
|
514
|
+
const cookies = safeRequestCookies(request);
|
|
515
|
+
const recoveryAccessToken = String(cookies[RECOVERY_TOKEN_COOKIE] || "").trim();
|
|
516
|
+
const payload = verifySignedToken(recoveryAccessToken, config.sessionSecret);
|
|
517
|
+
if (!payload?.sub || !payload?.sid) {
|
|
518
|
+
throw new AppError(401, "Authentication required.");
|
|
519
|
+
}
|
|
520
|
+
const password = await hashPassword(input.password);
|
|
521
|
+
await backend.withTransaction(async (tx) => {
|
|
522
|
+
const session = await tx.sessions.findById(String(payload.sid));
|
|
523
|
+
if (!session || session.revokedAt || isExpiredIso(session.expiresAt)) {
|
|
524
|
+
throw new AppError(401, "Authentication required.");
|
|
525
|
+
}
|
|
526
|
+
if (session.purpose !== "recovery") {
|
|
527
|
+
throw new AppError(401, "Recovery session required.");
|
|
528
|
+
}
|
|
529
|
+
await tx.users.updatePassword(String(payload.sub), password);
|
|
530
|
+
await tx.sessions.revokeForUser(String(payload.sub));
|
|
531
|
+
if (typeof tx.recovery.consumeForUser === "function") {
|
|
532
|
+
await tx.recovery.consumeForUser(String(payload.sub));
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
ok: true,
|
|
537
|
+
message: "Password updated. Sign in with your new password."
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function changePassword(request, input = {}) {
|
|
542
|
+
validatePasswordInput(input.newPassword);
|
|
543
|
+
const authResult = await authenticateRequest(request);
|
|
544
|
+
if (authResult.authenticated !== true || authResult.sessionPurpose !== "normal") {
|
|
545
|
+
throw new AppError(401, "Authentication required.");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const currentPassword = String(input.currentPassword || "");
|
|
549
|
+
const password = await hashPassword(input.newPassword);
|
|
550
|
+
await backend.withTransaction(async (tx) => {
|
|
551
|
+
const user = await tx.users.findById(authResult.actor.providerUserId);
|
|
552
|
+
if (!user || user.disabled) {
|
|
553
|
+
throw new AppError(401, "Authentication required.");
|
|
554
|
+
}
|
|
555
|
+
if (!(await verifyPassword(currentPassword, user.password))) {
|
|
556
|
+
throw new AppError(401, "Current password is invalid.");
|
|
557
|
+
}
|
|
558
|
+
await tx.users.updatePassword(user.id, password);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
message: "Password updated."
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function updateDisplayName(request, input = {}) {
|
|
568
|
+
const authResult = await authenticateRequest(request);
|
|
569
|
+
if (authResult.authenticated !== true) {
|
|
570
|
+
throw new AppError(401, "Authentication required.");
|
|
571
|
+
}
|
|
572
|
+
const displayName = String(input.displayName || "").trim();
|
|
573
|
+
if (!displayName) {
|
|
574
|
+
throw new AppError(400, "Display name is required.");
|
|
575
|
+
}
|
|
576
|
+
return backend.withTransaction(async (tx) => {
|
|
577
|
+
const user = await tx.users.updateProfile(authResult.actor.providerUserId, { displayName });
|
|
578
|
+
if (!user) {
|
|
579
|
+
throw new AppError(401, "Authentication required.");
|
|
580
|
+
}
|
|
581
|
+
return buildAuthPayload({ user, session: null, profileProjector });
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function getSecurityStatus(request) {
|
|
586
|
+
const authResult = await authenticateRequest(request);
|
|
587
|
+
if (authResult.authenticated !== true) {
|
|
588
|
+
throw new AppError(401, "Authentication required.");
|
|
589
|
+
}
|
|
590
|
+
return buildSecurityStatusFromAuthMethodsStatus(
|
|
591
|
+
{
|
|
592
|
+
methods: [
|
|
593
|
+
{
|
|
594
|
+
id: "password",
|
|
595
|
+
kind: "password",
|
|
596
|
+
provider: "email",
|
|
597
|
+
label: "Password",
|
|
598
|
+
configured: true,
|
|
599
|
+
enabled: true,
|
|
600
|
+
canDisable: false,
|
|
601
|
+
supportsSecretUpdate: true,
|
|
602
|
+
requiresCurrentPassword: false
|
|
603
|
+
}
|
|
604
|
+
],
|
|
605
|
+
minimumEnabledMethods: 1,
|
|
606
|
+
enabledMethodsCount: 1
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
actions: {
|
|
610
|
+
changePassword: true,
|
|
611
|
+
signOutOtherSessions: true
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function signOutOtherSessions(request) {
|
|
618
|
+
const authResult = await authenticateRequest(request);
|
|
619
|
+
if (authResult.authenticated !== true) {
|
|
620
|
+
throw new AppError(401, "Authentication required.");
|
|
621
|
+
}
|
|
622
|
+
const cookies = safeRequestCookies(request);
|
|
623
|
+
const refreshToken = String(cookies[REFRESH_TOKEN_COOKIE] || "").trim();
|
|
624
|
+
const currentSession = refreshToken ? await resolveValidSessionByRefreshToken(refreshToken) : null;
|
|
625
|
+
const count = await backend.withTransaction((tx) =>
|
|
626
|
+
tx.sessions.revokeForUser(authResult.actor.providerUserId, {
|
|
627
|
+
exceptSessionId: currentSession?.session?.id
|
|
628
|
+
})
|
|
629
|
+
);
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
revokedSessions: count
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function getCapabilities() {
|
|
637
|
+
return capabilities;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return Object.freeze({
|
|
641
|
+
getCapabilities,
|
|
642
|
+
authenticateRequest,
|
|
643
|
+
hasAccessTokenCookie(request) {
|
|
644
|
+
return Boolean(safeRequestCookies(request)[ACCESS_TOKEN_COOKIE] || safeRequestCookies(request)[RECOVERY_TOKEN_COOKIE]);
|
|
645
|
+
},
|
|
646
|
+
hasSessionCookie(request) {
|
|
647
|
+
const cookies = safeRequestCookies(request);
|
|
648
|
+
return Boolean(cookies[ACCESS_TOKEN_COOKIE] || cookies[REFRESH_TOKEN_COOKIE] || cookies[RECOVERY_TOKEN_COOKIE]);
|
|
649
|
+
},
|
|
650
|
+
writeSessionCookies,
|
|
651
|
+
clearSessionCookies,
|
|
652
|
+
register,
|
|
653
|
+
resendRegisterConfirmation() {
|
|
654
|
+
return throwUnsupportedAuthOperation("resendRegisterConfirmation");
|
|
655
|
+
},
|
|
656
|
+
login,
|
|
657
|
+
requestOtpLogin() {
|
|
658
|
+
return throwUnsupportedAuthOperation("requestOtpLogin");
|
|
659
|
+
},
|
|
660
|
+
verifyOtpLogin() {
|
|
661
|
+
return throwUnsupportedAuthOperation("verifyOtpLogin");
|
|
662
|
+
},
|
|
663
|
+
oauthStart() {
|
|
664
|
+
return throwUnsupportedAuthOperation("oauthStart");
|
|
665
|
+
},
|
|
666
|
+
oauthComplete() {
|
|
667
|
+
return throwUnsupportedAuthOperation("oauthComplete");
|
|
668
|
+
},
|
|
669
|
+
requestPasswordReset,
|
|
670
|
+
completePasswordRecovery,
|
|
671
|
+
resetPassword,
|
|
672
|
+
changePassword,
|
|
673
|
+
updateDisplayName,
|
|
674
|
+
getSecurityStatus,
|
|
675
|
+
setPasswordSignInEnabled() {
|
|
676
|
+
return throwUnsupportedAuthOperation("setPasswordSignInEnabled");
|
|
677
|
+
},
|
|
678
|
+
startProviderLink() {
|
|
679
|
+
return throwUnsupportedAuthOperation("startProviderLink");
|
|
680
|
+
},
|
|
681
|
+
unlinkProvider() {
|
|
682
|
+
return throwUnsupportedAuthOperation("unlinkProvider");
|
|
683
|
+
},
|
|
684
|
+
signOutOtherSessions,
|
|
685
|
+
logout
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export { createLocalAuthService };
|