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

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 (77) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +59 -2
  3. package/lib/authentication.service.d.ts +322 -0
  4. package/lib/authentication.service.d.ts.map +1 -0
  5. package/lib/authentication.service.js +978 -0
  6. package/lib/authentication.service.js.map +1 -0
  7. package/lib/certificate/certificate.service.d.ts +139 -0
  8. package/lib/certificate/certificate.service.d.ts.map +1 -0
  9. package/lib/certificate/certificate.service.js +343 -0
  10. package/lib/certificate/certificate.service.js.map +1 -0
  11. package/lib/compat/upgrade.d.ts +71 -0
  12. package/lib/compat/upgrade.d.ts.map +1 -0
  13. package/lib/compat/upgrade.js +200 -0
  14. package/lib/compat/upgrade.js.map +1 -0
  15. package/lib/email/emailpassword.service.d.ts +249 -0
  16. package/lib/email/emailpassword.service.d.ts.map +1 -0
  17. package/lib/email/emailpassword.service.js +714 -0
  18. package/lib/email/emailpassword.service.js.map +1 -0
  19. package/lib/email/tokens.d.ts +36 -0
  20. package/lib/email/tokens.d.ts.map +1 -0
  21. package/lib/email/tokens.js +46 -0
  22. package/lib/email/tokens.js.map +1 -0
  23. package/lib/errors.d.ts +53 -0
  24. package/lib/errors.d.ts.map +1 -0
  25. package/lib/errors.js +74 -0
  26. package/lib/errors.js.map +1 -0
  27. package/lib/index.d.ts +12 -0
  28. package/lib/index.d.ts.map +1 -0
  29. package/lib/index.js +12 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/mfa/secondfactor.service.d.ts +44 -0
  32. package/lib/mfa/secondfactor.service.d.ts.map +1 -0
  33. package/lib/mfa/secondfactor.service.js +38 -0
  34. package/lib/mfa/secondfactor.service.js.map +1 -0
  35. package/lib/mfa/totp.service.d.ts +41 -0
  36. package/lib/mfa/totp.service.d.ts.map +1 -0
  37. package/lib/mfa/totp.service.js +63 -0
  38. package/lib/mfa/totp.service.js.map +1 -0
  39. package/lib/oauth/oauth.service.d.ts +296 -0
  40. package/lib/oauth/oauth.service.d.ts.map +1 -0
  41. package/lib/oauth/oauth.service.js +560 -0
  42. package/lib/oauth/oauth.service.js.map +1 -0
  43. package/lib/provider.d.ts +53 -0
  44. package/lib/provider.d.ts.map +1 -0
  45. package/lib/provider.js +55 -0
  46. package/lib/provider.js.map +1 -0
  47. package/lib/test/authtest.d.ts +39 -0
  48. package/lib/test/authtest.d.ts.map +1 -0
  49. package/lib/test/authtest.js +62 -0
  50. package/lib/test/authtest.js.map +1 -0
  51. package/lib/test/emailtest.d.ts +25 -0
  52. package/lib/test/emailtest.d.ts.map +1 -0
  53. package/lib/test/emailtest.js +42 -0
  54. package/lib/test/emailtest.js.map +1 -0
  55. package/lib/test/fakeoauth.service.d.ts +45 -0
  56. package/lib/test/fakeoauth.service.d.ts.map +1 -0
  57. package/lib/test/fakeoauth.service.js +78 -0
  58. package/lib/test/fakeoauth.service.js.map +1 -0
  59. package/lib/test/mfauser.fixture.model.d.ts +11 -0
  60. package/lib/test/mfauser.fixture.model.d.ts.map +1 -0
  61. package/lib/test/mfauser.fixture.model.js +44 -0
  62. package/lib/test/mfauser.fixture.model.js.map +1 -0
  63. package/lib/test/stubprovider.d.ts +35 -0
  64. package/lib/test/stubprovider.d.ts.map +1 -0
  65. package/lib/test/stubprovider.js +46 -0
  66. package/lib/test/stubprovider.js.map +1 -0
  67. package/lib/test/v3.d.ts +29 -0
  68. package/lib/test/v3.d.ts.map +1 -0
  69. package/lib/test/v3.js +47 -0
  70. package/lib/test/v3.js.map +1 -0
  71. package/lib/throttle.d.ts +41 -0
  72. package/lib/throttle.d.ts.map +1 -0
  73. package/lib/throttle.js +49 -0
  74. package/lib/throttle.js.map +1 -0
  75. package/package.json +69 -3
  76. package/test/config.json +23 -0
  77. package/webda.module.json +891 -0
@@ -0,0 +1,978 @@
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 { Command, Counter, Ident, InvalidMfaCode, MFA, useBehaviorParameters, registerLoginMfa, Operation, registerUserResolver, runAsSystem, Service, ServiceParameters, useContext, useCore, useModel, useModelMetadata, useService, V3_PASSWORD_MAPPED, WebdaError } from "@webda/core";
36
+ import { AccountExists, IdentConflict, IdentLinkedElsewhere, LastLoginMethod, MfaNotEnabled, MfaNotPending, RegistrationDisabled } from "./errors.js";
37
+ import { isLegacyIdent, legacyIdents, legacyKey, splitLegacyKey, upgradeIdent } from "./compat/upgrade.js";
38
+ import { applyEmailPolicy, isAuthProvider } from "./provider.js";
39
+ /** Authentication methods (amr) that are a primary factor: a second factor only counts on top of one of them */
40
+ export const PRIMARY_FACTORS = ["pwd", "oauth"];
41
+ /**
42
+ * @param identity - resolved identity
43
+ * @returns the identity without its provider tokens
44
+ */
45
+ function withoutTokens(identity) {
46
+ if (!identity || identity.tokens === undefined)
47
+ return identity;
48
+ const { tokens, ...rest } = identity;
49
+ return rest;
50
+ }
51
+ /** Maximum ident passes of one migration */
52
+ const MIGRATION_MAX_PASSES = 100;
53
+ /** Authentication parameters */
54
+ export class AuthenticationParameters extends ServiceParameters {
55
+ /**
56
+ * @param params - raw parameters
57
+ * @returns this
58
+ */
59
+ load(params = {}) {
60
+ super.load(params);
61
+ this.userModel ?? (this.userModel = "Webda/User");
62
+ this.identModel ?? (this.identModel = "Webda/Ident");
63
+ this.linking ?? (this.linking = "verified");
64
+ this.registration ?? (this.registration = true);
65
+ this.compatibility ?? (this.compatibility = { v3: true });
66
+ return this;
67
+ }
68
+ }
69
+ /**
70
+ * Shared authentication logic: providers hand a ResolvedIdentity to complete()
71
+ * @WebdaModda Authentication
72
+ */
73
+ let Authentication = (() => {
74
+ var _a;
75
+ let _classSuper = Service;
76
+ let _instanceExtraInitializers = [];
77
+ let _providers_decorators;
78
+ let _me_decorators;
79
+ let _logout_decorators;
80
+ let _refresh_decorators;
81
+ let _idents_decorators;
82
+ let _unlink_decorators;
83
+ let _migrate_decorators;
84
+ return _a = class Authentication extends _classSuper {
85
+ constructor() {
86
+ super(...arguments);
87
+ this.providerMap = (__runInitializers(this, _instanceExtraInitializers), new Map());
88
+ }
89
+ /**
90
+ * @returns the configured user model
91
+ */
92
+ getUserModel() {
93
+ return useModel(this.parameters.userModel);
94
+ }
95
+ /**
96
+ * @returns the configured ident model
97
+ */
98
+ getIdentModel() {
99
+ return useModel(this.parameters.identModel);
100
+ }
101
+ /** @override */
102
+ resolve() {
103
+ super.resolve();
104
+ this.discoverProviders();
105
+ const userModel = this.getUserModel();
106
+ const metadata = useModelMetadata(userModel);
107
+ const loginAttributes = (metadata.Relations?.behaviors ?? []).filter(relation => relation.behavior === "Webda/MFA" &&
108
+ useBehaviorParameters(userModel, relation.attribute, MFA).purpose === "login");
109
+ if (loginAttributes.length > 1) {
110
+ throw new WebdaError.CodeError("MULTIPLE_LOGIN_MFA", `${metadata.Identifier} declares more than one login MFA attribute`);
111
+ }
112
+ this.loginMfaAttribute = loginAttributes[0]?.attribute;
113
+ registerLoginMfa(this.loginMfaAttribute
114
+ ? {
115
+ model: metadata.Identifier,
116
+ attribute: this.loginMfaAttribute,
117
+ run: (mfa, action, method, next) => this.runLoginMfa(mfa, action, method, next)
118
+ }
119
+ : undefined);
120
+ registerUserResolver({
121
+ resolve: async (id) => {
122
+ const ref = this.getUserModel().ref(id);
123
+ return (await ref.exists()) ? await ref.get() : undefined;
124
+ }
125
+ });
126
+ return this;
127
+ }
128
+ /** @override */
129
+ async init() {
130
+ await super.init();
131
+ // Providers may be created after this service was resolved
132
+ this.discoverProviders();
133
+ return this;
134
+ }
135
+ /**
136
+ * Collect every service implementing {@link AuthProvider}
137
+ * @throws Error when two providers share the same name
138
+ */
139
+ discoverProviders() {
140
+ // The resolve() pass is best-effort (later services may not exist yet), the init() pass is authoritative
141
+ const providers = new Map();
142
+ for (const service of Object.values(useCore().getServices())) {
143
+ if (!isAuthProvider(service))
144
+ continue;
145
+ if (providers.has(service.providerName)) {
146
+ throw new Error(`Duplicate auth provider '${service.providerName}'`);
147
+ }
148
+ providers.set(service.providerName, service);
149
+ }
150
+ this.providerMap = providers;
151
+ }
152
+ /** @override */
153
+ async stop() {
154
+ registerUserResolver(undefined);
155
+ await super.stop();
156
+ }
157
+ /**
158
+ * @param name - provider name
159
+ * @returns the provider
160
+ */
161
+ getProvider(name) {
162
+ return this.providerMap.get(name);
163
+ }
164
+ /**
165
+ * @param name - provider name
166
+ * @returns the email policy of that provider, undefined for unknown providers or without policy
167
+ */
168
+ emailPolicyFor(name) {
169
+ const provider = this.getProvider(name);
170
+ if (!provider)
171
+ return undefined;
172
+ if (typeof provider.getEmailPolicy === "function")
173
+ return provider.getEmailPolicy();
174
+ const params = provider.getParameters?.();
175
+ if (!params)
176
+ return undefined;
177
+ const { allowedEmailDomains, trustEmailVerification } = params;
178
+ const strings = (v) => Array.isArray(v) && v.every(d => typeof d === "string");
179
+ if (allowedEmailDomains !== undefined && !strings(allowedEmailDomains)) {
180
+ throw new Error(`Invalid email policy for provider '${name}': allowedEmailDomains must be a string[]`);
181
+ }
182
+ if (trustEmailVerification !== undefined &&
183
+ typeof trustEmailVerification !== "boolean" &&
184
+ !strings(trustEmailVerification)) {
185
+ throw new Error(`Invalid email policy for provider '${name}': trustEmailVerification must be a boolean or string[]`);
186
+ }
187
+ return allowedEmailDomains === undefined && trustEmailVerification === undefined
188
+ ? undefined
189
+ : { allowedEmailDomains, trustEmailVerification };
190
+ }
191
+ /**
192
+ * @returns public info of every provider
193
+ */
194
+ getProviders() {
195
+ return [...this.providerMap.values()].map(p => p.getPublicInfo());
196
+ }
197
+ /**
198
+ * List available login methods
199
+ * @returns providers
200
+ */
201
+ providers() {
202
+ return this.getProviders();
203
+ }
204
+ /**
205
+ * Current user
206
+ * @returns public entry of the logged user
207
+ */
208
+ async me() {
209
+ const ctx = useContext();
210
+ const user = ctx.getSession()?.isLogged() ? await ctx.getCurrentUser() : undefined;
211
+ if (!user) {
212
+ throw new WebdaError.NotFound("No user found");
213
+ }
214
+ await this.emit("Authentication.GetMe", { context: ctx, user });
215
+ return user.toPublicEntry();
216
+ }
217
+ /** @override */
218
+ initMetrics() {
219
+ super.initMetrics();
220
+ this.metrics ?? (this.metrics = {});
221
+ this.metrics.login = this.getMetric(Counter, {
222
+ name: "auth_login",
223
+ help: "Counter number of login per provider",
224
+ labelNames: ["provider"]
225
+ });
226
+ this.metrics.logout = this.getMetric(Counter, {
227
+ name: "auth_logout",
228
+ help: "Counter number of logout"
229
+ });
230
+ this.metrics.registration = this.getMetric(Counter, {
231
+ name: "auth_registration",
232
+ help: "Counter number of registration per provider",
233
+ labelNames: ["provider"]
234
+ });
235
+ }
236
+ /**
237
+ * Find an ident; with `compatibility.v3`, a miss falls back to the v3 record `"<uid>_<provider>"`, upgraded on
238
+ * the fly
239
+ * @param provider - provider
240
+ * @param providerUid - uid
241
+ * @returns the ident when it exists
242
+ */
243
+ async findIdent(provider, providerUid) {
244
+ const IdentModel = this.getIdentModel();
245
+ const ref = IdentModel.ref(Ident.key(providerUid, provider));
246
+ if (await ref.exists()) {
247
+ return ref.get();
248
+ }
249
+ if (!this.parameters.compatibility?.v3) {
250
+ return undefined;
251
+ }
252
+ const legacy = IdentModel.ref(legacyKey(providerUid, provider));
253
+ if (!(await legacy.exists())) {
254
+ return undefined;
255
+ }
256
+ // The v3-looking key of a shared store may hold another model's row: only a v3 ident is upgraded
257
+ const v3 = await legacy.get().catch(() => undefined);
258
+ if (!isLegacyIdent(v3, IdentModel)) {
259
+ this.log("WARN", `Ignoring a non-ident row stored under a v3 '${provider}' ident key`);
260
+ return undefined;
261
+ }
262
+ const ident = await runAsSystem(() => upgradeIdent(v3, IdentModel));
263
+ if (ident.provider !== provider || ident.providerUid !== providerUid) {
264
+ this.log("WARN", `v3 '${provider}' ident upgraded under another key: ignored`);
265
+ return undefined;
266
+ }
267
+ return ident;
268
+ }
269
+ /**
270
+ * MFA methods enabled for a user
271
+ * @param user - user
272
+ * @returns enabled methods, empty when MFA is off
273
+ */
274
+ mfaMethods(user) {
275
+ const mfa = this.loginMfaAttribute ? user?.[this.loginMfaAttribute] : undefined;
276
+ return mfa?.isEnabled?.() ? mfa.methods() : [];
277
+ }
278
+ /**
279
+ * Create and save a user for an identity
280
+ * @param identity - resolved identity
281
+ * @param data - extra profile data
282
+ * @param ctx - request context (complete() runs as system, so it passes the real one)
283
+ * @returns the user
284
+ */
285
+ async registerUser(identity, data = {}, ctx = useContext()) {
286
+ const user = await this.getUserModel().create({
287
+ ...data,
288
+ email: identity.email,
289
+ displayName: data.displayName ?? identity.profile?.name,
290
+ locale: ctx?.getLocale?.()
291
+ }, false);
292
+ await this.emit("Authentication.Register", {
293
+ context: ctx,
294
+ user,
295
+ data,
296
+ identId: `${identity.providerUid}:${identity.provider}`,
297
+ identity: withoutTokens(identity)
298
+ });
299
+ await user.save();
300
+ this.metrics?.registration?.inc({ provider: identity.provider });
301
+ return user;
302
+ }
303
+ /**
304
+ * Complete a login for an identity proven by a provider
305
+ * @param identity - resolved identity
306
+ * @param options - complete options
307
+ * @param options.newUser - identity.user was just created by the provider: its first ident is not a "link"
308
+ * @returns the result
309
+ * @throws IdentLinkedElsewhere when the ident belongs to another user than the logged one
310
+ * @throws AccountExists when the email matches an account the linking policy does not allow
311
+ * @throws RegistrationDisabled when a new user is needed but registration is off
312
+ * @throws WebdaError.BadRequest when `identity.provider` is not a registered AuthProvider
313
+ */
314
+ async complete(identity, options = {}) {
315
+ identity = this.applyPolicy(identity);
316
+ const ctx = useContext();
317
+ return runAsSystem(() => this.completeAs(identity, ctx, false, !!options.newUser));
318
+ }
319
+ /**
320
+ * Link an identity to the logged user without replacing their session or issuing credentials.
321
+ * @param identity - identity proved by a provider
322
+ * @returns a link acknowledgement
323
+ */
324
+ async linkIdentity(identity) {
325
+ identity = this.applyPolicy(identity);
326
+ const ctx = useContext();
327
+ const userId = ctx.getSession()?.isLogged() ? ctx.getCurrentUserId() : undefined;
328
+ if (!userId)
329
+ throw new WebdaError.Unauthorized("Login required");
330
+ return runAsSystem(async () => {
331
+ let ident = await this.findIdent(identity.provider, identity.providerUid);
332
+ if (ident?.getUser()) {
333
+ if (ident.getUser().toString() !== userId)
334
+ throw new IdentLinkedElsewhere();
335
+ return { status: "linked" };
336
+ }
337
+ if (ident) {
338
+ ident.setUser(userId);
339
+ await ident.save();
340
+ }
341
+ else {
342
+ ident = new (this.getIdentModel())({
343
+ ...Ident.key(identity.providerUid, identity.provider),
344
+ email: identity.email ? Ident.normalizeEmail(identity.email) : undefined,
345
+ __profile: identity.profile,
346
+ verifiedAt: identity.emailVerified ? new Date() : undefined
347
+ });
348
+ ident.setUser(userId);
349
+ try {
350
+ await ident.getRepository().create(ident);
351
+ }
352
+ catch (err) {
353
+ if (!/Already exists/.test(`${err instanceof Error ? err.message : err}`))
354
+ throw err;
355
+ const winner = await this.findIdent(identity.provider, identity.providerUid);
356
+ if (winner?.getUser()?.toString() !== userId)
357
+ throw new IdentLinkedElsewhere();
358
+ return { status: "linked" };
359
+ }
360
+ }
361
+ await this.emit("Authentication.Linked", {
362
+ context: ctx,
363
+ user: await this.loadUser(userId),
364
+ ident
365
+ });
366
+ return { status: "linked" };
367
+ });
368
+ }
369
+ /**
370
+ * Apply the email policy of the identity provider (complete() does it too, idempotently)
371
+ * Lets a provider refuse an identity before creating anything
372
+ * @param identity - resolved identity
373
+ * @returns the identity to use
374
+ * @throws EmailDomainNotAllowed when the policy refuses it
375
+ * @throws WebdaError.BadRequest when `identity.provider` is not a registered AuthProvider: an unknown name would
376
+ * escape the email policy of the provider it impersonates
377
+ */
378
+ applyPolicy(identity) {
379
+ if (!this.getProvider(identity?.provider)) {
380
+ throw new WebdaError.BadRequest(`Unknown auth provider '${identity?.provider}'`);
381
+ }
382
+ return applyEmailPolicy(identity, this.emailPolicyFor(identity.provider));
383
+ }
384
+ /**
385
+ * Body of {@link complete}, running as system with the request context passed explicitly
386
+ * @param identity - resolved identity
387
+ * @param ctx - request context
388
+ * @param retried - already retried after a concurrent creation
389
+ * @param newUser - identity.user was just created by the provider: its first ident is not a "link"
390
+ * @returns the result
391
+ */
392
+ async completeAs(identity, ctx, retried, newUser = false) {
393
+ const currentUserId = ctx.getSession()?.isLogged() ? ctx.getCurrentUserId() : undefined;
394
+ let ident = await this.findIdent(identity.provider, identity.providerUid);
395
+ if (ident?.getUser()) {
396
+ if (currentUserId && ident.getUser().toString() !== currentUserId) {
397
+ throw new IdentLinkedElsewhere();
398
+ }
399
+ // A provider handing another user than the owner (a registration that lost a race) must not become the owner
400
+ if (identity.user && identity.user.getUUID() !== ident.getUser().toString()) {
401
+ throw new AccountExists();
402
+ }
403
+ return this.establish(ident.getUser().toString(), ident, identity, ctx);
404
+ }
405
+ // An existing ident without owner is adopted by the resolved, current or newly registered user
406
+ const adopting = !!ident;
407
+ let user = identity.user;
408
+ let userId = currentUserId ?? user?.getUUID();
409
+ const email = identity.email ? Ident.normalizeEmail(identity.email) : undefined;
410
+ const emailIdent = email ? await this.findIdent("email", email) : undefined;
411
+ let linked = !!userId && !(newUser && !currentUserId);
412
+ let registered;
413
+ const policy = this.parameters.linking;
414
+ // never/verified: only a verified email ident is an owner, an unverified one is treated as absent
415
+ const owner = emailIdent?.getUser() && (policy === "always" || emailIdent.isVerified()) ? emailIdent.getUser() : undefined;
416
+ if (!userId && owner) {
417
+ if (policy === "never" || (policy === "verified" && !identity.emailVerified)) {
418
+ throw new AccountExists();
419
+ }
420
+ userId = owner.toString();
421
+ linked = true;
422
+ }
423
+ if (!userId) {
424
+ if (!this.parameters.registration) {
425
+ throw new RegistrationDisabled();
426
+ }
427
+ // Narrow the window of a phantom Register event when a concurrent login just created the ident
428
+ if (!adopting && (await this.findIdent(identity.provider, identity.providerUid))) {
429
+ return this.completeAs(identity, ctx, retried, newUser);
430
+ }
431
+ user = registered = await this.registerUser(identity, {}, ctx);
432
+ userId = user.getUUID();
433
+ }
434
+ if (ident) {
435
+ ident.setUser(userId);
436
+ // Verification is only kept when this identity proves it
437
+ if (identity.emailVerified) {
438
+ ident.verifiedAt ?? (ident.verifiedAt = new Date());
439
+ }
440
+ else {
441
+ // Use the model setter so the cleared value is persisted by its proxy.
442
+ Reflect.set(ident, "verifiedAt", undefined);
443
+ }
444
+ await ident.save();
445
+ }
446
+ else {
447
+ ident = new (this.getIdentModel())({
448
+ ...Ident.key(identity.providerUid, identity.provider),
449
+ email,
450
+ __profile: identity.profile,
451
+ verifiedAt: identity.emailVerified ? new Date() : undefined
452
+ });
453
+ ident.setUser(userId);
454
+ // Provider tokens are only stored encrypted
455
+ if (identity.tokens) {
456
+ await ident.tokens.set(identity.tokens);
457
+ }
458
+ try {
459
+ await ident.getRepository().create(ident);
460
+ }
461
+ catch (err) {
462
+ if (!retried && /Already exists/.test(`${err instanceof Error ? err.message : err}`)) {
463
+ // Lost a concurrent first login: drop the user created for nothing, the retry finds the winner's ident
464
+ await registered?.delete();
465
+ return this.completeAs(identity, ctx, true, newUser);
466
+ }
467
+ throw err;
468
+ }
469
+ }
470
+ // Only an email asserted as verified by the provider may claim the email ident
471
+ if (email && !emailIdent && identity.emailVerified && identity.provider !== "email") {
472
+ const extra = new (this.getIdentModel())({
473
+ ...Ident.key(email, "email"),
474
+ email,
475
+ verifiedAt: new Date()
476
+ });
477
+ extra.setUser(userId);
478
+ await extra
479
+ .getRepository()
480
+ .create(extra)
481
+ .catch(err => {
482
+ if (!/Already exists/.test(`${err instanceof Error ? err.message : err}`))
483
+ throw err;
484
+ });
485
+ }
486
+ if (linked) {
487
+ await this.emit("Authentication.Linked", {
488
+ context: ctx,
489
+ user: user ?? (await this.loadUser(userId)),
490
+ ident
491
+ });
492
+ }
493
+ return this.establish(user ?? userId, ident, identity, ctx);
494
+ }
495
+ /**
496
+ * @param userId - uuid
497
+ * @returns the user
498
+ */
499
+ loadUser(userId) {
500
+ return this.getUserModel().ref(userId).get();
501
+ }
502
+ /**
503
+ * Write the session, issue tokens, emit Login
504
+ * @param user - user or uuid
505
+ * @param ident - ident used
506
+ * @param identity - resolved identity
507
+ * @param ctx - request context (complete() runs as system, so it passes the real one)
508
+ * @returns the result
509
+ */
510
+ async establish(user, ident, identity, ctx = useContext()) {
511
+ const userObj = typeof user === "string" ? await this.loadUser(user) : user;
512
+ const userId = userObj.getUUID();
513
+ const session = ctx.getSession();
514
+ const methods = this.mfaMethods(userObj);
515
+ // MFA is satisfied by one of the user's enabled methods on top of a primary factor
516
+ const amr = identity.amr ?? [];
517
+ const secondFactor = amr.some(m => methods.includes(m)) && amr.some(m => PRIMARY_FACTORS.includes(m));
518
+ const mfa = methods.length ? (secondFactor ? "verified" : "pending") : "none";
519
+ session.login(userId, ident.getUUID(), { provider: identity.provider, amr: identity.amr, mfa });
520
+ // A later password change of the user ends this session (checked by the session manager on load)
521
+ session.authAt = Date.now();
522
+ // Provider profile and tokens are kept even when MFA is pending
523
+ const update = {
524
+ ...(identity.profile ? { __profile: identity.profile } : {}),
525
+ ...(await this.tokensUpdate(ident, identity.tokens))
526
+ };
527
+ if (mfa === "pending") {
528
+ if (Object.keys(update).length) {
529
+ await ident.ref().patch(update);
530
+ }
531
+ return { status: "mfa_required", methods };
532
+ }
533
+ return this.finish(userObj, ident, session, ctx, identity.provider, identity, update);
534
+ }
535
+ /**
536
+ * Finalise login after factors are verified: patch ident lastUsedAt, issue tokens, emit events, inc metric
537
+ * @param userObj - user instance
538
+ * @param ident - ident instance
539
+ * @param session - session
540
+ * @param ctx - operation context
541
+ * @param provider - provider name
542
+ * @param identity - optional resolved identity
543
+ * @param update - extra ident attributes to patch
544
+ * @returns auth result
545
+ */
546
+ async finish(userObj, ident, session, ctx, provider, identity, update = {}) {
547
+ const userId = userObj.getUUID();
548
+ await ident.ref().patch({ lastUsedAt: new Date(), ...update });
549
+ const tokens = await useService("TokenService").issue(session);
550
+ await this.emit("Authentication.Login", {
551
+ context: ctx,
552
+ userId,
553
+ user: userObj,
554
+ identId: ident.getUUID(),
555
+ ident,
556
+ provider,
557
+ // Provider tokens never travel in Authentication events (providers emit their own)
558
+ identity: identity ? withoutTokens(identity) : { provider, providerUid: ident.providerUid, amr: session.amr }
559
+ });
560
+ if (session.mfa === "verified") {
561
+ await this.emit("Authentication.MfaVerified", {
562
+ context: ctx,
563
+ user: userObj,
564
+ userId,
565
+ method: session.amr?.find((m) => !PRIMARY_FACTORS.includes(m))
566
+ });
567
+ }
568
+ this.metrics?.login?.inc({ provider });
569
+ return { status: "ok", user: userObj.toPublicEntry(), ...tokens };
570
+ }
571
+ /**
572
+ * Preserve login safeguards around the configured model attribute's actions.
573
+ * @param mfa - attached login MFA
574
+ * @param action - model behavior action
575
+ * @param method - requested factor method
576
+ * @param next - factor operation
577
+ * @returns model result, including tokens when authentication changes
578
+ */
579
+ async runLoginMfa(mfa, action, method, next) {
580
+ const ctx = useContext();
581
+ const session = ctx.getSession();
582
+ const user = mfa.getScope().owner;
583
+ const verification = action === "verify" || action === "recover";
584
+ if ((!session.isLogged() && !(verification && session.isPending())) || session.userId !== user.getUUID()) {
585
+ throw new WebdaError.Unauthorized("Own login MFA required");
586
+ }
587
+ if ((action === "disable" || action === "recoveryCodes") && session.mfa !== "verified") {
588
+ throw new WebdaError.Unauthorized("Verified login required");
589
+ }
590
+ if (action === "enroll" &&
591
+ mfa.getParameters().reauthMaxAge &&
592
+ (!session.authAt || Date.now() - session.authAt > mfa.getParameters().reauthMaxAge * 1000)) {
593
+ throw new WebdaError.Unauthorized("Reauthentication required");
594
+ }
595
+ let result;
596
+ try {
597
+ result = await next();
598
+ }
599
+ catch (err) {
600
+ if (err instanceof InvalidMfaCode) {
601
+ await this.emit("Authentication.MfaFailed", { context: ctx, user, userId: user.getUUID(), method });
602
+ }
603
+ throw err;
604
+ }
605
+ if (action === "confirm") {
606
+ await this.emit("Authentication.MfaEnabled", { context: ctx, user, userId: user.getUUID(), method });
607
+ const tokens = useService("TokenService");
608
+ await runAsSystem(() => tokens.revokeUser(user.getUUID()));
609
+ session.mfa = "verified";
610
+ session.amr = [...(session.amr ?? []).filter((value) => value !== method), method];
611
+ return { ...result, ...(await runAsSystem(() => tokens.issue(session))) };
612
+ }
613
+ if (verification && session.isPending()) {
614
+ return { ...result, ...(await this.completeSecondFactor([result.method])) };
615
+ }
616
+ if (action === "disable") {
617
+ await this.emit("Authentication.MfaDisabled", { context: ctx, user, userId: user.getUUID(), method });
618
+ }
619
+ return result;
620
+ }
621
+ /**
622
+ * Complete second-factor authentication for a pending session
623
+ * @param amr - second factor method(s) used (e.g. ["totp"], ["recovery"])
624
+ * @returns auth result with issued tokens
625
+ */
626
+ async completeSecondFactor(amr) {
627
+ const ctx = useContext();
628
+ const session = ctx.getSession();
629
+ if (!session?.isPending()) {
630
+ throw new MfaNotPending();
631
+ }
632
+ const userId = session.userId;
633
+ const user = await runAsSystem(() => this.loadUser(userId));
634
+ const enabledMethods = this.mfaMethods(user);
635
+ const isRecovery = amr.includes("recovery");
636
+ if (isRecovery) {
637
+ if (enabledMethods.length === 0) {
638
+ throw new MfaNotEnabled();
639
+ }
640
+ }
641
+ else if (!amr.some(m => enabledMethods.includes(m))) {
642
+ throw new MfaNotEnabled();
643
+ }
644
+ const provider = session.provider;
645
+ const ident = await runAsSystem(() => this.getIdentModel().ref(session.identUsed).get());
646
+ const authAt = session.authAt;
647
+ session.login(userId, session.identUsed, {
648
+ provider,
649
+ amr: [...(session.amr ?? []), ...amr],
650
+ mfa: "verified",
651
+ mfaVerifications: session.mfaVerifications
652
+ });
653
+ session.authAt = authAt;
654
+ return this.finish(user, ident, session, ctx, provider);
655
+ }
656
+ /**
657
+ * Patch storing new provider tokens encrypted; also drops plaintext tokens left by an earlier v4 beta (`__tokens`)
658
+ * @param ident - the ident
659
+ * @param tokens - tokens from the provider, if any
660
+ * @returns the attributes to patch
661
+ */
662
+ async tokensUpdate(ident, tokens) {
663
+ if (!tokens)
664
+ return {};
665
+ await ident.tokens.set(tokens);
666
+ return {
667
+ tokens: { __ciphertext: ident.tokens.__ciphertext },
668
+ ...(ident.__tokens !== undefined ? { __tokens: null } : {})
669
+ };
670
+ }
671
+ /**
672
+ * @returns the logged user id
673
+ * @throws WebdaError.Unauthorized when no fully logged session exists
674
+ */
675
+ requireUser() {
676
+ const ctx = useContext();
677
+ if (!ctx.getSession()?.isLogged()) {
678
+ throw new WebdaError.Unauthorized("Login required");
679
+ }
680
+ return ctx.getCurrentUserId();
681
+ }
682
+ /**
683
+ * Logout and revoke the current refresh family; also abandons a pending MFA session
684
+ */
685
+ async logout() {
686
+ const ctx = useContext();
687
+ const session = ctx.getSession();
688
+ if (!session?.isLogged() && !session?.isPending()) {
689
+ throw new WebdaError.Unauthorized("Login required");
690
+ }
691
+ const family = session.refreshFamily;
692
+ if (family) {
693
+ await runAsSystem(() => useService("TokenService").revokeFamily(family));
694
+ }
695
+ await this.emit("Authentication.Logout", { context: ctx });
696
+ this.metrics?.logout?.inc();
697
+ ctx.newSession();
698
+ }
699
+ /**
700
+ * Exchange a refresh token
701
+ * @param refreshToken - refresh token
702
+ * @returns new tokens
703
+ */
704
+ async refresh(refreshToken) {
705
+ const { session, ...tokens } = await runAsSystem(() => useService("TokenService").refresh(refreshToken));
706
+ return tokens;
707
+ }
708
+ /**
709
+ * List the current user's idents; with `compatibility.v3`, listing upgrades the user's v3 ident records first
710
+ * @returns idents of the current user
711
+ */
712
+ async idents() {
713
+ const userId = this.requireUser();
714
+ return (await this.listIdents(userId)).map(i => ({
715
+ provider: i.provider,
716
+ providerUid: i.providerUid,
717
+ email: i.email,
718
+ verifiedAt: i.verifiedAt,
719
+ lastUsedAt: i.lastUsedAt
720
+ }));
721
+ }
722
+ /**
723
+ * @param userId - user
724
+ * @returns all idents owned by the user
725
+ */
726
+ async listIdents(userId) {
727
+ return runAsSystem(async () => {
728
+ const IdentModel = this.getIdentModel();
729
+ const idents = (await IdentModel.query("_user = ?", [userId])).results;
730
+ if (!idents.some(i => i.getLegacyUID())) {
731
+ return idents;
732
+ }
733
+ // v3 records are upgraded before use, or ignored without v3 compatibility
734
+ const result = new Map();
735
+ for (const ident of idents) {
736
+ if (!ident.getLegacyUID()) {
737
+ result.set(ident.getUUID(), ident);
738
+ }
739
+ else if (this.parameters.compatibility?.v3) {
740
+ if (!isLegacyIdent(ident, IdentModel))
741
+ continue;
742
+ let upgraded;
743
+ try {
744
+ upgraded = await upgradeIdent(ident, IdentModel);
745
+ }
746
+ catch (err) {
747
+ if (!(err instanceof IdentConflict))
748
+ throw err;
749
+ // Kept for the operator (`webda auth migrate` reports it): the account stays usable meanwhile
750
+ this.log("WARN", `v3 ident ${ident.getLegacyUID()} conflicts with another user's ident: skipped`);
751
+ continue;
752
+ }
753
+ // A crash-left duplicate resolves to the record already listed
754
+ if (!result.has(upgraded.getUUID()))
755
+ result.set(upgraded.getUUID(), upgraded);
756
+ }
757
+ }
758
+ return [...result.values()];
759
+ });
760
+ }
761
+ /**
762
+ * Remove one of the current user's idents
763
+ * @param provider - provider
764
+ * @param providerUid - uid
765
+ * @throws LastLoginMethod when no usable login method would remain (non-email idents, plus email idents when the
766
+ * user has a password), or when it would remove the last email ident of a user with a password
767
+ */
768
+ async unlink(provider, providerUid) {
769
+ const userId = this.requireUser();
770
+ const idents = await this.listIdents(userId);
771
+ const target = idents.find(i => i.provider === provider && i.providerUid === providerUid);
772
+ if (!target) {
773
+ throw new WebdaError.NotFound("Ident not found");
774
+ }
775
+ const remaining = idents.filter(i => i !== target);
776
+ const user = await this.loadUser(userId);
777
+ const usesPassword = !!user.password?.hasPassword?.();
778
+ // An email ident is only a login method (and a recovery path) when the user has a password
779
+ const usable = remaining.filter(i => i.provider !== "email" || usesPassword);
780
+ const lastEmail = provider === "email" && !remaining.some(i => i.provider === "email");
781
+ if (usable.length === 0 || (usesPassword && lastEmail)) {
782
+ throw new LastLoginMethod();
783
+ }
784
+ await runAsSystem(() => target.ref().delete());
785
+ // A projection: the provider tokens and profile of the removed ident never leave this service
786
+ const ident = {
787
+ uuid: target.getUUID(),
788
+ provider: target.provider,
789
+ providerUid: target.providerUid,
790
+ email: target.email,
791
+ verifiedAt: target.verifiedAt,
792
+ lastUsedAt: target.lastUsedAt,
793
+ userId
794
+ };
795
+ await this.emit("Authentication.Unlinked", { context: useContext(), user, ident });
796
+ }
797
+ /**
798
+ * Migrate v3 authentication data to the v4 layout
799
+ *
800
+ * Idents: every v3 record (`"<providerUid>_<provider>"` key) is upgraded to its `"<providerUid>:<provider>"`
801
+ * record (emails normalised) then deleted; with a custom ident model, the v3 rows typed as the core
802
+ * `Webda/Ident` are found through its repository too. Upgrading shifts position-based paging, so passes run
803
+ * until one upgrades nothing. Users: a user whose stored record still has the v3 `__password` is saved again in
804
+ * the v4 `password` shape. Idempotent: a second run migrates nothing. Failures are reported by key and logged,
805
+ * the migration goes on.
806
+ *
807
+ * Operator notes:
808
+ * - A v3 ident whose upgraded key another user already holds (e.g. two v3 email keys differing only by case) is
809
+ * reported in `idents.failed` (IDENT_CONFLICT) and kept: decide which user keeps the email, then delete or
810
+ * rename the other v3 row and run the command again.
811
+ * - A malformed ident key in the store stops the scan: remove that row manually.
812
+ * - Re-running is always safe: migrated records are not touched again.
813
+ * @param dryRun - only count, write nothing
814
+ * @param batch - page size of the scans
815
+ * @returns the report
816
+ */
817
+ async migrate(
818
+ /** @description Only count the records to migrate, write nothing */
819
+ dryRun = false,
820
+ /** @description Page size of the store scans */
821
+ batch = 100) {
822
+ const pageSize = Math.max(1, Math.floor(Number(batch)) || 100);
823
+ const report = {
824
+ dryRun: !!dryRun,
825
+ idents: { migrated: 0, skipped: 0, failed: [] },
826
+ users: { migrated: 0, skipped: 0, failed: [] }
827
+ };
828
+ await runAsSystem(async () => {
829
+ await this.migrateIdents(report, pageSize);
830
+ await this.migrateUsers(report, pageSize);
831
+ });
832
+ this.log("INFO", "Authentication migration", report);
833
+ return report;
834
+ }
835
+ /**
836
+ * Enumerate the v3 ident rows: through the ident model, and through the core `Ident` repository when the
837
+ * ident model is a subclass (its queries only see rows typed as itself or its subclasses)
838
+ * @param pageSize - page size
839
+ * @returns the v3 keys found, each once per pass
840
+ */
841
+ async *legacyIdentKeys(pageSize) {
842
+ const IdentModel = this.getIdentModel();
843
+ const models = [IdentModel];
844
+ if (IdentModel !== Ident) {
845
+ try {
846
+ if (Ident.getRepository())
847
+ models.push(Ident);
848
+ }
849
+ catch {
850
+ // The core Ident model has no repository: no ancestor-typed rows to look for
851
+ }
852
+ }
853
+ const seen = new Set();
854
+ for (const model of models) {
855
+ const scan = legacyIdents(model, `LIMIT ${pageSize}`);
856
+ while (true) {
857
+ let next;
858
+ try {
859
+ next = await scan.next();
860
+ }
861
+ catch (err) {
862
+ const identifier = model.Metadata?.Identifier ?? model.name;
863
+ const error = new Error(`Cannot scan the ${identifier} records of the store: ${err instanceof Error ? err.message : err}. ` +
864
+ "A malformed ident key must be removed from the store manually; the migration is safe to re-run.");
865
+ error.cause = err;
866
+ throw error;
867
+ }
868
+ if (next.done)
869
+ break;
870
+ const uid = next.value.getLegacyUID();
871
+ if (uid === undefined || seen.has(uid))
872
+ continue;
873
+ seen.add(uid);
874
+ yield uid;
875
+ }
876
+ }
877
+ }
878
+ /**
879
+ * Upgrade the v3 idents
880
+ * @param report - report to fill
881
+ * @param pageSize - page size
882
+ */
883
+ async migrateIdents(report, pageSize) {
884
+ const IdentModel = this.getIdentModel();
885
+ const status = new Map();
886
+ let pass = 0;
887
+ let upgraded;
888
+ do {
889
+ upgraded = 0;
890
+ for await (const uid of this.legacyIdentKeys(pageSize)) {
891
+ if (status.get(uid) === "migrated" || status.get(uid) === "skipped")
892
+ continue;
893
+ if (!splitLegacyKey(uid)) {
894
+ status.set(uid, "skipped");
895
+ continue;
896
+ }
897
+ // Load through the ident model: a row found via the core repository hydrates as the ident model, a row
898
+ // its store cannot reach or refuses (another model's row) is not this application's v3 ident
899
+ const ref = IdentModel.ref(uid);
900
+ const row = await ref
901
+ .get()
902
+ .catch(err => this.log("WARN", `v3 ident ${uid} not readable through the ident model: ${err?.message}`));
903
+ if (!isLegacyIdent(row, IdentModel)) {
904
+ status.set(uid, "skipped");
905
+ continue;
906
+ }
907
+ try {
908
+ await upgradeIdent(row, IdentModel, { dryRun: report.dryRun });
909
+ status.set(uid, "migrated");
910
+ upgraded++;
911
+ }
912
+ catch (err) {
913
+ status.set(uid, "failed");
914
+ this.log("WARN", `v3 ident ${uid} not migrated: ${err instanceof Error ? err.message : err}`);
915
+ }
916
+ }
917
+ pass++;
918
+ // A dry run changes nothing: one pass sees every record
919
+ } while (!report.dryRun && upgraded > 0 && pass < MIGRATION_MAX_PASSES);
920
+ if (upgraded > 0 && pass >= MIGRATION_MAX_PASSES) {
921
+ report.incomplete = true;
922
+ this.log("WARN", `Ident migration stopped after ${pass} passes: run it again`);
923
+ }
924
+ for (const [uid, state] of status) {
925
+ if (state === "failed")
926
+ report.idents.failed.push(uid);
927
+ else
928
+ report.idents[state]++;
929
+ }
930
+ }
931
+ /**
932
+ * Save again the users whose stored record still has the v3 `__password`
933
+ * @param report - report to fill
934
+ * @param pageSize - page size
935
+ */
936
+ async migrateUsers(report, pageSize) {
937
+ for await (const user of this.getUserModel().iterate(`LIMIT ${pageSize}`)) {
938
+ if (!user[V3_PASSWORD_MAPPED])
939
+ continue;
940
+ try {
941
+ if (!report.dryRun) {
942
+ await user.save();
943
+ }
944
+ report.users.migrated++;
945
+ }
946
+ catch (err) {
947
+ report.users.failed.push(user.getUUID());
948
+ this.log("WARN", `v3 user ${user.getUUID()} not migrated: ${err instanceof Error ? err.message : err}`);
949
+ }
950
+ }
951
+ }
952
+ },
953
+ (() => {
954
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
955
+ _providers_decorators = [Operation({ id: "Auth.Providers", rest: { method: "get", path: "auth/providers" } })];
956
+ _me_decorators = [Operation({ id: "Auth.Me", rest: { method: "get", path: "auth/me" } })];
957
+ _logout_decorators = [Operation({ id: "Auth.Logout", rest: { method: "post", path: "auth/logout" } })];
958
+ _refresh_decorators = [Operation({ id: "Auth.Refresh", rest: { method: "post", path: "auth/refresh" } })];
959
+ _idents_decorators = [Operation({ id: "Auth.Idents", rest: { method: "get", path: "auth/idents" } })];
960
+ _unlink_decorators = [Operation({ id: "Auth.Unlink", rest: { method: "post", path: "auth/idents/unlink" } })];
961
+ _migrate_decorators = [Command("auth migrate", {
962
+ description: "Migrate v3 authentication data (idents, users) to v4",
963
+ phase: "initialized"
964
+ })];
965
+ __esDecorate(_a, null, _providers_decorators, { kind: "method", name: "providers", static: false, private: false, access: { has: obj => "providers" in obj, get: obj => obj.providers }, metadata: _metadata }, null, _instanceExtraInitializers);
966
+ __esDecorate(_a, null, _me_decorators, { kind: "method", name: "me", static: false, private: false, access: { has: obj => "me" in obj, get: obj => obj.me }, metadata: _metadata }, null, _instanceExtraInitializers);
967
+ __esDecorate(_a, null, _logout_decorators, { kind: "method", name: "logout", static: false, private: false, access: { has: obj => "logout" in obj, get: obj => obj.logout }, metadata: _metadata }, null, _instanceExtraInitializers);
968
+ __esDecorate(_a, null, _refresh_decorators, { kind: "method", name: "refresh", static: false, private: false, access: { has: obj => "refresh" in obj, get: obj => obj.refresh }, metadata: _metadata }, null, _instanceExtraInitializers);
969
+ __esDecorate(_a, null, _idents_decorators, { kind: "method", name: "idents", static: false, private: false, access: { has: obj => "idents" in obj, get: obj => obj.idents }, metadata: _metadata }, null, _instanceExtraInitializers);
970
+ __esDecorate(_a, null, _unlink_decorators, { kind: "method", name: "unlink", static: false, private: false, access: { has: obj => "unlink" in obj, get: obj => obj.unlink }, metadata: _metadata }, null, _instanceExtraInitializers);
971
+ __esDecorate(_a, null, _migrate_decorators, { kind: "method", name: "migrate", static: false, private: false, access: { has: obj => "migrate" in obj, get: obj => obj.migrate }, metadata: _metadata }, null, _instanceExtraInitializers);
972
+ if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
973
+ })(),
974
+ _a.Parameters = AuthenticationParameters,
975
+ _a;
976
+ })();
977
+ export { Authentication };
978
+ //# sourceMappingURL=authentication.service.js.map