@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.
- package/LICENSE +166 -0
- package/README.md +59 -2
- package/lib/authentication.service.d.ts +322 -0
- package/lib/authentication.service.d.ts.map +1 -0
- package/lib/authentication.service.js +978 -0
- package/lib/authentication.service.js.map +1 -0
- package/lib/certificate/certificate.service.d.ts +139 -0
- package/lib/certificate/certificate.service.d.ts.map +1 -0
- package/lib/certificate/certificate.service.js +343 -0
- package/lib/certificate/certificate.service.js.map +1 -0
- package/lib/compat/upgrade.d.ts +71 -0
- package/lib/compat/upgrade.d.ts.map +1 -0
- package/lib/compat/upgrade.js +200 -0
- package/lib/compat/upgrade.js.map +1 -0
- package/lib/email/emailpassword.service.d.ts +249 -0
- package/lib/email/emailpassword.service.d.ts.map +1 -0
- package/lib/email/emailpassword.service.js +714 -0
- package/lib/email/emailpassword.service.js.map +1 -0
- package/lib/email/tokens.d.ts +36 -0
- package/lib/email/tokens.d.ts.map +1 -0
- package/lib/email/tokens.js +46 -0
- package/lib/email/tokens.js.map +1 -0
- package/lib/errors.d.ts +53 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +74 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/mfa/secondfactor.service.d.ts +44 -0
- package/lib/mfa/secondfactor.service.d.ts.map +1 -0
- package/lib/mfa/secondfactor.service.js +38 -0
- package/lib/mfa/secondfactor.service.js.map +1 -0
- package/lib/mfa/totp.service.d.ts +41 -0
- package/lib/mfa/totp.service.d.ts.map +1 -0
- package/lib/mfa/totp.service.js +63 -0
- package/lib/mfa/totp.service.js.map +1 -0
- package/lib/oauth/oauth.service.d.ts +296 -0
- package/lib/oauth/oauth.service.d.ts.map +1 -0
- package/lib/oauth/oauth.service.js +560 -0
- package/lib/oauth/oauth.service.js.map +1 -0
- package/lib/provider.d.ts +53 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/provider.js +55 -0
- package/lib/provider.js.map +1 -0
- package/lib/test/authtest.d.ts +39 -0
- package/lib/test/authtest.d.ts.map +1 -0
- package/lib/test/authtest.js +62 -0
- package/lib/test/authtest.js.map +1 -0
- package/lib/test/emailtest.d.ts +25 -0
- package/lib/test/emailtest.d.ts.map +1 -0
- package/lib/test/emailtest.js +42 -0
- package/lib/test/emailtest.js.map +1 -0
- package/lib/test/fakeoauth.service.d.ts +45 -0
- package/lib/test/fakeoauth.service.d.ts.map +1 -0
- package/lib/test/fakeoauth.service.js +78 -0
- package/lib/test/fakeoauth.service.js.map +1 -0
- package/lib/test/mfauser.fixture.model.d.ts +11 -0
- package/lib/test/mfauser.fixture.model.d.ts.map +1 -0
- package/lib/test/mfauser.fixture.model.js +44 -0
- package/lib/test/mfauser.fixture.model.js.map +1 -0
- package/lib/test/stubprovider.d.ts +35 -0
- package/lib/test/stubprovider.d.ts.map +1 -0
- package/lib/test/stubprovider.js +46 -0
- package/lib/test/stubprovider.js.map +1 -0
- package/lib/test/v3.d.ts +29 -0
- package/lib/test/v3.d.ts.map +1 -0
- package/lib/test/v3.js +47 -0
- package/lib/test/v3.js.map +1 -0
- package/lib/throttle.d.ts +41 -0
- package/lib/throttle.d.ts.map +1 -0
- package/lib/throttle.js +49 -0
- package/lib/throttle.js.map +1 -0
- package/package.json +69 -3
- package/test/config.json +23 -0
- 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
|