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

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 (61) 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.d.ts +35 -0
  48. package/lib/test/stubprovider.d.ts.map +1 -0
  49. package/lib/test/stubprovider.js +46 -0
  50. package/lib/test/stubprovider.js.map +1 -0
  51. package/lib/test/v3.d.ts +29 -0
  52. package/lib/test/v3.d.ts.map +1 -0
  53. package/lib/test/v3.js +47 -0
  54. package/lib/test/v3.js.map +1 -0
  55. package/lib/throttle.d.ts +40 -0
  56. package/lib/throttle.d.ts.map +1 -0
  57. package/lib/throttle.js +47 -0
  58. package/lib/throttle.js.map +1 -0
  59. package/package.json +69 -3
  60. package/test/config.json +20 -0
  61. package/webda.module.json +632 -0
@@ -0,0 +1,798 @@
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, Operation, registerUserResolver, runAsSystem, Service, ServiceParameters, useContext, useCore, useModel, useService, V3_PASSWORD_MAPPED, WebdaError } from "@webda/core";
36
+ import { AccountExists, IdentConflict, IdentLinkedElsewhere, LastLoginMethod, 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
+ registerUserResolver({
106
+ resolve: async (id) => {
107
+ const ref = this.getUserModel().ref(id);
108
+ return (await ref.exists()) ? await ref.get() : undefined;
109
+ }
110
+ });
111
+ return this;
112
+ }
113
+ /** @override */
114
+ async init() {
115
+ await super.init();
116
+ // Providers may be created after this service was resolved
117
+ this.discoverProviders();
118
+ return this;
119
+ }
120
+ /**
121
+ * Collect every service implementing {@link AuthProvider}
122
+ * @throws Error when two providers share the same name
123
+ */
124
+ discoverProviders() {
125
+ // The resolve() pass is best-effort (later services may not exist yet), the init() pass is authoritative
126
+ const providers = new Map();
127
+ for (const service of Object.values(useCore().getServices())) {
128
+ if (!isAuthProvider(service))
129
+ continue;
130
+ if (providers.has(service.providerName)) {
131
+ throw new Error(`Duplicate auth provider '${service.providerName}'`);
132
+ }
133
+ providers.set(service.providerName, service);
134
+ }
135
+ this.providerMap = providers;
136
+ }
137
+ /** @override */
138
+ async stop() {
139
+ registerUserResolver(undefined);
140
+ await super.stop();
141
+ }
142
+ /**
143
+ * @param name - provider name
144
+ * @returns the provider
145
+ */
146
+ getProvider(name) {
147
+ return this.providerMap.get(name);
148
+ }
149
+ /**
150
+ * @param name - provider name
151
+ * @returns the email policy of that provider, undefined for unknown providers or without policy
152
+ */
153
+ emailPolicyFor(name) {
154
+ const provider = this.getProvider(name);
155
+ if (!provider)
156
+ return undefined;
157
+ if (typeof provider.getEmailPolicy === "function")
158
+ return provider.getEmailPolicy();
159
+ const params = provider.getParameters?.();
160
+ if (!params)
161
+ return undefined;
162
+ const { allowedEmailDomains, trustEmailVerification } = params;
163
+ const strings = (v) => Array.isArray(v) && v.every(d => typeof d === "string");
164
+ if (allowedEmailDomains !== undefined && !strings(allowedEmailDomains)) {
165
+ throw new Error(`Invalid email policy for provider '${name}': allowedEmailDomains must be a string[]`);
166
+ }
167
+ if (trustEmailVerification !== undefined &&
168
+ typeof trustEmailVerification !== "boolean" &&
169
+ !strings(trustEmailVerification)) {
170
+ throw new Error(`Invalid email policy for provider '${name}': trustEmailVerification must be a boolean or string[]`);
171
+ }
172
+ return allowedEmailDomains === undefined && trustEmailVerification === undefined
173
+ ? undefined
174
+ : { allowedEmailDomains, trustEmailVerification };
175
+ }
176
+ /**
177
+ * @returns public info of every provider
178
+ */
179
+ getProviders() {
180
+ return [...this.providerMap.values()].map(p => p.getPublicInfo());
181
+ }
182
+ /**
183
+ * List available login methods
184
+ * @returns providers
185
+ */
186
+ providers() {
187
+ return this.getProviders();
188
+ }
189
+ /**
190
+ * Current user
191
+ * @returns public entry of the logged user
192
+ */
193
+ async me() {
194
+ const ctx = useContext();
195
+ const user = ctx.getSession()?.isLogged() ? await ctx.getCurrentUser() : undefined;
196
+ if (!user) {
197
+ throw new WebdaError.NotFound("No user found");
198
+ }
199
+ await this.emit("Authentication.GetMe", { context: ctx, user });
200
+ return user.toPublicEntry();
201
+ }
202
+ /** @override */
203
+ initMetrics() {
204
+ super.initMetrics();
205
+ this.metrics.login = this.getMetric(Counter, {
206
+ name: "auth_login",
207
+ help: "Counter number of login per provider",
208
+ labelNames: ["provider"]
209
+ });
210
+ this.metrics.logout = this.getMetric(Counter, {
211
+ name: "auth_logout",
212
+ help: "Counter number of logout"
213
+ });
214
+ this.metrics.registration = this.getMetric(Counter, {
215
+ name: "auth_registration",
216
+ help: "Counter number of registration per provider",
217
+ labelNames: ["provider"]
218
+ });
219
+ }
220
+ /**
221
+ * Find an ident; with `compatibility.v3`, a miss falls back to the v3 record `"<uid>_<provider>"`, upgraded on
222
+ * the fly
223
+ * @param provider - provider
224
+ * @param providerUid - uid
225
+ * @returns the ident when it exists
226
+ */
227
+ async findIdent(provider, providerUid) {
228
+ const IdentModel = this.getIdentModel();
229
+ const ref = IdentModel.ref(Ident.key(providerUid, provider));
230
+ if (await ref.exists()) {
231
+ return ref.get();
232
+ }
233
+ if (!this.parameters.compatibility?.v3) {
234
+ return undefined;
235
+ }
236
+ const legacy = IdentModel.ref(legacyKey(providerUid, provider));
237
+ if (!(await legacy.exists())) {
238
+ return undefined;
239
+ }
240
+ // The v3-looking key of a shared store may hold another model's row: only a v3 ident is upgraded
241
+ const v3 = await legacy.get().catch(() => undefined);
242
+ if (!isLegacyIdent(v3, IdentModel)) {
243
+ this.log("WARN", `Ignoring a non-ident row stored under a v3 '${provider}' ident key`);
244
+ return undefined;
245
+ }
246
+ const ident = await runAsSystem(() => upgradeIdent(v3, IdentModel));
247
+ if (ident.provider !== provider || ident.providerUid !== providerUid) {
248
+ this.log("WARN", `v3 '${provider}' ident upgraded under another key: ignored`);
249
+ return undefined;
250
+ }
251
+ return ident;
252
+ }
253
+ /**
254
+ * MFA methods enabled for a user (implemented by sub-project 4)
255
+ * @param user - user
256
+ * @returns enabled methods, empty when MFA is off
257
+ */
258
+ mfaMethods(user) {
259
+ const mfa = user?.mfa;
260
+ return mfa?.isEnabled?.() ? mfa.methods() : [];
261
+ }
262
+ /**
263
+ * Create and save a user for an identity
264
+ * @param identity - resolved identity
265
+ * @param data - extra profile data
266
+ * @param ctx - request context (complete() runs as system, so it passes the real one)
267
+ * @returns the user
268
+ */
269
+ async registerUser(identity, data = {}, ctx = useContext()) {
270
+ const user = await this.getUserModel().create({
271
+ ...data,
272
+ email: identity.email,
273
+ displayName: data.displayName ?? identity.profile?.name,
274
+ locale: ctx?.getLocale?.()
275
+ }, false);
276
+ await this.emit("Authentication.Register", {
277
+ context: ctx,
278
+ user,
279
+ data,
280
+ identId: `${identity.providerUid}:${identity.provider}`,
281
+ identity: withoutTokens(identity)
282
+ });
283
+ await user.save();
284
+ this.metrics?.registration?.inc({ provider: identity.provider });
285
+ return user;
286
+ }
287
+ /**
288
+ * Complete a login for an identity proven by a provider
289
+ * @param identity - resolved identity
290
+ * @param options - complete options
291
+ * @param options.newUser - identity.user was just created by the provider: its first ident is not a "link"
292
+ * @returns the result
293
+ * @throws IdentLinkedElsewhere when the ident belongs to another user than the logged one
294
+ * @throws AccountExists when the email matches an account the linking policy does not allow
295
+ * @throws RegistrationDisabled when a new user is needed but registration is off
296
+ * @throws WebdaError.BadRequest when `identity.provider` is not a registered AuthProvider
297
+ */
298
+ async complete(identity, options = {}) {
299
+ identity = this.applyPolicy(identity);
300
+ const ctx = useContext();
301
+ return runAsSystem(() => this.completeAs(identity, ctx, false, !!options.newUser));
302
+ }
303
+ /**
304
+ * Apply the email policy of the identity provider (complete() does it too, idempotently)
305
+ * Lets a provider refuse an identity before creating anything
306
+ * @param identity - resolved identity
307
+ * @returns the identity to use
308
+ * @throws EmailDomainNotAllowed when the policy refuses it
309
+ * @throws WebdaError.BadRequest when `identity.provider` is not a registered AuthProvider: an unknown name would
310
+ * escape the email policy of the provider it impersonates
311
+ */
312
+ applyPolicy(identity) {
313
+ if (!this.getProvider(identity?.provider)) {
314
+ throw new WebdaError.BadRequest(`Unknown auth provider '${identity?.provider}'`);
315
+ }
316
+ return applyEmailPolicy(identity, this.emailPolicyFor(identity.provider));
317
+ }
318
+ /**
319
+ * Body of {@link complete}, running as system with the request context passed explicitly
320
+ * @param identity - resolved identity
321
+ * @param ctx - request context
322
+ * @param retried - already retried after a concurrent creation
323
+ * @param newUser - identity.user was just created by the provider: its first ident is not a "link"
324
+ * @returns the result
325
+ */
326
+ async completeAs(identity, ctx, retried, newUser = false) {
327
+ const currentUserId = ctx.getSession()?.isLogged() ? ctx.getCurrentUserId() : undefined;
328
+ let ident = await this.findIdent(identity.provider, identity.providerUid);
329
+ if (ident?.getUser()) {
330
+ if (currentUserId && ident.getUser().toString() !== currentUserId) {
331
+ throw new IdentLinkedElsewhere();
332
+ }
333
+ // A provider handing another user than the owner (a registration that lost a race) must not become the owner
334
+ if (identity.user && identity.user.getUUID() !== ident.getUser().toString()) {
335
+ throw new AccountExists();
336
+ }
337
+ return this.establish(ident.getUser().toString(), ident, identity, ctx);
338
+ }
339
+ // An existing ident without owner is adopted by the resolved, current or newly registered user
340
+ const adopting = !!ident;
341
+ let user = identity.user;
342
+ let userId = currentUserId ?? user?.getUUID();
343
+ const email = identity.email ? Ident.normalizeEmail(identity.email) : undefined;
344
+ const emailIdent = email ? await this.findIdent("email", email) : undefined;
345
+ let linked = !!userId && !(newUser && !currentUserId);
346
+ let registered;
347
+ const policy = this.parameters.linking;
348
+ // never/verified: only a verified email ident is an owner, an unverified one is treated as absent
349
+ const owner = emailIdent?.getUser() && (policy === "always" || emailIdent.isVerified()) ? emailIdent.getUser() : undefined;
350
+ if (!userId && owner) {
351
+ if (policy === "never" || (policy === "verified" && !identity.emailVerified)) {
352
+ throw new AccountExists();
353
+ }
354
+ userId = owner.toString();
355
+ linked = true;
356
+ }
357
+ if (!userId) {
358
+ if (!this.parameters.registration) {
359
+ throw new RegistrationDisabled();
360
+ }
361
+ // Narrow the window of a phantom Register event when a concurrent login just created the ident
362
+ if (!adopting && (await this.findIdent(identity.provider, identity.providerUid))) {
363
+ return this.completeAs(identity, ctx, retried, newUser);
364
+ }
365
+ user = registered = await this.registerUser(identity, {}, ctx);
366
+ userId = user.getUUID();
367
+ }
368
+ if (adopting) {
369
+ ident.setUser(userId);
370
+ // Verification is only kept when this identity proves it
371
+ ident.verifiedAt = identity.emailVerified ? (ident.verifiedAt ?? new Date()) : undefined;
372
+ await ident.save();
373
+ }
374
+ else {
375
+ ident = new (this.getIdentModel())({
376
+ ...Ident.key(identity.providerUid, identity.provider),
377
+ email,
378
+ __profile: identity.profile,
379
+ verifiedAt: identity.emailVerified ? new Date() : undefined
380
+ });
381
+ ident.setUser(userId);
382
+ // Provider tokens are only stored encrypted
383
+ if (identity.tokens) {
384
+ await ident.tokens.set(identity.tokens);
385
+ }
386
+ try {
387
+ await ident.getRepository().create(ident);
388
+ }
389
+ catch (err) {
390
+ if (!retried && /Already exists/.test(`${err?.message}`)) {
391
+ // Lost a concurrent first login: drop the user created for nothing, the retry finds the winner's ident
392
+ await registered?.delete();
393
+ return this.completeAs(identity, ctx, true, newUser);
394
+ }
395
+ throw err;
396
+ }
397
+ }
398
+ // Only an email asserted as verified by the provider may claim the email ident
399
+ if (email && !emailIdent && identity.emailVerified && identity.provider !== "email") {
400
+ const extra = new (this.getIdentModel())({
401
+ ...Ident.key(email, "email"),
402
+ email,
403
+ verifiedAt: new Date()
404
+ });
405
+ extra.setUser(userId);
406
+ await extra
407
+ .getRepository()
408
+ .create(extra)
409
+ .catch(err => {
410
+ if (!/Already exists/.test(`${err?.message}`))
411
+ throw err;
412
+ });
413
+ }
414
+ if (linked) {
415
+ await this.emit("Authentication.Linked", {
416
+ context: ctx,
417
+ user: user ?? (await this.loadUser(userId)),
418
+ ident
419
+ });
420
+ }
421
+ return this.establish(user ?? userId, ident, identity, ctx);
422
+ }
423
+ /**
424
+ * @param userId - uuid
425
+ * @returns the user
426
+ */
427
+ loadUser(userId) {
428
+ return this.getUserModel().ref(userId).get();
429
+ }
430
+ /**
431
+ * Write the session, issue tokens, emit Login
432
+ * @param user - user or uuid
433
+ * @param ident - ident used
434
+ * @param identity - resolved identity
435
+ * @param ctx - request context (complete() runs as system, so it passes the real one)
436
+ * @returns the result
437
+ */
438
+ async establish(user, ident, identity, ctx = useContext()) {
439
+ const userObj = typeof user === "string" ? await this.loadUser(user) : user;
440
+ const userId = userObj.getUUID();
441
+ const session = ctx.getSession();
442
+ const methods = this.mfaMethods(userObj);
443
+ // MFA is satisfied by one of the user's enabled methods on top of a primary factor
444
+ const amr = identity.amr ?? [];
445
+ const secondFactor = amr.some(m => methods.includes(m)) && amr.some(m => PRIMARY_FACTORS.includes(m));
446
+ const mfa = methods.length ? (secondFactor ? "verified" : "pending") : "none";
447
+ session.login(userId, ident.getUUID(), { provider: identity.provider, amr: identity.amr, mfa });
448
+ // A later password change of the user ends this session (checked by the session manager on load)
449
+ session.authAt = Date.now();
450
+ // Provider profile and tokens are kept even when MFA is pending
451
+ const update = {
452
+ ...(identity.profile ? { __profile: identity.profile } : {}),
453
+ ...(await this.tokensUpdate(ident, identity.tokens))
454
+ };
455
+ if (mfa === "pending") {
456
+ if (Object.keys(update).length) {
457
+ await ident.ref().patch(update);
458
+ }
459
+ return { status: "mfa_required", methods };
460
+ }
461
+ await ident.ref().patch({ lastUsedAt: new Date(), ...update });
462
+ const tokens = await useService("TokenService").issue(session);
463
+ await this.emit("Authentication.Login", {
464
+ context: ctx,
465
+ userId,
466
+ user: userObj,
467
+ identId: ident.getUUID(),
468
+ ident,
469
+ provider: identity.provider,
470
+ // Provider tokens never travel in Authentication events (providers emit their own)
471
+ identity: withoutTokens(identity)
472
+ });
473
+ this.metrics?.login?.inc({ provider: identity.provider });
474
+ return { status: "ok", user: userObj.toPublicEntry(), ...tokens };
475
+ }
476
+ /**
477
+ * Patch storing new provider tokens encrypted; also drops plaintext tokens left by an earlier v4 beta (`__tokens`)
478
+ * @param ident - the ident
479
+ * @param tokens - tokens from the provider, if any
480
+ * @returns the attributes to patch
481
+ */
482
+ async tokensUpdate(ident, tokens) {
483
+ if (!tokens)
484
+ return {};
485
+ await ident.tokens.set(tokens);
486
+ return {
487
+ tokens: { __ciphertext: ident.tokens.__ciphertext },
488
+ ...(ident.__tokens !== undefined ? { __tokens: null } : {})
489
+ };
490
+ }
491
+ /**
492
+ * @returns the logged user id
493
+ * @throws WebdaError.Unauthorized when no fully logged session exists
494
+ */
495
+ requireUser() {
496
+ const ctx = useContext();
497
+ if (!ctx.getSession()?.isLogged()) {
498
+ throw new WebdaError.Unauthorized("Login required");
499
+ }
500
+ return ctx.getCurrentUserId();
501
+ }
502
+ /**
503
+ * Logout and revoke the current refresh family; also abandons a pending MFA session
504
+ */
505
+ async logout() {
506
+ const ctx = useContext();
507
+ const session = ctx.getSession();
508
+ if (!session?.isLogged() && !session?.isPending()) {
509
+ throw new WebdaError.Unauthorized("Login required");
510
+ }
511
+ const family = session.refreshFamily;
512
+ if (family) {
513
+ await runAsSystem(() => useService("TokenService").revokeFamily(family));
514
+ }
515
+ await this.emit("Authentication.Logout", { context: ctx });
516
+ this.metrics?.logout?.inc();
517
+ ctx.newSession();
518
+ }
519
+ /**
520
+ * Exchange a refresh token
521
+ * @param refreshToken - refresh token
522
+ * @returns new tokens
523
+ */
524
+ async refresh(refreshToken) {
525
+ const { session, ...tokens } = await runAsSystem(() => useService("TokenService").refresh(refreshToken));
526
+ return tokens;
527
+ }
528
+ /**
529
+ * List the current user's idents; with `compatibility.v3`, listing upgrades the user's v3 ident records first
530
+ * @returns idents of the current user
531
+ */
532
+ async idents() {
533
+ const userId = this.requireUser();
534
+ return (await this.listIdents(userId)).map(i => ({
535
+ provider: i.provider,
536
+ providerUid: i.providerUid,
537
+ email: i.email,
538
+ verifiedAt: i.verifiedAt,
539
+ lastUsedAt: i.lastUsedAt
540
+ }));
541
+ }
542
+ /**
543
+ * @param userId - user
544
+ * @returns all idents owned by the user
545
+ */
546
+ async listIdents(userId) {
547
+ return runAsSystem(async () => {
548
+ const IdentModel = this.getIdentModel();
549
+ const idents = (await IdentModel.query("_user = ?", [userId])).results;
550
+ if (!idents.some(i => i.getLegacyUID())) {
551
+ return idents;
552
+ }
553
+ // v3 records are upgraded before use, or ignored without v3 compatibility
554
+ const result = new Map();
555
+ for (const ident of idents) {
556
+ if (!ident.getLegacyUID()) {
557
+ result.set(ident.getUUID(), ident);
558
+ }
559
+ else if (this.parameters.compatibility?.v3) {
560
+ if (!isLegacyIdent(ident, IdentModel))
561
+ continue;
562
+ let upgraded;
563
+ try {
564
+ upgraded = await upgradeIdent(ident, IdentModel);
565
+ }
566
+ catch (err) {
567
+ if (!(err instanceof IdentConflict))
568
+ throw err;
569
+ // Kept for the operator (`webda auth migrate` reports it): the account stays usable meanwhile
570
+ this.log("WARN", `v3 ident ${ident.getLegacyUID()} conflicts with another user's ident: skipped`);
571
+ continue;
572
+ }
573
+ // A crash-left duplicate resolves to the record already listed
574
+ if (!result.has(upgraded.getUUID()))
575
+ result.set(upgraded.getUUID(), upgraded);
576
+ }
577
+ }
578
+ return [...result.values()];
579
+ });
580
+ }
581
+ /**
582
+ * Remove one of the current user's idents
583
+ * @param provider - provider
584
+ * @param providerUid - uid
585
+ * @throws LastLoginMethod when no usable login method would remain (non-email idents, plus email idents when the
586
+ * user has a password), or when it would remove the last email ident of a user with a password
587
+ */
588
+ async unlink(provider, providerUid) {
589
+ const userId = this.requireUser();
590
+ const idents = await this.listIdents(userId);
591
+ const target = idents.find(i => i.provider === provider && i.providerUid === providerUid);
592
+ if (!target) {
593
+ throw new WebdaError.NotFound("Ident not found");
594
+ }
595
+ const remaining = idents.filter(i => i !== target);
596
+ const user = await this.loadUser(userId);
597
+ const usesPassword = !!user.password?.hasPassword?.();
598
+ // An email ident is only a login method (and a recovery path) when the user has a password
599
+ const usable = remaining.filter(i => i.provider !== "email" || usesPassword);
600
+ const lastEmail = provider === "email" && !remaining.some(i => i.provider === "email");
601
+ if (usable.length === 0 || (usesPassword && lastEmail)) {
602
+ throw new LastLoginMethod();
603
+ }
604
+ await runAsSystem(() => target.ref().delete());
605
+ // A projection: the provider tokens and profile of the removed ident never leave this service
606
+ const ident = {
607
+ uuid: target.getUUID(),
608
+ provider: target.provider,
609
+ providerUid: target.providerUid,
610
+ email: target.email,
611
+ verifiedAt: target.verifiedAt,
612
+ lastUsedAt: target.lastUsedAt,
613
+ userId
614
+ };
615
+ await this.emit("Authentication.Unlinked", { context: useContext(), user, ident });
616
+ }
617
+ /**
618
+ * Migrate v3 authentication data to the v4 layout
619
+ *
620
+ * Idents: every v3 record (`"<providerUid>_<provider>"` key) is upgraded to its `"<providerUid>:<provider>"`
621
+ * record (emails normalised) then deleted; with a custom ident model, the v3 rows typed as the core
622
+ * `Webda/Ident` are found through its repository too. Upgrading shifts position-based paging, so passes run
623
+ * until one upgrades nothing. Users: a user whose stored record still has the v3 `__password` is saved again in
624
+ * the v4 `password` shape. Idempotent: a second run migrates nothing. Failures are reported by key and logged,
625
+ * the migration goes on.
626
+ *
627
+ * Operator notes:
628
+ * - A v3 ident whose upgraded key another user already holds (e.g. two v3 email keys differing only by case) is
629
+ * reported in `idents.failed` (IDENT_CONFLICT) and kept: decide which user keeps the email, then delete or
630
+ * rename the other v3 row and run the command again.
631
+ * - A malformed ident key in the store stops the scan: remove that row manually.
632
+ * - Re-running is always safe: migrated records are not touched again.
633
+ * @param dryRun - only count, write nothing
634
+ * @param batch - page size of the scans
635
+ * @returns the report
636
+ */
637
+ async migrate(
638
+ /** @description Only count the records to migrate, write nothing */
639
+ dryRun = false,
640
+ /** @description Page size of the store scans */
641
+ batch = 100) {
642
+ const pageSize = Math.max(1, Math.floor(Number(batch)) || 100);
643
+ const report = {
644
+ dryRun: !!dryRun,
645
+ idents: { migrated: 0, skipped: 0, failed: [] },
646
+ users: { migrated: 0, skipped: 0, failed: [] }
647
+ };
648
+ await runAsSystem(async () => {
649
+ await this.migrateIdents(report, pageSize);
650
+ await this.migrateUsers(report, pageSize);
651
+ });
652
+ this.log("INFO", "Authentication migration", report);
653
+ return report;
654
+ }
655
+ /**
656
+ * Enumerate the v3 ident rows: through the ident model, and through the core `Ident` repository when the
657
+ * ident model is a subclass (its queries only see rows typed as itself or its subclasses)
658
+ * @param pageSize - page size
659
+ * @returns the v3 keys found, each once per pass
660
+ */
661
+ async *legacyIdentKeys(pageSize) {
662
+ const IdentModel = this.getIdentModel();
663
+ const models = [IdentModel];
664
+ if (IdentModel !== Ident) {
665
+ try {
666
+ if (Ident.getRepository())
667
+ models.push(Ident);
668
+ }
669
+ catch {
670
+ // The core Ident model has no repository: no ancestor-typed rows to look for
671
+ }
672
+ }
673
+ const seen = new Set();
674
+ for (const model of models) {
675
+ const scan = legacyIdents(model, `LIMIT ${pageSize}`);
676
+ while (true) {
677
+ let next;
678
+ try {
679
+ next = await scan.next();
680
+ }
681
+ catch (err) {
682
+ const identifier = model.Metadata?.Identifier ?? model.name;
683
+ const error = new Error(`Cannot scan the ${identifier} records of the store: ${err?.message ?? err}. ` +
684
+ "A malformed ident key must be removed from the store manually; the migration is safe to re-run.");
685
+ error.cause = err;
686
+ throw error;
687
+ }
688
+ if (next.done)
689
+ break;
690
+ const uid = next.value.getLegacyUID();
691
+ if (seen.has(uid))
692
+ continue;
693
+ seen.add(uid);
694
+ yield uid;
695
+ }
696
+ }
697
+ }
698
+ /**
699
+ * Upgrade the v3 idents
700
+ * @param report - report to fill
701
+ * @param pageSize - page size
702
+ */
703
+ async migrateIdents(report, pageSize) {
704
+ const IdentModel = this.getIdentModel();
705
+ const status = new Map();
706
+ let pass = 0;
707
+ let upgraded;
708
+ do {
709
+ upgraded = 0;
710
+ for await (const uid of this.legacyIdentKeys(pageSize)) {
711
+ if (status.get(uid) === "migrated" || status.get(uid) === "skipped")
712
+ continue;
713
+ if (!splitLegacyKey(uid)) {
714
+ status.set(uid, "skipped");
715
+ continue;
716
+ }
717
+ // Load through the ident model: a row found via the core repository hydrates as the ident model, a row
718
+ // its store cannot reach or refuses (another model's row) is not this application's v3 ident
719
+ const ref = IdentModel.ref(uid);
720
+ const row = await ref
721
+ .get()
722
+ .catch(err => this.log("WARN", `v3 ident ${uid} not readable through the ident model: ${err?.message}`));
723
+ if (!isLegacyIdent(row, IdentModel)) {
724
+ status.set(uid, "skipped");
725
+ continue;
726
+ }
727
+ try {
728
+ await upgradeIdent(row, IdentModel, { dryRun: report.dryRun });
729
+ status.set(uid, "migrated");
730
+ upgraded++;
731
+ }
732
+ catch (err) {
733
+ status.set(uid, "failed");
734
+ this.log("WARN", `v3 ident ${uid} not migrated: ${err?.message ?? err}`);
735
+ }
736
+ }
737
+ pass++;
738
+ // A dry run changes nothing: one pass sees every record
739
+ } while (!report.dryRun && upgraded > 0 && pass < MIGRATION_MAX_PASSES);
740
+ if (upgraded > 0 && pass >= MIGRATION_MAX_PASSES) {
741
+ report.incomplete = true;
742
+ this.log("WARN", `Ident migration stopped after ${pass} passes: run it again`);
743
+ }
744
+ for (const [uid, state] of status) {
745
+ if (state === "failed")
746
+ report.idents.failed.push(uid);
747
+ else
748
+ report.idents[state]++;
749
+ }
750
+ }
751
+ /**
752
+ * Save again the users whose stored record still has the v3 `__password`
753
+ * @param report - report to fill
754
+ * @param pageSize - page size
755
+ */
756
+ async migrateUsers(report, pageSize) {
757
+ for await (const user of this.getUserModel().iterate(`LIMIT ${pageSize}`)) {
758
+ if (!user[V3_PASSWORD_MAPPED])
759
+ continue;
760
+ try {
761
+ if (!report.dryRun) {
762
+ await user.save();
763
+ }
764
+ report.users.migrated++;
765
+ }
766
+ catch (err) {
767
+ report.users.failed.push(user.getUUID());
768
+ this.log("WARN", `v3 user ${user.getUUID()} not migrated: ${err?.message ?? err}`);
769
+ }
770
+ }
771
+ }
772
+ },
773
+ (() => {
774
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
775
+ _providers_decorators = [Operation({ id: "Auth.Providers", rest: { method: "get", path: "auth/providers" } })];
776
+ _me_decorators = [Operation({ id: "Auth.Me", rest: { method: "get", path: "auth/me" } })];
777
+ _logout_decorators = [Operation({ id: "Auth.Logout", rest: { method: "post", path: "auth/logout" } })];
778
+ _refresh_decorators = [Operation({ id: "Auth.Refresh", rest: { method: "post", path: "auth/refresh" } })];
779
+ _idents_decorators = [Operation({ id: "Auth.Idents", rest: { method: "get", path: "auth/idents" } })];
780
+ _unlink_decorators = [Operation({ id: "Auth.Unlink", rest: { method: "post", path: "auth/idents/unlink" } })];
781
+ _migrate_decorators = [Command("auth migrate", {
782
+ description: "Migrate v3 authentication data (idents, users) to v4",
783
+ phase: "initialized"
784
+ })];
785
+ __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);
786
+ __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);
787
+ __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);
788
+ __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);
789
+ __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);
790
+ __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);
791
+ __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);
792
+ if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
793
+ })(),
794
+ _a.Parameters = AuthenticationParameters,
795
+ _a;
796
+ })();
797
+ export { Authentication };
798
+ //# sourceMappingURL=authentication.service.js.map