@jwilleke/ngdpbase-types 5.40.0 → 5.41.0
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/lib/packages/ngdpbase-types/index.d.ts +1 -1
- package/lib/src/ice/fileHandling.d.ts +6 -1
- package/lib/src/ice/runExport.d.ts +41 -0
- package/lib/src/ice/runImport.d.ts +16 -7
- package/lib/src/ice/shims/csv.d.ts +24 -0
- package/lib/src/ice/shims/export.d.ts +23 -0
- package/lib/src/ice/shims/index.d.ts +3 -1
- package/lib/src/ice/shims/svg.d.ts +27 -0
- package/lib/src/ice/shims/takeout.d.ts +83 -0
- package/lib/src/ice/types.d.ts +251 -5
- package/lib/src/ice/vet.d.ts +19 -0
- package/lib/src/managers/AttachmentManager.d.ts +86 -36
- package/lib/src/managers/AuthManager.d.ts +117 -7
- package/lib/src/managers/AuthManager.js +341 -37
- package/lib/src/managers/ExportManager.d.ts +16 -0
- package/lib/src/managers/ExportManager.js +25 -0
- package/lib/src/managers/FileManager.d.ts +182 -0
- package/lib/src/managers/ImportManager.d.ts +264 -16
- package/lib/src/managers/MediaManager.d.ts +2 -0
- package/lib/src/managers/SharedFolderManager.d.ts +320 -0
- package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
- package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
- package/lib/src/security/permissions.generated.d.ts +3 -3
- package/lib/src/types/File.d.ts +90 -0
- package/lib/src/types/express.d.ts +3 -1
- package/lib/src/types/linkedom.d.ts +2 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
- package/lib/src/utils/auditEventNames.generated.js +2 -0
- package/lib/src/utils/auditEvents.d.ts +3 -0
- package/lib/src/utils/auditEvents.js +3 -0
- package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
- package/lib/src/utils/privateStoreAccess.d.ts +21 -0
- package/lib/src/utils/privateStoreAccess.js +11 -0
- package/lib/src/utils/privateStoreImport.d.ts +0 -10
- package/lib/src/utils/privateStorePath.d.ts +12 -0
- package/lib/src/utils/privateStorePath.js +18 -0
- package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
- package/lib/src/utils/sessionEnd.d.ts +1 -0
- package/lib/src/utils/sessionEnd.js +4 -0
- package/lib/src/utils/sessionIdle.d.ts +4 -0
- package/lib/src/utils/sessionIdle.js +6 -0
- package/package.json +1 -1
|
@@ -69,6 +69,24 @@ import { sameUsername } from '../utils/username.js';
|
|
|
69
69
|
import { isEmail } from '../types/guards.js';
|
|
70
70
|
import { jobContextFromRequestWithReason } from '../context/JobContext.js';
|
|
71
71
|
import { systemContext, systemPrincipalOf } from '../context/bootActions.js';
|
|
72
|
+
/**
|
|
73
|
+
* Old provider ids whose entries still govern the provider (#1743; #1523 Q3,
|
|
74
|
+
* operator 2026-10-31): an entry, or an account's `allowedAuthMethods`, under
|
|
75
|
+
* the old id is read as the current one.
|
|
76
|
+
*/
|
|
77
|
+
const FACTOR_ALIASES = { 'google-oidc': 'oidc:google', 'email-link': 'magic-link' };
|
|
78
|
+
/**
|
|
79
|
+
* Do an account's `allowedAuthMethods` let it use `providerId`? Absent or
|
|
80
|
+
* empty means any; an old id names the provider it was renamed to (Q3). The
|
|
81
|
+
* one reading for signing in and for the level the account can reach.
|
|
82
|
+
*/
|
|
83
|
+
const allowsProvider = (allowed, providerId) => !allowed || allowed.length === 0 || allowed.some(a => (FACTOR_ALIASES[a] ?? a) === providerId);
|
|
84
|
+
/**
|
|
85
|
+
* The entry that governs the email link as a second factor (#1523 Q3): the
|
|
86
|
+
* magic link's, since starting a sign-in and confirming one are the same
|
|
87
|
+
* provider. The second step runs it itself, so it needs no registered provider.
|
|
88
|
+
*/
|
|
89
|
+
const EMAIL_LINK_ENTRY = 'magic-link';
|
|
72
90
|
/** A factor as offered: its entry lowered to what the provider's code gives. */
|
|
73
91
|
/**
|
|
74
92
|
* Providers that count toward a sign-in level only once the account has
|
|
@@ -291,6 +309,91 @@ function proxyGrantLive(grant, now) {
|
|
|
291
309
|
export function answeredWithPage(mode) {
|
|
292
310
|
return mode === 'page' || mode === 'download';
|
|
293
311
|
}
|
|
312
|
+
/** How many prompts one session keeps (#2193), the newest: a few tabs' worth. */
|
|
313
|
+
const STEP_UP_PROMPTS_PER_SESSION = 5;
|
|
314
|
+
/** A prompt id as minted (#2193), or null for anything else. */
|
|
315
|
+
export function stepUpPromptId(value) {
|
|
316
|
+
return typeof value === 'string' && /^[0-9a-f]{32}$/.test(value) ? value : null;
|
|
317
|
+
}
|
|
318
|
+
/** A waiting prompt of this session confirmed or cancelled (#2193); one already settled stays as it is. */
|
|
319
|
+
function settleStepUpPrompt(req, id, state) {
|
|
320
|
+
const prompt = stepUpPromptId(id);
|
|
321
|
+
const kept = req.session?.stepUpPrompts?.find((p) => p.id === prompt);
|
|
322
|
+
if (kept?.state === 'waiting')
|
|
323
|
+
kept.state = state;
|
|
324
|
+
}
|
|
325
|
+
/** Where a fresh sign-in sends the browser to redo a form POST (#2154). */
|
|
326
|
+
export const STEP_UP_CONTINUE_PATH = '/auth/reauth/continue';
|
|
327
|
+
/** How long a kept form POST waits for its fresh sign-in (#2154): reading the prompt may take a while. */
|
|
328
|
+
const STEP_UP_REPLAY_WAIT_MS = 60 * 60_000;
|
|
329
|
+
/** How long a confirmed form POST waits to be redone (#2154), from the confirmation: the browser goes there at once. */
|
|
330
|
+
const STEP_UP_REPLAY_TTL_MS = 2 * 60_000;
|
|
331
|
+
/** How many kept form POSTs one session holds at once (#2176): a few tabs' worth, so memory stays bounded. */
|
|
332
|
+
const STEP_UP_REPLAYS_PER_SESSION = 5;
|
|
333
|
+
/** How many one account holds across its sessions (#2176): signing in many times does not hold more. */
|
|
334
|
+
const STEP_UP_REPLAYS_PER_ACCOUNT = 10;
|
|
335
|
+
/** How many the site holds in all (#2176): each may hold a form body of up to 100 KB, so at most 20 MB. */
|
|
336
|
+
const STEP_UP_REPLAYS_TOTAL = 200;
|
|
337
|
+
/** The kept form POST a prompt's `next` names (#2154), or null when it names none. */
|
|
338
|
+
function stepUpReplayId(next) {
|
|
339
|
+
const url = new URL(next, 'http://site.invalid');
|
|
340
|
+
return url.pathname === STEP_UP_CONTINUE_PATH ? url.searchParams.get('replay') : null;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Field names whose value is a secret (#2154): a password, recovery words, a
|
|
344
|
+
* token. A form holding one is never kept or written back into a page; it
|
|
345
|
+
* returns with the note instead.
|
|
346
|
+
*/
|
|
347
|
+
const SECRET_FIELD = /password|secret|token|^words$/i;
|
|
348
|
+
/** Drop kept form POSTs past their time (#2154). */
|
|
349
|
+
function dropExpiredReplays(replays, now) {
|
|
350
|
+
for (const [id, kept] of replays)
|
|
351
|
+
if (kept.expires <= now)
|
|
352
|
+
replays.delete(id);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Drop the oldest of `entries` from `replays` until `room` are left (#2176),
|
|
356
|
+
* the unconfirmed first: a confirmed one is an action about to be carried out.
|
|
357
|
+
* `entries` are in the Map's insertion order, oldest first.
|
|
358
|
+
*/
|
|
359
|
+
function dropOldestReplays(replays, entries, room) {
|
|
360
|
+
const order = [...entries.filter(([, kept]) => !kept.ready), ...entries.filter(([, kept]) => kept.ready)];
|
|
361
|
+
for (const [id] of order.slice(0, Math.max(0, order.length - room)))
|
|
362
|
+
replays.delete(id);
|
|
363
|
+
}
|
|
364
|
+
/** A kept form POST's address for the log and the audit (#2176): the path, never the query. */
|
|
365
|
+
function replayPath(action) {
|
|
366
|
+
return new URL(action, 'http://site.invalid').pathname;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A form POST's fields as they can be posted again to give the same body
|
|
370
|
+
* (#2154), or null when they cannot: a file upload, a nested field, a name
|
|
371
|
+
* with brackets. A list goes back as `name[]`, which parses to a list even
|
|
372
|
+
* of one. `_csrf` is left out: the replay carries the session's token. A
|
|
373
|
+
* secret (`SECRET_FIELD`) is never kept, so such a form is null too.
|
|
374
|
+
*/
|
|
375
|
+
export function replayableFields(req) {
|
|
376
|
+
if (!req.is('application/x-www-form-urlencoded'))
|
|
377
|
+
return null;
|
|
378
|
+
const body = req.body;
|
|
379
|
+
if (!body || typeof body !== 'object')
|
|
380
|
+
return null;
|
|
381
|
+
const fields = [];
|
|
382
|
+
for (const [name, value] of Object.entries(body)) {
|
|
383
|
+
if (name === '_csrf')
|
|
384
|
+
continue;
|
|
385
|
+
if (/[[\]]/.test(name) || SECRET_FIELD.test(name))
|
|
386
|
+
return null;
|
|
387
|
+
if (typeof value === 'string')
|
|
388
|
+
fields.push([name, value]);
|
|
389
|
+
else if (Array.isArray(value) && value.every((v) => typeof v === 'string'))
|
|
390
|
+
for (const v of value)
|
|
391
|
+
fields.push([`${name}[]`, v]);
|
|
392
|
+
else
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return fields;
|
|
396
|
+
}
|
|
294
397
|
/**
|
|
295
398
|
* Where to return after re-authenticating: this page for a page GET, and the
|
|
296
399
|
* page the person was on for anything else — a POST (the form is filled in
|
|
@@ -419,6 +522,7 @@ class AuthManager extends BaseManager {
|
|
|
419
522
|
await this.openCredentialsStore(configManager);
|
|
420
523
|
this.registerPasskeys(configManager);
|
|
421
524
|
this.registerOidcProviders(configManager);
|
|
525
|
+
this.warnIfEmailSecondFactorsDropped();
|
|
422
526
|
logger.info(`[AuthManager] Initialized — factors: [${this.factorEntries.map(e => e.authproviderid).join(', ')}]`);
|
|
423
527
|
}
|
|
424
528
|
/**
|
|
@@ -430,24 +534,61 @@ class AuthManager extends BaseManager {
|
|
|
430
534
|
return [{ authproviderid: 'password' }];
|
|
431
535
|
const legacy = configManager.getCustomProperty('ngdpbase.auth.required-factors');
|
|
432
536
|
const custom = configManager.getCustomProperty('ngdpbase.auth.factors');
|
|
433
|
-
|
|
537
|
+
const legacyIds = Array.isArray(legacy) ? legacy.filter((id) => typeof id === 'string') : null;
|
|
538
|
+
if (legacyIds) {
|
|
434
539
|
logger.warn('[AuthManager] ngdpbase.auth.required-factors is retired — ' +
|
|
435
|
-
(custom
|
|
436
|
-
? '
|
|
437
|
-
:
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
const entries =
|
|
540
|
+
(custom !== undefined
|
|
541
|
+
? 'ignored, because ngdpbase.auth.factors is also set; remove it from custom config. (#1523)'
|
|
542
|
+
: legacyIds.some(id => (FACTOR_ALIASES[id] ?? id) === EMAIL_LINK_ENTRY)
|
|
543
|
+
? 'converted to ngdpbase.auth.factors entries; rename it in custom config. (#1523)'
|
|
544
|
+
: `converted to ngdpbase.auth.factors entries; rename it in custom config. It lists no ${EMAIL_LINK_ENTRY}, so the email second factor is off: ` +
|
|
545
|
+
`accounts that enrolled one sign in with one step. List ${EMAIL_LINK_ENTRY} to keep it. (#1523)`));
|
|
546
|
+
}
|
|
547
|
+
const entries = legacyIds && custom === undefined
|
|
548
|
+
? legacyIds.map(id => ({ authproviderid: id }))
|
|
549
|
+
: configManager.getProperty('ngdpbase.auth.factors', [{ authproviderid: 'password' }]);
|
|
443
550
|
if (!Array.isArray(entries)) {
|
|
444
551
|
logger.error('[AuthManager] ngdpbase.auth.factors is not a list — offering password only. (#1523)');
|
|
445
552
|
return [{ authproviderid: 'password' }];
|
|
446
553
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
554
|
+
// An entry without a string authproviderid is dropped, and it also made
|
|
555
|
+
// the merge replace the shipped list instead of merging by id.
|
|
556
|
+
const wellFormed = entries.filter((e) => {
|
|
557
|
+
if (typeof e === 'object' && e !== null && typeof e.authproviderid === 'string')
|
|
558
|
+
return true;
|
|
559
|
+
logger.warn(`[AuthManager] ngdpbase.auth.factors: dropped ${JSON.stringify(e)}, which has no string authproviderid. ` +
|
|
560
|
+
'A custom list holding such an entry replaces the shipped list rather than merging with it, so every shipped entry it does not restate is off. (#1523)');
|
|
561
|
+
return false;
|
|
562
|
+
});
|
|
563
|
+
// #1743, #1523 Q3: an entry under an old id governs the provider. The merge
|
|
564
|
+
// appends a custom entry under the old id after the shipped one, so of two
|
|
565
|
+
// entries for one provider the later wins, in the earlier one's place.
|
|
566
|
+
const byProvider = new Map();
|
|
567
|
+
for (const e of wellFormed) {
|
|
568
|
+
const id = FACTOR_ALIASES[e.authproviderid] ?? e.authproviderid;
|
|
569
|
+
byProvider.set(id, { ...e, authproviderid: id });
|
|
570
|
+
}
|
|
571
|
+
const read = [...byProvider.values()];
|
|
572
|
+
// #1523 Q10: no longer shipped, so only a site's own config still says it.
|
|
573
|
+
const trusting = read.filter(e => Array.isArray(e.amr) && e.amr.includes('trust')).map(e => e.authproviderid);
|
|
574
|
+
if (trusting.length > 0) {
|
|
575
|
+
logger.warn(`[AuthManager] ngdpbase.auth.factors: "amr": ["trust"] on ${trusting.join(', ')} does nothing — trust an outside provider's own second factor with "trust-mfa": true on its entry in ngdpbase.auth.oidc.providers. (#1633)`);
|
|
576
|
+
}
|
|
577
|
+
return read;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* #1523: with the `magic-link` entry off or not listed, an enrolled email
|
|
581
|
+
* second factor is no longer asked for. The operator may mean that, but it
|
|
582
|
+
* must not happen silently.
|
|
583
|
+
*/
|
|
584
|
+
warnIfEmailSecondFactorsDropped() {
|
|
585
|
+
if (this.factorEntries.some(e => e.authproviderid === EMAIL_LINK_ENTRY && e.enabled !== false))
|
|
586
|
+
return;
|
|
587
|
+
const accounts = new Set((this.credentials?.listKind('email') ?? []).map(r => r.username)).size;
|
|
588
|
+
if (accounts === 0)
|
|
589
|
+
return;
|
|
590
|
+
logger.warn(`[AuthManager] ngdpbase.auth.factors: the ${EMAIL_LINK_ENTRY} entry is off or not listed, so the enrolled email second factor ` +
|
|
591
|
+
`of ${accounts} account(s) is no longer asked for — they sign in with one step, and magic-link sign-in is off too. (#1523)`);
|
|
451
592
|
}
|
|
452
593
|
/**
|
|
453
594
|
* The outside OpenID Connect providers (#1743): one `OidcAuthProvider` per
|
|
@@ -1027,9 +1168,7 @@ class AuthManager extends BaseManager {
|
|
|
1027
1168
|
*/
|
|
1028
1169
|
async userMayUseProvider(username, providerId) {
|
|
1029
1170
|
const user = await this.engine.getManager('UserManager')?.getUser(username);
|
|
1030
|
-
|
|
1031
|
-
// #1743: Google's provider was `google-oidc`; an account limited to it keeps it as `oidc:google`.
|
|
1032
|
-
return !allowed || allowed.length === 0 || allowed.includes(providerId) || (providerId === 'oidc:google' && allowed.includes('google-oidc'));
|
|
1171
|
+
return allowsProvider(user?.allowedAuthMethods, providerId);
|
|
1033
1172
|
}
|
|
1034
1173
|
async passkeyRegistrationOptions(ctx, username, displayName) {
|
|
1035
1174
|
if (!(await this.mayManageCredentials(ctx, username)))
|
|
@@ -2085,7 +2224,7 @@ class AuthManager extends BaseManager {
|
|
|
2085
2224
|
async aalReachFor(username) {
|
|
2086
2225
|
const user = await this.engine.getManager('UserManager')?.getUser(username);
|
|
2087
2226
|
const allowed = user?.allowedAuthMethods;
|
|
2088
|
-
const offered = this.getFactors().filter(f =>
|
|
2227
|
+
const offered = this.getFactors().filter(f => allowsProvider(allowed, f.provider));
|
|
2089
2228
|
const reach = (factors) => Math.max(factors.some(f => f.primary) ? this.assess(factors).aal : 0, 1);
|
|
2090
2229
|
const usable = offered.filter(f => {
|
|
2091
2230
|
const kind = ENROLLED_FACTORS[f.provider];
|
|
@@ -2185,11 +2324,21 @@ class AuthManager extends BaseManager {
|
|
|
2185
2324
|
await this.recordReauth(req, AUDIT_EVENT.REAUTH_PROMPT, 'failure', permission, delegated ? 'delegated credential cannot re-authenticate' : 'fresh sign-in asked for');
|
|
2186
2325
|
if (delegated)
|
|
2187
2326
|
return { permission, delegated, error: 'This needs a fresh sign-in by the person; a token cannot give one' };
|
|
2188
|
-
const
|
|
2327
|
+
const back = reauthReturnTo(req, mode);
|
|
2328
|
+
// #2154: a form POST is kept, and the confirmation returns to redo it.
|
|
2329
|
+
const replay = req.session ? this.keepStepUpReplay(req, permission, mode, back) : null;
|
|
2330
|
+
const next = replay ? `${STEP_UP_CONTINUE_PATH}?replay=${replay}` : back;
|
|
2331
|
+
// #2193: a script's request names its own prompt, so the page that sent it
|
|
2332
|
+
// waits for that confirmation and no other.
|
|
2333
|
+
const prompt = req.session && mode === 'json' ? randomBytes(16).toString('hex') : null;
|
|
2334
|
+
if (prompt)
|
|
2335
|
+
req.session.stepUpPrompts = [...(req.session.stepUpPrompts ?? []), { id: prompt, state: 'waiting' }].slice(-STEP_UP_PROMPTS_PER_SESSION);
|
|
2189
2336
|
// #2145: what the prompt was for, so its success is recorded against it and,
|
|
2190
2337
|
// when returning does not redo the action, the page says it can be done now.
|
|
2191
2338
|
if (req.session) {
|
|
2192
|
-
req.session.pendingStepUp =
|
|
2339
|
+
req.session.pendingStepUp = replay
|
|
2340
|
+
? { permission, next, replays: true, replay }
|
|
2341
|
+
: { permission, next, replays: req.method === 'GET' && mode === 'page' };
|
|
2193
2342
|
// Saved before the answer goes out: the browser follows a redirect on its
|
|
2194
2343
|
// headers, before express-session's own save at the end lands, and the
|
|
2195
2344
|
// prompt's request would read (and write back) the session without it.
|
|
@@ -2202,9 +2351,22 @@ class AuthManager extends BaseManager {
|
|
|
2202
2351
|
permission,
|
|
2203
2352
|
delegated,
|
|
2204
2353
|
error: 'A fresh sign-in is needed',
|
|
2205
|
-
reauth: `/auth/reauth?next=${encodeURIComponent(next)}`
|
|
2354
|
+
reauth: `/auth/reauth?next=${encodeURIComponent(next)}${prompt ? `&prompt=${prompt}` : ''}`
|
|
2206
2355
|
};
|
|
2207
2356
|
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Where this session's prompt `id` is (#2193): waiting, confirmed or
|
|
2359
|
+
* cancelled, or unknown (another session's, dropped, or not an id). Reads
|
|
2360
|
+
* only, so asking it often changes nothing in the session.
|
|
2361
|
+
*/
|
|
2362
|
+
stepUpPromptState(req, id) {
|
|
2363
|
+
const prompt = stepUpPromptId(id);
|
|
2364
|
+
return req.session?.stepUpPrompts?.find((p) => p.id === prompt)?.state ?? 'unknown';
|
|
2365
|
+
}
|
|
2366
|
+
/** The prompt's Cancel (#2193): a waiting prompt of this session is cancelled, so its page stops waiting. */
|
|
2367
|
+
cancelStepUpPrompt(req, id) {
|
|
2368
|
+
settleStepUpPrompt(req, id, 'cancelled');
|
|
2369
|
+
}
|
|
2208
2370
|
/** Record a step-up event: a prompt, a success or a failure (#1525). */
|
|
2209
2371
|
async recordReauth(req, eventType, result, permission, detail) {
|
|
2210
2372
|
await recordAuditEvent(this.engine.getManager('AuditManager'), {
|
|
@@ -2229,17 +2391,36 @@ class AuthManager extends BaseManager {
|
|
|
2229
2391
|
* directly, against the method), and when going there does not redo the
|
|
2230
2392
|
* action (a form POST, a script's request, a download) the page there says
|
|
2231
2393
|
* it can be done now (`takeStepUpNotice`).
|
|
2394
|
+
*
|
|
2395
|
+
* #2193: `prompt`, the id a script's refusal named, is confirmed when it is
|
|
2396
|
+
* this session's and still waiting; no other prompt is.
|
|
2232
2397
|
*/
|
|
2233
|
-
async completeReauth(req, result, method, next) {
|
|
2398
|
+
async completeReauth(req, result, method, next, prompt) {
|
|
2234
2399
|
const record = this.reauthenticated(req.session.signIn, result);
|
|
2235
2400
|
if (record)
|
|
2236
2401
|
req.session.signIn = record;
|
|
2402
|
+
if (record)
|
|
2403
|
+
settleStepUpPrompt(req, prompt, 'confirmed');
|
|
2237
2404
|
const pending = req.session.pendingStepUp;
|
|
2238
2405
|
delete req.session.pendingStepUp;
|
|
2239
2406
|
const answered = pending && pending.next === next ? pending : undefined;
|
|
2240
2407
|
if (answered && !answered.replays)
|
|
2241
2408
|
req.session.stepUpConfirmed = new URL(next, 'http://site.invalid').pathname;
|
|
2242
|
-
|
|
2409
|
+
// #2154: the confirmation releases the form POST it was asked for, named by
|
|
2410
|
+
// `next` itself (the session's pending prompt may be another tab's), and
|
|
2411
|
+
// its time to be redone starts now, however long the prompt was open.
|
|
2412
|
+
// Another session's or person's is not released, nor is the success recorded
|
|
2413
|
+
// against its permission: the attempt is a `reauth-failure`, as at the take.
|
|
2414
|
+
const kept = this.stepUpReplays.get(stepUpReplayId(next) ?? '');
|
|
2415
|
+
const mine = kept && kept.sessionId === req.sessionID && kept.username === req.session.username ? kept : undefined;
|
|
2416
|
+
if (kept && !mine) {
|
|
2417
|
+
await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} confirmed from another session; not released`);
|
|
2418
|
+
}
|
|
2419
|
+
if (mine && mine.expires > Date.now()) {
|
|
2420
|
+
mine.ready = true;
|
|
2421
|
+
mine.expires = Date.now() + STEP_UP_REPLAY_TTL_MS;
|
|
2422
|
+
}
|
|
2423
|
+
await this.recordReauth(req, AUDIT_EVENT.REAUTH_SUCCESS, 'success', answered?.permission ?? mine?.permission ?? method, `re-authenticated with ${method}${answered ? ` for ${answered.permission}` : ''}; acr ${record?.acr ?? 'unknown'}`);
|
|
2243
2424
|
return record;
|
|
2244
2425
|
}
|
|
2245
2426
|
/**
|
|
@@ -2257,6 +2438,79 @@ class AuthManager extends BaseManager {
|
|
|
2257
2438
|
delete req.session.stepUpConfirmed;
|
|
2258
2439
|
return req.path === path;
|
|
2259
2440
|
}
|
|
2441
|
+
/** #2154: form POSTs kept for their fresh sign-in, by a random id. In memory: a restart drops them. */
|
|
2442
|
+
stepUpReplays = new Map();
|
|
2443
|
+
/**
|
|
2444
|
+
* Keep a form POST answered with the prompt, to be redone after it
|
|
2445
|
+
* (#2154); its id, or null when it cannot be redone exactly. Only a page's
|
|
2446
|
+
* POST by a signed-in person, to an address on this site. Each prompt keeps
|
|
2447
|
+
* its own, so the same button pressed in two tabs loses neither (#2176); a
|
|
2448
|
+
* session keeps at most `STEP_UP_REPLAYS_PER_SESSION`, an account
|
|
2449
|
+
* `STEP_UP_REPLAYS_PER_ACCOUNT` and the site `STEP_UP_REPLAYS_TOTAL`, a new
|
|
2450
|
+
* prompt dropping the oldest past that, unconfirmed first, and any past its
|
|
2451
|
+
* time. It waits `STEP_UP_REPLAY_WAIT_MS` for the confirmation, which
|
|
2452
|
+
* restarts its clock. A session's end drops its own (`dropStepUpReplays`).
|
|
2453
|
+
*/
|
|
2454
|
+
keepStepUpReplay(req, permission, mode, back) {
|
|
2455
|
+
const username = req.session.username;
|
|
2456
|
+
if (req.method !== 'POST' || mode !== 'page' || typeof username !== 'string' || !req.sessionID)
|
|
2457
|
+
return null;
|
|
2458
|
+
const action = req.originalUrl;
|
|
2459
|
+
if (safeRedirect(action) !== action)
|
|
2460
|
+
return null;
|
|
2461
|
+
const fields = replayableFields(req);
|
|
2462
|
+
if (!fields)
|
|
2463
|
+
return null;
|
|
2464
|
+
const now = Date.now();
|
|
2465
|
+
dropExpiredReplays(this.stepUpReplays, now);
|
|
2466
|
+
// A Map iterates in insertion order: the oldest come first.
|
|
2467
|
+
const all = () => Array.from(this.stepUpReplays);
|
|
2468
|
+
dropOldestReplays(this.stepUpReplays, all().filter(([, kept]) => kept.sessionId === req.sessionID), STEP_UP_REPLAYS_PER_SESSION - 1);
|
|
2469
|
+
dropOldestReplays(this.stepUpReplays, all().filter(([, kept]) => kept.username === username), STEP_UP_REPLAYS_PER_ACCOUNT - 1);
|
|
2470
|
+
dropOldestReplays(this.stepUpReplays, all(), STEP_UP_REPLAYS_TOTAL - 1);
|
|
2471
|
+
const id = randomBytes(16).toString('hex');
|
|
2472
|
+
this.stepUpReplays.set(id, { sessionId: req.sessionID, username, permission, action, fields, back, expires: now + STEP_UP_REPLAY_WAIT_MS, ready: false });
|
|
2473
|
+
return id;
|
|
2474
|
+
}
|
|
2475
|
+
/** A session ended (#2176): the form POSTs it kept go with it (`endSession`). */
|
|
2476
|
+
dropStepUpReplays(sessionId) {
|
|
2477
|
+
for (const [id, kept] of this.stepUpReplays)
|
|
2478
|
+
if (kept.sessionId === sessionId)
|
|
2479
|
+
this.stepUpReplays.delete(id);
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Take the kept form POST `id` for the continue page (#2154). Single-use:
|
|
2483
|
+
* taken, it is gone, whatever the answer. Another session's or person's is
|
|
2484
|
+
* refused (null); one not confirmed (the prompt's Cancel) or past its time
|
|
2485
|
+
* only goes back, a confirmed one with the note (#2145). Otherwise the page
|
|
2486
|
+
* posts the fields to the same address. A refusal or one past its time is
|
|
2487
|
+
* recorded as a `reauth-failure`; any other past its time is dropped.
|
|
2488
|
+
*/
|
|
2489
|
+
async takeStepUpReplay(req, id) {
|
|
2490
|
+
const kept = typeof id === 'string' ? this.stepUpReplays.get(id) : undefined;
|
|
2491
|
+
if (kept)
|
|
2492
|
+
this.stepUpReplays.delete(id);
|
|
2493
|
+
const now = Date.now();
|
|
2494
|
+
dropExpiredReplays(this.stepUpReplays, now);
|
|
2495
|
+
if (!kept) {
|
|
2496
|
+
await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', STEP_UP_CONTINUE_PATH, 'kept form POST not found: already used, past its time, or unknown');
|
|
2497
|
+
return null;
|
|
2498
|
+
}
|
|
2499
|
+
if (kept.sessionId !== req.sessionID || kept.username !== req.session?.username) {
|
|
2500
|
+
logger.warn(`[step-up] a kept form POST for ${replayPath(kept.action)} was asked for from another session; refused`);
|
|
2501
|
+
await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} asked for from another session`);
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
if (now > kept.expires) {
|
|
2505
|
+
await this.recordReauth(req, AUDIT_EVENT.REAUTH_FAILURE, 'failure', kept.permission, `kept form POST for ${replayPath(kept.action)} past its time; not redone`);
|
|
2506
|
+
if (kept.ready)
|
|
2507
|
+
req.session.stepUpConfirmed = new URL(kept.back, 'http://site.invalid').pathname;
|
|
2508
|
+
return { back: kept.back };
|
|
2509
|
+
}
|
|
2510
|
+
if (!kept.ready)
|
|
2511
|
+
return { back: kept.back };
|
|
2512
|
+
return { action: kept.action, fields: kept.fields, back: kept.back };
|
|
2513
|
+
}
|
|
2260
2514
|
/**
|
|
2261
2515
|
* The one sign-in completion step (#1758): every way a person finishes
|
|
2262
2516
|
* signing in — a password, a passkey, a second factor, an email link, an
|
|
@@ -2475,11 +2729,24 @@ class AuthManager extends BaseManager {
|
|
|
2475
2729
|
const policy = this.stepUpPolicy();
|
|
2476
2730
|
if (!policy.permissions.has(permission))
|
|
2477
2731
|
return false;
|
|
2732
|
+
return !this.freshUnder(policy.maxAgeMs, signIn, roles, delegated, now, cap);
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Whether `signIn` is fresh enough for every step-up permission (#2193): the
|
|
2736
|
+
* window and the level are one for all of them, so a page waiting on its
|
|
2737
|
+
* prompt asks this without naming the permission. With step-up off, every
|
|
2738
|
+
* sign-in is.
|
|
2739
|
+
*/
|
|
2740
|
+
signInFresh(signIn, roles, delegated, now = Date.now(), cap) {
|
|
2741
|
+
const policy = this.stepUpPolicy();
|
|
2742
|
+
return policy.permissions.size === 0 || this.freshUnder(policy.maxAgeMs, signIn, roles, delegated, now, cap);
|
|
2743
|
+
}
|
|
2744
|
+
freshUnder(maxAgeMs, signIn, roles, delegated, now, cap) {
|
|
2478
2745
|
if (delegated)
|
|
2479
|
-
return
|
|
2746
|
+
return false;
|
|
2480
2747
|
const need = Math.max(this.requiredAalFor(roles, cap), 1);
|
|
2481
|
-
const since = now -
|
|
2482
|
-
return
|
|
2748
|
+
const since = now - maxAgeMs;
|
|
2749
|
+
return (signIn?.factors ?? []).some((f) => f.aal >= need && Date.parse(f.at) >= since);
|
|
2483
2750
|
}
|
|
2484
2751
|
/**
|
|
2485
2752
|
* A session's sign-in after a re-authentication (#1525): the new factors
|
|
@@ -2793,16 +3060,48 @@ class AuthManager extends BaseManager {
|
|
|
2793
3060
|
return this.mailForLinks() !== null;
|
|
2794
3061
|
}
|
|
2795
3062
|
/**
|
|
2796
|
-
*
|
|
2797
|
-
*
|
|
2798
|
-
*
|
|
3063
|
+
* Whether the email link can be a second factor now (#1523): its entry in
|
|
3064
|
+
* `ngdpbase.auth.factors` (`magic-link`, or its alias `email-link`) listed
|
|
3065
|
+
* and enabled, mail, and an explicit base-url.
|
|
3066
|
+
*/
|
|
3067
|
+
emailSecondFactorAvailable() {
|
|
3068
|
+
return this.mailForLinks() !== null && this.factorEntries.some(e => e.authproviderid === EMAIL_LINK_ENTRY && e.enabled !== false);
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* The second factors that may follow `first`, in the order they are offered
|
|
3072
|
+
* (#1523; Q2, operator 2026-10-31): every enabled entry of
|
|
3073
|
+
* `ngdpbase.auth.factors` other than the one the sign-in started with, whose
|
|
3074
|
+
* level is known (its provider registered, or the email link). Those that,
|
|
3075
|
+
* with the first step, reach `requiredAal` come first; then by `preference`,
|
|
3076
|
+
* lower first, an entry with none last. Entry ids.
|
|
2799
3077
|
*/
|
|
2800
|
-
|
|
3078
|
+
secondFactorOrder(first, requiredAal) {
|
|
3079
|
+
const ranked = this.factorEntries.flatMap((entry) => {
|
|
3080
|
+
if (entry.enabled === false || entry.authproviderid === first.provider)
|
|
3081
|
+
return [];
|
|
3082
|
+
const factor = entry.authproviderid === EMAIL_LINK_ENTRY ? EMAIL_FACTOR : this.getFactors().find(f => f.provider === entry.authproviderid);
|
|
3083
|
+
if (!factor)
|
|
3084
|
+
return [];
|
|
3085
|
+
const reaches = this.assess([...(first.factors ?? []), factor]).aal >= requiredAal;
|
|
3086
|
+
return [{ id: entry.authproviderid, reaches, preference: entry.preference ?? Number.MAX_SAFE_INTEGER }];
|
|
3087
|
+
});
|
|
3088
|
+
return ranked.sort((a, b) => Number(b.reaches) - Number(a.reaches) || a.preference - b.preference).map(r => r.id);
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* The second factors `username` has enrolled and can use now (#1523), in
|
|
3092
|
+
* `secondFactorOrder` after the pending sign-in's first step; without one,
|
|
3093
|
+
* by `preference`. Only what the second step can run is offered — today the
|
|
3094
|
+
* email link — and one that is not truly available (no mail transport) is
|
|
3095
|
+
* not. Empty means the sign-in is one step, as before.
|
|
3096
|
+
*/
|
|
3097
|
+
secondFactorsFor(username, signIn) {
|
|
2801
3098
|
const rows = this.credentials?.list(username) ?? [];
|
|
2802
3099
|
const out = [];
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3100
|
+
for (const id of this.secondFactorOrder(signIn?.first ?? {}, signIn?.requiredAal ?? 0)) {
|
|
3101
|
+
if (id === EMAIL_LINK_ENTRY && this.mailForLinks()) {
|
|
3102
|
+
for (const r of rows.filter((c) => c.kind === 'email')) {
|
|
3103
|
+
out.push({ id: 'email-link', label: 'Email link', target: maskEmail(r.subject) });
|
|
3104
|
+
}
|
|
2806
3105
|
}
|
|
2807
3106
|
}
|
|
2808
3107
|
return out;
|
|
@@ -2820,7 +3119,7 @@ class AuthManager extends BaseManager {
|
|
|
2820
3119
|
const record = result.success && result.username ? this.signInRecord(result) : null;
|
|
2821
3120
|
if (!record || record.aal >= 2 || record.amr.includes('email'))
|
|
2822
3121
|
return false;
|
|
2823
|
-
return this.secondFactorsFor(result.username).length > 0;
|
|
3122
|
+
return this.secondFactorsFor(result.username, { first: result }).length > 0;
|
|
2824
3123
|
}
|
|
2825
3124
|
/**
|
|
2826
3125
|
* Roughly where `req` came from, for the approval message (#1532):
|
|
@@ -2851,6 +3150,9 @@ class AuthManager extends BaseManager {
|
|
|
2851
3150
|
if (!username)
|
|
2852
3151
|
throw new Error('beginTwoStep: no stored account has that username (#1882)');
|
|
2853
3152
|
first = { ...first, username };
|
|
3153
|
+
// #1523 Q2: what this account's roles require, so the factors that reach it are offered first.
|
|
3154
|
+
const pip = this.engine.getManager('PolicyInformationPoint');
|
|
3155
|
+
const requiredAal = this.requiredAalFor((await pip?.subjectFor(username))?.roles ?? [], await this.aalCapFor(username));
|
|
2854
3156
|
this.sweepPending();
|
|
2855
3157
|
const handle = randomBytes(24).toString('base64url');
|
|
2856
3158
|
const now = Date.now();
|
|
@@ -2865,7 +3167,8 @@ class AuthManager extends BaseManager {
|
|
|
2865
3167
|
requestIp: ctx.ip,
|
|
2866
3168
|
requestUserAgent: ctx.userAgent,
|
|
2867
3169
|
requestLocation: ctx.location ?? null,
|
|
2868
|
-
privateStoreHandle: ctx.privateStoreHandle
|
|
3170
|
+
privateStoreHandle: ctx.privateStoreHandle,
|
|
3171
|
+
requiredAal
|
|
2869
3172
|
});
|
|
2870
3173
|
// #1532: an expiry is recorded when it happens, not when someone next looks.
|
|
2871
3174
|
setTimeout(() => this.expirePending(handle), PENDING_SIGN_IN_TTL_MS + 1).unref?.();
|
|
@@ -2938,11 +3241,12 @@ class AuthManager extends BaseManager {
|
|
|
2938
3241
|
const p = this.pendingSignIn(handle, binding);
|
|
2939
3242
|
if (!p)
|
|
2940
3243
|
return 'unavailable';
|
|
2941
|
-
const mail = this.mailForLinks();
|
|
3244
|
+
const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
|
|
2942
3245
|
const row = (this.credentials?.list(p.username) ?? []).find((c) => c.kind === 'email');
|
|
2943
3246
|
const from = AuthManager.askedFrom(p);
|
|
2944
3247
|
if (!mail || !row) {
|
|
2945
|
-
|
|
3248
|
+
const reason = mail ? 'no email factor enrolled' : this.mailForLinks() ? 'the email link is off in ngdpbase.auth.factors' : 'mail is not configured';
|
|
3249
|
+
await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_SENDFAIL, p, 'error', from, { reason });
|
|
2946
3250
|
return 'unavailable';
|
|
2947
3251
|
}
|
|
2948
3252
|
if (p.lastSentAt && Date.now() - p.lastSentAt < APPROVAL_RESEND_MS) {
|
|
@@ -3143,7 +3447,7 @@ class AuthManager extends BaseManager {
|
|
|
3143
3447
|
async startEmailFactorEnrolment(ctx, username) {
|
|
3144
3448
|
if (!(await this.mayManageCredentials(ctx, username)))
|
|
3145
3449
|
throw new Error('Permission denied');
|
|
3146
|
-
const mail = this.mailForLinks();
|
|
3450
|
+
const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
|
|
3147
3451
|
if (!mail)
|
|
3148
3452
|
return 'unavailable';
|
|
3149
3453
|
const user = await this.engine.getManager('UserManager')?.getUser(username);
|
|
@@ -128,6 +128,11 @@ declare class ExportManager extends BaseManager {
|
|
|
128
128
|
* with the reason, as `none` is.
|
|
129
129
|
*/
|
|
130
130
|
private pdfProvider;
|
|
131
|
+
/**
|
|
132
|
+
* Is a PDF provider chosen (#2120)? The Export action offers PDF only then.
|
|
133
|
+
* Whether the chosen one works is known only by rendering, as before.
|
|
134
|
+
*/
|
|
135
|
+
isPdfConfigured(): boolean;
|
|
131
136
|
/** Log why, flag the manager (unless something worse is already shown), and refuse. */
|
|
132
137
|
private pdfUnavailable;
|
|
133
138
|
private acquirePdfSlot;
|
|
@@ -148,6 +153,17 @@ declare class ExportManager extends BaseManager {
|
|
|
148
153
|
* @returns {Promise<string>} HTML content
|
|
149
154
|
*/
|
|
150
155
|
exportPageToHtml(pageName: string, ctx: ActorContext, user?: ExportUser | null): Promise<string>;
|
|
156
|
+
/**
|
|
157
|
+
* One page as a complete HTML document: its content rendered, with the
|
|
158
|
+
* export's styling and metadata footer. It reads nothing: the caller has
|
|
159
|
+
* read the page through its door (the page export source, #2120).
|
|
160
|
+
*
|
|
161
|
+
* @param pageName - The page's name, its heading
|
|
162
|
+
* @param page - Its content and metadata
|
|
163
|
+
* @param user - User object for locale-aware formatting
|
|
164
|
+
* @returns The HTML document
|
|
165
|
+
*/
|
|
166
|
+
renderPageDocument(pageName: string, page: PageForExport, user?: ExportUser | null): Promise<string>;
|
|
151
167
|
/**
|
|
152
168
|
* Export multiple pages to a single HTML file
|
|
153
169
|
* @param {string[]} pageNames - Array of page names
|
|
@@ -182,6 +182,14 @@ class ExportManager extends BaseManager {
|
|
|
182
182
|
return new NonePdfProvider(`ngdpbase.export.pdf.provider '${name}' is not a PDF provider: use ${PDF_PROVIDERS.join(', ')}`);
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Is a PDF provider chosen (#2120)? The Export action offers PDF only then.
|
|
187
|
+
* Whether the chosen one works is known only by rendering, as before.
|
|
188
|
+
*/
|
|
189
|
+
isPdfConfigured() {
|
|
190
|
+
const name = this.engine.getManager('ConfigurationManager')?.getProperty('ngdpbase.export.pdf.provider', PDF_DEFAULTS.provider);
|
|
191
|
+
return (name ?? PDF_DEFAULTS.provider) !== 'none';
|
|
192
|
+
}
|
|
185
193
|
/** Log why, flag the manager (unless something worse is already shown), and refuse. */
|
|
186
194
|
pdfUnavailable(reason) {
|
|
187
195
|
logger.warn(`[ExportManager] PDF export unavailable: ${reason}`);
|
|
@@ -248,6 +256,23 @@ class ExportManager extends BaseManager {
|
|
|
248
256
|
if (!page) {
|
|
249
257
|
throw new Error(`Page '${pageName}' not found`);
|
|
250
258
|
}
|
|
259
|
+
return this.renderPageDocument(pageName, page, user);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* One page as a complete HTML document: its content rendered, with the
|
|
263
|
+
* export's styling and metadata footer. It reads nothing: the caller has
|
|
264
|
+
* read the page through its door (the page export source, #2120).
|
|
265
|
+
*
|
|
266
|
+
* @param pageName - The page's name, its heading
|
|
267
|
+
* @param page - Its content and metadata
|
|
268
|
+
* @param user - User object for locale-aware formatting
|
|
269
|
+
* @returns The HTML document
|
|
270
|
+
*/
|
|
271
|
+
async renderPageDocument(pageName, page, user = null) {
|
|
272
|
+
const renderingManager = this.engine.getManager('RenderingManager');
|
|
273
|
+
if (!renderingManager) {
|
|
274
|
+
throw new Error('Required managers not available');
|
|
275
|
+
}
|
|
251
276
|
// Render the page content (without user context for exports)
|
|
252
277
|
const renderedContent = await renderingManager.renderMarkdown(page.content, pageName, null);
|
|
253
278
|
// Create full HTML document
|