@aooth/auth-moost 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/atscript/index.d.mts +2 -0
- package/dist/atscript/index.mjs +2 -0
- package/dist/forms-BE62OrN1.mjs +230 -0
- package/dist/index.d.mts +1279 -0
- package/dist/index.mjs +3347 -0
- package/package.json +90 -0
- package/src/atscript/models/forms.as +331 -0
- package/src/atscript/models/forms.as.d.ts +357 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3347 @@
|
|
|
1
|
+
import { AuthCredential, AuthError, generateMagicLinkToken } from "@aooth/auth";
|
|
2
|
+
import { current, defineWook, eventTypeKey, key } from "@wooksjs/event-core";
|
|
3
|
+
import { HttpError, useAuthorization, useCookies, useRequest, useResponse, useUrlParams } from "@wooksjs/event-http";
|
|
4
|
+
import { Controller, Injectable, Intercept, Resolve, TInterceptorPriority, defineAfterInterceptor, defineBeforeInterceptor, getMoostMate, useControllerContext } from "moost";
|
|
5
|
+
import { ArbacAction, ArbacResource, getArbacMate } from "@aooth/arbac-moost";
|
|
6
|
+
import { Body, Get, HttpError as HttpError$1, Post } from "@moostjs/event-http";
|
|
7
|
+
import { createAsHttpOutlet, extractPassContext, finishWfAborted, finishWfWithChoice, finishWfWithData, finishWfWithRedirect, handleAsOutletRequest, serializeFormSchema } from "@atscript/moost-wf";
|
|
8
|
+
import { HandleStateStrategy, MoostWf, Step, WfStateStoreMemory, Workflow, WorkflowParam, WorkflowSchema, createEmailOutlet, outletEmail, outletHttp, useWfFinished } from "@moostjs/event-wf";
|
|
9
|
+
import { UserAuthError, UserService, maskEmail, maskPhone, verifyTotpCode } from "@aooth/user";
|
|
10
|
+
import { defineAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
|
|
11
|
+
//#region src/auth.config.ts
|
|
12
|
+
/**
|
|
13
|
+
* Resolve `AuthOptions` to its `ResolvedAuthOptions` form, applying the same
|
|
14
|
+
* defaults the legacy `MoostAuthConfig` constructor did. Pure — call once per
|
|
15
|
+
* `authGuardInterceptor` factory invocation; the resolved value is stashed in
|
|
16
|
+
* a wook slot for the event chain.
|
|
17
|
+
*/
|
|
18
|
+
function resolveAuthOptions(opts = {}) {
|
|
19
|
+
const cookie = {
|
|
20
|
+
name: "aooth_session",
|
|
21
|
+
secure: true,
|
|
22
|
+
sameSite: "lax",
|
|
23
|
+
httpOnly: true,
|
|
24
|
+
path: "/",
|
|
25
|
+
...opts.cookie
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
cookie,
|
|
29
|
+
refreshCookie: {
|
|
30
|
+
name: "aooth_refresh",
|
|
31
|
+
secure: cookie.secure,
|
|
32
|
+
sameSite: cookie.sameSite,
|
|
33
|
+
httpOnly: cookie.httpOnly,
|
|
34
|
+
...cookie.domain !== void 0 && { domain: cookie.domain },
|
|
35
|
+
path: "/auth/refresh",
|
|
36
|
+
...opts.refreshCookie
|
|
37
|
+
},
|
|
38
|
+
enableCookie: opts.enableCookie ?? true,
|
|
39
|
+
enableBearer: opts.enableBearer ?? true
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/auth.composables.ts
|
|
44
|
+
const authContextKey = key("auth.context");
|
|
45
|
+
/**
|
|
46
|
+
* Slot populated by `authGuardInterceptor(opts)` on the HTTP event. Workflow
|
|
47
|
+
* step events started with `start({ eventContext: current() })` inherit the
|
|
48
|
+
* HTTP parent chain — `useAuth()` reads the slot transparently from inside
|
|
49
|
+
* step bodies. Exported only for `auth.guard.ts`.
|
|
50
|
+
*/
|
|
51
|
+
const authOptionsKey = key("auth.options");
|
|
52
|
+
/**
|
|
53
|
+
* `sameSite` is upper-cased because wooks accepts `'Lax' | 'Strict' | 'None'`
|
|
54
|
+
* while `ResolvedAuthCookieConfig` stores lower-case for ergonomics.
|
|
55
|
+
*/
|
|
56
|
+
function cookieAttrsFrom(c, extra) {
|
|
57
|
+
return {
|
|
58
|
+
httpOnly: c.httpOnly,
|
|
59
|
+
secure: c.secure,
|
|
60
|
+
sameSite: c.sameSite.charAt(0).toUpperCase() + c.sameSite.slice(1),
|
|
61
|
+
path: c.path,
|
|
62
|
+
domain: c.domain,
|
|
63
|
+
...extra
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Composable for accessing the current event's auth state + transport helpers.
|
|
68
|
+
*
|
|
69
|
+
* `defineWook` memoizes the bindings object per event so multiple calls share
|
|
70
|
+
* one closure. Identity bindings (`getAuthContext`/`getUserId`/`isAuthenticated`)
|
|
71
|
+
* read the `authContextKey` slot populated by `authGuardInterceptor`. The
|
|
72
|
+
* remaining closures read the `authOptionsKey` slot stashed by that same
|
|
73
|
+
* interceptor; calling them outside an HTTP-or-HTTP-parented event throws
|
|
74
|
+
* `HttpError(500)` loudly — that's a configuration error, not a runtime
|
|
75
|
+
* fallback case.
|
|
76
|
+
*/
|
|
77
|
+
const useAuth = defineWook((ctx) => {
|
|
78
|
+
const getAuthContext = () => {
|
|
79
|
+
if (!ctx.has(authContextKey)) return null;
|
|
80
|
+
return ctx.get(authContextKey);
|
|
81
|
+
};
|
|
82
|
+
const getUserId = () => {
|
|
83
|
+
const auth = getAuthContext();
|
|
84
|
+
if (!auth) throw new HttpError(401, "Not authenticated");
|
|
85
|
+
return auth.userId;
|
|
86
|
+
};
|
|
87
|
+
const isAuthenticated = () => getAuthContext() !== null;
|
|
88
|
+
let cachedOptions;
|
|
89
|
+
const requireOptions = () => {
|
|
90
|
+
if (cachedOptions !== void 0) return cachedOptions;
|
|
91
|
+
if (!ctx.has(authOptionsKey)) throw new HttpError(500, "useAuth(): authGuardInterceptor(opts) must be installed on the HTTP event chain");
|
|
92
|
+
cachedOptions = ctx.get(authOptionsKey);
|
|
93
|
+
return cachedOptions;
|
|
94
|
+
};
|
|
95
|
+
const extractToken = () => {
|
|
96
|
+
const options = requireOptions();
|
|
97
|
+
let token;
|
|
98
|
+
if (options.enableBearer) {
|
|
99
|
+
const auth = useAuthorization(ctx);
|
|
100
|
+
if (auth.is("bearer")) token = auth.credentials() ?? void 0;
|
|
101
|
+
}
|
|
102
|
+
if (!token && options.enableCookie) token = useCookies(ctx).getCookie(options.cookie.name) ?? void 0;
|
|
103
|
+
return token;
|
|
104
|
+
};
|
|
105
|
+
const writeCookies = (issue) => {
|
|
106
|
+
const options = requireOptions();
|
|
107
|
+
if (!options.enableCookie) return;
|
|
108
|
+
const response = useResponse(current());
|
|
109
|
+
response.setCookie(options.cookie.name, issue.accessToken, cookieAttrsFrom(options.cookie));
|
|
110
|
+
if (issue.refreshToken) response.setCookie(options.refreshCookie.name, issue.refreshToken, cookieAttrsFrom(options.refreshCookie));
|
|
111
|
+
};
|
|
112
|
+
const clearCookies = () => {
|
|
113
|
+
const options = requireOptions();
|
|
114
|
+
if (!options.enableCookie) return;
|
|
115
|
+
const response = useResponse(current());
|
|
116
|
+
response.setCookie(options.cookie.name, "", cookieAttrsFrom(options.cookie, { maxAge: 0 }));
|
|
117
|
+
response.setCookie(options.refreshCookie.name, "", cookieAttrsFrom(options.refreshCookie, { maxAge: 0 }));
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* With `enableBearer === false` the response shape still carries `userId` +
|
|
121
|
+
* `accessExpiresAt` so clients can schedule a silent refresh, but the tokens
|
|
122
|
+
* themselves travel only via the cookies set by `writeCookies`.
|
|
123
|
+
*/
|
|
124
|
+
const buildLoginResponseFn = (userId, issue) => {
|
|
125
|
+
const options = requireOptions();
|
|
126
|
+
return {
|
|
127
|
+
userId,
|
|
128
|
+
accessExpiresAt: issue.accessExpiresAt,
|
|
129
|
+
...issue.refreshExpiresAt !== void 0 && { refreshExpiresAt: issue.refreshExpiresAt },
|
|
130
|
+
...options.enableBearer && {
|
|
131
|
+
accessToken: issue.accessToken,
|
|
132
|
+
...issue.refreshToken && { refreshToken: issue.refreshToken }
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Build the `cookies` map for `useWfFinished({ cookies })`. The outlet
|
|
138
|
+
* trigger's HTTP layer turns the entries into `Set-Cookie` headers, mirroring
|
|
139
|
+
* what `writeCookies()` does for the REST controller.
|
|
140
|
+
*/
|
|
141
|
+
const buildFinishedCookies = (issue) => {
|
|
142
|
+
const options = requireOptions();
|
|
143
|
+
if (!options.enableCookie) return void 0;
|
|
144
|
+
const cookies = { [options.cookie.name]: {
|
|
145
|
+
value: issue.accessToken,
|
|
146
|
+
options: cookieAttrsFrom(options.cookie)
|
|
147
|
+
} };
|
|
148
|
+
if (issue.refreshToken) cookies[options.refreshCookie.name] = {
|
|
149
|
+
value: issue.refreshToken,
|
|
150
|
+
options: cookieAttrsFrom(options.refreshCookie)
|
|
151
|
+
};
|
|
152
|
+
return cookies;
|
|
153
|
+
};
|
|
154
|
+
const cookieAttrs = (extra) => {
|
|
155
|
+
return cookieAttrsFrom(requireOptions().cookie, extra);
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
getAuthContext,
|
|
159
|
+
getUserId,
|
|
160
|
+
isAuthenticated,
|
|
161
|
+
get options() {
|
|
162
|
+
return requireOptions();
|
|
163
|
+
},
|
|
164
|
+
extractToken,
|
|
165
|
+
writeCookies,
|
|
166
|
+
clearCookies,
|
|
167
|
+
buildLoginResponse: buildLoginResponseFn,
|
|
168
|
+
buildFinishedCookies,
|
|
169
|
+
cookieAttrs
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
/** Internal: only `authGuardInterceptor` writes the slot. */
|
|
173
|
+
function setAuthContext(ctx, value) {
|
|
174
|
+
ctx.set(authContextKey, value);
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/auth.guard.ts
|
|
178
|
+
/**
|
|
179
|
+
* `GUARD`-priority interceptor factory that authenticates incoming requests.
|
|
180
|
+
*
|
|
181
|
+
* Returns a configured `TInterceptorDef`. Each invocation captures its own
|
|
182
|
+
* resolved options and stashes them onto the HTTP event context's
|
|
183
|
+
* `authOptionsKey` slot so `useAuth()` (and the workflows that depend on it)
|
|
184
|
+
* can read the same transport config.
|
|
185
|
+
*
|
|
186
|
+
* Token extraction precedence: `Authorization: Bearer ...` wins over cookie
|
|
187
|
+
* when both transports are enabled. On `@Public()` routes a missing or
|
|
188
|
+
* invalid token leaves AuthContext as `null` and the handler runs anyway;
|
|
189
|
+
* on protected routes it throws `HttpError(401)`.
|
|
190
|
+
*
|
|
191
|
+
* Never auto-refreshes — refresh is a separate REST endpoint.
|
|
192
|
+
*
|
|
193
|
+
* No-ops on non-HTTP event contexts (workflow steps, CLI, WS messages). The
|
|
194
|
+
* guard reads tokens from HTTP headers/cookies; nothing to read elsewhere.
|
|
195
|
+
* Authorization for workflow steps is the step's own responsibility (e.g.
|
|
196
|
+
* an admin-only invite endpoint protects its outlet HTTP route, not the
|
|
197
|
+
* step handler).
|
|
198
|
+
*/
|
|
199
|
+
function authGuardInterceptor(opts) {
|
|
200
|
+
const resolved = resolveAuthOptions(opts);
|
|
201
|
+
return defineBeforeInterceptor(async () => {
|
|
202
|
+
const ctx = current();
|
|
203
|
+
if (ctx.get(eventTypeKey) !== "http") return;
|
|
204
|
+
ctx.set(authOptionsKey, resolved);
|
|
205
|
+
const cc = useControllerContext(ctx);
|
|
206
|
+
const cMeta = cc.getControllerMeta();
|
|
207
|
+
const isPublic = cc.getMethodMeta()?.authPublic ?? cMeta?.authPublic ?? false;
|
|
208
|
+
const token = useAuth().extractToken();
|
|
209
|
+
if (!token) {
|
|
210
|
+
if (isPublic) {
|
|
211
|
+
setAuthContext(ctx, null);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
throw new HttpError(401, "Unauthorized");
|
|
215
|
+
}
|
|
216
|
+
const authContext = await (await cc.instantiate(AuthCredential)).validate(token);
|
|
217
|
+
if (!authContext) {
|
|
218
|
+
if (isPublic) {
|
|
219
|
+
setAuthContext(ctx, null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
throw new HttpError(401, "Invalid credential");
|
|
223
|
+
}
|
|
224
|
+
setAuthContext(ctx, authContext);
|
|
225
|
+
}, TInterceptorPriority.GUARD);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Decorator-factory sugar for attaching `authGuardInterceptor(opts)` to a
|
|
229
|
+
* specific controller or method instead of globally. Equivalent to
|
|
230
|
+
* `@Intercept(authGuardInterceptor(opts))`.
|
|
231
|
+
*/
|
|
232
|
+
function AuthGuarded(opts) {
|
|
233
|
+
return Intercept(authGuardInterceptor(opts));
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/auth.mate.ts
|
|
237
|
+
function getAuthMate() {
|
|
238
|
+
return getMoostMate();
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/auth.decorator.ts
|
|
242
|
+
/**
|
|
243
|
+
* Marks a route or controller as fully public — opts out of BOTH
|
|
244
|
+
* authentication (auth-moost's bearer guard) AND authorization
|
|
245
|
+
* (arbac-moost's `arbacAuthorizeInterceptor`).
|
|
246
|
+
*
|
|
247
|
+
* `authGuardInterceptor` still runs on `@Public()` handlers — it populates the
|
|
248
|
+
* AuthContext when a valid credential is presented, but does NOT throw when
|
|
249
|
+
* the token is missing or invalid (the handler runs with a `null` AuthContext).
|
|
250
|
+
* The ARBAC interceptor short-circuits entirely via its `isPublic` check.
|
|
251
|
+
*
|
|
252
|
+
* Method-level decoration overrides class-level.
|
|
253
|
+
*/
|
|
254
|
+
const Public = () => {
|
|
255
|
+
const auth = getAuthMate().decorate("authPublic", true);
|
|
256
|
+
const arbac = getArbacMate().decorate("arbacPublic", true);
|
|
257
|
+
return ((target, key, descriptor) => {
|
|
258
|
+
auth(target, key, descriptor);
|
|
259
|
+
arbac(target, key, descriptor);
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Resolves the authenticated user's id (string) for a handler parameter.
|
|
264
|
+
*
|
|
265
|
+
* Delegates to `useAuth().getUserId()`, which throws `HttpError(401)` when no
|
|
266
|
+
* `AuthContext` is present in the event. There is no `@User()` counterpart —
|
|
267
|
+
* `AuthContext` is credential context, not user profile data, and this library
|
|
268
|
+
* does not own a user profile type.
|
|
269
|
+
*/
|
|
270
|
+
const UserId = () => Resolve(() => useAuth().getUserId());
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region \0@oxc-project+runtime@0.129.0/helpers/decorateMetadata.js
|
|
273
|
+
function __decorateMetadata(k, v) {
|
|
274
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region \0@oxc-project+runtime@0.129.0/helpers/decorate.js
|
|
278
|
+
function __decorate(decorators, target, key, desc) {
|
|
279
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
280
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
281
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
282
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/wf-trigger/provider.ts
|
|
286
|
+
var _ref$4;
|
|
287
|
+
let WfTriggerProvider = class WfTriggerProvider {
|
|
288
|
+
state = new HandleStateStrategy({ store: new WfStateStoreMemory() });
|
|
289
|
+
outlets = [createAsHttpOutlet()];
|
|
290
|
+
token = {
|
|
291
|
+
read: [
|
|
292
|
+
"body",
|
|
293
|
+
"query",
|
|
294
|
+
"cookie"
|
|
295
|
+
],
|
|
296
|
+
write: "body",
|
|
297
|
+
name: "wfs"
|
|
298
|
+
};
|
|
299
|
+
constructor(wf) {
|
|
300
|
+
this.wf = wf;
|
|
301
|
+
}
|
|
302
|
+
async handle(opts = {}) {
|
|
303
|
+
const wfApp = this.wf.getWfApp();
|
|
304
|
+
const deps = {
|
|
305
|
+
start: (schemaId, context, o) => wfApp.start(schemaId, context, {
|
|
306
|
+
input: o?.input,
|
|
307
|
+
eventContext: o?.eventContext ?? current()
|
|
308
|
+
}),
|
|
309
|
+
resume: (state, o) => wfApp.resume(state, {
|
|
310
|
+
input: o?.input,
|
|
311
|
+
eventContext: o?.eventContext ?? current()
|
|
312
|
+
})
|
|
313
|
+
};
|
|
314
|
+
return handleAsOutletRequest({
|
|
315
|
+
...opts.allow && { allow: opts.allow },
|
|
316
|
+
state: this.state,
|
|
317
|
+
outlets: this.outlets,
|
|
318
|
+
token: opts.token ?? this.token
|
|
319
|
+
}, deps);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
WfTriggerProvider = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof (_ref$4 = typeof MoostWf !== "undefined" && MoostWf) === "function" ? _ref$4 : Object])], WfTriggerProvider);
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/wf-trigger/decorator.ts
|
|
325
|
+
/**
|
|
326
|
+
* Method decorator that turns a handler into a workflow trigger.
|
|
327
|
+
*
|
|
328
|
+
* The handler may have an empty body — the interceptor's after-phase invokes
|
|
329
|
+
* `WfTriggerProvider.handle()` when the handler returns `undefined`. Subclasses
|
|
330
|
+
* that need to short-circuit (e.g. emit a custom error response) just return a
|
|
331
|
+
* non-undefined value from the overridden handler and the trigger is skipped.
|
|
332
|
+
*
|
|
333
|
+
* The single `useControllerContext().instantiate(...)` call is the one
|
|
334
|
+
* documented escape hatch: interceptors are functions, not classes, so there's
|
|
335
|
+
* no ctor to inject into. Every class still uses constructor injection.
|
|
336
|
+
*/
|
|
337
|
+
const WfTrigger = (opts = {}) => Intercept(defineAfterInterceptor(async (response, reply) => {
|
|
338
|
+
if (await response !== void 0) return;
|
|
339
|
+
reply(await (await useControllerContext().instantiate(WfTriggerProvider)).handle(opts));
|
|
340
|
+
}, TInterceptorPriority.INTERCEPTOR));
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region \0@oxc-project+runtime@0.129.0/helpers/decorateParam.js
|
|
343
|
+
function __decorateParam(paramIndex, decorator) {
|
|
344
|
+
return function(target, key) {
|
|
345
|
+
decorator(target, key, paramIndex);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/auth.controller.ts
|
|
350
|
+
var _ref$3;
|
|
351
|
+
/** Workflows allowed by the bundled `/auth/trigger` endpoint. Subclasses override `triggerWf()` to extend. */
|
|
352
|
+
const DEFAULT_AUTH_WORKFLOWS = [
|
|
353
|
+
"auth.login",
|
|
354
|
+
"auth.recovery",
|
|
355
|
+
"auth.invite"
|
|
356
|
+
];
|
|
357
|
+
/** Prefer an explicit body field, fall back to the refresh cookie when enabled. */
|
|
358
|
+
function resolveRefreshToken(auth, body) {
|
|
359
|
+
if (typeof body?.refreshToken === "string") return body.refreshToken;
|
|
360
|
+
if (!auth.options.enableCookie) return void 0;
|
|
361
|
+
return useCookies(current()).getCookie(auth.options.refreshCookie.name) ?? void 0;
|
|
362
|
+
}
|
|
363
|
+
let AuthController = class AuthController {
|
|
364
|
+
constructor(auth) {
|
|
365
|
+
this.auth = auth;
|
|
366
|
+
}
|
|
367
|
+
async logout(body) {
|
|
368
|
+
const auth = useAuth();
|
|
369
|
+
if (!auth.getAuthContext()) throw new HttpError$1(401, "Not authenticated");
|
|
370
|
+
const accessToken = auth.extractToken();
|
|
371
|
+
const refreshToken = resolveRefreshToken(auth, body);
|
|
372
|
+
if (accessToken) try {
|
|
373
|
+
await this.auth.revoke(accessToken);
|
|
374
|
+
} catch {}
|
|
375
|
+
if (refreshToken) try {
|
|
376
|
+
await this.auth.revoke(refreshToken);
|
|
377
|
+
} catch {}
|
|
378
|
+
auth.clearCookies();
|
|
379
|
+
return { ok: true };
|
|
380
|
+
}
|
|
381
|
+
async refresh(body) {
|
|
382
|
+
const auth = useAuth();
|
|
383
|
+
const refreshToken = resolveRefreshToken(auth, body);
|
|
384
|
+
if (!refreshToken) throw new HttpError$1(401, "Missing refresh token");
|
|
385
|
+
let issue;
|
|
386
|
+
try {
|
|
387
|
+
issue = await this.auth.refresh(refreshToken);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
if (err instanceof AuthError) throw new HttpError$1(401, "Invalid refresh token");
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
auth.writeCookies(issue);
|
|
393
|
+
const validated = await this.auth.validate(issue.accessToken);
|
|
394
|
+
return auth.buildLoginResponse(validated?.userId ?? "", issue);
|
|
395
|
+
}
|
|
396
|
+
status() {
|
|
397
|
+
const auth = useAuth().getAuthContext();
|
|
398
|
+
if (!auth) throw new HttpError$1(401, "Not authenticated");
|
|
399
|
+
return auth;
|
|
400
|
+
}
|
|
401
|
+
triggerWf() {}
|
|
402
|
+
};
|
|
403
|
+
__decorate([
|
|
404
|
+
Post("logout"),
|
|
405
|
+
Public(),
|
|
406
|
+
__decorateParam(0, Body()),
|
|
407
|
+
__decorateMetadata("design:type", Function),
|
|
408
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
409
|
+
__decorateMetadata("design:returntype", Promise)
|
|
410
|
+
], AuthController.prototype, "logout", null);
|
|
411
|
+
__decorate([
|
|
412
|
+
Post("refresh"),
|
|
413
|
+
Public(),
|
|
414
|
+
__decorateParam(0, Body()),
|
|
415
|
+
__decorateMetadata("design:type", Function),
|
|
416
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
417
|
+
__decorateMetadata("design:returntype", Promise)
|
|
418
|
+
], AuthController.prototype, "refresh", null);
|
|
419
|
+
__decorate([
|
|
420
|
+
Get("status"),
|
|
421
|
+
Public(),
|
|
422
|
+
__decorateMetadata("design:type", Function),
|
|
423
|
+
__decorateMetadata("design:paramtypes", []),
|
|
424
|
+
__decorateMetadata("design:returntype", Object)
|
|
425
|
+
], AuthController.prototype, "status", null);
|
|
426
|
+
__decorate([
|
|
427
|
+
Post("trigger"),
|
|
428
|
+
Public(),
|
|
429
|
+
WfTrigger({ allow: [...DEFAULT_AUTH_WORKFLOWS] }),
|
|
430
|
+
__decorateMetadata("design:type", Function),
|
|
431
|
+
__decorateMetadata("design:paramtypes", []),
|
|
432
|
+
__decorateMetadata("design:returntype", void 0)
|
|
433
|
+
], AuthController.prototype, "triggerWf", null);
|
|
434
|
+
AuthController = __decorate([
|
|
435
|
+
Controller("auth"),
|
|
436
|
+
ArbacResource("auth"),
|
|
437
|
+
__decorateMetadata("design:paramtypes", [typeof (_ref$3 = typeof AuthCredential !== "undefined" && AuthCredential) === "function" ? _ref$3 : Object])
|
|
438
|
+
], AuthController);
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/atscript/models/forms.as.js
|
|
441
|
+
var LoginCredentialsForm = class {
|
|
442
|
+
static __is_atscript_annotated_type = true;
|
|
443
|
+
static type = {};
|
|
444
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
445
|
+
static id = "LoginCredentialsForm";
|
|
446
|
+
static toJsonSchema() {
|
|
447
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
var MfaCodeForm = class {
|
|
451
|
+
static __is_atscript_annotated_type = true;
|
|
452
|
+
static type = {};
|
|
453
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
454
|
+
static id = "MfaCodeForm";
|
|
455
|
+
static toJsonSchema() {
|
|
456
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
var BackupCodeForm = class {
|
|
460
|
+
static __is_atscript_annotated_type = true;
|
|
461
|
+
static type = {};
|
|
462
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
463
|
+
static id = "BackupCodeForm";
|
|
464
|
+
static toJsonSchema() {
|
|
465
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
var EmailIdentifierForm = class {
|
|
469
|
+
static __is_atscript_annotated_type = true;
|
|
470
|
+
static type = {};
|
|
471
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
472
|
+
static id = "EmailIdentifierForm";
|
|
473
|
+
static toJsonSchema() {
|
|
474
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
var SetPasswordForm = class {
|
|
478
|
+
static __is_atscript_annotated_type = true;
|
|
479
|
+
static type = {};
|
|
480
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
481
|
+
static id = "SetPasswordForm";
|
|
482
|
+
static toJsonSchema() {
|
|
483
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
var InviteForm = class {
|
|
487
|
+
static __is_atscript_annotated_type = true;
|
|
488
|
+
static type = {};
|
|
489
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
490
|
+
static id = "InviteForm";
|
|
491
|
+
static toJsonSchema() {
|
|
492
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
var InviteEmailForm = class {
|
|
496
|
+
static __is_atscript_annotated_type = true;
|
|
497
|
+
static type = {};
|
|
498
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
499
|
+
static id = "InviteEmailForm";
|
|
500
|
+
static toJsonSchema() {
|
|
501
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var InviteSendModeForm = class {
|
|
505
|
+
static __is_atscript_annotated_type = true;
|
|
506
|
+
static type = {};
|
|
507
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
508
|
+
static id = "InviteSendModeForm";
|
|
509
|
+
static toJsonSchema() {
|
|
510
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
var Select2faForm = class {
|
|
514
|
+
static __is_atscript_annotated_type = true;
|
|
515
|
+
static type = {};
|
|
516
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
517
|
+
static id = "Select2faForm";
|
|
518
|
+
static toJsonSchema() {
|
|
519
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
var PincodeForm = class {
|
|
523
|
+
static __is_atscript_annotated_type = true;
|
|
524
|
+
static type = {};
|
|
525
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
526
|
+
static id = "PincodeForm";
|
|
527
|
+
static toJsonSchema() {
|
|
528
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
var AskEmailForm = class {
|
|
532
|
+
static __is_atscript_annotated_type = true;
|
|
533
|
+
static type = {};
|
|
534
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
535
|
+
static id = "AskEmailForm";
|
|
536
|
+
static toJsonSchema() {
|
|
537
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
var AskPhoneForm = class {
|
|
541
|
+
static __is_atscript_annotated_type = true;
|
|
542
|
+
static type = {};
|
|
543
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
544
|
+
static id = "AskPhoneForm";
|
|
545
|
+
static toJsonSchema() {
|
|
546
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
var TermsAcceptForm = class {
|
|
550
|
+
static __is_atscript_annotated_type = true;
|
|
551
|
+
static type = {};
|
|
552
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
553
|
+
static id = "TermsAcceptForm";
|
|
554
|
+
static toJsonSchema() {
|
|
555
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
var ProfileCompleteForm = class {
|
|
559
|
+
static __is_atscript_annotated_type = true;
|
|
560
|
+
static type = {};
|
|
561
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
562
|
+
static id = "ProfileCompleteForm";
|
|
563
|
+
static toJsonSchema() {
|
|
564
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
var ConsentMarketingForm = class {
|
|
568
|
+
static __is_atscript_annotated_type = true;
|
|
569
|
+
static type = {};
|
|
570
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
571
|
+
static id = "ConsentMarketingForm";
|
|
572
|
+
static toJsonSchema() {
|
|
573
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
var TenantSelectForm = class {
|
|
577
|
+
static __is_atscript_annotated_type = true;
|
|
578
|
+
static type = {};
|
|
579
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
580
|
+
static id = "TenantSelectForm";
|
|
581
|
+
static toJsonSchema() {
|
|
582
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
var PersonaSelectForm = class {
|
|
586
|
+
static __is_atscript_annotated_type = true;
|
|
587
|
+
static type = {};
|
|
588
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
589
|
+
static id = "PersonaSelectForm";
|
|
590
|
+
static toJsonSchema() {
|
|
591
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var ConcurrencyLimitForm = class {
|
|
595
|
+
static __is_atscript_annotated_type = true;
|
|
596
|
+
static type = {};
|
|
597
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
598
|
+
static id = "ConcurrencyLimitForm";
|
|
599
|
+
static toJsonSchema() {
|
|
600
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
var MagicLinkRequestForm = class {
|
|
604
|
+
static __is_atscript_annotated_type = true;
|
|
605
|
+
static type = {};
|
|
606
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
607
|
+
static id = "MagicLinkRequestForm";
|
|
608
|
+
static toJsonSchema() {
|
|
609
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var RecoveryModeSelectForm = class {
|
|
613
|
+
static __is_atscript_annotated_type = true;
|
|
614
|
+
static type = {};
|
|
615
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
616
|
+
static id = "RecoveryModeSelectForm";
|
|
617
|
+
static toJsonSchema() {
|
|
618
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
var RecoveryFactorForm = class {
|
|
622
|
+
static __is_atscript_annotated_type = true;
|
|
623
|
+
static type = {};
|
|
624
|
+
static metadata = /* @__PURE__ */ new Map();
|
|
625
|
+
static id = "RecoveryFactorForm";
|
|
626
|
+
static toJsonSchema() {
|
|
627
|
+
throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
defineAnnotatedType("object", LoginCredentialsForm).prop("username", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Username").annotate("ui.form.autocomplete", "username").annotate("meta.required", {}).annotate("expect.minLength", { length: 1 }).$type).prop("password", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "password").annotate("meta.label", "Password").annotate("ui.form.autocomplete", "current-password").annotate("meta.sensitive", true).annotate("meta.required", {}).annotate("expect.minLength", { length: 1 }).$type);
|
|
631
|
+
defineAnnotatedType("object", MfaCodeForm).prop("code", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Verification code").annotate("ui.form.autocomplete", "one-time-code").annotate("meta.required", {}).annotate("expect.minLength", { length: 4 }).annotate("expect.maxLength", { length: 12 }).annotate("expect.pattern", { pattern: "^[0-9]+$" }, true).$type);
|
|
632
|
+
defineAnnotatedType("object", BackupCodeForm).prop("code", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Backup code").annotate("ui.form.autocomplete", "one-time-code").annotate("meta.required", {}).annotate("expect.minLength", { length: 4 }).annotate("expect.maxLength", { length: 32 }).annotate("expect.pattern", { pattern: "^[A-Z2-9-]+$" }, true).$type);
|
|
633
|
+
defineAnnotatedType("object", EmailIdentifierForm).prop("email", defineAnnotatedType().designType("string").tags("email", "string").annotate("ui.form.type", "text").annotate("meta.label", "Email").annotate("ui.form.autocomplete", "email").annotate("meta.required", {}).annotate("expect.pattern", {
|
|
634
|
+
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
|
|
635
|
+
flags: "",
|
|
636
|
+
message: "Invalid email format."
|
|
637
|
+
}, true).$type).annotate("wf.context.pass", "defaults", true);
|
|
638
|
+
defineAnnotatedType("object", SetPasswordForm).prop("newPassword", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "password").annotate("meta.label", "New password").annotate("ui.form.autocomplete", "new-password").annotate("meta.sensitive", true).annotate("meta.required", {}).annotate("expect.minLength", { length: 8 }).$type).prop("confirmPassword", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "password").annotate("meta.label", "Confirm password").annotate("ui.form.autocomplete", "new-password").annotate("meta.sensitive", true).annotate("meta.required", {}).annotate("expect.minLength", { length: 8 }).$type);
|
|
639
|
+
defineAnnotatedType("object", InviteForm).prop("email", defineAnnotatedType().designType("string").tags("email", "string").annotate("ui.form.type", "text").annotate("meta.label", "Email").annotate("ui.form.autocomplete", "email").annotate("meta.required", {}).annotate("expect.pattern", {
|
|
640
|
+
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
|
|
641
|
+
flags: "",
|
|
642
|
+
message: "Invalid email format."
|
|
643
|
+
}, true).$type).prop("firstName", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "First name").optional().$type).prop("lastName", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Last name").optional().$type).prop("roles", defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).annotate("ui.form.type", "select").annotate("ui.form.fn.options", "(_, _data, context) => Array.isArray(context.availableRoles) ? context.availableRoles.map(r => ({ value: r, label: r })) : []").annotate("meta.label", "Roles").optional().$type).annotate("wf.context.pass", "availableRoles", true);
|
|
644
|
+
defineAnnotatedType("object", InviteEmailForm).prop("email", defineAnnotatedType().designType("string").tags("email", "string").annotate("ui.form.type", "text").annotate("meta.label", "Email").annotate("ui.form.autocomplete", "email").annotate("meta.required", {}).annotate("expect.pattern", {
|
|
645
|
+
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
|
|
646
|
+
flags: "",
|
|
647
|
+
message: "Invalid email format."
|
|
648
|
+
}, true).$type);
|
|
649
|
+
defineAnnotatedType("object", InviteSendModeForm).prop("mode", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Delivery mode").annotate("meta.required", {}).annotate("expect.pattern", { pattern: "^(email|shareableLink)$" }, true).$type);
|
|
650
|
+
defineAnnotatedType("object", Select2faForm).prop("methodName", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "MFA method").annotate("meta.required", {}).$type).prop("saveAsDefault", defineAnnotatedType().designType("boolean").tags("boolean").annotate("ui.form.type", "checkbox").annotate("meta.label", "Save as default").optional().$type);
|
|
651
|
+
defineAnnotatedType("object", PincodeForm).prop("code", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Verification code").annotate("ui.form.autocomplete", "one-time-code").annotate("meta.required", {}).annotate("expect.minLength", { length: 4 }).annotate("expect.maxLength", { length: 12 }).annotate("expect.pattern", { pattern: "^[0-9]+$" }, true).$type).prop("rememberDevice", defineAnnotatedType().designType("boolean").tags("boolean").annotate("ui.form.type", "checkbox").annotate("meta.label", "Remember this device").optional().$type);
|
|
652
|
+
defineAnnotatedType("object", AskEmailForm).prop("email", defineAnnotatedType().designType("string").tags("email", "string").annotate("ui.form.type", "text").annotate("meta.label", "Email").annotate("ui.form.autocomplete", "email").annotate("meta.required", {}).annotate("expect.pattern", {
|
|
653
|
+
pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$",
|
|
654
|
+
flags: "",
|
|
655
|
+
message: "Invalid email format."
|
|
656
|
+
}, true).$type);
|
|
657
|
+
defineAnnotatedType("object", AskPhoneForm).prop("phone", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Phone (E.164)").annotate("ui.form.autocomplete", "tel").annotate("meta.required", {}).$type);
|
|
658
|
+
defineAnnotatedType("object", TermsAcceptForm).prop("acceptedVersion", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Accepted version").annotate("meta.required", {}).$type).prop("accepted", defineAnnotatedType().designType("boolean").tags("boolean").annotate("ui.form.type", "checkbox").annotate("meta.label", "I accept the Terms & Conditions").annotate("meta.required", {}).$type);
|
|
659
|
+
defineAnnotatedType("object", ProfileCompleteForm).prop("firstName", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "First name").optional().$type).prop("lastName", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Last name").optional().$type);
|
|
660
|
+
defineAnnotatedType("object", ConsentMarketingForm).prop("optIn", defineAnnotatedType().designType("boolean").tags("boolean").annotate("ui.form.type", "checkbox").annotate("meta.label", "I would like to receive marketing emails").optional().$type);
|
|
661
|
+
defineAnnotatedType("object", TenantSelectForm).prop("tenantId", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Tenant").annotate("meta.required", {}).$type);
|
|
662
|
+
defineAnnotatedType("object", PersonaSelectForm).prop("personaId", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Persona").annotate("meta.required", {}).$type);
|
|
663
|
+
defineAnnotatedType("object", ConcurrencyLimitForm).prop("action", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Action").annotate("meta.required", {}).annotate("expect.pattern", { pattern: "^(logoutOthers|cancel)$" }, true).$type);
|
|
664
|
+
defineAnnotatedType("object", MagicLinkRequestForm).prop("identifier", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Email or username").annotate("ui.form.autocomplete", "username").annotate("meta.required", {}).$type);
|
|
665
|
+
defineAnnotatedType("object", RecoveryModeSelectForm).prop("mode", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Recovery method").annotate("meta.required", {}).annotate("expect.pattern", { pattern: "^(magicLink|otp)$" }, true).$type);
|
|
666
|
+
defineAnnotatedType("object", RecoveryFactorForm).prop("factor", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Factor").annotate("meta.required", {}).annotate("expect.pattern", { pattern: "^(phone|totp)$" }, true).$type).prop("value", defineAnnotatedType().designType("string").tags("string").annotate("ui.form.type", "text").annotate("meta.label", "Value").annotate("meta.required", {}).annotate("expect.minLength", { length: 4 }).annotate("expect.maxLength", { length: 12 }).$type);
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/workflows/login.workflow.options.ts
|
|
669
|
+
const DEFAULT_MFA_CODE_TTL_MS = 300 * 1e3;
|
|
670
|
+
/**
|
|
671
|
+
* Deep-merge defaults with the user-supplied nested pojo. Each group has its
|
|
672
|
+
* own `{ ...defaults, ...input }` line — small enough that pulling in lodash
|
|
673
|
+
* would be silly.
|
|
674
|
+
*/
|
|
675
|
+
function mergeLoginOpts(opts = {}) {
|
|
676
|
+
return {
|
|
677
|
+
alternateCredentials: {
|
|
678
|
+
forgotPassword: true,
|
|
679
|
+
signup: false,
|
|
680
|
+
magicLink: false,
|
|
681
|
+
magicLinkSkipsMfa: false,
|
|
682
|
+
magicLinkTtlMs: 30 * 6e4,
|
|
683
|
+
ssoProviders: [],
|
|
684
|
+
recoveryUrl: "/recover",
|
|
685
|
+
signupUrl: "/signup",
|
|
686
|
+
embedRecovery: false,
|
|
687
|
+
...opts.alternateCredentials
|
|
688
|
+
},
|
|
689
|
+
guards: {
|
|
690
|
+
emailVerifiedRequired: false,
|
|
691
|
+
passwordExpiry: true,
|
|
692
|
+
passwordInitial: true,
|
|
693
|
+
...opts.guards
|
|
694
|
+
},
|
|
695
|
+
enrollment: {
|
|
696
|
+
ensureEmail: false,
|
|
697
|
+
ensurePhone: false,
|
|
698
|
+
...opts.enrollment
|
|
699
|
+
},
|
|
700
|
+
mfa: {
|
|
701
|
+
enabled: true,
|
|
702
|
+
transports: [
|
|
703
|
+
"sms",
|
|
704
|
+
"email",
|
|
705
|
+
"totp"
|
|
706
|
+
],
|
|
707
|
+
backupCodes: true,
|
|
708
|
+
enrollRequired: false,
|
|
709
|
+
pincodeTtlMs: DEFAULT_MFA_CODE_TTL_MS,
|
|
710
|
+
pincodeResendTimeoutMs: 6e4,
|
|
711
|
+
pincodeLength: 6,
|
|
712
|
+
...opts.mfa
|
|
713
|
+
},
|
|
714
|
+
deviceTrust: {
|
|
715
|
+
enabled: false,
|
|
716
|
+
optIn: true,
|
|
717
|
+
cookieName: "aooth_trusted_device",
|
|
718
|
+
ttlMs: 1440 * 6e4,
|
|
719
|
+
skipsMfa: true,
|
|
720
|
+
bindsTo: "cookie",
|
|
721
|
+
...opts.deviceTrust
|
|
722
|
+
},
|
|
723
|
+
acceptance: {
|
|
724
|
+
profileCompleteRequired: false,
|
|
725
|
+
consentMarketing: false,
|
|
726
|
+
...opts.acceptance
|
|
727
|
+
},
|
|
728
|
+
multiContext: {
|
|
729
|
+
tenantSelect: false,
|
|
730
|
+
personaSelect: false,
|
|
731
|
+
...opts.multiContext
|
|
732
|
+
},
|
|
733
|
+
sessionPolicy: { ...opts.sessionPolicy },
|
|
734
|
+
finalize: {
|
|
735
|
+
auditLogin: true,
|
|
736
|
+
notifyNewDevice: false,
|
|
737
|
+
redirect: false,
|
|
738
|
+
...opts.finalize
|
|
739
|
+
},
|
|
740
|
+
forms: {
|
|
741
|
+
askEmail: AskEmailForm,
|
|
742
|
+
askPhone: AskPhoneForm,
|
|
743
|
+
backupCode: BackupCodeForm,
|
|
744
|
+
concurrencyLimit: ConcurrencyLimitForm,
|
|
745
|
+
consentMarketing: ConsentMarketingForm,
|
|
746
|
+
loginCredentials: LoginCredentialsForm,
|
|
747
|
+
mfaCode: MfaCodeForm,
|
|
748
|
+
personaSelect: PersonaSelectForm,
|
|
749
|
+
pincode: PincodeForm,
|
|
750
|
+
profileComplete: ProfileCompleteForm,
|
|
751
|
+
select2fa: Select2faForm,
|
|
752
|
+
setPassword: SetPasswordForm,
|
|
753
|
+
tenantSelect: TenantSelectForm,
|
|
754
|
+
termsAccept: TermsAcceptForm,
|
|
755
|
+
...opts.forms
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/workflows/wf-helpers.ts
|
|
761
|
+
/**
|
|
762
|
+
* Internal helpers for the three auth workflows.
|
|
763
|
+
*
|
|
764
|
+
* Co-locates two patterns the @atscript/moost-wf reference docs tell consumers
|
|
765
|
+
* to copy into their own projects (`httpInputRequired` + `validateFormInput`)
|
|
766
|
+
* with auth-specific glue for the password-set error translation.
|
|
767
|
+
*
|
|
768
|
+
* Cookie + finished-response building lives on `useAuth()` (see
|
|
769
|
+
* `auth.composables.ts`) so it shares the same resolved options the guard
|
|
770
|
+
* stashed onto the HTTP event chain.
|
|
771
|
+
*/
|
|
772
|
+
/**
|
|
773
|
+
* Special error keys:
|
|
774
|
+
* - `__form` — top-level form-wide error (e.g. "Invalid credentials").
|
|
775
|
+
* - everything else — keyed by field path.
|
|
776
|
+
*/
|
|
777
|
+
function httpInputRequired(type, wfContext, errors) {
|
|
778
|
+
const context = { ...extractPassContext(type, wfContext) };
|
|
779
|
+
if (errors) context.errors = errors;
|
|
780
|
+
return outletHttp(serializeFormSchema(type), context);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Returns `null` when valid, or a flat `field → message` map. Top-level errors
|
|
784
|
+
* land on `__form`. `partial: 'deep'` validates only the fields the caller
|
|
785
|
+
* supplied (action-with-data submits).
|
|
786
|
+
*/
|
|
787
|
+
function validateFormInput(type, input, opts = {}) {
|
|
788
|
+
const validator = type.validator({
|
|
789
|
+
unknownProps: "strip",
|
|
790
|
+
...opts.partial === "deep" && { partial: "deep" }
|
|
791
|
+
});
|
|
792
|
+
try {
|
|
793
|
+
validator.validate(input);
|
|
794
|
+
return null;
|
|
795
|
+
} catch (err) {
|
|
796
|
+
if (err !== null && typeof err === "object" && "errors" in err && Array.isArray(err.errors)) {
|
|
797
|
+
const out = {};
|
|
798
|
+
for (const e of err.errors) out[e.path || "__form"] = e.message;
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
throw err;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Translate password-mutation errors from `UserService.setPassword` /
|
|
806
|
+
* `createUser` into the matching HTTP status. Mirrors `translatePasswordError`
|
|
807
|
+
* in `auth.controller.ts`; kept here so workflow steps don't import from the
|
|
808
|
+
* controller module. All `UserAuthError` shapes from a set-password call are
|
|
809
|
+
* client-side (policy / history / mismatch), so they collapse to 400.
|
|
810
|
+
*/
|
|
811
|
+
function translatePasswordSetError(err) {
|
|
812
|
+
if (err instanceof UserAuthError) throw new HttpError$1(400, err.message);
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Asserts `ctx.username` is populated. Workflow steps reach for `ctx.username`
|
|
817
|
+
* after `credentials`/`init` has set it; losing it indicates a workflow-state
|
|
818
|
+
* bug, not a client error. Throws `HttpError(500)` on miss; otherwise narrows
|
|
819
|
+
* the field to `string` for the caller via `asserts`.
|
|
820
|
+
*/
|
|
821
|
+
function requireUsername(ctx) {
|
|
822
|
+
if (!ctx.username) throw new HttpError$1(500, "Workflow state corrupted: missing username");
|
|
823
|
+
}
|
|
824
|
+
/** Mint a numeric pincode of the requested length using Math.random — fine for OTPs. */
|
|
825
|
+
function generatePincode$1(length) {
|
|
826
|
+
let out = "";
|
|
827
|
+
for (let i = 0; i < length; i++) out += Math.floor(Math.random() * 10).toString();
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Mint a pincode + its expiry onto `ctx.pin` / `ctx.pinExpire`. Returns the
|
|
832
|
+
* code so the caller can hand it to the delivery transport.
|
|
833
|
+
*/
|
|
834
|
+
function mintPin$1(ctx, length, ttlMs) {
|
|
835
|
+
const code = generatePincode$1(length);
|
|
836
|
+
ctx.pin = code;
|
|
837
|
+
ctx.pinExpire = Date.now() + ttlMs;
|
|
838
|
+
return code;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Verify a submitted pincode against `ctx.pin`. Returns a `{ code: '…' }`
|
|
842
|
+
* error map on expired/invalid, or `null` on success. Callers wrap with
|
|
843
|
+
* `httpInputRequired(PincodeForm, ctx, …)` to render.
|
|
844
|
+
*/
|
|
845
|
+
function verifyPin$1(ctx, submitted) {
|
|
846
|
+
if (!ctx.pin || !ctx.pinExpire || Date.now() > ctx.pinExpire) return { code: "Code expired" };
|
|
847
|
+
if (submitted !== ctx.pin) return { code: "Invalid code" };
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Resolve the client IP from the active HTTP request, swallowing the case
|
|
852
|
+
* where there is no HTTP context (unit tests that hand-roll the wf runtime).
|
|
853
|
+
*/
|
|
854
|
+
function resolveClientIp() {
|
|
855
|
+
try {
|
|
856
|
+
return useRequest(current()).getIp?.() || void 0;
|
|
857
|
+
} catch {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region src/workflows/login.workflow.ts
|
|
863
|
+
/**
|
|
864
|
+
* LoginWorkflow — `wfid = 'auth.login'`.
|
|
865
|
+
*
|
|
866
|
+
* Full step catalog per `WF_LOGIN.md`. Every advanced step is gated by the
|
|
867
|
+
* matching `LoginWorkflowOpts` flag so the default-opts flow matches today's
|
|
868
|
+
* "credentials → optional totp MFA → issue tokens" behaviour with no surprise
|
|
869
|
+
* prompts.
|
|
870
|
+
*
|
|
871
|
+
* **Step routing model.** Phase 1's `credentials` step is the main happy
|
|
872
|
+
* path. Alternate credential paths (`magicLink*`, `passkey`, `ssoCallback`)
|
|
873
|
+
* are stubs that throw `HttpError(501)` — the brief allows ships-as-stub for
|
|
874
|
+
* these. Channel-enrollment loops (`ensureEmail` / `ensurePhone`) are a
|
|
875
|
+
* single-pass ask-and-verify per the brief, since moost-wf does not provide
|
|
876
|
+
* a clean "loop while" primitive at the @WorkflowSchema level.
|
|
877
|
+
*
|
|
878
|
+
* **Alt-action delivery.** Form payloads carry `action?: string` alongside
|
|
879
|
+
* the regular form fields. Each form-bearing step inspects `input.action`
|
|
880
|
+
* for routing rather than wiring `@AltAction()` (the existing trigger
|
|
881
|
+
* controller does not call `useWfAction().setAction()`).
|
|
882
|
+
*
|
|
883
|
+
* **Consumer subclass pattern (Phase 2 reshape).** Consumers subclass
|
|
884
|
+
* `LoginWorkflow` to override `protected` hook methods. The subclass MUST
|
|
885
|
+
* re-apply `@Inherit() @Injectable('FOR_EVENT') @Controller()` and re-declare
|
|
886
|
+
* the constructor signature (TS emits fresh design-paramtypes per class).
|
|
887
|
+
*
|
|
888
|
+
* **Side-effect deps as protected methods (this reshape).** The four
|
|
889
|
+
* optional sender/store/emitter DI providers (EmailSender, SmsSender,
|
|
890
|
+
* DeviceTrustStore, AuditEmitter) have been DROPPED from the constructor.
|
|
891
|
+
* Side-effecting hooks live as `protected` methods consumers override:
|
|
892
|
+
*
|
|
893
|
+
* - `deliver(payload)` — unified email + SMS dispatch. Default throws
|
|
894
|
+
* `Error("deliver() not configured …")`; override to wire your senders.
|
|
895
|
+
* - `audit(event)` — fire audit events. Default: no-op.
|
|
896
|
+
* - `loadTrustedDevice(userId, token, ip?)` — return `true` to grant
|
|
897
|
+
* trust. Default: delegates to `UserService.verifyTrustedDevice`.
|
|
898
|
+
* - `storeTrustedDevice(userId, record)` — persist a freshly issued trust
|
|
899
|
+
* record. Default: delegates to `UserService.addTrustedDevice`.
|
|
900
|
+
* - `revokeTrustedDevice(userId, token)` — remove a trust record.
|
|
901
|
+
* Default: delegates to `UserService.revokeTrustedDevice`.
|
|
902
|
+
*
|
|
903
|
+
* Validation of senders is now INHERENT — the first `deliver()` invocation
|
|
904
|
+
* without an override throws. Boot-time "X required when Y enabled" checks
|
|
905
|
+
* are gone for sender/store/emitter; only data-validity checks remain.
|
|
906
|
+
*/
|
|
907
|
+
var _ref$2, _ref2$2;
|
|
908
|
+
function mfaKindOf(methodName) {
|
|
909
|
+
if (methodName === "sms" || methodName === "email" || methodName === "totp") return methodName;
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Sentinel returned by alt-action handlers that have already short-circuited
|
|
914
|
+
* the step (via `useWfFinished().set(...)` or by mutating ctx to drive the
|
|
915
|
+
* next iteration). The step body re-returns `undefined` after seeing this so
|
|
916
|
+
* the schema advances without running form validation against the alt-action
|
|
917
|
+
* payload (which lacks the form's required fields).
|
|
918
|
+
*/
|
|
919
|
+
const ALT_HANDLED$2 = Symbol("ALT_HANDLED");
|
|
920
|
+
/** Mint a numeric pincode of the requested length using Math.random — fine for OTPs. */
|
|
921
|
+
function generatePincode(length) {
|
|
922
|
+
let out = "";
|
|
923
|
+
for (let i = 0; i < length; i++) out += Math.floor(Math.random() * 10).toString();
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Mint and stash a fresh pincode + its expiry on `ctx`. Returns the code so
|
|
928
|
+
* the caller can hand it to the delivery transport.
|
|
929
|
+
*/
|
|
930
|
+
function mintPin(ctx, length, ttlMs) {
|
|
931
|
+
const code = generatePincode(length);
|
|
932
|
+
ctx.pin = code;
|
|
933
|
+
ctx.pinExpire = Date.now() + ttlMs;
|
|
934
|
+
return code;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Verify a submitted pincode against the one stashed on `ctx`. Returns a
|
|
938
|
+
* `{ code: '…' }` error map when expired/invalid, or `null` on success.
|
|
939
|
+
* Callers wrap with `httpInputRequired(PincodeForm, ctx, …)` to render.
|
|
940
|
+
*/
|
|
941
|
+
function verifyPin(ctx, submitted) {
|
|
942
|
+
if (!ctx.pin || !ctx.pinExpire || Date.now() > ctx.pinExpire) return { code: "Code expired" };
|
|
943
|
+
if (submitted !== ctx.pin) return { code: "Invalid code" };
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Construction-time invariants for DATA validity only. Sender/store/emitter
|
|
948
|
+
* absence is no longer checked — those default to fail-loud (`deliver()`) or
|
|
949
|
+
* no-op (`audit()`, `loadTrustedDevice()`) protected methods that consumers
|
|
950
|
+
* override.
|
|
951
|
+
*/
|
|
952
|
+
function validateOpts$1(opts) {
|
|
953
|
+
if (opts.mfa.enabled && opts.mfa.transports.length === 0) throw new Error("LoginWorkflow: mfa.transports cannot be empty when mfa.enabled is true");
|
|
954
|
+
}
|
|
955
|
+
let LoginWorkflow = class LoginWorkflow {
|
|
956
|
+
opts;
|
|
957
|
+
users;
|
|
958
|
+
auth;
|
|
959
|
+
constructor(opts, users, auth) {
|
|
960
|
+
this.opts = mergeLoginOpts(opts);
|
|
961
|
+
this.users = users;
|
|
962
|
+
this.auth = auth;
|
|
963
|
+
validateOpts$1(this.opts);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Dispatch an email or SMS event. Default throws — consumers MUST override
|
|
967
|
+
* if any feature that emits is enabled (MFA pincode, ensureEmail/Phone OTP,
|
|
968
|
+
* notifyNewDevice). The throw surfaces at the HTTP layer as 500 on the
|
|
969
|
+
* first event that triggers a send, which is the fail-loud signal.
|
|
970
|
+
*/
|
|
971
|
+
async deliver(_payload) {
|
|
972
|
+
throw new Error("LoginWorkflow.deliver() not configured — override to wire your email/sms sender");
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Emit an audit event. Default: no-op. Consumers override to fan out to
|
|
976
|
+
* their audit sink (DB table, log file, Kafka topic, …).
|
|
977
|
+
*/
|
|
978
|
+
async audit(_event) {}
|
|
979
|
+
/**
|
|
980
|
+
* Verify whether a presented trust-cookie token belongs to `userId` and is
|
|
981
|
+
* still valid. Default: delegates to `UserService.verifyTrustedDevice`
|
|
982
|
+
* (HMAC + persisted record + expiry + IP-binding). Override to use a
|
|
983
|
+
* different trust backend.
|
|
984
|
+
*/
|
|
985
|
+
async loadTrustedDevice(userId, token, ip) {
|
|
986
|
+
return this.users.verifyTrustedDevice(userId, token, ip);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Persist a freshly-issued trust record. Default: delegates to
|
|
990
|
+
* `UserService.addTrustedDevice` — the record is appended to the user's
|
|
991
|
+
* `trustedDevices` array on the user store. `userId` is the username the
|
|
992
|
+
* record belongs to (passed alongside since `TrustedDeviceRecord` itself
|
|
993
|
+
* carries no user identifier).
|
|
994
|
+
*/
|
|
995
|
+
async storeTrustedDevice(userId, record) {
|
|
996
|
+
await this.users.addTrustedDevice(userId, record);
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Revoke a trust record. Default: delegates to
|
|
1000
|
+
* `UserService.revokeTrustedDevice`. Currently unused by the workflow's own
|
|
1001
|
+
* happy path but exposed so consumers can call it from their own "sign out
|
|
1002
|
+
* everywhere" flows for symmetry with `storeTrustedDevice`.
|
|
1003
|
+
*/
|
|
1004
|
+
async revokeTrustedDevice(userId, token) {
|
|
1005
|
+
await this.users.revokeTrustedDevice(userId, token);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Mint a new device-trust record + cookie value. Default: delegates to
|
|
1009
|
+
* `UserService.issueTrustedDevice` — produces an HMAC-signed token bound to
|
|
1010
|
+
* `userId` (+ `ip` when `bindsTo === 'cookie+ip'`). Consumers running
|
|
1011
|
+
* multiple instances typically override `loadTrustedDevice`/
|
|
1012
|
+
* `storeTrustedDevice` against Redis but keep this default.
|
|
1013
|
+
*/
|
|
1014
|
+
async issueTrustedDevice(userId, ip, ttlMs) {
|
|
1015
|
+
return this.users.issueTrustedDevice(userId, {
|
|
1016
|
+
ttlMs,
|
|
1017
|
+
...ip !== void 0 && { ip }
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
flow() {}
|
|
1021
|
+
init(ctx) {
|
|
1022
|
+
ctx.opts = this.snapshotOpts(this.opts);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
|
|
1026
|
+
* conditions to read. Default: drop the `forms` group (atscript form classes
|
|
1027
|
+
* are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
|
|
1028
|
+
* Step bodies still consult the form classes via `this.opts.forms.*`.
|
|
1029
|
+
*
|
|
1030
|
+
* Consumers who put non-JSON values on `opts` (e.g. by extending the type)
|
|
1031
|
+
* can override this to strip them.
|
|
1032
|
+
*/
|
|
1033
|
+
snapshotOpts(opts) {
|
|
1034
|
+
const { forms: _forms, ...rest } = opts;
|
|
1035
|
+
return rest;
|
|
1036
|
+
}
|
|
1037
|
+
async credentials(input, ctx) {
|
|
1038
|
+
if (!input) return httpInputRequired(this.opts.forms.loginCredentials, ctx);
|
|
1039
|
+
if (input.action) {
|
|
1040
|
+
if (this.handleCredentialsAlt(input.action, input.username) === ALT_HANDLED$2) return void 0;
|
|
1041
|
+
}
|
|
1042
|
+
const errors = validateFormInput(this.opts.forms.loginCredentials, input);
|
|
1043
|
+
if (errors) return httpInputRequired(this.opts.forms.loginCredentials, ctx, errors);
|
|
1044
|
+
try {
|
|
1045
|
+
const result = await this.users.login(input.username, input.password);
|
|
1046
|
+
ctx.username = result.user.username;
|
|
1047
|
+
ctx.mfaRequired = result.mfaRequired;
|
|
1048
|
+
if (this.opts.guards.passwordInitial && result.user.password.isInitial) ctx.isPasswordInitial = true;
|
|
1049
|
+
const email = result.user.mfa.methods.find((m) => m.name === "email" && m.confirmed);
|
|
1050
|
+
if (email) {
|
|
1051
|
+
ctx.email = email.value;
|
|
1052
|
+
ctx.emailConfirmed = true;
|
|
1053
|
+
}
|
|
1054
|
+
const phone = result.user.mfa.methods.find((m) => m.name === "sms" && m.confirmed);
|
|
1055
|
+
if (phone) {
|
|
1056
|
+
ctx.phone = phone.value;
|
|
1057
|
+
ctx.phoneConfirmed = true;
|
|
1058
|
+
}
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
if (err instanceof UserAuthError) {
|
|
1061
|
+
if (err.type === "LOCKED") throw new HttpError$1(423, "Account locked");
|
|
1062
|
+
return httpInputRequired(this.opts.forms.loginCredentials, ctx, { __form: "Invalid credentials" });
|
|
1063
|
+
}
|
|
1064
|
+
throw err;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
handleCredentialsAlt(action, typedUsername) {
|
|
1068
|
+
const alt = this.opts.alternateCredentials;
|
|
1069
|
+
if (action === "forgotPassword" && alt.forgotPassword) {
|
|
1070
|
+
finishWfWithRedirect(this.buildRecoveryUrl(typedUsername), { reason: "forgot-password" });
|
|
1071
|
+
return ALT_HANDLED$2;
|
|
1072
|
+
}
|
|
1073
|
+
if (action === "signup" && alt.signup) {
|
|
1074
|
+
finishWfWithRedirect(alt.signupUrl, { reason: "signup" });
|
|
1075
|
+
return ALT_HANDLED$2;
|
|
1076
|
+
}
|
|
1077
|
+
if (action === "magicLink" && alt.magicLink) throw new HttpError$1(501, "Magic-link login path not implemented in this version");
|
|
1078
|
+
const sso = alt.ssoProviders.find((p) => p.id === action);
|
|
1079
|
+
if (sso) {
|
|
1080
|
+
finishWfWithRedirect(sso.url, { reason: `sso-${sso.id}` });
|
|
1081
|
+
return ALT_HANDLED$2;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Builds the redirect URL the `forgotPassword` alt-action navigates to.
|
|
1086
|
+
* Receives whatever the user typed into the username field so the recovery
|
|
1087
|
+
* page can pre-fill it. Default:
|
|
1088
|
+
* `${alternateCredentials.recoveryUrl}?username=${encodeURIComponent(username ?? '')}`.
|
|
1089
|
+
*/
|
|
1090
|
+
buildRecoveryUrl(username) {
|
|
1091
|
+
return `${this.opts.alternateCredentials.recoveryUrl}?username=${encodeURIComponent(username ?? "")}`;
|
|
1092
|
+
}
|
|
1093
|
+
magicLinkRequest() {
|
|
1094
|
+
throw new HttpError$1(501, "magicLinkRequest step not implemented");
|
|
1095
|
+
}
|
|
1096
|
+
magicLinkSend() {
|
|
1097
|
+
throw new HttpError$1(501, "magicLinkSend step not implemented");
|
|
1098
|
+
}
|
|
1099
|
+
magicLinkVerified() {
|
|
1100
|
+
throw new HttpError$1(501, "magicLinkVerified step not implemented");
|
|
1101
|
+
}
|
|
1102
|
+
passkey() {
|
|
1103
|
+
throw new HttpError$1(501, "passkey step not implemented");
|
|
1104
|
+
}
|
|
1105
|
+
ssoCallback() {
|
|
1106
|
+
throw new HttpError$1(501, "ssoCallback step not implemented");
|
|
1107
|
+
}
|
|
1108
|
+
async ensureEmail(input, ctx) {
|
|
1109
|
+
requireUsername(ctx);
|
|
1110
|
+
if (!ctx.email) {
|
|
1111
|
+
if (!input?.email) return httpInputRequired(this.opts.forms.askEmail, ctx);
|
|
1112
|
+
const errors = validateFormInput(this.opts.forms.askEmail, input);
|
|
1113
|
+
if (errors) return httpInputRequired(this.opts.forms.askEmail, ctx, errors);
|
|
1114
|
+
await this.users.addMfaMethod(ctx.username, {
|
|
1115
|
+
name: "email",
|
|
1116
|
+
value: input.email,
|
|
1117
|
+
confirmed: false
|
|
1118
|
+
});
|
|
1119
|
+
ctx.email = input.email;
|
|
1120
|
+
const code = mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
1121
|
+
await this.deliver({
|
|
1122
|
+
channel: "email",
|
|
1123
|
+
kind: "login.pincode",
|
|
1124
|
+
recipient: input.email,
|
|
1125
|
+
code,
|
|
1126
|
+
expiresAt: ctx.pinExpire
|
|
1127
|
+
});
|
|
1128
|
+
return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
1129
|
+
}
|
|
1130
|
+
if (!input?.code) return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
1131
|
+
const errors = validateFormInput(this.opts.forms.pincode, input);
|
|
1132
|
+
if (errors) return httpInputRequired(this.opts.forms.pincode, ctx, errors);
|
|
1133
|
+
const pinErr = verifyPin(ctx, input.code);
|
|
1134
|
+
if (pinErr) return httpInputRequired(this.opts.forms.pincode, ctx, pinErr);
|
|
1135
|
+
await this.users.confirmMfaMethod(ctx.username, "email");
|
|
1136
|
+
ctx.emailConfirmed = true;
|
|
1137
|
+
ctx.pin = void 0;
|
|
1138
|
+
ctx.pinExpire = void 0;
|
|
1139
|
+
}
|
|
1140
|
+
async ensurePhone(input, ctx) {
|
|
1141
|
+
requireUsername(ctx);
|
|
1142
|
+
if (!ctx.phone) {
|
|
1143
|
+
if (!input?.phone) return httpInputRequired(this.opts.forms.askPhone, ctx);
|
|
1144
|
+
const errors = validateFormInput(this.opts.forms.askPhone, input);
|
|
1145
|
+
if (errors) return httpInputRequired(this.opts.forms.askPhone, ctx, errors);
|
|
1146
|
+
await this.users.addMfaMethod(ctx.username, {
|
|
1147
|
+
name: "sms",
|
|
1148
|
+
value: input.phone,
|
|
1149
|
+
confirmed: false
|
|
1150
|
+
});
|
|
1151
|
+
ctx.phone = input.phone;
|
|
1152
|
+
const code = mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
1153
|
+
await this.deliver({
|
|
1154
|
+
channel: "sms",
|
|
1155
|
+
kind: "login.pincode",
|
|
1156
|
+
recipient: input.phone,
|
|
1157
|
+
code,
|
|
1158
|
+
ttlMs: this.opts.mfa.pincodeTtlMs,
|
|
1159
|
+
userId: ctx.username
|
|
1160
|
+
});
|
|
1161
|
+
return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
1162
|
+
}
|
|
1163
|
+
if (!input?.code) return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
1164
|
+
const errors = validateFormInput(this.opts.forms.pincode, input);
|
|
1165
|
+
if (errors) return httpInputRequired(this.opts.forms.pincode, ctx, errors);
|
|
1166
|
+
const pinErr = verifyPin(ctx, input.code);
|
|
1167
|
+
if (pinErr) return httpInputRequired(this.opts.forms.pincode, ctx, pinErr);
|
|
1168
|
+
await this.users.confirmMfaMethod(ctx.username, "sms");
|
|
1169
|
+
ctx.phoneConfirmed = true;
|
|
1170
|
+
ctx.pin = void 0;
|
|
1171
|
+
ctx.pinExpire = void 0;
|
|
1172
|
+
}
|
|
1173
|
+
async checkTrustedDevice(ctx) {
|
|
1174
|
+
if (!ctx.username) return void 0;
|
|
1175
|
+
const cookieValue = useCookies(current()).getCookie(this.opts.deviceTrust.cookieName);
|
|
1176
|
+
if (!cookieValue) {
|
|
1177
|
+
ctx.newDevice = true;
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const ip = this.opts.deviceTrust.bindsTo === "cookie+ip" ? this.resolveClientIp() : void 0;
|
|
1181
|
+
if (await this.loadTrustedDevice(ctx.username, cookieValue, ip)) {
|
|
1182
|
+
ctx.mfaChecked = true;
|
|
1183
|
+
ctx.deviceTrustToken = cookieValue;
|
|
1184
|
+
} else ctx.newDevice = true;
|
|
1185
|
+
}
|
|
1186
|
+
async prepareMfaOptions(ctx) {
|
|
1187
|
+
if (!ctx.username) return void 0;
|
|
1188
|
+
const user = await this.users.getUser(ctx.username);
|
|
1189
|
+
const allowed = new Set(this.opts.mfa.transports);
|
|
1190
|
+
const summary = this.users.getAvailableMfaMethods(user.mfa).filter((m) => {
|
|
1191
|
+
const kind = mfaKindOf(m.name);
|
|
1192
|
+
return kind !== null && allowed.has(kind);
|
|
1193
|
+
}).map((m) => {
|
|
1194
|
+
return {
|
|
1195
|
+
kind: mfaKindOf(m.name),
|
|
1196
|
+
methodName: m.name,
|
|
1197
|
+
masked: m.masked,
|
|
1198
|
+
isDefault: m.isDefault
|
|
1199
|
+
};
|
|
1200
|
+
});
|
|
1201
|
+
ctx.mfaEnrolledMethods = summary;
|
|
1202
|
+
if (summary.length === 0) {
|
|
1203
|
+
ctx.mfaChecked = true;
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (summary.length === 1) {
|
|
1207
|
+
ctx.mfaMethod = summary[0].kind;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (!ctx.ignoreMfaDefault) {
|
|
1211
|
+
const def = summary.find((m) => m.isDefault);
|
|
1212
|
+
if (def) ctx.mfaMethod = def.kind;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async select2fa(input, ctx) {
|
|
1216
|
+
if (!input) return httpInputRequired(this.opts.forms.select2fa, ctx);
|
|
1217
|
+
if (input.action === "useBackupCode" && this.opts.mfa.backupCodes) return this.handleBackupCode(input.code ? { code: input.code } : void 0, ctx);
|
|
1218
|
+
const errors = validateFormInput(this.opts.forms.select2fa, input);
|
|
1219
|
+
if (errors) return httpInputRequired(this.opts.forms.select2fa, ctx, errors);
|
|
1220
|
+
const picked = (ctx.mfaEnrolledMethods ?? []).find((m) => m.methodName === input.methodName);
|
|
1221
|
+
if (!picked) return httpInputRequired(this.opts.forms.select2fa, ctx, { methodName: "Unknown MFA method" });
|
|
1222
|
+
ctx.mfaMethod = picked.kind;
|
|
1223
|
+
ctx.mfaSaveAsDefault = Boolean(input.saveAsDefault);
|
|
1224
|
+
if (ctx.mfaSaveAsDefault && ctx.username) await this.users.setDefaultMfaMethod(ctx.username, picked.methodName);
|
|
1225
|
+
}
|
|
1226
|
+
async pincodeSendLogin(ctx) {
|
|
1227
|
+
if (!ctx.username || !ctx.mfaMethod) return void 0;
|
|
1228
|
+
const summary = (ctx.mfaEnrolledMethods ?? []).find((m) => m.kind === ctx.mfaMethod);
|
|
1229
|
+
if (!summary) throw new HttpError$1(500, "Workflow state corrupted: missing mfa method");
|
|
1230
|
+
const method = (await this.users.getUser(ctx.username)).mfa.methods.find((m) => m.name === summary.methodName && m.confirmed);
|
|
1231
|
+
if (!method) throw new HttpError$1(500, "MFA method no longer present");
|
|
1232
|
+
const code = mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
1233
|
+
ctx.pinTimeout = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
|
|
1234
|
+
if (ctx.mfaMethod === "email") {
|
|
1235
|
+
ctx.pinSentTo = maskEmail(method.value);
|
|
1236
|
+
await this.deliver({
|
|
1237
|
+
channel: "email",
|
|
1238
|
+
kind: "login.pincode",
|
|
1239
|
+
recipient: method.value,
|
|
1240
|
+
code,
|
|
1241
|
+
expiresAt: ctx.pinExpire,
|
|
1242
|
+
userId: ctx.username
|
|
1243
|
+
});
|
|
1244
|
+
} else if (ctx.mfaMethod === "sms") {
|
|
1245
|
+
ctx.pinSentTo = maskPhone(method.value);
|
|
1246
|
+
await this.deliver({
|
|
1247
|
+
channel: "sms",
|
|
1248
|
+
kind: "login.pincode",
|
|
1249
|
+
recipient: method.value,
|
|
1250
|
+
code,
|
|
1251
|
+
ttlMs: this.opts.mfa.pincodeTtlMs,
|
|
1252
|
+
userId: ctx.username
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async pincodeCheckLogin(input, ctx) {
|
|
1257
|
+
if (!input) return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
1258
|
+
if (input.action === "resend") {
|
|
1259
|
+
if (ctx.pinTimeout && Date.now() < ctx.pinTimeout) {
|
|
1260
|
+
const waitSec = Math.ceil((ctx.pinTimeout - Date.now()) / 1e3);
|
|
1261
|
+
return httpInputRequired(this.opts.forms.pincode, ctx, { __form: `Please wait ${waitSec}s` });
|
|
1262
|
+
}
|
|
1263
|
+
ctx.pin = void 0;
|
|
1264
|
+
ctx.pinExpire = void 0;
|
|
1265
|
+
return httpInputRequired(this.opts.forms.pincode, ctx, { __form: "Code resent" });
|
|
1266
|
+
}
|
|
1267
|
+
if (input.action === "useDifferentMethod") {
|
|
1268
|
+
ctx.ignoreMfaDefault = true;
|
|
1269
|
+
ctx.mfaMethod = void 0;
|
|
1270
|
+
ctx.pin = void 0;
|
|
1271
|
+
ctx.pinExpire = void 0;
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (input.action === "useBackupCode" && this.opts.mfa.backupCodes) return this.handleBackupCode(input.code ? { code: input.code } : void 0, ctx);
|
|
1275
|
+
const errors = validateFormInput(this.opts.forms.pincode, input);
|
|
1276
|
+
if (errors) return httpInputRequired(this.opts.forms.pincode, ctx, errors);
|
|
1277
|
+
const pinErr = verifyPin(ctx, input.code);
|
|
1278
|
+
if (pinErr) return httpInputRequired(this.opts.forms.pincode, ctx, pinErr);
|
|
1279
|
+
ctx.mfaChecked = true;
|
|
1280
|
+
ctx.riskStepUpEvaluated = false;
|
|
1281
|
+
if (this.opts.deviceTrust.enabled && this.opts.deviceTrust.optIn) ctx.rememberDevice = Boolean(input.rememberDevice);
|
|
1282
|
+
}
|
|
1283
|
+
async mfaTotp(input, ctx) {
|
|
1284
|
+
if (!input) return httpInputRequired(this.opts.forms.mfaCode, ctx);
|
|
1285
|
+
if (input.action === "useDifferentMethod") {
|
|
1286
|
+
ctx.ignoreMfaDefault = true;
|
|
1287
|
+
ctx.mfaMethod = void 0;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (input.action === "useBackupCode" && this.opts.mfa.backupCodes) return this.handleBackupCode(input.code ? { code: input.code } : void 0, ctx);
|
|
1291
|
+
const errors = validateFormInput(this.opts.forms.mfaCode, input);
|
|
1292
|
+
if (errors) return httpInputRequired(this.opts.forms.mfaCode, ctx, errors);
|
|
1293
|
+
requireUsername(ctx);
|
|
1294
|
+
try {
|
|
1295
|
+
await this.users.verifyMfa(ctx.username, input.code);
|
|
1296
|
+
ctx.mfaChecked = true;
|
|
1297
|
+
ctx.riskStepUpEvaluated = false;
|
|
1298
|
+
if (this.opts.deviceTrust.enabled && this.opts.deviceTrust.optIn) ctx.rememberDevice = Boolean(input.rememberDevice);
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
if (err instanceof UserAuthError) {
|
|
1301
|
+
if (err.type === "LOCKED") throw new HttpError$1(423, "Account locked");
|
|
1302
|
+
if (err.type === "INACTIVE") throw new HttpError$1(401, "Invalid credentials");
|
|
1303
|
+
if (err.type === "MFA_NOT_CONFIGURED") throw new HttpError$1(400, "No TOTP MFA configured");
|
|
1304
|
+
if (err.type === "MFA_INVALID") {
|
|
1305
|
+
if (err.details?.lockEnds !== void 0) throw new HttpError$1(423, "Account locked");
|
|
1306
|
+
return httpInputRequired(this.opts.forms.mfaCode, ctx, { code: "Invalid code" });
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
throw err;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Backup-code alt-action handler shared by `select2fa`, `pincode-check-login`,
|
|
1314
|
+
* and `mfa-totp`. Validates against `BackupCodeForm` (alphanumeric +
|
|
1315
|
+
* hyphen-grouped — `MfaCodeForm` is digits-only and rejects backup codes
|
|
1316
|
+
* produced by `UserService.generateBackupCodes`).
|
|
1317
|
+
*/
|
|
1318
|
+
async handleBackupCode(input, ctx) {
|
|
1319
|
+
if (!input) return httpInputRequired(this.opts.forms.backupCode, ctx);
|
|
1320
|
+
const errors = validateFormInput(this.opts.forms.backupCode, input);
|
|
1321
|
+
if (errors) return httpInputRequired(this.opts.forms.backupCode, ctx, errors);
|
|
1322
|
+
requireUsername(ctx);
|
|
1323
|
+
if (!await this.users.consumeBackupCode(ctx.username, input.code)) return httpInputRequired(this.opts.forms.backupCode, ctx, { code: "Invalid backup code" });
|
|
1324
|
+
ctx.mfaChecked = true;
|
|
1325
|
+
ctx.riskStepUpEvaluated = false;
|
|
1326
|
+
}
|
|
1327
|
+
mfaEnrollRequired() {
|
|
1328
|
+
throw new HttpError$1(501, "mfa-enroll-required step not implemented");
|
|
1329
|
+
}
|
|
1330
|
+
async deviceTrust(ctx) {
|
|
1331
|
+
if (!ctx.username) return void 0;
|
|
1332
|
+
const ip = this.opts.deviceTrust.bindsTo === "cookie+ip" ? this.resolveClientIp() : void 0;
|
|
1333
|
+
const record = await this.issueTrustedDevice(ctx.username, ip, this.opts.deviceTrust.ttlMs);
|
|
1334
|
+
await this.storeTrustedDevice(ctx.username, record);
|
|
1335
|
+
ctx.deviceTrustToken = record.token;
|
|
1336
|
+
useResponse(current()).setCookie(this.opts.deviceTrust.cookieName, record.token, useAuth().cookieAttrs({ maxAge: this.opts.deviceTrust.ttlMs / 1e3 }));
|
|
1337
|
+
}
|
|
1338
|
+
preparePasswordRules(ctx) {
|
|
1339
|
+
ctx.passwordPolicies = this.users.getTransferablePolicies();
|
|
1340
|
+
}
|
|
1341
|
+
async createPasswordForm(input, ctx) {
|
|
1342
|
+
if (!input) return httpInputRequired(this.opts.forms.setPassword, ctx);
|
|
1343
|
+
if (input.action === "logout") {
|
|
1344
|
+
finishWfAborted("logout", { message: {
|
|
1345
|
+
level: "info",
|
|
1346
|
+
text: "Signed out."
|
|
1347
|
+
} });
|
|
1348
|
+
ctx.aborted = true;
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const errors = validateFormInput(this.opts.forms.setPassword, input);
|
|
1352
|
+
if (errors) return httpInputRequired(this.opts.forms.setPassword, ctx, errors);
|
|
1353
|
+
if (input.newPassword !== input.confirmPassword) return httpInputRequired(this.opts.forms.setPassword, ctx, { confirmPassword: "Passwords do not match" });
|
|
1354
|
+
requireUsername(ctx);
|
|
1355
|
+
try {
|
|
1356
|
+
await this.users.setPassword(ctx.username, input.newPassword);
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
translatePasswordSetError(err);
|
|
1359
|
+
}
|
|
1360
|
+
ctx.passwordChanged = true;
|
|
1361
|
+
ctx.isPasswordInitial = false;
|
|
1362
|
+
}
|
|
1363
|
+
async termsAccept(input, ctx) {
|
|
1364
|
+
if (!input) return httpInputRequired(this.opts.forms.termsAccept, ctx);
|
|
1365
|
+
if (input.action === "decline") {
|
|
1366
|
+
finishWfAborted("termsDeclined", { message: {
|
|
1367
|
+
level: "info",
|
|
1368
|
+
text: "You must accept to continue"
|
|
1369
|
+
} });
|
|
1370
|
+
ctx.aborted = true;
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const errors = validateFormInput(this.opts.forms.termsAccept, input);
|
|
1374
|
+
if (errors) return httpInputRequired(this.opts.forms.termsAccept, ctx, errors);
|
|
1375
|
+
if (input.accepted !== true) return httpInputRequired(this.opts.forms.termsAccept, ctx, { accepted: "You must accept the terms" });
|
|
1376
|
+
if (input.acceptedVersion !== this.opts.acceptance.termsVersion) return httpInputRequired(this.opts.forms.termsAccept, ctx, { acceptedVersion: "Version mismatch — please retry" });
|
|
1377
|
+
ctx.termsAcceptedVersion = input.acceptedVersion;
|
|
1378
|
+
ctx.termsAcceptedDone = true;
|
|
1379
|
+
}
|
|
1380
|
+
async profileComplete(input, ctx) {
|
|
1381
|
+
const form = this.opts.forms.profileComplete;
|
|
1382
|
+
if (!input) return httpInputRequired(form, ctx);
|
|
1383
|
+
const errors = validateFormInput(form, input, { partial: "deep" });
|
|
1384
|
+
if (errors) return httpInputRequired(form, ctx, errors);
|
|
1385
|
+
requireUsername(ctx);
|
|
1386
|
+
await this.applyProfile(ctx.username, input);
|
|
1387
|
+
ctx.profileApplied = true;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Persists the profile-complete payload onto the user record. Default:
|
|
1391
|
+
* no-op (the workflow records the form was submitted but writes nothing).
|
|
1392
|
+
* Consumers override to write into their user store.
|
|
1393
|
+
*/
|
|
1394
|
+
async applyProfile(_username, _payload) {}
|
|
1395
|
+
async consentMarketing(input, ctx) {
|
|
1396
|
+
if (!input) return httpInputRequired(this.opts.forms.consentMarketing, ctx);
|
|
1397
|
+
requireUsername(ctx);
|
|
1398
|
+
await this.applyConsentMarketing(ctx.username, Boolean(input.optIn));
|
|
1399
|
+
ctx.consentApplied = true;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Persists the marketing consent decision. Default: no-op.
|
|
1403
|
+
*/
|
|
1404
|
+
async applyConsentMarketing(_username, _optIn) {}
|
|
1405
|
+
async tenantSelect(input, ctx) {
|
|
1406
|
+
if (!ctx.availableTenants && ctx.username) ctx.availableTenants = await this.loadTenants(ctx.username);
|
|
1407
|
+
if (!input) return httpInputRequired(this.opts.forms.tenantSelect, ctx);
|
|
1408
|
+
const errors = validateFormInput(this.opts.forms.tenantSelect, input);
|
|
1409
|
+
if (errors) return httpInputRequired(this.opts.forms.tenantSelect, ctx, errors);
|
|
1410
|
+
if (!(ctx.availableTenants ?? []).some((t) => t.id === input.tenantId)) return httpInputRequired(this.opts.forms.tenantSelect, ctx, { tenantId: "Unknown tenant" });
|
|
1411
|
+
ctx.selectedTenantId = input.tenantId;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Resolves the user's available tenants. Default: empty array. Consumers
|
|
1415
|
+
* who enable `multiContext.tenantSelect` must override this to return the
|
|
1416
|
+
* tenants the user belongs to.
|
|
1417
|
+
*/
|
|
1418
|
+
async loadTenants(_username) {
|
|
1419
|
+
return [];
|
|
1420
|
+
}
|
|
1421
|
+
async personaSelect(input, ctx) {
|
|
1422
|
+
if (!ctx.availablePersonas && ctx.username) ctx.availablePersonas = await this.loadPersonas(ctx.username);
|
|
1423
|
+
if (!input) return httpInputRequired(this.opts.forms.personaSelect, ctx);
|
|
1424
|
+
const errors = validateFormInput(this.opts.forms.personaSelect, input);
|
|
1425
|
+
if (errors) return httpInputRequired(this.opts.forms.personaSelect, ctx, errors);
|
|
1426
|
+
if (!(ctx.availablePersonas ?? []).some((p) => p.id === input.personaId)) return httpInputRequired(this.opts.forms.personaSelect, ctx, { personaId: "Unknown persona" });
|
|
1427
|
+
ctx.selectedPersonaId = input.personaId;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Resolves the user's available personas. Default: empty array. Consumers
|
|
1431
|
+
* who enable `multiContext.personaSelect` must override this.
|
|
1432
|
+
*/
|
|
1433
|
+
async loadPersonas(_username) {
|
|
1434
|
+
return [];
|
|
1435
|
+
}
|
|
1436
|
+
async concurrencyLimit(input, ctx) {
|
|
1437
|
+
const cfg = this.opts.sessionPolicy.concurrencyLimit;
|
|
1438
|
+
if (!cfg) return void 0;
|
|
1439
|
+
if (cfg.onLimit === "reject") throw new HttpError$1(429, "Session limit reached");
|
|
1440
|
+
if (!input) return httpInputRequired(this.opts.forms.concurrencyLimit, ctx);
|
|
1441
|
+
if (input.action === "cancel") {
|
|
1442
|
+
finishWfAborted("sessionLimit", { message: {
|
|
1443
|
+
level: "warn",
|
|
1444
|
+
text: "Concurrent session limit reached."
|
|
1445
|
+
} });
|
|
1446
|
+
ctx.aborted = true;
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const errors = validateFormInput(this.opts.forms.concurrencyLimit, input);
|
|
1450
|
+
if (errors) return httpInputRequired(this.opts.forms.concurrencyLimit, ctx, errors);
|
|
1451
|
+
if (input.action === "logoutOthers" && ctx.username) await this.logoutOtherSessions(ctx.username);
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
|
|
1455
|
+
* Default: no-op. Consumers override to revoke sessions in their auth store.
|
|
1456
|
+
*/
|
|
1457
|
+
async logoutOtherSessions(_username) {}
|
|
1458
|
+
async riskStepUp(ctx) {
|
|
1459
|
+
ctx.riskStepUpEvaluated = true;
|
|
1460
|
+
const res = await this.assessRiskStepUp(ctx);
|
|
1461
|
+
if (res.require) {
|
|
1462
|
+
ctx.riskStepUpReason = res.reason ?? "additional verification required";
|
|
1463
|
+
ctx.mfaChecked = false;
|
|
1464
|
+
delete ctx.pin;
|
|
1465
|
+
delete ctx.pinExpire;
|
|
1466
|
+
} else delete ctx.riskStepUpReason;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Risk-step-up assessor. Default: never requires an extra factor. Consumers
|
|
1470
|
+
* override to inspect ctx (IP, geo, time since last login, etc.) and return
|
|
1471
|
+
* `{require: true, reason: '…'}` to force an additional MFA round.
|
|
1472
|
+
*/
|
|
1473
|
+
async assessRiskStepUp(_ctx) {
|
|
1474
|
+
return { require: false };
|
|
1475
|
+
}
|
|
1476
|
+
async issue(ctx) {
|
|
1477
|
+
requireUsername(ctx);
|
|
1478
|
+
const issue = await this.auth.issue(ctx.username);
|
|
1479
|
+
ctx.tokensIssued = true;
|
|
1480
|
+
const auth = useAuth();
|
|
1481
|
+
const envelope = {
|
|
1482
|
+
finished: true,
|
|
1483
|
+
data: auth.buildLoginResponse(ctx.username, issue)
|
|
1484
|
+
};
|
|
1485
|
+
useWfFinished().set({
|
|
1486
|
+
type: "data",
|
|
1487
|
+
value: envelope,
|
|
1488
|
+
cookies: auth.buildFinishedCookies(issue)
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
async auditLogin(ctx) {
|
|
1492
|
+
await this.audit({
|
|
1493
|
+
kind: "login.success",
|
|
1494
|
+
userId: ctx.username,
|
|
1495
|
+
workflow: "auth.login",
|
|
1496
|
+
method: ctx.mfaMethod ?? (ctx.mfaChecked ? "mfa.skipped" : "password"),
|
|
1497
|
+
ip: this.resolveClientIp(),
|
|
1498
|
+
...ctx.selectedTenantId && { tenantId: ctx.selectedTenantId }
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
async notifyNewDevice(ctx) {
|
|
1502
|
+
if (!ctx.email) return void 0;
|
|
1503
|
+
await this.deliver({
|
|
1504
|
+
channel: "email",
|
|
1505
|
+
kind: "notifyNewDevice",
|
|
1506
|
+
recipient: ctx.email,
|
|
1507
|
+
expiresAt: Date.now(),
|
|
1508
|
+
userId: ctx.username,
|
|
1509
|
+
metadata: { ip: this.resolveClientIp() ?? "" }
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
redirect(ctx) {
|
|
1513
|
+
const url = this.resolveRedirect(ctx);
|
|
1514
|
+
if (!url) return void 0;
|
|
1515
|
+
const existing = useWfFinished().get();
|
|
1516
|
+
const envelope = {
|
|
1517
|
+
finished: true,
|
|
1518
|
+
end: {
|
|
1519
|
+
mode: "immediate",
|
|
1520
|
+
action: {
|
|
1521
|
+
type: "redirect",
|
|
1522
|
+
target: url,
|
|
1523
|
+
reason: "finalize-redirect"
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
useWfFinished().set({
|
|
1528
|
+
type: "data",
|
|
1529
|
+
value: envelope,
|
|
1530
|
+
...existing?.cookies && { cookies: existing.cookies }
|
|
1531
|
+
});
|
|
1532
|
+
ctx.redirectUrl = url;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Resolves the post-login redirect URL. Default reads
|
|
1536
|
+
* `finalize.redirect`: `false` / `null` (the default) → no redirect, the
|
|
1537
|
+
* `issue` step's data response stands (typical for SPAs/API clients);
|
|
1538
|
+
* `'home'` → `/`; `'referer'` → request `Referer` header (undefined when
|
|
1539
|
+
* absent, falling back to the data response).
|
|
1540
|
+
*
|
|
1541
|
+
* Consumers who want a computed redirect override this method.
|
|
1542
|
+
*/
|
|
1543
|
+
resolveRedirect(_ctx) {
|
|
1544
|
+
const r = this.opts.finalize.redirect;
|
|
1545
|
+
if (r === false || r === null) return void 0;
|
|
1546
|
+
if (r === "home") return "/";
|
|
1547
|
+
if (r === "referer") try {
|
|
1548
|
+
const headers = useRequest(current()).headers;
|
|
1549
|
+
const ref = headers?.referer ?? headers?.referrer;
|
|
1550
|
+
const first = Array.isArray(ref) ? ref[0] : ref;
|
|
1551
|
+
return typeof first === "string" && first.length > 0 ? first : void 0;
|
|
1552
|
+
} catch {
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
resolveClientIp() {
|
|
1557
|
+
try {
|
|
1558
|
+
return useRequest(current()).getIp?.() || void 0;
|
|
1559
|
+
} catch {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
__decorate([
|
|
1565
|
+
Workflow("auth.login"),
|
|
1566
|
+
WorkflowSchema([
|
|
1567
|
+
{ id: "init" },
|
|
1568
|
+
{ id: "credentials" },
|
|
1569
|
+
{
|
|
1570
|
+
id: "magicLinkRequest",
|
|
1571
|
+
condition: () => false
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
id: "magicLinkSend",
|
|
1575
|
+
condition: () => false
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
id: "magicLinkVerified",
|
|
1579
|
+
condition: () => false
|
|
1580
|
+
},
|
|
1581
|
+
{
|
|
1582
|
+
id: "passkey",
|
|
1583
|
+
condition: () => false
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
id: "ssoCallback",
|
|
1587
|
+
condition: () => false
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
id: "ensureEmail",
|
|
1591
|
+
condition: (ctx) => !!ctx.username && (ctx.opts.enrollment.ensureEmail || ctx.opts.guards.emailVerifiedRequired) && !ctx.emailConfirmed && !ctx.aborted
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
id: "ensurePhone",
|
|
1595
|
+
condition: (ctx) => !!ctx.username && ctx.opts.enrollment.ensurePhone && !ctx.phoneConfirmed && !ctx.aborted
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
while: (ctx) => !!(ctx.username && ctx.opts.mfa.enabled && !ctx.mfaChecked && !ctx.aborted),
|
|
1599
|
+
steps: [
|
|
1600
|
+
{
|
|
1601
|
+
id: "check-trusted-device",
|
|
1602
|
+
condition: (ctx) => ctx.opts.deviceTrust.enabled && ctx.opts.deviceTrust.skipsMfa && !ctx.mfaChecked
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
id: "prepare-mfa-options",
|
|
1606
|
+
condition: (ctx) => !ctx.mfaChecked
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
id: "select2fa",
|
|
1610
|
+
condition: (ctx) => !ctx.mfaChecked && !ctx.mfaMethod && (ctx.mfaEnrolledMethods?.length ?? 0) > 1
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
id: "pincode-send-login",
|
|
1614
|
+
condition: (ctx) => !ctx.mfaChecked && (ctx.mfaMethod === "sms" || ctx.mfaMethod === "email") && !ctx.pin
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
id: "pincode-check-login",
|
|
1618
|
+
condition: (ctx) => !ctx.mfaChecked && (ctx.mfaMethod === "sms" || ctx.mfaMethod === "email")
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
id: "mfa-totp",
|
|
1622
|
+
condition: (ctx) => !ctx.mfaChecked && ctx.mfaMethod === "totp"
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
id: "mfa-enroll-required",
|
|
1626
|
+
condition: (ctx) => ctx.opts.mfa.enrollRequired && !ctx.mfaChecked && (ctx.mfaEnrolledMethods?.length ?? 0) === 0
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
id: "risk-step-up",
|
|
1630
|
+
condition: (ctx) => !!(ctx.mfaChecked && !ctx.riskStepUpEvaluated)
|
|
1631
|
+
}
|
|
1632
|
+
]
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
id: "device-trust",
|
|
1636
|
+
condition: (ctx) => !!ctx.username && ctx.opts.deviceTrust.enabled && !!ctx.mfaChecked && !!ctx.newDevice && (!ctx.opts.deviceTrust.optIn || !!ctx.rememberDevice) && !ctx.aborted
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
id: "prepare-password-rules",
|
|
1640
|
+
condition: (ctx) => !!ctx.isPasswordInitial && !ctx.passwordChanged && !ctx.aborted
|
|
1641
|
+
},
|
|
1642
|
+
{
|
|
1643
|
+
id: "create-password-form",
|
|
1644
|
+
condition: (ctx) => !!ctx.isPasswordInitial && !ctx.passwordChanged && !ctx.aborted
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
id: "terms-accept",
|
|
1648
|
+
condition: (ctx) => !!ctx.username && !!ctx.opts.acceptance.termsVersion && ctx.termsAcceptedVersion !== ctx.opts.acceptance.termsVersion && !ctx.termsAcceptedDone && !ctx.aborted
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
id: "profile-complete",
|
|
1652
|
+
condition: (ctx) => !!ctx.username && ctx.opts.acceptance.profileCompleteRequired && !ctx.profileApplied && (ctx.profileMissingFields?.length ?? 0) > 0 && !ctx.aborted
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
id: "consent-marketing",
|
|
1656
|
+
condition: (ctx) => !!ctx.username && ctx.opts.acceptance.consentMarketing && !ctx.consentApplied && !ctx.aborted
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
id: "tenant-select",
|
|
1660
|
+
condition: (ctx) => !!ctx.username && ctx.opts.multiContext.tenantSelect && !ctx.selectedTenantId && (ctx.availableTenants?.length ?? 0) > 1 && !ctx.aborted
|
|
1661
|
+
},
|
|
1662
|
+
{
|
|
1663
|
+
id: "persona-select",
|
|
1664
|
+
condition: (ctx) => !!ctx.username && ctx.opts.multiContext.personaSelect && !ctx.selectedPersonaId && (ctx.availablePersonas?.length ?? 0) > 1 && !ctx.aborted
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
id: "concurrency-limit",
|
|
1668
|
+
condition: (ctx) => !!ctx.username && !!ctx.opts.sessionPolicy.concurrencyLimit && (ctx.activeSessions ?? 0) >= ctx.opts.sessionPolicy.concurrencyLimit.max && !ctx.aborted
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
id: "issue",
|
|
1672
|
+
condition: (ctx) => !!ctx.username && !ctx.tokensIssued && !ctx.aborted
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
id: "audit-login",
|
|
1676
|
+
condition: (ctx) => !!ctx.username && ctx.opts.finalize.auditLogin && !!ctx.tokensIssued && !ctx.aborted
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
id: "notify-new-device",
|
|
1680
|
+
condition: (ctx) => !!ctx.username && ctx.opts.finalize.notifyNewDevice && !!ctx.newDevice && !!ctx.tokensIssued && !ctx.aborted
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
id: "redirect",
|
|
1684
|
+
condition: (ctx) => !!ctx.username && !!ctx.tokensIssued && !ctx.aborted
|
|
1685
|
+
}
|
|
1686
|
+
]),
|
|
1687
|
+
__decorateMetadata("design:type", Function),
|
|
1688
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1689
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1690
|
+
], LoginWorkflow.prototype, "flow", null);
|
|
1691
|
+
__decorate([
|
|
1692
|
+
Step("init"),
|
|
1693
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1694
|
+
__decorateMetadata("design:type", Function),
|
|
1695
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1696
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1697
|
+
], LoginWorkflow.prototype, "init", null);
|
|
1698
|
+
__decorate([
|
|
1699
|
+
Step("credentials"),
|
|
1700
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1701
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1702
|
+
__decorateMetadata("design:type", Function),
|
|
1703
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1704
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1705
|
+
], LoginWorkflow.prototype, "credentials", null);
|
|
1706
|
+
__decorate([
|
|
1707
|
+
Step("magicLinkRequest"),
|
|
1708
|
+
__decorateMetadata("design:type", Function),
|
|
1709
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1710
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1711
|
+
], LoginWorkflow.prototype, "magicLinkRequest", null);
|
|
1712
|
+
__decorate([
|
|
1713
|
+
Step("magicLinkSend"),
|
|
1714
|
+
__decorateMetadata("design:type", Function),
|
|
1715
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1716
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1717
|
+
], LoginWorkflow.prototype, "magicLinkSend", null);
|
|
1718
|
+
__decorate([
|
|
1719
|
+
Step("magicLinkVerified"),
|
|
1720
|
+
__decorateMetadata("design:type", Function),
|
|
1721
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1722
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1723
|
+
], LoginWorkflow.prototype, "magicLinkVerified", null);
|
|
1724
|
+
__decorate([
|
|
1725
|
+
Step("passkey"),
|
|
1726
|
+
__decorateMetadata("design:type", Function),
|
|
1727
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1728
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1729
|
+
], LoginWorkflow.prototype, "passkey", null);
|
|
1730
|
+
__decorate([
|
|
1731
|
+
Step("ssoCallback"),
|
|
1732
|
+
__decorateMetadata("design:type", Function),
|
|
1733
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1734
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1735
|
+
], LoginWorkflow.prototype, "ssoCallback", null);
|
|
1736
|
+
__decorate([
|
|
1737
|
+
Step("ensureEmail"),
|
|
1738
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1739
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1740
|
+
__decorateMetadata("design:type", Function),
|
|
1741
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1742
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1743
|
+
], LoginWorkflow.prototype, "ensureEmail", null);
|
|
1744
|
+
__decorate([
|
|
1745
|
+
Step("ensurePhone"),
|
|
1746
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1747
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1748
|
+
__decorateMetadata("design:type", Function),
|
|
1749
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1750
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1751
|
+
], LoginWorkflow.prototype, "ensurePhone", null);
|
|
1752
|
+
__decorate([
|
|
1753
|
+
Step("check-trusted-device"),
|
|
1754
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1755
|
+
__decorateMetadata("design:type", Function),
|
|
1756
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1757
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1758
|
+
], LoginWorkflow.prototype, "checkTrustedDevice", null);
|
|
1759
|
+
__decorate([
|
|
1760
|
+
Step("prepare-mfa-options"),
|
|
1761
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1762
|
+
__decorateMetadata("design:type", Function),
|
|
1763
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1764
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1765
|
+
], LoginWorkflow.prototype, "prepareMfaOptions", null);
|
|
1766
|
+
__decorate([
|
|
1767
|
+
Step("select2fa"),
|
|
1768
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1769
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1770
|
+
__decorateMetadata("design:type", Function),
|
|
1771
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1772
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1773
|
+
], LoginWorkflow.prototype, "select2fa", null);
|
|
1774
|
+
__decorate([
|
|
1775
|
+
Step("pincode-send-login"),
|
|
1776
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1777
|
+
__decorateMetadata("design:type", Function),
|
|
1778
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1779
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1780
|
+
], LoginWorkflow.prototype, "pincodeSendLogin", null);
|
|
1781
|
+
__decorate([
|
|
1782
|
+
Step("pincode-check-login"),
|
|
1783
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1784
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1785
|
+
__decorateMetadata("design:type", Function),
|
|
1786
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1787
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1788
|
+
], LoginWorkflow.prototype, "pincodeCheckLogin", null);
|
|
1789
|
+
__decorate([
|
|
1790
|
+
Step("mfa-totp"),
|
|
1791
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1792
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1793
|
+
__decorateMetadata("design:type", Function),
|
|
1794
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1795
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1796
|
+
], LoginWorkflow.prototype, "mfaTotp", null);
|
|
1797
|
+
__decorate([
|
|
1798
|
+
Step("mfa-enroll-required"),
|
|
1799
|
+
__decorateMetadata("design:type", Function),
|
|
1800
|
+
__decorateMetadata("design:paramtypes", []),
|
|
1801
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1802
|
+
], LoginWorkflow.prototype, "mfaEnrollRequired", null);
|
|
1803
|
+
__decorate([
|
|
1804
|
+
Step("device-trust"),
|
|
1805
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1806
|
+
__decorateMetadata("design:type", Function),
|
|
1807
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1808
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1809
|
+
], LoginWorkflow.prototype, "deviceTrust", null);
|
|
1810
|
+
__decorate([
|
|
1811
|
+
Step("prepare-password-rules"),
|
|
1812
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1813
|
+
__decorateMetadata("design:type", Function),
|
|
1814
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1815
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1816
|
+
], LoginWorkflow.prototype, "preparePasswordRules", null);
|
|
1817
|
+
__decorate([
|
|
1818
|
+
Step("create-password-form"),
|
|
1819
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1820
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1821
|
+
__decorateMetadata("design:type", Function),
|
|
1822
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1823
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1824
|
+
], LoginWorkflow.prototype, "createPasswordForm", null);
|
|
1825
|
+
__decorate([
|
|
1826
|
+
Step("terms-accept"),
|
|
1827
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1828
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1829
|
+
__decorateMetadata("design:type", Function),
|
|
1830
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1831
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1832
|
+
], LoginWorkflow.prototype, "termsAccept", null);
|
|
1833
|
+
__decorate([
|
|
1834
|
+
Step("profile-complete"),
|
|
1835
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1836
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1837
|
+
__decorateMetadata("design:type", Function),
|
|
1838
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1839
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1840
|
+
], LoginWorkflow.prototype, "profileComplete", null);
|
|
1841
|
+
__decorate([
|
|
1842
|
+
Step("consent-marketing"),
|
|
1843
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1844
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1845
|
+
__decorateMetadata("design:type", Function),
|
|
1846
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1847
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1848
|
+
], LoginWorkflow.prototype, "consentMarketing", null);
|
|
1849
|
+
__decorate([
|
|
1850
|
+
Step("tenant-select"),
|
|
1851
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1852
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1853
|
+
__decorateMetadata("design:type", Function),
|
|
1854
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1855
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1856
|
+
], LoginWorkflow.prototype, "tenantSelect", null);
|
|
1857
|
+
__decorate([
|
|
1858
|
+
Step("persona-select"),
|
|
1859
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1860
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1861
|
+
__decorateMetadata("design:type", Function),
|
|
1862
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1863
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1864
|
+
], LoginWorkflow.prototype, "personaSelect", null);
|
|
1865
|
+
__decorate([
|
|
1866
|
+
Step("concurrency-limit"),
|
|
1867
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
1868
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
1869
|
+
__decorateMetadata("design:type", Function),
|
|
1870
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
1871
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1872
|
+
], LoginWorkflow.prototype, "concurrencyLimit", null);
|
|
1873
|
+
__decorate([
|
|
1874
|
+
Step("risk-step-up"),
|
|
1875
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1876
|
+
__decorateMetadata("design:type", Function),
|
|
1877
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1878
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1879
|
+
], LoginWorkflow.prototype, "riskStepUp", null);
|
|
1880
|
+
__decorate([
|
|
1881
|
+
Step("issue"),
|
|
1882
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1883
|
+
__decorateMetadata("design:type", Function),
|
|
1884
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1885
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1886
|
+
], LoginWorkflow.prototype, "issue", null);
|
|
1887
|
+
__decorate([
|
|
1888
|
+
Step("audit-login"),
|
|
1889
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1890
|
+
__decorateMetadata("design:type", Function),
|
|
1891
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1892
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1893
|
+
], LoginWorkflow.prototype, "auditLogin", null);
|
|
1894
|
+
__decorate([
|
|
1895
|
+
Step("notify-new-device"),
|
|
1896
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1897
|
+
__decorateMetadata("design:type", Function),
|
|
1898
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1899
|
+
__decorateMetadata("design:returntype", Promise)
|
|
1900
|
+
], LoginWorkflow.prototype, "notifyNewDevice", null);
|
|
1901
|
+
__decorate([
|
|
1902
|
+
Step("redirect"),
|
|
1903
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
1904
|
+
__decorateMetadata("design:type", Function),
|
|
1905
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
1906
|
+
__decorateMetadata("design:returntype", void 0)
|
|
1907
|
+
], LoginWorkflow.prototype, "redirect", null);
|
|
1908
|
+
LoginWorkflow = __decorate([
|
|
1909
|
+
Public(),
|
|
1910
|
+
Injectable("FOR_EVENT"),
|
|
1911
|
+
Controller(),
|
|
1912
|
+
__decorateMetadata("design:paramtypes", [
|
|
1913
|
+
Object,
|
|
1914
|
+
typeof (_ref$2 = typeof UserService !== "undefined" && UserService) === "function" ? _ref$2 : Object,
|
|
1915
|
+
typeof (_ref2$2 = typeof AuthCredential !== "undefined" && AuthCredential) === "function" ? _ref2$2 : Object
|
|
1916
|
+
])
|
|
1917
|
+
], LoginWorkflow);
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/workflows/recovery.workflow.options.ts
|
|
1920
|
+
/** Magic-link TTL default — also used as the persisted wf-state token TTL. */
|
|
1921
|
+
const DEFAULT_RECOVERY_TOKEN_TTL_MS = 3600 * 1e3;
|
|
1922
|
+
/**
|
|
1923
|
+
* Deep-merge defaults with the user-supplied nested pojo. Each group has its
|
|
1924
|
+
* own `{ ...defaults, ...input }` line — small enough that pulling in lodash
|
|
1925
|
+
* would be silly.
|
|
1926
|
+
*/
|
|
1927
|
+
function mergeRecoveryOpts(opts = {}) {
|
|
1928
|
+
const inputDelivery = opts.delivery ?? {};
|
|
1929
|
+
const inputOtp = inputDelivery.otp ?? {};
|
|
1930
|
+
return {
|
|
1931
|
+
delivery: {
|
|
1932
|
+
mode: "magicLink",
|
|
1933
|
+
magicLinkTtlMs: DEFAULT_RECOVERY_TOKEN_TTL_MS,
|
|
1934
|
+
...inputDelivery,
|
|
1935
|
+
otp: {
|
|
1936
|
+
transports: ["email"],
|
|
1937
|
+
codeLength: 6,
|
|
1938
|
+
ttlMs: 5 * 6e4,
|
|
1939
|
+
resendCooldownMs: 6e4,
|
|
1940
|
+
...inputOtp
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
preReset: {
|
|
1944
|
+
requireKnownFactor: false,
|
|
1945
|
+
...opts.preReset
|
|
1946
|
+
},
|
|
1947
|
+
postReset: {
|
|
1948
|
+
revokeAllSessions: true,
|
|
1949
|
+
freshLoginRequired: false,
|
|
1950
|
+
loginUrl: "/login",
|
|
1951
|
+
...opts.postReset
|
|
1952
|
+
},
|
|
1953
|
+
altActions: {
|
|
1954
|
+
backToLogin: true,
|
|
1955
|
+
...opts.altActions
|
|
1956
|
+
},
|
|
1957
|
+
audit: {
|
|
1958
|
+
enabled: true,
|
|
1959
|
+
...opts.audit
|
|
1960
|
+
},
|
|
1961
|
+
forms: {
|
|
1962
|
+
emailIdentifier: EmailIdentifierForm,
|
|
1963
|
+
pincode: PincodeForm,
|
|
1964
|
+
recoveryFactor: RecoveryFactorForm,
|
|
1965
|
+
recoveryModeSelect: RecoveryModeSelectForm,
|
|
1966
|
+
setPassword: SetPasswordForm,
|
|
1967
|
+
...opts.forms
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
//#endregion
|
|
1972
|
+
//#region src/workflows/recovery.workflow.ts
|
|
1973
|
+
/**
|
|
1974
|
+
* RecoveryWorkflow — `wfid = 'auth.recovery'`.
|
|
1975
|
+
*
|
|
1976
|
+
* Full step catalog per `WF_RECOVERY.md`. Defaults give today's 3-step
|
|
1977
|
+
* magic-link flow (`request` → `sendMagicLink` → `setPassword`); consumers
|
|
1978
|
+
* turn on OTP delivery, pre-reset factor verification, fresh-login redirect
|
|
1979
|
+
* etc. via `RecoveryWorkflowOpts`.
|
|
1980
|
+
*
|
|
1981
|
+
* **Step routing model.** Mirrors `LoginWorkflow`: alt-action handlers run
|
|
1982
|
+
* BEFORE form validation (so `backToLogin` works without filling fields) and
|
|
1983
|
+
* return the `ALT_HANDLED` sentinel after short-circuiting via
|
|
1984
|
+
* `useWfFinished().set(...)`. The step body then returns `undefined` so the
|
|
1985
|
+
* schema advances cleanly, with terminal steps gated on `!ctx.aborted` so the
|
|
1986
|
+
* abort response set via `useWfFinished()` is not overwritten.
|
|
1987
|
+
*
|
|
1988
|
+
* **Consumer subclass pattern (Phase 3 reshape).** Consumers subclass
|
|
1989
|
+
* `RecoveryWorkflow` to override `protected` hook methods. The subclass MUST
|
|
1990
|
+
* re-apply `@Inherit() @Injectable('FOR_EVENT') @Controller()` and re-declare
|
|
1991
|
+
* the constructor signature (TS emits fresh design-paramtypes per class).
|
|
1992
|
+
*
|
|
1993
|
+
* **Side-effect deps as protected methods.** The optional sender/emitter DI
|
|
1994
|
+
* providers have been DROPPED from the constructor. The hooks live as
|
|
1995
|
+
* `protected` methods consumers override:
|
|
1996
|
+
*
|
|
1997
|
+
* - `deliver(payload)` — unified email + SMS dispatch (see `DeliverPayload`).
|
|
1998
|
+
* Default throws; override to wire your senders.
|
|
1999
|
+
* - `audit(event)` — fire audit events. Default: no-op.
|
|
2000
|
+
* - `emailToUserId(email)` — resolve recovery email to canonical username.
|
|
2001
|
+
* - `verifyRecoveryFactor(...)` — phone last-4 / TOTP / custom factors.
|
|
2002
|
+
*
|
|
2003
|
+
* Rate-limiting is intentionally NOT part of this workflow — consumers who
|
|
2004
|
+
* want a cap wire it themselves at the HTTP / trigger layer.
|
|
2005
|
+
*/
|
|
2006
|
+
var _ref$1, _ref2$1;
|
|
2007
|
+
/**
|
|
2008
|
+
* Sentinel returned by alt-action handlers that have already short-circuited
|
|
2009
|
+
* via `useWfFinished().set(...)`. The step body returns `undefined` after
|
|
2010
|
+
* seeing this so the schema advances without running form validation on the
|
|
2011
|
+
* alt-action payload (which lacks the form's required fields).
|
|
2012
|
+
*/
|
|
2013
|
+
const ALT_HANDLED$1 = Symbol("ALT_HANDLED");
|
|
2014
|
+
/**
|
|
2015
|
+
* Construction-time invariants for DATA validity only. Sender/emitter absence
|
|
2016
|
+
* is no longer checked — those default to fail-loud (`deliver()`) or safe
|
|
2017
|
+
* (`audit()` no-op) protected methods that consumers override.
|
|
2018
|
+
*/
|
|
2019
|
+
function validateOpts(opts) {
|
|
2020
|
+
if (opts.delivery.mode !== "magicLink" && opts.delivery.otp.transports.length === 0) throw new Error("RecoveryWorkflow: delivery.otp.transports cannot be empty when delivery.mode includes OTP");
|
|
2021
|
+
}
|
|
2022
|
+
let RecoveryWorkflow = class RecoveryWorkflow {
|
|
2023
|
+
opts;
|
|
2024
|
+
users;
|
|
2025
|
+
auth;
|
|
2026
|
+
constructor(opts, users, auth) {
|
|
2027
|
+
this.opts = mergeRecoveryOpts(opts);
|
|
2028
|
+
this.users = users;
|
|
2029
|
+
this.auth = auth;
|
|
2030
|
+
validateOpts(this.opts);
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Dispatch an email or SMS event. Default throws — consumers MUST override
|
|
2034
|
+
* if `delivery.mode` ever drives email/SMS (i.e. for any non-`magicLink`
|
|
2035
|
+
* mode AND for `magicLink` mode the `outletEmail` outlet still runs the
|
|
2036
|
+
* email through `createAuthEmailOutlet`'s `EmailSender` — see the trigger
|
|
2037
|
+
* controller wiring; this method covers OTP code dispatch).
|
|
2038
|
+
*/
|
|
2039
|
+
async deliver(_payload) {
|
|
2040
|
+
throw new Error("RecoveryWorkflow.deliver() not configured — override to wire your email/sms sender");
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Emit an audit event. Default: no-op. Consumers override to fan out to
|
|
2044
|
+
* their audit sink.
|
|
2045
|
+
*/
|
|
2046
|
+
async audit(_event) {}
|
|
2047
|
+
flow() {}
|
|
2048
|
+
init(ctx) {
|
|
2049
|
+
ctx.opts = this.snapshotOpts(this.opts);
|
|
2050
|
+
if (this.opts.delivery.mode !== "choice") ctx.resolvedMode = this.opts.delivery.mode;
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
|
|
2054
|
+
* conditions to read. Default: drop the `forms` group (atscript form classes
|
|
2055
|
+
* are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
|
|
2056
|
+
* Step bodies still consult the form classes via `this.opts.forms.*`.
|
|
2057
|
+
*
|
|
2058
|
+
* Consumers who extend the opts type with non-JSON values can override this
|
|
2059
|
+
* to strip them so `AsWfStore`'s plain-JSON persistence doesn't choke.
|
|
2060
|
+
*/
|
|
2061
|
+
snapshotOpts(opts) {
|
|
2062
|
+
const { forms: _forms, ...rest } = opts;
|
|
2063
|
+
return rest;
|
|
2064
|
+
}
|
|
2065
|
+
async request(input, ctx) {
|
|
2066
|
+
if (!input) {
|
|
2067
|
+
const prefilled = readUsernameQueryParam();
|
|
2068
|
+
const formCtx = { ...ctx };
|
|
2069
|
+
if (prefilled) formCtx.defaults = { email: prefilled };
|
|
2070
|
+
return httpInputRequired(this.opts.forms.emailIdentifier, formCtx);
|
|
2071
|
+
}
|
|
2072
|
+
if (input.action === "backToLogin" && this.opts.altActions.backToLogin) {
|
|
2073
|
+
this.abortToLogin(ctx);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const errors = validateFormInput(this.opts.forms.emailIdentifier, input);
|
|
2077
|
+
if (errors) return httpInputRequired(this.opts.forms.emailIdentifier, ctx, errors);
|
|
2078
|
+
const email = input.email;
|
|
2079
|
+
ctx.email = email;
|
|
2080
|
+
let username;
|
|
2081
|
+
try {
|
|
2082
|
+
const userId = await this.emailToUserId(email);
|
|
2083
|
+
if (userId) username = (await this.users.getUser(userId)).username;
|
|
2084
|
+
} catch (err) {
|
|
2085
|
+
if (!(err instanceof UserAuthError) || err.type !== "NOT_FOUND") throw err;
|
|
2086
|
+
}
|
|
2087
|
+
await this.emitRequested(ctx, username);
|
|
2088
|
+
if (!username) {
|
|
2089
|
+
this.finishGeneric();
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
ctx.username = username;
|
|
2093
|
+
if (ctx.resolvedMode === "otp" && !ctx.otpTransport) ctx.otpTransport = this.opts.delivery.otp.transports[0];
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Resolves the recovery-step `email` input to the `username` (user-id) that
|
|
2097
|
+
* `UserService.getUser` expects. Default: returns the email unchanged (treats
|
|
2098
|
+
* email as username). Apps whose user model separates `username` from
|
|
2099
|
+
* `email` MUST override this; return `null` when no user matches.
|
|
2100
|
+
*/
|
|
2101
|
+
async emailToUserId(email) {
|
|
2102
|
+
return email;
|
|
2103
|
+
}
|
|
2104
|
+
selectMode(input, ctx) {
|
|
2105
|
+
if (!input) return httpInputRequired(this.opts.forms.recoveryModeSelect, ctx);
|
|
2106
|
+
if (input.action === "backToLogin" && this.opts.altActions.backToLogin) {
|
|
2107
|
+
this.abortToLogin(ctx);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const errors = validateFormInput(this.opts.forms.recoveryModeSelect, input);
|
|
2111
|
+
if (errors) return httpInputRequired(this.opts.forms.recoveryModeSelect, ctx, errors);
|
|
2112
|
+
const mode = input.mode;
|
|
2113
|
+
ctx.selectedMode = mode;
|
|
2114
|
+
ctx.resolvedMode = mode;
|
|
2115
|
+
if (mode === "otp" && !ctx.otpTransport) ctx.otpTransport = this.opts.delivery.otp.transports[0];
|
|
2116
|
+
}
|
|
2117
|
+
sendMagicLink(ctx) {
|
|
2118
|
+
if (ctx.linkSent) return void 0;
|
|
2119
|
+
ctx.linkSent = true;
|
|
2120
|
+
return {
|
|
2121
|
+
...outletEmail(ctx.email, "recovery.magicLink", {
|
|
2122
|
+
username: ctx.username,
|
|
2123
|
+
expiresAtMs: this.opts.delivery.magicLinkTtlMs
|
|
2124
|
+
}),
|
|
2125
|
+
expires: Date.now() + this.opts.delivery.magicLinkTtlMs
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
async sendOtp(ctx) {
|
|
2129
|
+
requireUsername(ctx);
|
|
2130
|
+
const transport = ctx.otpTransport ?? this.opts.delivery.otp.transports[0] ?? "email";
|
|
2131
|
+
ctx.otpTransport = transport;
|
|
2132
|
+
ctx.otpCodeLength = this.opts.delivery.otp.codeLength;
|
|
2133
|
+
const code = mintPin$1(ctx, this.opts.delivery.otp.codeLength, this.opts.delivery.otp.ttlMs);
|
|
2134
|
+
ctx.pinResendAllowedAt = Date.now() + this.opts.delivery.otp.resendCooldownMs;
|
|
2135
|
+
if (transport === "email") await this.deliver({
|
|
2136
|
+
channel: "email",
|
|
2137
|
+
kind: "recovery.pincode",
|
|
2138
|
+
recipient: ctx.email,
|
|
2139
|
+
code,
|
|
2140
|
+
expiresAt: ctx.pinExpire,
|
|
2141
|
+
userId: ctx.username
|
|
2142
|
+
});
|
|
2143
|
+
else {
|
|
2144
|
+
const phone = await this.resolveUserPhone(ctx.username);
|
|
2145
|
+
await this.deliver({
|
|
2146
|
+
channel: "sms",
|
|
2147
|
+
kind: "recovery.pincode",
|
|
2148
|
+
recipient: phone ?? ctx.email,
|
|
2149
|
+
code,
|
|
2150
|
+
ttlMs: this.opts.delivery.otp.ttlMs,
|
|
2151
|
+
userId: ctx.username
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
async checkOtp(input, ctx) {
|
|
2156
|
+
if (!input) return httpInputRequired(this.opts.forms.pincode, ctx);
|
|
2157
|
+
if (input.action === "backToLogin" && this.opts.altActions.backToLogin) {
|
|
2158
|
+
this.abortToLogin(ctx);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
if (input.action === "resend") {
|
|
2162
|
+
if (ctx.pinResendAllowedAt && Date.now() < ctx.pinResendAllowedAt) {
|
|
2163
|
+
const waitSec = Math.ceil((ctx.pinResendAllowedAt - Date.now()) / 1e3);
|
|
2164
|
+
return httpInputRequired(this.opts.forms.pincode, ctx, { __form: `Please wait ${waitSec}s` });
|
|
2165
|
+
}
|
|
2166
|
+
delete ctx.pin;
|
|
2167
|
+
delete ctx.pinExpire;
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (input.action === "useDifferentTransport") {
|
|
2171
|
+
const transports = this.opts.delivery.otp.transports;
|
|
2172
|
+
if (transports.length < 2) return httpInputRequired(this.opts.forms.pincode, ctx, { __form: "Only one transport configured" });
|
|
2173
|
+
const current = ctx.otpTransport ?? transports[0];
|
|
2174
|
+
ctx.otpTransport = transports.find((t) => t !== current) ?? transports[0];
|
|
2175
|
+
delete ctx.pin;
|
|
2176
|
+
delete ctx.pinExpire;
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const errors = validateFormInput(this.opts.forms.pincode, input);
|
|
2180
|
+
if (errors) return httpInputRequired(this.opts.forms.pincode, ctx, errors);
|
|
2181
|
+
const pinErr = verifyPin$1(ctx, input.code);
|
|
2182
|
+
if (pinErr) return httpInputRequired(this.opts.forms.pincode, ctx, pinErr);
|
|
2183
|
+
ctx.pinVerified = true;
|
|
2184
|
+
delete ctx.pin;
|
|
2185
|
+
delete ctx.pinExpire;
|
|
2186
|
+
}
|
|
2187
|
+
async verifyFactor(input, ctx) {
|
|
2188
|
+
if (!input) return httpInputRequired(this.opts.forms.recoveryFactor, ctx);
|
|
2189
|
+
if (input.action === "backToLogin" && this.opts.altActions.backToLogin) {
|
|
2190
|
+
this.abortToLogin(ctx);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const errors = validateFormInput(this.opts.forms.recoveryFactor, input);
|
|
2194
|
+
if (errors) return httpInputRequired(this.opts.forms.recoveryFactor, ctx, errors);
|
|
2195
|
+
requireUsername(ctx);
|
|
2196
|
+
const factor = input.factor;
|
|
2197
|
+
const value = input.value;
|
|
2198
|
+
if (!await this.verifyRecoveryFactor({
|
|
2199
|
+
factor,
|
|
2200
|
+
value,
|
|
2201
|
+
ctx
|
|
2202
|
+
})) return httpInputRequired(this.opts.forms.recoveryFactor, ctx, { value: "Invalid factor" });
|
|
2203
|
+
ctx.factorVerified = true;
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Verifies a recovery factor against the user's enrolled MFA methods.
|
|
2207
|
+
* Default: supports `'phone'` (phone last-4 match) and `'totp'` (current
|
|
2208
|
+
* TOTP code). Returns `true` when the factor matches.
|
|
2209
|
+
*
|
|
2210
|
+
* Consumers extend by overriding to support additional factors (e.g.
|
|
2211
|
+
* security questions); call `super.verifyRecoveryFactor(...)` to keep
|
|
2212
|
+
* the built-in checks.
|
|
2213
|
+
*/
|
|
2214
|
+
async verifyRecoveryFactor(input) {
|
|
2215
|
+
const { factor, value, ctx } = input;
|
|
2216
|
+
if (!ctx.username) return false;
|
|
2217
|
+
const user = await this.users.getUser(ctx.username);
|
|
2218
|
+
if (factor === "phone") {
|
|
2219
|
+
const phoneMethod = user.mfa.methods.find((m) => m.name === "sms" && m.confirmed);
|
|
2220
|
+
if (!phoneMethod) return false;
|
|
2221
|
+
return value === phoneMethod.value.slice(-4);
|
|
2222
|
+
}
|
|
2223
|
+
if (factor === "totp") {
|
|
2224
|
+
const totpMethod = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
2225
|
+
if (!totpMethod) return false;
|
|
2226
|
+
return verifyTotpCode(totpMethod.value, value);
|
|
2227
|
+
}
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
async setPassword(input, ctx) {
|
|
2231
|
+
if (!input) {
|
|
2232
|
+
const formCtx = { ...ctx };
|
|
2233
|
+
formCtx.passwordPolicies = this.users.getTransferablePolicies();
|
|
2234
|
+
return httpInputRequired(this.opts.forms.setPassword, formCtx);
|
|
2235
|
+
}
|
|
2236
|
+
if (input.action === "backToLogin" && this.opts.altActions.backToLogin) {
|
|
2237
|
+
this.abortToLogin(ctx);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const errors = validateFormInput(this.opts.forms.setPassword, input);
|
|
2241
|
+
if (errors) return httpInputRequired(this.opts.forms.setPassword, ctx, errors);
|
|
2242
|
+
if (input.newPassword !== input.confirmPassword) return httpInputRequired(this.opts.forms.setPassword, ctx, { confirmPassword: "Passwords do not match" });
|
|
2243
|
+
if (!ctx.username) return httpInputRequired(this.opts.forms.setPassword, ctx, { __form: "Recovery session expired" });
|
|
2244
|
+
try {
|
|
2245
|
+
await this.users.setPassword(ctx.username, input.newPassword);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
translatePasswordSetError(err);
|
|
2248
|
+
}
|
|
2249
|
+
ctx.passwordChanged = true;
|
|
2250
|
+
}
|
|
2251
|
+
async revokeSessions(ctx) {
|
|
2252
|
+
if (!ctx.username) return void 0;
|
|
2253
|
+
await this.auth.revokeAllForUser(ctx.username);
|
|
2254
|
+
ctx.sessionsRevoked = true;
|
|
2255
|
+
}
|
|
2256
|
+
async auditStep(ctx) {
|
|
2257
|
+
await this.audit({
|
|
2258
|
+
kind: "recovery.completed",
|
|
2259
|
+
userId: ctx.username,
|
|
2260
|
+
workflow: "auth.recovery",
|
|
2261
|
+
deliveryMode: ctx.resolvedMode ?? this.opts.delivery.mode,
|
|
2262
|
+
ip: resolveClientIp(),
|
|
2263
|
+
...ctx.sessionsRevoked && { sessionsRevoked: true }
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
freshLoginFinish(_ctx) {
|
|
2267
|
+
finishWfWithRedirect(this.opts.postReset.loginUrl, {
|
|
2268
|
+
autoMs: 5e3,
|
|
2269
|
+
skipLabel: "Go now",
|
|
2270
|
+
message: {
|
|
2271
|
+
level: "success",
|
|
2272
|
+
text: "Password updated. Redirecting to sign-in…"
|
|
2273
|
+
},
|
|
2274
|
+
reason: "reset-success"
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
async autoLoginFinish(ctx) {
|
|
2278
|
+
requireUsername(ctx);
|
|
2279
|
+
const issue = await this.auth.issue(ctx.username);
|
|
2280
|
+
ctx.tokensIssued = true;
|
|
2281
|
+
const auth = useAuth();
|
|
2282
|
+
const envelope = {
|
|
2283
|
+
finished: true,
|
|
2284
|
+
data: auth.buildLoginResponse(ctx.username, issue)
|
|
2285
|
+
};
|
|
2286
|
+
useWfFinished().set({
|
|
2287
|
+
type: "data",
|
|
2288
|
+
value: envelope,
|
|
2289
|
+
cookies: auth.buildFinishedCookies(issue)
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Send the generic "if an account exists, you'll receive instructions"
|
|
2294
|
+
* finished response. Used for unknown emails so a known/unknown lookup is
|
|
2295
|
+
* indistinguishable to the client (anti-enumeration).
|
|
2296
|
+
*/
|
|
2297
|
+
finishGeneric() {
|
|
2298
|
+
finishWfWithData({ sent: true }, {
|
|
2299
|
+
level: "info",
|
|
2300
|
+
text: "If an account exists, you will receive instructions."
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
abortToLogin(ctx) {
|
|
2304
|
+
finishWfWithRedirect(this.opts.postReset.loginUrl, { reason: "user-cancelled" });
|
|
2305
|
+
ctx.aborted = true;
|
|
2306
|
+
return ALT_HANDLED$1;
|
|
2307
|
+
}
|
|
2308
|
+
async emitRequested(ctx, username) {
|
|
2309
|
+
if (!this.opts.audit.enabled) return;
|
|
2310
|
+
await this.audit({
|
|
2311
|
+
kind: "recovery.requested",
|
|
2312
|
+
workflow: "auth.recovery",
|
|
2313
|
+
userId: username,
|
|
2314
|
+
email: ctx.email,
|
|
2315
|
+
ip: resolveClientIp()
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
async resolveUserPhone(username) {
|
|
2319
|
+
try {
|
|
2320
|
+
return (await this.users.getUser(username)).mfa.methods.find((m) => m.name === "sms" && m.confirmed)?.value;
|
|
2321
|
+
} catch {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
__decorate([
|
|
2327
|
+
Workflow("auth.recovery"),
|
|
2328
|
+
WorkflowSchema([
|
|
2329
|
+
{ id: "recoveryInit" },
|
|
2330
|
+
{ id: "recoveryRequest" },
|
|
2331
|
+
{
|
|
2332
|
+
id: "recoverySelectMode",
|
|
2333
|
+
condition: (ctx) => !!(ctx.username && ctx.opts.delivery.mode === "choice" && !ctx.selectedMode && !ctx.aborted)
|
|
2334
|
+
},
|
|
2335
|
+
{
|
|
2336
|
+
id: "recoverySendMagicLink",
|
|
2337
|
+
condition: (ctx) => !!(ctx.username && ctx.resolvedMode === "magicLink" && !ctx.aborted)
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
while: (ctx) => !!(ctx.username && ctx.resolvedMode === "otp" && !ctx.pinVerified && !ctx.aborted),
|
|
2341
|
+
steps: [{
|
|
2342
|
+
id: "recoverySendOtp",
|
|
2343
|
+
condition: (ctx) => !ctx.pin
|
|
2344
|
+
}, { id: "recoveryCheckOtp" }]
|
|
2345
|
+
},
|
|
2346
|
+
{
|
|
2347
|
+
id: "recoveryVerifyFactor",
|
|
2348
|
+
condition: (ctx) => !!(ctx.username && ctx.opts.preReset.requireKnownFactor && !ctx.factorVerified && (ctx.linkSent || ctx.pinVerified) && !ctx.aborted)
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
id: "recoverySetPassword",
|
|
2352
|
+
condition: (ctx) => !!(ctx.username && (ctx.linkSent || ctx.pinVerified) && (!ctx.opts.preReset.requireKnownFactor || ctx.factorVerified) && !ctx.aborted)
|
|
2353
|
+
},
|
|
2354
|
+
{
|
|
2355
|
+
id: "recoveryRevokeSessions",
|
|
2356
|
+
condition: (ctx) => !!(ctx.opts.postReset.revokeAllSessions && ctx.passwordChanged && !ctx.aborted)
|
|
2357
|
+
},
|
|
2358
|
+
{
|
|
2359
|
+
id: "recoveryAudit",
|
|
2360
|
+
condition: (ctx) => !!(ctx.opts.audit.enabled && ctx.passwordChanged && !ctx.aborted)
|
|
2361
|
+
},
|
|
2362
|
+
{
|
|
2363
|
+
id: "recoveryFreshLoginFinish",
|
|
2364
|
+
condition: (ctx) => !!(ctx.opts.postReset.freshLoginRequired && ctx.passwordChanged && !ctx.aborted)
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
id: "recoveryAutoLoginFinish",
|
|
2368
|
+
condition: (ctx) => !!(!ctx.opts.postReset.freshLoginRequired && ctx.passwordChanged && !ctx.tokensIssued && !ctx.aborted)
|
|
2369
|
+
}
|
|
2370
|
+
]),
|
|
2371
|
+
__decorateMetadata("design:type", Function),
|
|
2372
|
+
__decorateMetadata("design:paramtypes", []),
|
|
2373
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2374
|
+
], RecoveryWorkflow.prototype, "flow", null);
|
|
2375
|
+
__decorate([
|
|
2376
|
+
Step("recoveryInit"),
|
|
2377
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2378
|
+
__decorateMetadata("design:type", Function),
|
|
2379
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2380
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2381
|
+
], RecoveryWorkflow.prototype, "init", null);
|
|
2382
|
+
__decorate([
|
|
2383
|
+
Step("recoveryRequest"),
|
|
2384
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
2385
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
2386
|
+
__decorateMetadata("design:type", Function),
|
|
2387
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
2388
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2389
|
+
], RecoveryWorkflow.prototype, "request", null);
|
|
2390
|
+
__decorate([
|
|
2391
|
+
Step("recoverySelectMode"),
|
|
2392
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
2393
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
2394
|
+
__decorateMetadata("design:type", Function),
|
|
2395
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
2396
|
+
__decorateMetadata("design:returntype", Object)
|
|
2397
|
+
], RecoveryWorkflow.prototype, "selectMode", null);
|
|
2398
|
+
__decorate([
|
|
2399
|
+
Step("recoverySendMagicLink"),
|
|
2400
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2401
|
+
__decorateMetadata("design:type", Function),
|
|
2402
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2403
|
+
__decorateMetadata("design:returntype", Object)
|
|
2404
|
+
], RecoveryWorkflow.prototype, "sendMagicLink", null);
|
|
2405
|
+
__decorate([
|
|
2406
|
+
Step("recoverySendOtp"),
|
|
2407
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2408
|
+
__decorateMetadata("design:type", Function),
|
|
2409
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2410
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2411
|
+
], RecoveryWorkflow.prototype, "sendOtp", null);
|
|
2412
|
+
__decorate([
|
|
2413
|
+
Step("recoveryCheckOtp"),
|
|
2414
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
2415
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
2416
|
+
__decorateMetadata("design:type", Function),
|
|
2417
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
2418
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2419
|
+
], RecoveryWorkflow.prototype, "checkOtp", null);
|
|
2420
|
+
__decorate([
|
|
2421
|
+
Step("recoveryVerifyFactor"),
|
|
2422
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
2423
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
2424
|
+
__decorateMetadata("design:type", Function),
|
|
2425
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
2426
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2427
|
+
], RecoveryWorkflow.prototype, "verifyFactor", null);
|
|
2428
|
+
__decorate([
|
|
2429
|
+
Step("recoverySetPassword"),
|
|
2430
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
2431
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
2432
|
+
__decorateMetadata("design:type", Function),
|
|
2433
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
2434
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2435
|
+
], RecoveryWorkflow.prototype, "setPassword", null);
|
|
2436
|
+
__decorate([
|
|
2437
|
+
Step("recoveryRevokeSessions"),
|
|
2438
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2439
|
+
__decorateMetadata("design:type", Function),
|
|
2440
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2441
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2442
|
+
], RecoveryWorkflow.prototype, "revokeSessions", null);
|
|
2443
|
+
__decorate([
|
|
2444
|
+
Step("recoveryAudit"),
|
|
2445
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2446
|
+
__decorateMetadata("design:type", Function),
|
|
2447
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2448
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2449
|
+
], RecoveryWorkflow.prototype, "auditStep", null);
|
|
2450
|
+
__decorate([
|
|
2451
|
+
Step("recoveryFreshLoginFinish"),
|
|
2452
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2453
|
+
__decorateMetadata("design:type", Function),
|
|
2454
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2455
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2456
|
+
], RecoveryWorkflow.prototype, "freshLoginFinish", null);
|
|
2457
|
+
__decorate([
|
|
2458
|
+
Step("recoveryAutoLoginFinish"),
|
|
2459
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
2460
|
+
__decorateMetadata("design:type", Function),
|
|
2461
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
2462
|
+
__decorateMetadata("design:returntype", Promise)
|
|
2463
|
+
], RecoveryWorkflow.prototype, "autoLoginFinish", null);
|
|
2464
|
+
RecoveryWorkflow = __decorate([
|
|
2465
|
+
Public(),
|
|
2466
|
+
Injectable("FOR_EVENT"),
|
|
2467
|
+
Controller(),
|
|
2468
|
+
__decorateMetadata("design:paramtypes", [
|
|
2469
|
+
Object,
|
|
2470
|
+
typeof (_ref$1 = typeof UserService !== "undefined" && UserService) === "function" ? _ref$1 : Object,
|
|
2471
|
+
typeof (_ref2$1 = typeof AuthCredential !== "undefined" && AuthCredential) === "function" ? _ref2$1 : Object
|
|
2472
|
+
])
|
|
2473
|
+
], RecoveryWorkflow);
|
|
2474
|
+
/**
|
|
2475
|
+
* Reads the `?username=` query parameter when the workflow is triggered (e.g.
|
|
2476
|
+
* via the login workflow's `forgotPassword` alt-action). Returns undefined
|
|
2477
|
+
* outside of an HTTP event context (e.g. unit tests that hand-roll the wf
|
|
2478
|
+
* runtime). Used purely for form pre-fill.
|
|
2479
|
+
*/
|
|
2480
|
+
function readUsernameQueryParam() {
|
|
2481
|
+
try {
|
|
2482
|
+
const { params } = useUrlParams(current());
|
|
2483
|
+
return params().get("username") ?? void 0;
|
|
2484
|
+
} catch {
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
//#endregion
|
|
2489
|
+
//#region src/workflows/invite.workflow.options.ts
|
|
2490
|
+
const DEFAULT_INVITE_TOKEN_TTL_MS = 10080 * 60 * 1e3;
|
|
2491
|
+
/**
|
|
2492
|
+
* Deep-merge defaults with the user-supplied nested pojo. Each group has its
|
|
2493
|
+
* own `{ ...defaults, ...input }` line — small enough that pulling in lodash
|
|
2494
|
+
* would be silly.
|
|
2495
|
+
*/
|
|
2496
|
+
function mergeInviteOpts(opts = {}) {
|
|
2497
|
+
return {
|
|
2498
|
+
adminForm: {
|
|
2499
|
+
collectRoles: true,
|
|
2500
|
+
...opts.adminForm
|
|
2501
|
+
},
|
|
2502
|
+
send: {
|
|
2503
|
+
mode: "email",
|
|
2504
|
+
tokenTtlMs: DEFAULT_INVITE_TOKEN_TTL_MS,
|
|
2505
|
+
...opts.send
|
|
2506
|
+
},
|
|
2507
|
+
accept: {
|
|
2508
|
+
alreadyAcceptedRedirectUrl: "/login",
|
|
2509
|
+
freshLoginRequired: false,
|
|
2510
|
+
loginUrl: "/login",
|
|
2511
|
+
showConfirmation: true,
|
|
2512
|
+
confirmationMessage: "Your account has been created.",
|
|
2513
|
+
...opts.accept
|
|
2514
|
+
},
|
|
2515
|
+
cancellation: {
|
|
2516
|
+
allowed: true,
|
|
2517
|
+
...opts.cancellation
|
|
2518
|
+
},
|
|
2519
|
+
audit: {
|
|
2520
|
+
enabled: true,
|
|
2521
|
+
...opts.audit
|
|
2522
|
+
},
|
|
2523
|
+
forms: {
|
|
2524
|
+
invite: InviteForm,
|
|
2525
|
+
inviteEmail: InviteEmailForm,
|
|
2526
|
+
inviteSendMode: InviteSendModeForm,
|
|
2527
|
+
setPassword: SetPasswordForm,
|
|
2528
|
+
...opts.forms
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
//#endregion
|
|
2533
|
+
//#region src/workflows/invite.workflow.ts
|
|
2534
|
+
/**
|
|
2535
|
+
* InviteWorkflow — registers three workflow ids:
|
|
2536
|
+
*
|
|
2537
|
+
* - `auth.invite` — admin invites a new user → user accepts (full flow)
|
|
2538
|
+
* - `auth.reInvite` — admin resends to an existing `pendingInvitation` user
|
|
2539
|
+
* - `auth.cancelInvite` — admin hard-deletes a pending invite (gated by
|
|
2540
|
+
* `opts.cancellation.allowed`)
|
|
2541
|
+
*
|
|
2542
|
+
* Full step catalog per `WF_INVITE.md`. Step IDs are workflow-scoped (all
|
|
2543
|
+
* prefixed `invite*`) because `@moostjs/event-wf` registers `@Step('id')`
|
|
2544
|
+
* globally — identical IDs across login/recovery/invite would silently
|
|
2545
|
+
* collide and overwrite handlers. The accept tail (`inviteCheckPending…`
|
|
2546
|
+
* through `inviteAutoLoginFinish`) is shared between `auth.invite` and
|
|
2547
|
+
* `auth.reInvite` schemas by re-referencing the same step IDs.
|
|
2548
|
+
*
|
|
2549
|
+
* **Step routing model.** Same shape as `RecoveryWorkflow`: alt-action
|
|
2550
|
+
* handlers run BEFORE form validation (so `cancel` works without filling
|
|
2551
|
+
* fields) and return the `ALT_HANDLED` sentinel after short-circuiting via
|
|
2552
|
+
* `useWfFinished().set(...)`. Terminal steps gate on `!ctx.aborted` so the
|
|
2553
|
+
* abort response stays.
|
|
2554
|
+
*
|
|
2555
|
+
* **Consumer subclass pattern (Phase 4 reshape).** Consumers subclass
|
|
2556
|
+
* `InviteWorkflow` to override `protected` hook methods. The subclass MUST
|
|
2557
|
+
* re-apply `@Inherit() @Injectable('FOR_EVENT') @Controller()` and re-declare
|
|
2558
|
+
* the constructor signature (TS emits fresh design-paramtypes per class).
|
|
2559
|
+
*
|
|
2560
|
+
* **Side-effect deps as protected methods.** Sender/store/emitter DI
|
|
2561
|
+
* providers have been DROPPED from the constructor. Hooks live as `protected`
|
|
2562
|
+
* methods consumers override:
|
|
2563
|
+
*
|
|
2564
|
+
* - `deliver(payload)` — unified email + SMS dispatch (see `DeliverPayload`).
|
|
2565
|
+
* Default throws; override to wire your senders. The default invite send
|
|
2566
|
+
* uses `outletEmail` (handled by `createAuthEmailOutlet` at the trigger
|
|
2567
|
+
* route) so `deliver()` is only invoked if a consumer's accept-tail steps
|
|
2568
|
+
* drive a manual send. Kept exposed for parity with login/recovery and to
|
|
2569
|
+
* give consumer subclasses a single override seam for future SMS-invite
|
|
2570
|
+
* work.
|
|
2571
|
+
* - `audit(event)` — fire audit events. Default: no-op.
|
|
2572
|
+
*
|
|
2573
|
+
* **Replaceable behaviour as protected methods.**
|
|
2574
|
+
*
|
|
2575
|
+
* - `prepareUser(input)` — extras merged into the freshly-created user row.
|
|
2576
|
+
* - `getAvailableRoles()` — multi-select source for the admin invite form.
|
|
2577
|
+
* - `inferRoles(input)` — derive roles server-side (e.g. AD lookup).
|
|
2578
|
+
* - `applyProfile({username, profile})` — persist the accept-time profile.
|
|
2579
|
+
* - `duplicateCheck({email, existingUser})` — override the structural
|
|
2580
|
+
* duplicate rule (multi-tenant escape hatch).
|
|
2581
|
+
* - `getProfileForm()` — return the consumer's `.as` profile form schema;
|
|
2582
|
+
* `undefined` skips the profile-collection step.
|
|
2583
|
+
*
|
|
2584
|
+
* Rate-limiting is intentionally NOT part of this workflow — consumers who
|
|
2585
|
+
* want a cap wire it themselves at the HTTP / trigger layer.
|
|
2586
|
+
*/
|
|
2587
|
+
var _ref, _ref2;
|
|
2588
|
+
/**
|
|
2589
|
+
* Sentinel returned by alt-action handlers that have already short-circuited
|
|
2590
|
+
* via `useWfFinished().set(...)`. The step body returns `undefined` after
|
|
2591
|
+
* seeing this so the schema advances without running form validation on the
|
|
2592
|
+
* alt-action payload (which lacks the form's required fields).
|
|
2593
|
+
*/
|
|
2594
|
+
const ALT_HANDLED = Symbol("ALT_HANDLED");
|
|
2595
|
+
/** Audit `kind` → `wfid` reverse map. Default falls through to `auth.invite`. */
|
|
2596
|
+
const AUDIT_WORKFLOW_BY_KIND = {
|
|
2597
|
+
"invite.cancelled": "auth.cancelInvite",
|
|
2598
|
+
"invite.resent": "auth.reInvite"
|
|
2599
|
+
};
|
|
2600
|
+
/** Trim + de-duplicate role identifiers submitted via the admin invite form. */
|
|
2601
|
+
function parseInviteRoles(input) {
|
|
2602
|
+
if (!Array.isArray(input)) return [];
|
|
2603
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2604
|
+
for (const v of input) {
|
|
2605
|
+
const trimmed = typeof v === "string" ? v.trim() : "";
|
|
2606
|
+
if (trimmed) seen.add(trimmed);
|
|
2607
|
+
}
|
|
2608
|
+
return [...seen];
|
|
2609
|
+
}
|
|
2610
|
+
let InviteWorkflow = class InviteWorkflow {
|
|
2611
|
+
opts;
|
|
2612
|
+
users;
|
|
2613
|
+
auth;
|
|
2614
|
+
constructor(opts, users, auth) {
|
|
2615
|
+
this.opts = mergeInviteOpts(opts);
|
|
2616
|
+
this.users = users;
|
|
2617
|
+
this.auth = auth;
|
|
2618
|
+
this.opts;
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Dispatch an email or SMS event. Default throws — the default invite send
|
|
2622
|
+
* uses `outletEmail` (handled by `createAuthEmailOutlet`) so this method is
|
|
2623
|
+
* only invoked when a consumer's accept-tail steps drive a manual send.
|
|
2624
|
+
* Override to wire your senders.
|
|
2625
|
+
*/
|
|
2626
|
+
async deliver(_payload) {
|
|
2627
|
+
throw new Error("InviteWorkflow.deliver() not configured — override to wire your email/sms sender");
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* Emit an audit event. Default: no-op. Consumers override to fan out to
|
|
2631
|
+
* their audit sink.
|
|
2632
|
+
*/
|
|
2633
|
+
async audit(_event) {}
|
|
2634
|
+
/**
|
|
2635
|
+
* Build the extras dictionary merged into the freshly-created user row in
|
|
2636
|
+
* `invitePreCreateUser`. Default: `{}`. Override to populate e.g. a
|
|
2637
|
+
* required `tenantId`. This is the ONLY seam through which the admin form's
|
|
2638
|
+
* `firstName` / `lastName` reach persistence — map them into your schema's
|
|
2639
|
+
* own columns (e.g. `displayName`) and return them here.
|
|
2640
|
+
*/
|
|
2641
|
+
async prepareUser(_input) {
|
|
2642
|
+
return {};
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Return the list of selectable role identifiers for the admin invite form.
|
|
2646
|
+
* When defined AND `adminForm.collectRoles` is true → form ships
|
|
2647
|
+
* `ctx.availableRoles` so the UI renders a multi-select AND the
|
|
2648
|
+
* `inviteAdminInviteForm` step rejects admin-submitted roles outside the
|
|
2649
|
+
* list. When `undefined` (default) → no whitelist is enforced and any role
|
|
2650
|
+
* value the admin form supplies is accepted.
|
|
2651
|
+
*/
|
|
2652
|
+
async getAvailableRoles() {}
|
|
2653
|
+
/**
|
|
2654
|
+
* Derive roles server-side from the admin-form payload (e.g. email domain
|
|
2655
|
+
* → tenant role, AD lookup). Result is set-unioned with admin-supplied
|
|
2656
|
+
* roles when `adminForm.collectRoles` is true. Default: `[]` (no inference).
|
|
2657
|
+
*/
|
|
2658
|
+
async inferRoles(_input) {
|
|
2659
|
+
return [];
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Persist the accept-time profile payload. Default: deep-merge into the
|
|
2663
|
+
* user record via `UserService.update(username, profile)`. Override to
|
|
2664
|
+
* route into a separate profile table / external CRM.
|
|
2665
|
+
*/
|
|
2666
|
+
async applyProfile(input) {
|
|
2667
|
+
await this.users.update(input.username, input.profile);
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Override the structural duplicate rule for `inviteAdminInviteForm`.
|
|
2671
|
+
* Default: any existing row → `'reject'`; nothing → `'allow'`. Multi-tenant
|
|
2672
|
+
* apps that allow re-inviting the same email into a different tenant
|
|
2673
|
+
* override to return `'allow'` for those cases.
|
|
2674
|
+
*/
|
|
2675
|
+
async duplicateCheck(input) {
|
|
2676
|
+
return input.existingUser ? "reject" : "allow";
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Return the consumer-supplied `.as` form schema rendered in the
|
|
2680
|
+
* `inviteCollectProfile` step. `undefined` (default) skips the step
|
|
2681
|
+
* entirely (just password collection).
|
|
2682
|
+
*/
|
|
2683
|
+
getProfileForm() {}
|
|
2684
|
+
/**
|
|
2685
|
+
* Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
|
|
2686
|
+
* conditions to read. Default: drop the `forms` group (atscript form classes
|
|
2687
|
+
* are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
|
|
2688
|
+
* Step bodies still consult the form classes via `this.opts.forms.*`.
|
|
2689
|
+
*
|
|
2690
|
+
* Consumers who extend the opts type with non-JSON values can override this
|
|
2691
|
+
* to strip them so `AsWfStore`'s plain-JSON persistence doesn't choke.
|
|
2692
|
+
*/
|
|
2693
|
+
snapshotOpts(opts) {
|
|
2694
|
+
const { forms: _forms, ...rest } = opts;
|
|
2695
|
+
return rest;
|
|
2696
|
+
}
|
|
2697
|
+
inviteFlow() {}
|
|
2698
|
+
reInviteFlow() {}
|
|
2699
|
+
cancelInviteFlow() {}
|
|
2700
|
+
init(ctx) {
|
|
2701
|
+
ctx.opts = this.snapshotOpts(this.opts);
|
|
2702
|
+
ctx.acceptProfileFormPresent = this.getProfileForm() !== void 0;
|
|
2703
|
+
if (this.opts.send.mode !== "choice") ctx.resolvedSendMode = this.opts.send.mode;
|
|
2704
|
+
}
|
|
2705
|
+
async prepareAvailableRoles(ctx) {
|
|
2706
|
+
const roles = await this.getAvailableRoles();
|
|
2707
|
+
if (roles) ctx.availableRoles = roles;
|
|
2708
|
+
}
|
|
2709
|
+
selectSendMode(input, ctx) {
|
|
2710
|
+
if (!input) return httpInputRequired(this.opts.forms.inviteSendMode, ctx);
|
|
2711
|
+
if (input.action === "cancel") return this.abort(ctx, "cancel");
|
|
2712
|
+
const errors = validateFormInput(this.opts.forms.inviteSendMode, input);
|
|
2713
|
+
if (errors) return httpInputRequired(this.opts.forms.inviteSendMode, ctx, errors);
|
|
2714
|
+
const mode = input.mode;
|
|
2715
|
+
ctx.selectedSendMode = mode;
|
|
2716
|
+
ctx.resolvedSendMode = mode;
|
|
2717
|
+
}
|
|
2718
|
+
async adminInviteForm(input, ctx) {
|
|
2719
|
+
if (!input) return httpInputRequired(this.opts.forms.invite, ctx);
|
|
2720
|
+
if (input.action === "cancel") return this.abort(ctx, "cancel");
|
|
2721
|
+
const errors = validateFormInput(this.opts.forms.invite, input);
|
|
2722
|
+
if (errors) return httpInputRequired(this.opts.forms.invite, ctx, errors);
|
|
2723
|
+
const email = input.email;
|
|
2724
|
+
const parsed = parseInviteRoles(input.roles);
|
|
2725
|
+
if (Array.isArray(ctx.availableRoles)) {
|
|
2726
|
+
const allowed = new Set(ctx.availableRoles);
|
|
2727
|
+
if (parsed.find((r) => !allowed.has(r)) !== void 0) return httpInputRequired(this.opts.forms.invite, ctx, { roles: "Invalid role" });
|
|
2728
|
+
}
|
|
2729
|
+
const existing = await this.loadUserOrNull(email);
|
|
2730
|
+
if (await this.duplicateCheck({
|
|
2731
|
+
email,
|
|
2732
|
+
existingUser: existing
|
|
2733
|
+
}) === "reject") {
|
|
2734
|
+
if (existing?.account?.pendingInvitation) throw new HttpError$1(409, "Invite already pending, use reInvite");
|
|
2735
|
+
if (existing) throw new HttpError$1(409, "User already exists");
|
|
2736
|
+
throw new HttpError$1(409, "Duplicate invite rejected");
|
|
2737
|
+
}
|
|
2738
|
+
ctx.email = email;
|
|
2739
|
+
if (input.firstName) ctx.firstName = input.firstName;
|
|
2740
|
+
if (input.lastName) ctx.lastName = input.lastName;
|
|
2741
|
+
if (parsed.length > 0) ctx.roles = parsed;
|
|
2742
|
+
}
|
|
2743
|
+
async inferRolesStep(ctx) {
|
|
2744
|
+
if (!ctx.email) return void 0;
|
|
2745
|
+
const inferred = await this.inferRoles({
|
|
2746
|
+
email: ctx.email,
|
|
2747
|
+
...ctx.firstName && { firstName: ctx.firstName },
|
|
2748
|
+
...ctx.lastName && { lastName: ctx.lastName }
|
|
2749
|
+
});
|
|
2750
|
+
if (inferred.length === 0) return void 0;
|
|
2751
|
+
const merged = new Set([...ctx.roles ?? [], ...inferred]);
|
|
2752
|
+
ctx.roles = Array.from(merged);
|
|
2753
|
+
}
|
|
2754
|
+
async preCreateUser(ctx) {
|
|
2755
|
+
if (!ctx.email) throw new HttpError$1(500, "Workflow state corrupted: missing email");
|
|
2756
|
+
const invitedBy = useAuth().getAuthContext()?.userId;
|
|
2757
|
+
const preparedInput = {
|
|
2758
|
+
email: ctx.email,
|
|
2759
|
+
...ctx.firstName && { firstName: ctx.firstName },
|
|
2760
|
+
...ctx.lastName && { lastName: ctx.lastName },
|
|
2761
|
+
roles: ctx.roles ?? [],
|
|
2762
|
+
...invitedBy && { invitedBy }
|
|
2763
|
+
};
|
|
2764
|
+
const fields = {
|
|
2765
|
+
...await this.prepareUser(preparedInput),
|
|
2766
|
+
...ctx.roles && ctx.roles.length > 0 && { roles: ctx.roles }
|
|
2767
|
+
};
|
|
2768
|
+
try {
|
|
2769
|
+
await this.users.createUser(ctx.email, void 0, fields);
|
|
2770
|
+
} catch (err) {
|
|
2771
|
+
if (err instanceof UserAuthError && err.type === "ALREADY_EXISTS") throw new HttpError$1(409, "User already exists");
|
|
2772
|
+
throw err;
|
|
2773
|
+
}
|
|
2774
|
+
await this.users.update(ctx.email, { account: { pendingInvitation: true } });
|
|
2775
|
+
ctx.username = ctx.email;
|
|
2776
|
+
await this.emitAudit("invite.created", ctx);
|
|
2777
|
+
}
|
|
2778
|
+
sendInviteEmail(ctx) {
|
|
2779
|
+
if (ctx.linkSent) return void 0;
|
|
2780
|
+
ctx.linkSent = true;
|
|
2781
|
+
return {
|
|
2782
|
+
...outletEmail(ctx.email, "invite.magicLink", {
|
|
2783
|
+
username: ctx.username,
|
|
2784
|
+
...ctx.roles && { roles: ctx.roles },
|
|
2785
|
+
expiresAtMs: this.opts.send.tokenTtlMs
|
|
2786
|
+
}),
|
|
2787
|
+
expires: Date.now() + this.opts.send.tokenTtlMs
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
returnShareableLink(ctx) {
|
|
2791
|
+
if (ctx.linkSent) return void 0;
|
|
2792
|
+
ctx.linkSent = true;
|
|
2793
|
+
return {
|
|
2794
|
+
...outletEmail(ctx.email, "invite.magicLink", {
|
|
2795
|
+
username: ctx.username,
|
|
2796
|
+
...ctx.roles && { roles: ctx.roles },
|
|
2797
|
+
expiresAtMs: this.opts.send.tokenTtlMs,
|
|
2798
|
+
shareableLink: true
|
|
2799
|
+
}),
|
|
2800
|
+
expires: Date.now() + this.opts.send.tokenTtlMs
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
async loadPendingUser(input, ctx) {
|
|
2804
|
+
if (!input) return httpInputRequired(this.opts.forms.inviteEmail, ctx);
|
|
2805
|
+
if (input.action === "cancel") return this.abort(ctx, "cancel");
|
|
2806
|
+
const errors = validateFormInput(this.opts.forms.inviteEmail, input);
|
|
2807
|
+
if (errors) return httpInputRequired(this.opts.forms.inviteEmail, ctx, errors);
|
|
2808
|
+
const email = input.email;
|
|
2809
|
+
const existing = await this.loadUserOrNull(email);
|
|
2810
|
+
if (!existing) throw new HttpError$1(404, "No pending invite for this email");
|
|
2811
|
+
if (!existing.account?.pendingInvitation) throw new HttpError$1(409, "User has already accepted; cannot resend");
|
|
2812
|
+
ctx.email = email;
|
|
2813
|
+
ctx.username = existing.username;
|
|
2814
|
+
const u = existing;
|
|
2815
|
+
if (u.firstName) ctx.firstName = u.firstName;
|
|
2816
|
+
if (u.lastName) ctx.lastName = u.lastName;
|
|
2817
|
+
if (u.roles && u.roles.length > 0) ctx.roles = u.roles;
|
|
2818
|
+
await this.emitAudit("invite.resent", ctx);
|
|
2819
|
+
}
|
|
2820
|
+
async checkPendingInvitation(ctx) {
|
|
2821
|
+
if (!ctx.username) throw new HttpError$1(500, "Workflow state corrupted: missing username at accept");
|
|
2822
|
+
const existing = await this.loadUserOrNull(ctx.username);
|
|
2823
|
+
if (!existing) throw new HttpError$1(410, "This invite has been cancelled");
|
|
2824
|
+
if (!existing.account?.pendingInvitation) ctx.alreadyAccepted = true;
|
|
2825
|
+
}
|
|
2826
|
+
idempotentRedirect(ctx) {
|
|
2827
|
+
const altUrl = this.opts.accept.alreadyAcceptedRedirectUrl;
|
|
2828
|
+
finishWfWithChoice({
|
|
2829
|
+
message: {
|
|
2830
|
+
level: "info",
|
|
2831
|
+
text: "This invite was already accepted."
|
|
2832
|
+
},
|
|
2833
|
+
primary: {
|
|
2834
|
+
label: "Go to sign-in",
|
|
2835
|
+
action: {
|
|
2836
|
+
type: "redirect",
|
|
2837
|
+
target: this.opts.accept.loginUrl,
|
|
2838
|
+
reason: "already-accepted"
|
|
2839
|
+
}
|
|
2840
|
+
},
|
|
2841
|
+
...altUrl && { options: [{
|
|
2842
|
+
label: "Request a new invite",
|
|
2843
|
+
action: {
|
|
2844
|
+
type: "redirect",
|
|
2845
|
+
target: altUrl,
|
|
2846
|
+
reason: "request-new-invite"
|
|
2847
|
+
}
|
|
2848
|
+
}] }
|
|
2849
|
+
});
|
|
2850
|
+
ctx.aborted = true;
|
|
2851
|
+
}
|
|
2852
|
+
preparePasswordRules(ctx) {
|
|
2853
|
+
ctx.passwordPolicies = this.users.getTransferablePolicies();
|
|
2854
|
+
}
|
|
2855
|
+
async createPasswordForm(input, ctx) {
|
|
2856
|
+
if (!input) return httpInputRequired(this.opts.forms.setPassword, ctx);
|
|
2857
|
+
if (input.action === "cancel") return this.abort(ctx, "cancel");
|
|
2858
|
+
const errors = validateFormInput(this.opts.forms.setPassword, input);
|
|
2859
|
+
if (errors) return httpInputRequired(this.opts.forms.setPassword, ctx, errors);
|
|
2860
|
+
if (input.newPassword !== input.confirmPassword) return httpInputRequired(this.opts.forms.setPassword, ctx, { confirmPassword: "Passwords do not match" });
|
|
2861
|
+
requireUsername(ctx);
|
|
2862
|
+
try {
|
|
2863
|
+
await this.users.setPassword(ctx.username, input.newPassword);
|
|
2864
|
+
} catch (err) {
|
|
2865
|
+
translatePasswordSetError(err);
|
|
2866
|
+
}
|
|
2867
|
+
ctx.passwordSet = true;
|
|
2868
|
+
}
|
|
2869
|
+
async collectProfile(input, ctx) {
|
|
2870
|
+
const form = this.getProfileForm();
|
|
2871
|
+
if (!form) return void 0;
|
|
2872
|
+
if (!input) return httpInputRequired(form, ctx);
|
|
2873
|
+
if (input.action === "skip") {
|
|
2874
|
+
ctx.profile = {};
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
const errors = validateFormInput(form, input, { partial: "deep" });
|
|
2878
|
+
if (errors) return httpInputRequired(form, ctx, errors);
|
|
2879
|
+
ctx.profile = input;
|
|
2880
|
+
}
|
|
2881
|
+
async applyProfileStep(ctx) {
|
|
2882
|
+
requireUsername(ctx);
|
|
2883
|
+
const profile = ctx.profile ?? {};
|
|
2884
|
+
if (Object.keys(profile).length === 0) {
|
|
2885
|
+
ctx.profileApplied = true;
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
await this.applyProfile({
|
|
2889
|
+
username: ctx.username,
|
|
2890
|
+
profile
|
|
2891
|
+
});
|
|
2892
|
+
ctx.profileApplied = true;
|
|
2893
|
+
}
|
|
2894
|
+
async unsetPendingInvitation(ctx) {
|
|
2895
|
+
requireUsername(ctx);
|
|
2896
|
+
await this.users.update(ctx.username, { account: { pendingInvitation: false } });
|
|
2897
|
+
ctx.pendingInvitationCleared = true;
|
|
2898
|
+
}
|
|
2899
|
+
async activateUser(ctx) {
|
|
2900
|
+
requireUsername(ctx);
|
|
2901
|
+
await this.users.activateAccount(ctx.username);
|
|
2902
|
+
ctx.activated = true;
|
|
2903
|
+
await this.emitAudit("invite.accepted", ctx);
|
|
2904
|
+
}
|
|
2905
|
+
confirmation(ctx) {
|
|
2906
|
+
ctx.confirmationShown = true;
|
|
2907
|
+
finishWfWithData({ confirmed: true }, {
|
|
2908
|
+
level: "success",
|
|
2909
|
+
text: this.opts.accept.confirmationMessage
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
freshLoginFinish(_ctx) {
|
|
2913
|
+
finishWfWithRedirect(this.opts.accept.loginUrl, { reason: "fresh-login-required" });
|
|
2914
|
+
}
|
|
2915
|
+
async autoLoginFinish(ctx) {
|
|
2916
|
+
requireUsername(ctx);
|
|
2917
|
+
const issue = await this.auth.issue(ctx.username);
|
|
2918
|
+
ctx.tokensIssued = true;
|
|
2919
|
+
const auth = useAuth();
|
|
2920
|
+
const envelope = {
|
|
2921
|
+
finished: true,
|
|
2922
|
+
data: auth.buildLoginResponse(ctx.username, issue)
|
|
2923
|
+
};
|
|
2924
|
+
useWfFinished().set({
|
|
2925
|
+
type: "data",
|
|
2926
|
+
value: envelope,
|
|
2927
|
+
cookies: auth.buildFinishedCookies(issue)
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
async cancelInvite(input, ctx) {
|
|
2931
|
+
if (!this.opts.cancellation.allowed) throw new HttpError$1(403, "Invite cancellation is disabled");
|
|
2932
|
+
if (!input) return httpInputRequired(this.opts.forms.inviteEmail, ctx);
|
|
2933
|
+
const errors = validateFormInput(this.opts.forms.inviteEmail, input);
|
|
2934
|
+
if (errors) return httpInputRequired(this.opts.forms.inviteEmail, ctx, errors);
|
|
2935
|
+
const email = input.email;
|
|
2936
|
+
const existing = await this.loadUserOrNull(email);
|
|
2937
|
+
if (!existing) throw new HttpError$1(404, "No invite to cancel for this email");
|
|
2938
|
+
if (!existing.account?.pendingInvitation) throw new HttpError$1(409, "Cannot cancel: user has already accepted the invite");
|
|
2939
|
+
await this.users.deleteUser(existing.username);
|
|
2940
|
+
await this.emitAudit("invite.cancelled", {
|
|
2941
|
+
...ctx,
|
|
2942
|
+
email,
|
|
2943
|
+
username: existing.username
|
|
2944
|
+
});
|
|
2945
|
+
finishWfWithData({
|
|
2946
|
+
cancelled: true,
|
|
2947
|
+
email
|
|
2948
|
+
}, {
|
|
2949
|
+
level: "info",
|
|
2950
|
+
text: "Invite cancelled."
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
abort(ctx, reason) {
|
|
2954
|
+
finishWfAborted(reason, { message: {
|
|
2955
|
+
level: "info",
|
|
2956
|
+
text: "Invite cancelled."
|
|
2957
|
+
} });
|
|
2958
|
+
ctx.aborted = true;
|
|
2959
|
+
return ALT_HANDLED;
|
|
2960
|
+
}
|
|
2961
|
+
async loadUserOrNull(username) {
|
|
2962
|
+
try {
|
|
2963
|
+
return await this.users.getUser(username);
|
|
2964
|
+
} catch (err) {
|
|
2965
|
+
if (err instanceof UserAuthError && err.type === "NOT_FOUND") return null;
|
|
2966
|
+
throw err;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
async emitAudit(kind, ctx) {
|
|
2970
|
+
if (!this.opts.audit.enabled) return;
|
|
2971
|
+
const invitedBy = useAuth().getAuthContext()?.userId;
|
|
2972
|
+
await this.audit({
|
|
2973
|
+
kind,
|
|
2974
|
+
workflow: AUDIT_WORKFLOW_BY_KIND[kind] ?? "auth.invite",
|
|
2975
|
+
...ctx.username && { userId: ctx.username },
|
|
2976
|
+
...invitedBy && { invitedBy },
|
|
2977
|
+
...ctx.email && { email: ctx.email },
|
|
2978
|
+
ip: resolveClientIp()
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
};
|
|
2982
|
+
__decorate([
|
|
2983
|
+
Workflow("auth.invite"),
|
|
2984
|
+
WorkflowSchema([
|
|
2985
|
+
{ id: "inviteInit" },
|
|
2986
|
+
{
|
|
2987
|
+
id: "invitePrepareAvailableRoles",
|
|
2988
|
+
condition: (ctx) => ctx.opts.adminForm.collectRoles && !ctx.aborted
|
|
2989
|
+
},
|
|
2990
|
+
{
|
|
2991
|
+
id: "inviteSelectSendMode",
|
|
2992
|
+
condition: (ctx) => ctx.opts.send.mode === "choice" && !ctx.resolvedSendMode && !ctx.aborted
|
|
2993
|
+
},
|
|
2994
|
+
{
|
|
2995
|
+
id: "inviteAdminInviteForm",
|
|
2996
|
+
condition: (ctx) => !ctx.email && !ctx.aborted
|
|
2997
|
+
},
|
|
2998
|
+
{
|
|
2999
|
+
id: "inviteInferRolesStep",
|
|
3000
|
+
condition: (ctx) => !!(ctx.email && !ctx.aborted)
|
|
3001
|
+
},
|
|
3002
|
+
{
|
|
3003
|
+
id: "invitePreCreateUser",
|
|
3004
|
+
condition: (ctx) => !!(ctx.email && !ctx.username && !ctx.aborted)
|
|
3005
|
+
},
|
|
3006
|
+
{
|
|
3007
|
+
id: "inviteSendInviteEmail",
|
|
3008
|
+
condition: (ctx) => !!(ctx.username && ctx.resolvedSendMode === "email" && !ctx.aborted)
|
|
3009
|
+
},
|
|
3010
|
+
{
|
|
3011
|
+
id: "inviteReturnShareableLink",
|
|
3012
|
+
condition: (ctx) => !!(ctx.username && ctx.resolvedSendMode === "shareableLink" && !ctx.aborted)
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
id: "inviteCheckPendingInvitation",
|
|
3016
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.aborted)
|
|
3017
|
+
},
|
|
3018
|
+
{
|
|
3019
|
+
id: "inviteIdempotentRedirect",
|
|
3020
|
+
condition: (ctx) => !!(ctx.alreadyAccepted && !ctx.aborted)
|
|
3021
|
+
},
|
|
3022
|
+
{
|
|
3023
|
+
id: "invitePreparePasswordRules",
|
|
3024
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.alreadyAccepted && !ctx.aborted)
|
|
3025
|
+
},
|
|
3026
|
+
{
|
|
3027
|
+
id: "inviteCreatePasswordForm",
|
|
3028
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.alreadyAccepted && !ctx.passwordSet && !ctx.aborted)
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
id: "inviteCollectProfile",
|
|
3032
|
+
condition: (ctx) => !!(ctx.passwordSet && ctx.acceptProfileFormPresent && !ctx.profile && !ctx.aborted)
|
|
3033
|
+
},
|
|
3034
|
+
{
|
|
3035
|
+
id: "inviteApplyProfile",
|
|
3036
|
+
condition: (ctx) => !!(ctx.passwordSet && ctx.acceptProfileFormPresent && ctx.profile && !ctx.profileApplied && !ctx.aborted)
|
|
3037
|
+
},
|
|
3038
|
+
{
|
|
3039
|
+
id: "inviteUnsetPendingInvitation",
|
|
3040
|
+
condition: (ctx) => !!(ctx.passwordSet && !ctx.pendingInvitationCleared && !ctx.aborted)
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
id: "inviteActivateUser",
|
|
3044
|
+
condition: (ctx) => !!(ctx.pendingInvitationCleared && !ctx.activated && !ctx.aborted)
|
|
3045
|
+
},
|
|
3046
|
+
{
|
|
3047
|
+
id: "inviteConfirmation",
|
|
3048
|
+
condition: (ctx) => !!(ctx.activated && ctx.opts.accept.showConfirmation && !ctx.confirmationShown && !ctx.aborted)
|
|
3049
|
+
},
|
|
3050
|
+
{
|
|
3051
|
+
id: "inviteFreshLoginFinish",
|
|
3052
|
+
condition: (ctx) => !!(ctx.activated && ctx.opts.accept.freshLoginRequired && !ctx.aborted)
|
|
3053
|
+
},
|
|
3054
|
+
{
|
|
3055
|
+
id: "inviteAutoLoginFinish",
|
|
3056
|
+
condition: (ctx) => !!(ctx.activated && !ctx.opts.accept.freshLoginRequired && !ctx.tokensIssued && !ctx.aborted)
|
|
3057
|
+
}
|
|
3058
|
+
]),
|
|
3059
|
+
Public(),
|
|
3060
|
+
__decorateMetadata("design:type", Function),
|
|
3061
|
+
__decorateMetadata("design:paramtypes", []),
|
|
3062
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3063
|
+
], InviteWorkflow.prototype, "inviteFlow", null);
|
|
3064
|
+
__decorate([
|
|
3065
|
+
Workflow("auth.reInvite"),
|
|
3066
|
+
WorkflowSchema([
|
|
3067
|
+
{ id: "inviteInit" },
|
|
3068
|
+
{
|
|
3069
|
+
id: "inviteLoadPendingUser",
|
|
3070
|
+
condition: (ctx) => !ctx.aborted
|
|
3071
|
+
},
|
|
3072
|
+
{
|
|
3073
|
+
id: "inviteSendInviteEmail",
|
|
3074
|
+
condition: (ctx) => !!(ctx.username && ctx.resolvedSendMode === "email" && !ctx.aborted)
|
|
3075
|
+
},
|
|
3076
|
+
{
|
|
3077
|
+
id: "inviteReturnShareableLink",
|
|
3078
|
+
condition: (ctx) => !!(ctx.username && ctx.resolvedSendMode === "shareableLink" && !ctx.aborted)
|
|
3079
|
+
},
|
|
3080
|
+
{
|
|
3081
|
+
id: "inviteCheckPendingInvitation",
|
|
3082
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.aborted)
|
|
3083
|
+
},
|
|
3084
|
+
{
|
|
3085
|
+
id: "inviteIdempotentRedirect",
|
|
3086
|
+
condition: (ctx) => !!(ctx.alreadyAccepted && !ctx.aborted)
|
|
3087
|
+
},
|
|
3088
|
+
{
|
|
3089
|
+
id: "invitePreparePasswordRules",
|
|
3090
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.alreadyAccepted && !ctx.aborted)
|
|
3091
|
+
},
|
|
3092
|
+
{
|
|
3093
|
+
id: "inviteCreatePasswordForm",
|
|
3094
|
+
condition: (ctx) => !!(ctx.linkSent && !ctx.alreadyAccepted && !ctx.passwordSet && !ctx.aborted)
|
|
3095
|
+
},
|
|
3096
|
+
{
|
|
3097
|
+
id: "inviteCollectProfile",
|
|
3098
|
+
condition: (ctx) => !!(ctx.passwordSet && ctx.acceptProfileFormPresent && !ctx.profile && !ctx.aborted)
|
|
3099
|
+
},
|
|
3100
|
+
{
|
|
3101
|
+
id: "inviteApplyProfile",
|
|
3102
|
+
condition: (ctx) => !!(ctx.passwordSet && ctx.acceptProfileFormPresent && ctx.profile && !ctx.profileApplied && !ctx.aborted)
|
|
3103
|
+
},
|
|
3104
|
+
{
|
|
3105
|
+
id: "inviteUnsetPendingInvitation",
|
|
3106
|
+
condition: (ctx) => !!(ctx.passwordSet && !ctx.pendingInvitationCleared && !ctx.aborted)
|
|
3107
|
+
},
|
|
3108
|
+
{
|
|
3109
|
+
id: "inviteActivateUser",
|
|
3110
|
+
condition: (ctx) => !!(ctx.pendingInvitationCleared && !ctx.activated && !ctx.aborted)
|
|
3111
|
+
},
|
|
3112
|
+
{
|
|
3113
|
+
id: "inviteConfirmation",
|
|
3114
|
+
condition: (ctx) => !!(ctx.activated && ctx.opts.accept.showConfirmation && !ctx.confirmationShown && !ctx.aborted)
|
|
3115
|
+
},
|
|
3116
|
+
{
|
|
3117
|
+
id: "inviteFreshLoginFinish",
|
|
3118
|
+
condition: (ctx) => !!(ctx.activated && ctx.opts.accept.freshLoginRequired && !ctx.aborted)
|
|
3119
|
+
},
|
|
3120
|
+
{
|
|
3121
|
+
id: "inviteAutoLoginFinish",
|
|
3122
|
+
condition: (ctx) => !!(ctx.activated && !ctx.opts.accept.freshLoginRequired && !ctx.tokensIssued && !ctx.aborted)
|
|
3123
|
+
}
|
|
3124
|
+
]),
|
|
3125
|
+
Public(),
|
|
3126
|
+
__decorateMetadata("design:type", Function),
|
|
3127
|
+
__decorateMetadata("design:paramtypes", []),
|
|
3128
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3129
|
+
], InviteWorkflow.prototype, "reInviteFlow", null);
|
|
3130
|
+
__decorate([
|
|
3131
|
+
Workflow("auth.cancelInvite"),
|
|
3132
|
+
WorkflowSchema([{ id: "inviteInit" }, {
|
|
3133
|
+
id: "inviteCancelInvite",
|
|
3134
|
+
condition: (ctx) => !ctx.aborted
|
|
3135
|
+
}]),
|
|
3136
|
+
Public(),
|
|
3137
|
+
__decorateMetadata("design:type", Function),
|
|
3138
|
+
__decorateMetadata("design:paramtypes", []),
|
|
3139
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3140
|
+
], InviteWorkflow.prototype, "cancelInviteFlow", null);
|
|
3141
|
+
__decorate([
|
|
3142
|
+
Step("inviteInit"),
|
|
3143
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3144
|
+
__decorateMetadata("design:type", Function),
|
|
3145
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3146
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3147
|
+
], InviteWorkflow.prototype, "init", null);
|
|
3148
|
+
__decorate([
|
|
3149
|
+
Step("invitePrepareAvailableRoles"),
|
|
3150
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3151
|
+
__decorateMetadata("design:type", Function),
|
|
3152
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3153
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3154
|
+
], InviteWorkflow.prototype, "prepareAvailableRoles", null);
|
|
3155
|
+
__decorate([
|
|
3156
|
+
Step("inviteSelectSendMode"),
|
|
3157
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3158
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3159
|
+
__decorateMetadata("design:type", Function),
|
|
3160
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3161
|
+
__decorateMetadata("design:returntype", Object)
|
|
3162
|
+
], InviteWorkflow.prototype, "selectSendMode", null);
|
|
3163
|
+
__decorate([
|
|
3164
|
+
Step("inviteAdminInviteForm"),
|
|
3165
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3166
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3167
|
+
__decorateMetadata("design:type", Function),
|
|
3168
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3169
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3170
|
+
], InviteWorkflow.prototype, "adminInviteForm", null);
|
|
3171
|
+
__decorate([
|
|
3172
|
+
Step("inviteInferRolesStep"),
|
|
3173
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3174
|
+
__decorateMetadata("design:type", Function),
|
|
3175
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3176
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3177
|
+
], InviteWorkflow.prototype, "inferRolesStep", null);
|
|
3178
|
+
__decorate([
|
|
3179
|
+
Step("invitePreCreateUser"),
|
|
3180
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3181
|
+
__decorateMetadata("design:type", Function),
|
|
3182
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3183
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3184
|
+
], InviteWorkflow.prototype, "preCreateUser", null);
|
|
3185
|
+
__decorate([
|
|
3186
|
+
Step("inviteSendInviteEmail"),
|
|
3187
|
+
Public(),
|
|
3188
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3189
|
+
__decorateMetadata("design:type", Function),
|
|
3190
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3191
|
+
__decorateMetadata("design:returntype", Object)
|
|
3192
|
+
], InviteWorkflow.prototype, "sendInviteEmail", null);
|
|
3193
|
+
__decorate([
|
|
3194
|
+
Step("inviteReturnShareableLink"),
|
|
3195
|
+
Public(),
|
|
3196
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3197
|
+
__decorateMetadata("design:type", Function),
|
|
3198
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3199
|
+
__decorateMetadata("design:returntype", Object)
|
|
3200
|
+
], InviteWorkflow.prototype, "returnShareableLink", null);
|
|
3201
|
+
__decorate([
|
|
3202
|
+
Step("inviteLoadPendingUser"),
|
|
3203
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3204
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3205
|
+
__decorateMetadata("design:type", Function),
|
|
3206
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3207
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3208
|
+
], InviteWorkflow.prototype, "loadPendingUser", null);
|
|
3209
|
+
__decorate([
|
|
3210
|
+
Step("inviteCheckPendingInvitation"),
|
|
3211
|
+
Public(),
|
|
3212
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3213
|
+
__decorateMetadata("design:type", Function),
|
|
3214
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3215
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3216
|
+
], InviteWorkflow.prototype, "checkPendingInvitation", null);
|
|
3217
|
+
__decorate([
|
|
3218
|
+
Step("inviteIdempotentRedirect"),
|
|
3219
|
+
Public(),
|
|
3220
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3221
|
+
__decorateMetadata("design:type", Function),
|
|
3222
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3223
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3224
|
+
], InviteWorkflow.prototype, "idempotentRedirect", null);
|
|
3225
|
+
__decorate([
|
|
3226
|
+
Step("invitePreparePasswordRules"),
|
|
3227
|
+
Public(),
|
|
3228
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3229
|
+
__decorateMetadata("design:type", Function),
|
|
3230
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3231
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3232
|
+
], InviteWorkflow.prototype, "preparePasswordRules", null);
|
|
3233
|
+
__decorate([
|
|
3234
|
+
Step("inviteCreatePasswordForm"),
|
|
3235
|
+
Public(),
|
|
3236
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3237
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3238
|
+
__decorateMetadata("design:type", Function),
|
|
3239
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3240
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3241
|
+
], InviteWorkflow.prototype, "createPasswordForm", null);
|
|
3242
|
+
__decorate([
|
|
3243
|
+
Step("inviteCollectProfile"),
|
|
3244
|
+
Public(),
|
|
3245
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3246
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3247
|
+
__decorateMetadata("design:type", Function),
|
|
3248
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3249
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3250
|
+
], InviteWorkflow.prototype, "collectProfile", null);
|
|
3251
|
+
__decorate([
|
|
3252
|
+
Step("inviteApplyProfile"),
|
|
3253
|
+
Public(),
|
|
3254
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3255
|
+
__decorateMetadata("design:type", Function),
|
|
3256
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3257
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3258
|
+
], InviteWorkflow.prototype, "applyProfileStep", null);
|
|
3259
|
+
__decorate([
|
|
3260
|
+
Step("inviteUnsetPendingInvitation"),
|
|
3261
|
+
Public(),
|
|
3262
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3263
|
+
__decorateMetadata("design:type", Function),
|
|
3264
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3265
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3266
|
+
], InviteWorkflow.prototype, "unsetPendingInvitation", null);
|
|
3267
|
+
__decorate([
|
|
3268
|
+
Step("inviteActivateUser"),
|
|
3269
|
+
Public(),
|
|
3270
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3271
|
+
__decorateMetadata("design:type", Function),
|
|
3272
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3273
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3274
|
+
], InviteWorkflow.prototype, "activateUser", null);
|
|
3275
|
+
__decorate([
|
|
3276
|
+
Step("inviteConfirmation"),
|
|
3277
|
+
Public(),
|
|
3278
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3279
|
+
__decorateMetadata("design:type", Function),
|
|
3280
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3281
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3282
|
+
], InviteWorkflow.prototype, "confirmation", null);
|
|
3283
|
+
__decorate([
|
|
3284
|
+
Step("inviteFreshLoginFinish"),
|
|
3285
|
+
Public(),
|
|
3286
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3287
|
+
__decorateMetadata("design:type", Function),
|
|
3288
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3289
|
+
__decorateMetadata("design:returntype", void 0)
|
|
3290
|
+
], InviteWorkflow.prototype, "freshLoginFinish", null);
|
|
3291
|
+
__decorate([
|
|
3292
|
+
Step("inviteAutoLoginFinish"),
|
|
3293
|
+
Public(),
|
|
3294
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
3295
|
+
__decorateMetadata("design:type", Function),
|
|
3296
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
3297
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3298
|
+
], InviteWorkflow.prototype, "autoLoginFinish", null);
|
|
3299
|
+
__decorate([
|
|
3300
|
+
Step("inviteCancelInvite"),
|
|
3301
|
+
__decorateParam(0, WorkflowParam("input")),
|
|
3302
|
+
__decorateParam(1, WorkflowParam("context")),
|
|
3303
|
+
__decorateMetadata("design:type", Function),
|
|
3304
|
+
__decorateMetadata("design:paramtypes", [Object, Object]),
|
|
3305
|
+
__decorateMetadata("design:returntype", Promise)
|
|
3306
|
+
], InviteWorkflow.prototype, "cancelInvite", null);
|
|
3307
|
+
InviteWorkflow = __decorate([
|
|
3308
|
+
ArbacResource("auth.invite"),
|
|
3309
|
+
ArbacAction("start"),
|
|
3310
|
+
Injectable("FOR_EVENT"),
|
|
3311
|
+
Controller(),
|
|
3312
|
+
__decorateMetadata("design:paramtypes", [
|
|
3313
|
+
Object,
|
|
3314
|
+
typeof (_ref = typeof UserService !== "undefined" && UserService) === "function" ? _ref : Object,
|
|
3315
|
+
typeof (_ref2 = typeof AuthCredential !== "undefined" && AuthCredential) === "function" ? _ref2 : Object
|
|
3316
|
+
])
|
|
3317
|
+
], InviteWorkflow);
|
|
3318
|
+
//#endregion
|
|
3319
|
+
//#region src/workflows/auth-email-outlet.ts
|
|
3320
|
+
/**
|
|
3321
|
+
* Build the email outlet that delivers magic links via the consumer's
|
|
3322
|
+
* `EmailSender`. Single-use: pass the same instance into one
|
|
3323
|
+
* `handleWfOutletRequest({ outlets })`.
|
|
3324
|
+
*/
|
|
3325
|
+
function createAuthEmailOutlet(deps) {
|
|
3326
|
+
return createEmailOutlet(async (opts) => {
|
|
3327
|
+
const template = opts.template;
|
|
3328
|
+
const ttlHint = typeof opts.context?.expiresAtMs === "number" ? opts.context.expiresAtMs : 0;
|
|
3329
|
+
const expiresAt = Date.now() + (ttlHint || deps.magicLinkTtlMs(template));
|
|
3330
|
+
const url = deps.buildMagicLinkUrl(template, opts.token);
|
|
3331
|
+
const event = {
|
|
3332
|
+
kind: template,
|
|
3333
|
+
recipient: opts.target,
|
|
3334
|
+
url,
|
|
3335
|
+
expiresAt
|
|
3336
|
+
};
|
|
3337
|
+
if (typeof opts.context?.username === "string") event.username = opts.context.username;
|
|
3338
|
+
const rolesRaw = opts.context?.roles;
|
|
3339
|
+
if (Array.isArray(rolesRaw)) {
|
|
3340
|
+
const roles = rolesRaw.filter((r) => typeof r === "string");
|
|
3341
|
+
if (roles.length > 0) event.metadata = { roles };
|
|
3342
|
+
}
|
|
3343
|
+
await deps.emailSender.send(event);
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
//#endregion
|
|
3347
|
+
export { AuthController, AuthGuarded, DEFAULT_AUTH_WORKFLOWS, InviteWorkflow, LoginWorkflow, Public, RecoveryWorkflow, UserId, WfTrigger, WfTriggerProvider, authGuardInterceptor, createAuthEmailOutlet, generateMagicLinkToken, getAuthMate, mergeInviteOpts, mergeLoginOpts, mergeRecoveryOpts, parseInviteRoles, useAuth };
|