@webda/auth 0.0.0-stage → 4.0.0-beta.4

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.
Files changed (59) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +26 -2
  3. package/lib/authentication.service.d.ts +286 -0
  4. package/lib/authentication.service.d.ts.map +1 -0
  5. package/lib/authentication.service.js +798 -0
  6. package/lib/authentication.service.js.map +1 -0
  7. package/lib/compat/upgrade.d.ts +71 -0
  8. package/lib/compat/upgrade.d.ts.map +1 -0
  9. package/lib/compat/upgrade.js +185 -0
  10. package/lib/compat/upgrade.js.map +1 -0
  11. package/lib/email/emailpassword.service.d.ts +241 -0
  12. package/lib/email/emailpassword.service.d.ts.map +1 -0
  13. package/lib/email/emailpassword.service.js +718 -0
  14. package/lib/email/emailpassword.service.js.map +1 -0
  15. package/lib/email/tokens.d.ts +36 -0
  16. package/lib/email/tokens.d.ts.map +1 -0
  17. package/lib/email/tokens.js +46 -0
  18. package/lib/email/tokens.js.map +1 -0
  19. package/lib/errors.d.ts +53 -0
  20. package/lib/errors.d.ts.map +1 -0
  21. package/lib/errors.js +73 -0
  22. package/lib/errors.js.map +1 -0
  23. package/lib/index.d.ts +9 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +9 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/oauth/oauth.service.d.ts +293 -0
  28. package/lib/oauth/oauth.service.d.ts.map +1 -0
  29. package/lib/oauth/oauth.service.js +557 -0
  30. package/lib/oauth/oauth.service.js.map +1 -0
  31. package/lib/provider.d.ts +53 -0
  32. package/lib/provider.d.ts.map +1 -0
  33. package/lib/provider.js +55 -0
  34. package/lib/provider.js.map +1 -0
  35. package/lib/test/authtest.d.ts +39 -0
  36. package/lib/test/authtest.d.ts.map +1 -0
  37. package/lib/test/authtest.js +62 -0
  38. package/lib/test/authtest.js.map +1 -0
  39. package/lib/test/emailtest.d.ts +25 -0
  40. package/lib/test/emailtest.d.ts.map +1 -0
  41. package/lib/test/emailtest.js +39 -0
  42. package/lib/test/emailtest.js.map +1 -0
  43. package/lib/test/fakeoauth.service.d.ts +45 -0
  44. package/lib/test/fakeoauth.service.d.ts.map +1 -0
  45. package/lib/test/fakeoauth.service.js +78 -0
  46. package/lib/test/fakeoauth.service.js.map +1 -0
  47. package/lib/test/stubprovider.js +37 -0
  48. package/lib/test/stubprovider.js.map +1 -0
  49. package/lib/test/v3.d.ts +29 -0
  50. package/lib/test/v3.d.ts.map +1 -0
  51. package/lib/test/v3.js +47 -0
  52. package/lib/test/v3.js.map +1 -0
  53. package/lib/throttle.d.ts +40 -0
  54. package/lib/throttle.d.ts.map +1 -0
  55. package/lib/throttle.js +47 -0
  56. package/lib/throttle.js.map +1 -0
  57. package/package.json +65 -3
  58. package/test/config.json +20 -0
  59. package/webda.module.json +636 -0
@@ -0,0 +1,718 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ import { Ident, Operation, Password, Route, registerPasswordPolicy, runAsSystem, Service, ServiceParameters, useContext, useDynamicService, useModelMetadata, WebdaError } from "@webda/core";
36
+ import { WEBDA_PRIMARY_KEY } from "@webda/models";
37
+ import { useAuthentication } from "../provider.js";
38
+ import { AccountExists, IdentLinkedElsewhere, InvalidCredentials, Throttled, TokenInvalid } from "../errors.js";
39
+ import { signEmailToken, verifyEmailToken } from "./tokens.js";
40
+ import { canSend, isLocked, markSent } from "../throttle.js";
41
+ /** EmailPasswordProvider parameters */
42
+ export class EmailPasswordParameters extends ServiceParameters {
43
+ /**
44
+ * @param params - raw parameters
45
+ * @returns this
46
+ */
47
+ load(params = {}) {
48
+ super.load(params);
49
+ this.mailer ?? (this.mailer = "Mailer");
50
+ this.verification ?? (this.verification = "before");
51
+ this.password = { policy: ".{8,}", ...(this.password ?? {}) };
52
+ this.throttle = { resendDelay: 14400000, failedBeforeDelay: 3, lockout: 900000, ...(this.throttle ?? {}) };
53
+ this.redirects ?? (this.redirects = {});
54
+ this.url ?? (this.url = "/auth/email");
55
+ return this;
56
+ }
57
+ }
58
+ // bcrypt cost-10 hash of a throwaway string: compared against for unknown emails so timing matches
59
+ const DUMMY_HASH = "$2b$10$Loq148myoR.Yp2D2x5zkj.Z0FQq2afzaddIc2PGWzzdJCOEL.FZ4W";
60
+ /**
61
+ * Profile attributes a client may set: attributes of the user model input schema, minus behaviors, relations,
62
+ * primary key fields and private (`_`-prefixed) fields
63
+ * @param model - user model
64
+ * @returns the allowed attribute names
65
+ */
66
+ function allowedProfileKeys(model) {
67
+ const meta = useModelMetadata(model);
68
+ const relations = meta?.Relations ?? {};
69
+ const denied = new Set([
70
+ ...(model.prototype?.[WEBDA_PRIMARY_KEY] ?? ["uuid"]),
71
+ ...(relations.behaviors ?? []).map((r) => r.attribute),
72
+ ...(relations.links ?? []).map((r) => r.attribute),
73
+ ...(relations.queries ?? []).map((r) => r.attribute),
74
+ ...(relations.maps ?? []).map((r) => r.attribute),
75
+ ...(relations.binaries ?? []).map((r) => r.attribute),
76
+ ...(relations.parent ? [relations.parent.attribute] : [])
77
+ ]);
78
+ const props = Object.keys(meta?.Schemas?.Input?.properties ?? {});
79
+ return new Set(props.filter(k => !denied.has(k) && !k.startsWith("_")));
80
+ }
81
+ /**
82
+ * @param model - user model
83
+ * @param profile - client supplied profile
84
+ * @returns the profile restricted to the attributes the model allows
85
+ */
86
+ function sanitizeProfile(model, profile) {
87
+ const safe = {};
88
+ if (!profile || typeof profile !== "object" || Array.isArray(profile))
89
+ return safe;
90
+ const allowed = allowedProfileKeys(model);
91
+ for (const [key, value] of Object.entries(profile)) {
92
+ if (allowed.has(key))
93
+ safe[key] = value;
94
+ }
95
+ return safe;
96
+ }
97
+ /**
98
+ * Email + password login method
99
+ *
100
+ * The password policy is process-wide: resolve() registers the one of this provider and stop() restores the
101
+ * default. With several instances the last one resolved wins.
102
+ *
103
+ * Known residual exposure: Auth.Email.Register answers AccountExists for a registered email (enumeration), and
104
+ * login timing differs slightly for known emails. An unverified squatter owning an email blocks its registration
105
+ * until the real owner goes through account recovery.
106
+ *
107
+ * Under `allowedEmailDomains` an email must be verified: with `verification` "after" or "none" a new registration is
108
+ * unverified and therefore refused (EMAIL_DOMAIN_NOT_ALLOWED); use "before".
109
+ * @WebdaModda EmailPasswordProvider
110
+ */
111
+ let EmailPasswordProvider = (() => {
112
+ var _a;
113
+ let _classSuper = Service;
114
+ let _instanceExtraInitializers = [];
115
+ let _login_decorators;
116
+ let _register_decorators;
117
+ let _startVerification_decorators;
118
+ let _verify_decorators;
119
+ let _startRecovery_decorators;
120
+ let _recover_decorators;
121
+ let _changePassword_decorators;
122
+ let _recoverRedirect_decorators;
123
+ let _verifyRedirect_decorators;
124
+ return _a = class EmailPasswordProvider extends _classSuper {
125
+ constructor() {
126
+ super(...arguments);
127
+ this.providerName = (__runInitializers(this, _instanceExtraInitializers), "email");
128
+ /** Mails sent without being awaited */
129
+ this.pendingMails = new Set();
130
+ }
131
+ /** @override */
132
+ resolve() {
133
+ super.resolve();
134
+ const verifierName = this.parameters.password.verifier;
135
+ const regexp = new RegExp(this.parameters.password.policy);
136
+ registerPasswordPolicy(verifierName
137
+ ? { validate: (p, u) => useDynamicService(verifierName).validate(p, u) }
138
+ : { validate: async (p) => regexp.test(p) });
139
+ return this;
140
+ }
141
+ /** @override */
142
+ async init() {
143
+ await super.init();
144
+ if (this.parameters.verification !== "none" && !useDynamicService(this.parameters.mailer)) {
145
+ throw new Error(`EmailPasswordProvider requires a Mailer service '${this.parameters.mailer}'`);
146
+ }
147
+ const required = [
148
+ "failure",
149
+ "recover",
150
+ ...(this.parameters.verification !== "none" ? ["verified", "register"] : [])
151
+ ];
152
+ for (const key of required) {
153
+ if (!this.parameters.redirects?.[key]) {
154
+ throw new Error(`EmailPasswordProvider '${this.getName()}' requires the 'redirects.${key}' parameter`);
155
+ }
156
+ }
157
+ return this;
158
+ }
159
+ /** @override */
160
+ async stop() {
161
+ registerPasswordPolicy(undefined);
162
+ await super.stop();
163
+ }
164
+ /**
165
+ * @returns public info
166
+ */
167
+ getPublicInfo() {
168
+ return { name: "email", type: "password" };
169
+ }
170
+ /**
171
+ * @param email - normalised email
172
+ * @returns the email ident
173
+ */
174
+ async getIdent(email) {
175
+ return runAsSystem(() => useAuthentication().findIdent("email", email));
176
+ }
177
+ /**
178
+ * Absolute link for an emailed token
179
+ * @param path - path under the provider url
180
+ * @param token - token
181
+ * @returns url
182
+ */
183
+ buildLink(path, token) {
184
+ const ctx = useContext();
185
+ const relative = `${this.parameters.url}${path}?token=${encodeURIComponent(token)}`;
186
+ return ctx?.getHttpContext ? ctx.getHttpContext().getAbsoluteUrl(relative) : relative;
187
+ }
188
+ /**
189
+ * @param template - mail template
190
+ * @param to - recipient
191
+ * @param url - link
192
+ * @param token - token
193
+ */
194
+ async sendMail(template, to, url, token) {
195
+ const ctx = useContext();
196
+ await useDynamicService(this.parameters.mailer).send({
197
+ to,
198
+ locale: ctx?.getLocale?.(),
199
+ template,
200
+ replacements: { url, token, to }
201
+ });
202
+ }
203
+ /**
204
+ * Count a login attempt before verifying it and decide whether the ident is locked
205
+ *
206
+ * Rule, equivalent to `isLocked(ident, failedBeforeDelay, lockout)` on the state before this attempt:
207
+ * `_loginAttempts` is incremented first (atomically; the new value is returned by the memory repository and
208
+ * re-read otherwise), so this attempt is locked when the count of PREVIOUS attempts (`_loginAttempts - 1`) is at
209
+ * least `failedBeforeDelay`, unless that lock has expired, ie `_lastLoginAttemptAt` as read when this call started
210
+ * is older than `lockout`. `_lastLoginAttemptAt` is only refreshed by attempts that are not refused, so hammering
211
+ * a locked ident does not extend the lock; a count reaching the threshold without timestamp is an expired lock.
212
+ * Both are top-level attributes: stores implement atomic increment and
213
+ * single attribute set on them (nested-path atomic operations are not portable across stores). The timestamp
214
+ * is a plain set (last writer wins); the counter is never written back from a snapshot. A burst starting exactly
215
+ * when an expired lock is read is verified as a whole: it is bounded to one burst per lock window.
216
+ * @param ident - the ident
217
+ * @returns true when this attempt must be refused without checking the password
218
+ */
219
+ async countAttempt(ident) {
220
+ const { failedBeforeDelay, lockout } = this.parameters.throttle;
221
+ const now = Date.now();
222
+ // A lock whose last attempt is older than the lockout is over: attempts restart a window. A count without any
223
+ // timestamp (v3 `_failedLogin` upgraded without `_lastFailedLogin`) is an expired lock too: v4 always stamps an
224
+ // attempt it verifies, so only such data reaches the threshold unstamped
225
+ const expired = (ident._loginAttempts ?? 0) >= failedBeforeDelay &&
226
+ (!ident._lastLoginAttemptAt || !isLocked(ident, failedBeforeDelay, lockout, now));
227
+ const ref = ident.ref();
228
+ const updated = await runAsSystem(() => ref.incrementAttribute("_loginAttempts"));
229
+ const attempts = updated?.["_loginAttempts"] ?? (await runAsSystem(() => ref.get()))._loginAttempts;
230
+ const locked = !expired && attempts - 1 >= failedBeforeDelay;
231
+ if (!locked) {
232
+ await runAsSystem(() => ref.setAttribute("_lastLoginAttemptAt", now));
233
+ }
234
+ return locked;
235
+ }
236
+ /**
237
+ * Login with email and password
238
+ * @param email - email
239
+ * @param password - password
240
+ * @returns the auth result
241
+ */
242
+ // Schemas are compiled per class: name them explicitly so any service instance name works
243
+ async login(email, password) {
244
+ const normalized = Ident.normalizeEmail(email);
245
+ const ctx = useContext();
246
+ const auth = useAuthentication();
247
+ const ident = await this.getIdent(normalized);
248
+ const userId = ident?.getUser()?.toString();
249
+ const user = userId
250
+ ? await runAsSystem(() => auth.getUserModel().ref(userId).get()).catch(() => undefined)
251
+ : undefined;
252
+ // Count before verifying: parallel guesses are each counted and refused once the threshold is crossed
253
+ if (ident && (await this.countAttempt(ident))) {
254
+ throw new Throttled();
255
+ }
256
+ let ok;
257
+ if (user && user.password) {
258
+ ok = await user.password.verify(password);
259
+ }
260
+ else {
261
+ // Same cost as a real check so timing does not reveal unknown emails
262
+ const dummy = new Password();
263
+ dummy.setHash(DUMMY_HASH);
264
+ await dummy.verify(`${password}`);
265
+ ok = false;
266
+ }
267
+ if (!ok) {
268
+ await auth.emit("Authentication.LoginFailed", { context: ctx, user });
269
+ throw new InvalidCredentials();
270
+ }
271
+ // A success always follows a counted attempt (a user implies an ident), so the counter is non-zero here
272
+ await runAsSystem(() => ident.ref().setAttribute("_loginAttempts", 0));
273
+ return auth.complete({
274
+ provider: "email",
275
+ providerUid: normalized,
276
+ email: normalized,
277
+ emailVerified: ident.isVerified(),
278
+ amr: ["pwd"],
279
+ user
280
+ });
281
+ }
282
+ /**
283
+ * Register with email and password
284
+ * @param email - email
285
+ * @param password - password
286
+ * @param token - register token from the emailed link (verification "before")
287
+ * @param profile - extra user fields
288
+ * @returns the auth result, or verification_sent
289
+ */
290
+ // Schemas are compiled per class: name them explicitly so any service instance name works
291
+ async register(email, password, token, profile) {
292
+ const ctx = useContext();
293
+ if (ctx.getSession()?.isLogged()) {
294
+ throw new WebdaError.Gone("Already logged in");
295
+ }
296
+ const normalized = Ident.normalizeEmail(email);
297
+ const mode = this.parameters.verification;
298
+ const auth = useAuthentication();
299
+ const existing = await this.getIdent(normalized);
300
+ // Any owned email ident refuses: logging in as its owner here would skip the password check
301
+ if (existing?.getUser()) {
302
+ throw new AccountExists();
303
+ }
304
+ let verified = false;
305
+ if (mode === "before") {
306
+ if (!token) {
307
+ // Refuse early what the policy would refuse later
308
+ auth.applyPolicy({
309
+ provider: "email",
310
+ providerUid: normalized,
311
+ email: normalized,
312
+ emailVerified: true,
313
+ amr: ["pwd"]
314
+ });
315
+ // Same silent throttle as a logged-out StartVerification: the answer never depends on it
316
+ await this.sendUnownedLink(normalized);
317
+ return { status: "verification_sent" };
318
+ }
319
+ const claims = await verifyEmailToken(token, "register");
320
+ if (claims.email !== normalized) {
321
+ throw new TokenInvalid();
322
+ }
323
+ verified = true;
324
+ }
325
+ const base = {
326
+ provider: "email",
327
+ providerUid: normalized,
328
+ email: normalized,
329
+ emailVerified: verified,
330
+ amr: ["pwd"]
331
+ };
332
+ // Nothing is created when the email policy refuses
333
+ auth.applyPolicy(base);
334
+ const safeProfile = sanitizeProfile(auth.getUserModel(), profile);
335
+ const user = await runAsSystem(() => auth.getUserModel().create({ ...safeProfile, email: normalized }, false));
336
+ await user.password.set(password);
337
+ const identity = { ...base, user };
338
+ await auth.emit("Authentication.PasswordCreate", {
339
+ context: ctx,
340
+ user,
341
+ password: user.password.__hash
342
+ });
343
+ await auth.emit("Authentication.Register", {
344
+ context: ctx,
345
+ user,
346
+ data: safeProfile,
347
+ identId: `${normalized}:email`,
348
+ identity
349
+ });
350
+ await runAsSystem(() => user.save());
351
+ let result;
352
+ try {
353
+ result = await auth.complete(identity, { newUser: true });
354
+ }
355
+ catch (err) {
356
+ await runAsSystem(() => user.delete());
357
+ throw err;
358
+ }
359
+ if (mode === "after") {
360
+ const t = await signEmailToken("verify", { email: normalized, sub: user.getUUID() });
361
+ await this.sendMail("EMAIL_REGISTER", normalized, this.buildLink("/verify", t), t);
362
+ }
363
+ return result;
364
+ }
365
+ /**
366
+ * Send (or resend) a verification link
367
+ *
368
+ * Logged in: the link proves the email for the current account (`sub` is only in the token, the ident stays
369
+ * unowned until Auth.Email.Verify is called by the same session). Logged out: always answers with no content and
370
+ * never reveals anything; a link is only sent for an unowned or unknown email.
371
+ * @param email - email
372
+ */
373
+ async startVerification(email) {
374
+ const ctx = useContext();
375
+ const normalized = Ident.normalizeEmail(email);
376
+ const userId = ctx.getSession()?.isLogged() ? ctx.getCurrentUserId() : undefined;
377
+ if (!userId) {
378
+ await this.sendUnownedLink(normalized);
379
+ return;
380
+ }
381
+ const token = await runAsSystem(() => this.prepareLink(normalized, userId));
382
+ await this.sendMail("EMAIL_REGISTER", normalized, this.buildLink("/verify", token), token);
383
+ }
384
+ /**
385
+ * Logged-out link: a register link is emailed, without awaiting the mail, only for an unknown or unowned and
386
+ * unverified email whose send throttle allows it; anything else is silently ignored
387
+ * @param normalized - normalised email
388
+ */
389
+ async sendUnownedLink(normalized) {
390
+ const token = await runAsSystem(() => this.prepareLink(normalized, undefined));
391
+ if (!token)
392
+ return;
393
+ // Not awaited: the answer must not depend on whether a mail was sent. The link is built in the request context
394
+ this.sendMailDetached("EMAIL_REGISTER", normalized, this.buildLink("/verify", token), token);
395
+ }
396
+ /**
397
+ * Check and mark the send throttle of an email ident, then sign the token to email; runs as system
398
+ * @param normalized - normalised email
399
+ * @param userId - logged user (verify token), undefined for a logged-out register token
400
+ * @returns the token to send, undefined when nothing must be sent (logged out only)
401
+ * @throws IdentLinkedElsewhere / PreconditionFailed / Throttled (logged in only)
402
+ */
403
+ async prepareLink(normalized, userId) {
404
+ const { resendDelay } = this.parameters.throttle;
405
+ const auth = useAuthentication();
406
+ let ident = await this.getIdent(normalized);
407
+ const owner = ident?.getUser()?.toString();
408
+ if (!userId && (owner || ident?.isVerified())) {
409
+ return undefined;
410
+ }
411
+ if (userId) {
412
+ if (owner && owner !== userId) {
413
+ throw new IdentLinkedElsewhere();
414
+ }
415
+ if (ident?.isVerified()) {
416
+ throw new WebdaError.PreconditionFailed("Email already verified");
417
+ }
418
+ }
419
+ if (ident && !canSend(ident._throttle, resendDelay)) {
420
+ if (userId)
421
+ throw new Throttled();
422
+ return undefined;
423
+ }
424
+ if (!ident) {
425
+ // Unowned: only tracks the send throttle
426
+ ident = new (auth.getIdentModel())({ ...Ident.key(normalized, "email"), email: normalized });
427
+ ident._throttle = markSent(undefined);
428
+ try {
429
+ await ident.getRepository().create(ident);
430
+ }
431
+ catch (err) {
432
+ // A concurrent request just created it, and sends the mail: stay silent when logged out
433
+ if (userId)
434
+ throw err;
435
+ return undefined;
436
+ }
437
+ }
438
+ else {
439
+ await ident.ref().patch({ _throttle: markSent(ident._throttle) });
440
+ }
441
+ return signEmailToken(userId ? "verify" : "register", { email: normalized, sub: userId });
442
+ }
443
+ /**
444
+ * Send a mail without awaiting it; failures are logged without any secret
445
+ * @param template - mail template
446
+ * @param to - recipient
447
+ * @param url - link
448
+ * @param token - token
449
+ */
450
+ sendMailDetached(template, to, url, token) {
451
+ const pending = this.sendMail(template, to, url, token)
452
+ .catch(err => this.log("ERROR", `Cannot send ${template} mail`, err?.code ?? err?.name))
453
+ .finally(() => this.pendingMails.delete(pending));
454
+ this.pendingMails.add(pending);
455
+ }
456
+ /**
457
+ * Wait for the mails sent without being awaited (logged out verification, recovery)
458
+ */
459
+ async flushMails() {
460
+ while (this.pendingMails.size) {
461
+ await Promise.all([...this.pendingMails]);
462
+ }
463
+ }
464
+ /**
465
+ * Mark an email as verified: only the session of the user named by the token can complete it
466
+ * @param token - verify token
467
+ * @returns status
468
+ */
469
+ async verify(token) {
470
+ return this.verifyWith(token, useContext());
471
+ }
472
+ /**
473
+ * Verify with an explicit context
474
+ * @param token - verify token
475
+ * @param ctx - context whose session must match the token
476
+ * @returns status
477
+ */
478
+ async verifyWith(token, ctx) {
479
+ const claims = await verifyEmailToken(token, "verify");
480
+ await this.completeVerify(claims, ctx);
481
+ return { status: "verified" };
482
+ }
483
+ /**
484
+ * @param claims - verified token claims
485
+ * @param claims.sub - user id
486
+ * @param ctx - context
487
+ * @returns true when the session is logged as the user of the token
488
+ */
489
+ sessionMatches(claims, ctx) {
490
+ return !!claims.sub && !!ctx?.getSession?.()?.isLogged() && ctx.getCurrentUserId() === claims.sub;
491
+ }
492
+ /**
493
+ * Complete a verification; nothing changes unless every check passes
494
+ * @param claims - verified token claims
495
+ * @param claims.email - email of the token
496
+ * @param claims.sub - user id
497
+ * @param ctx - context
498
+ */
499
+ async completeVerify(claims, ctx) {
500
+ if (!ctx?.getSession?.()?.isLogged()) {
501
+ throw new WebdaError.Unauthorized("Login required");
502
+ }
503
+ if (!this.sessionMatches(claims, ctx)) {
504
+ throw new IdentLinkedElsewhere();
505
+ }
506
+ await runAsSystem(async () => {
507
+ const ident = await this.getIdent(claims.email);
508
+ if (!ident) {
509
+ throw new TokenInvalid();
510
+ }
511
+ const owner = ident.getUser()?.toString();
512
+ if (owner && owner !== claims.sub) {
513
+ throw new IdentLinkedElsewhere();
514
+ }
515
+ const patch = {};
516
+ if (!ident.isVerified())
517
+ patch.verifiedAt = new Date();
518
+ if (!owner) {
519
+ ident.setUser(claims.sub);
520
+ patch._user = ident._user;
521
+ }
522
+ if (Object.keys(patch).length)
523
+ await ident.ref().patch(patch);
524
+ });
525
+ }
526
+ /**
527
+ * Start password recovery; always succeeds to avoid revealing accounts
528
+ * @param email - email
529
+ */
530
+ async startRecovery(email) {
531
+ const normalized = Ident.normalizeEmail(email);
532
+ await runAsSystem(async () => {
533
+ const ident = await this.getIdent(normalized);
534
+ if (!ident?.getUser() || !canSend(ident._throttle, this.parameters.throttle.resendDelay))
535
+ return;
536
+ const user = await useAuthentication()
537
+ .getUserModel()
538
+ .ref(ident.getUser().toString())
539
+ .get()
540
+ .catch(() => undefined);
541
+ if (!user?.password?.hasPassword())
542
+ return;
543
+ await ident.ref().patch({ _throttle: markSent(ident._throttle) });
544
+ const t = await signEmailToken("recover", {
545
+ email: normalized,
546
+ sub: user.getUUID(),
547
+ pwdAt: user.password.changedAt
548
+ });
549
+ this.sendMailDetached("EMAIL_RECOVERY", normalized, this.buildLink("/recover", t), t);
550
+ });
551
+ }
552
+ /**
553
+ * Set a new password from a recovery link (does not log in): revokes the refresh tokens and ends every session of
554
+ * the user (see `Session.authAt`), resets the login attempts of the email ident
555
+ * @param token - recover token
556
+ * @param password - new password
557
+ */
558
+ async recover(token, password) {
559
+ const claims = await verifyEmailToken(token, "recover");
560
+ await runAsSystem(async () => {
561
+ const auth = useAuthentication();
562
+ const user = await auth
563
+ .getUserModel()
564
+ .ref(claims.sub)
565
+ .get()
566
+ .catch(() => undefined);
567
+ if (!user || !user.password || user.password.changedAt !== claims.pwdAt) {
568
+ throw new TokenInvalid();
569
+ }
570
+ // Checked before anything changes: a recovery that cannot revoke the sessions must not set the password
571
+ const tokens = useDynamicService("TokenService");
572
+ if (!tokens) {
573
+ throw new Error("Password recovery requires the TokenService to revoke sessions");
574
+ }
575
+ await user.password.set(password);
576
+ await user.save();
577
+ await tokens.revokeUser(user.getUUID());
578
+ // Receiving the mail proves possession of the address; the new password also ends any login lock
579
+ const ident = await this.getIdent(claims.email);
580
+ if (ident && ident.getUser()?.toString() === user.getUUID()) {
581
+ await ident.ref().setAttribute("_loginAttempts", 0);
582
+ if (!ident.isVerified())
583
+ await ident.ref().patch({ verifiedAt: new Date() });
584
+ }
585
+ await auth.emit("Authentication.PasswordUpdate", {
586
+ context: useContext(),
587
+ user,
588
+ password: user.password.__hash
589
+ });
590
+ });
591
+ }
592
+ /**
593
+ * Change the current user's password: revokes the refresh tokens and ends the other sessions of the user, the
594
+ * calling cookie session is re-stamped and stays logged in
595
+ * @param current - current password
596
+ * @param next - new password
597
+ */
598
+ async changePassword(current, next) {
599
+ const ctx = useContext();
600
+ if (!ctx.getSession()?.isLogged()) {
601
+ throw new WebdaError.Unauthorized("Login required");
602
+ }
603
+ const user = await ctx.getCurrentUser();
604
+ await runAsSystem(() => user.password.change(current, next));
605
+ // Every session authenticated before the change ends, except this one (a Bearer caller logs in again)
606
+ ctx.getSession().authAt = Date.now();
607
+ await useAuthentication().emit("Authentication.PasswordUpdate", {
608
+ context: ctx,
609
+ user,
610
+ password: user.password.__hash
611
+ });
612
+ }
613
+ /**
614
+ * Browser landing for recovery links: redirects a valid recover token to `redirects.recover` without consuming it,
615
+ * anything else to `redirects.failure`; never throws
616
+ * @param ctx - web context
617
+ */
618
+ async recoverRedirect(ctx) {
619
+ const { recover, failure } = this.parameters.redirects;
620
+ const token = ctx.parameter("token");
621
+ try {
622
+ await verifyEmailToken(token, "recover");
623
+ ctx.writeHead(302, { Location: `${recover}?token=${encodeURIComponent(token)}` });
624
+ }
625
+ catch (err) {
626
+ ctx.writeHead(302, {
627
+ Location: `${failure}?reason=${encodeURIComponent(err?.code ?? "TOKEN_INVALID")}`
628
+ });
629
+ }
630
+ }
631
+ /**
632
+ * Browser landing for emailed links: verifies then redirects, never throws
633
+ * @param ctx - web context
634
+ */
635
+ async verifyRedirect(ctx) {
636
+ const { verified, failure, register } = this.parameters.redirects;
637
+ const confirm = this.parameters.redirects.confirm ?? failure;
638
+ const token = ctx.parameter("token");
639
+ const redirect = (url) => ctx.writeHead(302, { Location: url });
640
+ try {
641
+ const claims = await verifyEmailToken(token, "verify");
642
+ if (!this.sessionMatches(claims, ctx)) {
643
+ // Nothing changes: a mail scanner prefetching the link must not verify anything
644
+ redirect(`${confirm}?reason=LOGIN_REQUIRED&token=${encodeURIComponent(token)}`);
645
+ return;
646
+ }
647
+ await this.completeVerify(claims, ctx);
648
+ redirect(`${verified}?validation=email`);
649
+ }
650
+ catch (err) {
651
+ try {
652
+ const claims = await verifyEmailToken(token, "register");
653
+ redirect(`${register}?token=${encodeURIComponent(token)}&email=${encodeURIComponent(claims.email)}`);
654
+ }
655
+ catch {
656
+ redirect(`${failure}?reason=${encodeURIComponent(err?.code ?? "TOKEN_INVALID")}`);
657
+ }
658
+ }
659
+ }
660
+ },
661
+ (() => {
662
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
663
+ _login_decorators = [Operation({
664
+ id: "Auth.Email.Login",
665
+ input: "EmailPasswordProvider.login.input",
666
+ output: "EmailPasswordProvider.login.output",
667
+ rest: { method: "post", path: "auth/email/login" }
668
+ })];
669
+ _register_decorators = [Operation({
670
+ id: "Auth.Email.Register",
671
+ input: "EmailPasswordProvider.register.input",
672
+ output: "EmailPasswordProvider.register.output",
673
+ rest: { method: "post", path: "auth/email/register" }
674
+ })];
675
+ _startVerification_decorators = [Operation({
676
+ id: "Auth.Email.StartVerification",
677
+ input: "EmailPasswordProvider.startVerification.input",
678
+ rest: { method: "post", path: "auth/email/verification" }
679
+ })];
680
+ _verify_decorators = [Operation({
681
+ id: "Auth.Email.Verify",
682
+ input: "EmailPasswordProvider.verify.input",
683
+ output: "EmailPasswordProvider.verify.output",
684
+ rest: { method: "post", path: "auth/email/verify" }
685
+ })];
686
+ _startRecovery_decorators = [Operation({
687
+ id: "Auth.Password.StartRecovery",
688
+ input: "EmailPasswordProvider.startRecovery.input",
689
+ rest: { method: "post", path: "auth/password/recovery" }
690
+ })];
691
+ _recover_decorators = [Operation({
692
+ id: "Auth.Password.Recover",
693
+ input: "EmailPasswordProvider.recover.input",
694
+ rest: { method: "post", path: "auth/password/recover" }
695
+ })];
696
+ _changePassword_decorators = [Operation({
697
+ id: "Auth.Password.Change",
698
+ input: "EmailPasswordProvider.changePassword.input",
699
+ rest: { method: "post", path: "auth/password/change" }
700
+ })];
701
+ _recoverRedirect_decorators = [Route("./recover{?token}", ["GET"], { hidden: true })];
702
+ _verifyRedirect_decorators = [Route("./verify{?token}", ["GET"], { hidden: true })];
703
+ __esDecorate(_a, null, _login_decorators, { kind: "method", name: "login", static: false, private: false, access: { has: obj => "login" in obj, get: obj => obj.login }, metadata: _metadata }, null, _instanceExtraInitializers);
704
+ __esDecorate(_a, null, _register_decorators, { kind: "method", name: "register", static: false, private: false, access: { has: obj => "register" in obj, get: obj => obj.register }, metadata: _metadata }, null, _instanceExtraInitializers);
705
+ __esDecorate(_a, null, _startVerification_decorators, { kind: "method", name: "startVerification", static: false, private: false, access: { has: obj => "startVerification" in obj, get: obj => obj.startVerification }, metadata: _metadata }, null, _instanceExtraInitializers);
706
+ __esDecorate(_a, null, _verify_decorators, { kind: "method", name: "verify", static: false, private: false, access: { has: obj => "verify" in obj, get: obj => obj.verify }, metadata: _metadata }, null, _instanceExtraInitializers);
707
+ __esDecorate(_a, null, _startRecovery_decorators, { kind: "method", name: "startRecovery", static: false, private: false, access: { has: obj => "startRecovery" in obj, get: obj => obj.startRecovery }, metadata: _metadata }, null, _instanceExtraInitializers);
708
+ __esDecorate(_a, null, _recover_decorators, { kind: "method", name: "recover", static: false, private: false, access: { has: obj => "recover" in obj, get: obj => obj.recover }, metadata: _metadata }, null, _instanceExtraInitializers);
709
+ __esDecorate(_a, null, _changePassword_decorators, { kind: "method", name: "changePassword", static: false, private: false, access: { has: obj => "changePassword" in obj, get: obj => obj.changePassword }, metadata: _metadata }, null, _instanceExtraInitializers);
710
+ __esDecorate(_a, null, _recoverRedirect_decorators, { kind: "method", name: "recoverRedirect", static: false, private: false, access: { has: obj => "recoverRedirect" in obj, get: obj => obj.recoverRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
711
+ __esDecorate(_a, null, _verifyRedirect_decorators, { kind: "method", name: "verifyRedirect", static: false, private: false, access: { has: obj => "verifyRedirect" in obj, get: obj => obj.verifyRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
712
+ if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
713
+ })(),
714
+ _a.Parameters = EmailPasswordParameters,
715
+ _a;
716
+ })();
717
+ export { EmailPasswordProvider };
718
+ //# sourceMappingURL=emailpassword.service.js.map