@jwilleke/ngdpbase-types 5.39.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.
Files changed (99) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  2. package/lib/src/context/ApiContext.d.ts +3 -2
  3. package/lib/src/context/ApiContext.js +4 -2
  4. package/lib/src/ice/fileHandling.d.ts +6 -1
  5. package/lib/src/ice/givenName.d.ts +19 -0
  6. package/lib/src/ice/runExport.d.ts +41 -0
  7. package/lib/src/ice/runImport.d.ts +37 -8
  8. package/lib/src/ice/shims/csv.d.ts +24 -0
  9. package/lib/src/ice/shims/export.d.ts +23 -0
  10. package/lib/src/ice/shims/index.d.ts +3 -1
  11. package/lib/src/ice/shims/svg.d.ts +27 -0
  12. package/lib/src/ice/shims/takeout.d.ts +83 -0
  13. package/lib/src/ice/types.d.ts +301 -5
  14. package/lib/src/ice/vet.d.ts +19 -0
  15. package/lib/src/managers/AttachmentManager.d.ts +100 -36
  16. package/lib/src/managers/AuthManager.d.ts +152 -12
  17. package/lib/src/managers/AuthManager.js +3830 -0
  18. package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
  19. package/lib/src/managers/BackupManager.d.ts +24 -0
  20. package/lib/src/managers/BackupManager.js +48 -1
  21. package/lib/src/managers/ExportManager.d.ts +16 -0
  22. package/lib/src/managers/ExportManager.js +25 -0
  23. package/lib/src/managers/FileManager.d.ts +182 -0
  24. package/lib/src/managers/ImportManager.d.ts +279 -16
  25. package/lib/src/managers/MediaManager.d.ts +2 -0
  26. package/lib/src/managers/PageManager.d.ts +53 -0
  27. package/lib/src/managers/SearchManager.d.ts +4 -0
  28. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  29. package/lib/src/managers/ValidationManager.d.ts +5 -2
  30. package/lib/src/middleware/csrf.js +99 -0
  31. package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
  32. package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
  33. package/lib/src/providers/BaseCredentialsProvider.js +21 -0
  34. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  35. package/lib/src/providers/BasePageProvider.d.ts +8 -0
  36. package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
  37. package/lib/src/providers/FileCredentialsProvider.js +188 -0
  38. package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
  39. package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
  40. package/lib/src/providers/OidcAuthProvider.js +350 -0
  41. package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
  42. package/lib/src/providers/PasskeyAuthProvider.js +147 -0
  43. package/lib/src/providers/PasswordAuthProvider.js +44 -0
  44. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  45. package/lib/src/security/pageActions.js +43 -0
  46. package/lib/src/security/permissions.generated.d.ts +7 -3
  47. package/lib/src/types/File.d.ts +90 -0
  48. package/lib/src/types/Provider.d.ts +15 -0
  49. package/lib/src/types/express.d.ts +7 -1
  50. package/lib/src/types/guards.js +366 -0
  51. package/lib/src/types/linkedom.d.ts +2 -0
  52. package/lib/src/utils/atomicWrite.js +129 -0
  53. package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
  54. package/lib/src/utils/auditEventNames.generated.js +12 -0
  55. package/lib/src/utils/auditEvents.d.ts +15 -0
  56. package/lib/src/utils/auditEvents.js +17 -1
  57. package/lib/src/utils/bip39English.js +2 -0
  58. package/lib/src/utils/browserDescription.js +28 -0
  59. package/lib/src/utils/delegation.js +79 -0
  60. package/lib/src/utils/deriveUsername.js +38 -0
  61. package/lib/src/utils/emailForLog.js +28 -0
  62. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  63. package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
  64. package/lib/src/utils/instanceEnvBackup.js +13 -0
  65. package/lib/src/utils/ipLocation.js +106 -0
  66. package/lib/src/utils/magicLinkDeviceState.js +97 -0
  67. package/lib/src/utils/pageUrl.d.ts +1 -0
  68. package/lib/src/utils/passwordHash.js +150 -0
  69. package/lib/src/utils/permissionCatalog.js +102 -0
  70. package/lib/src/utils/privateStoreAccess.d.ts +25 -0
  71. package/lib/src/utils/privateStoreAccess.js +22 -1
  72. package/lib/src/utils/privateStoreCatalogs.js +57 -0
  73. package/lib/src/utils/privateStoreCrypto.js +266 -0
  74. package/lib/src/utils/privateStoreDoor.d.ts +10 -0
  75. package/lib/src/utils/privateStoreDoor.js +298 -0
  76. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  77. package/lib/src/utils/privateStoreMeta.js +123 -0
  78. package/lib/src/utils/privateStorePath.d.ts +37 -1
  79. package/lib/src/utils/privateStorePath.js +60 -3
  80. package/lib/src/utils/privateStoreTakeout.js +149 -0
  81. package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
  82. package/lib/src/utils/privateStoreUnlock.js +499 -0
  83. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  84. package/lib/src/utils/safeRedirect.js +55 -0
  85. package/lib/src/utils/sessionAccount.js +55 -0
  86. package/lib/src/utils/sessionEnd.d.ts +1 -0
  87. package/lib/src/utils/sessionEnd.js +42 -0
  88. package/lib/src/utils/sessionGeneration.js +29 -0
  89. package/lib/src/utils/sessionIdle.d.ts +4 -0
  90. package/lib/src/utils/sessionIdle.js +72 -0
  91. package/lib/src/utils/sessionLimits.js +72 -0
  92. package/lib/src/utils/sessionSecurity.js +122 -0
  93. package/lib/src/utils/shareLockbox.js +101 -0
  94. package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
  95. package/lib/src/utils/sharedFolderAccess.js +80 -0
  96. package/lib/src/utils/userCreateError.js +82 -0
  97. package/lib/src/utils/username.d.ts +2 -0
  98. package/lib/src/utils/username.js +105 -0
  99. package/package.json +2 -1
@@ -0,0 +1,3830 @@
1
+ /**
2
+ * AuthManager — pluggable authentication provider chain.
3
+ *
4
+ * Registers one or more AuthProviders and delegates authenticate/initiate
5
+ * calls to the appropriate provider. Routes call only AuthManager — never
6
+ * individual providers directly.
7
+ *
8
+ * `ngdpbase.auth.factors` lists the methods offered (#1523): one entry per
9
+ * provider, with the `amr` / `aal` / `acr` it gives — lowered to what the
10
+ * provider's own code declares, never raised. What a sign-in must reach is set
11
+ * per role (`required-aal`), not here. Signing in still uses one factor; the
12
+ * multi-factor flow, the per-role check and the boot refusal follow under #1523.
13
+ *
14
+ * Built-in providers, each gated on its own config key and all registered
15
+ * through the public {@link AuthManager.registerProvider} (#1050):
16
+ * - PasswordAuthProvider (unless ngdpbase.auth.password.enabled is false)
17
+ * - MagicLinkAuthProvider (ngdpbase.auth.magic-link.enabled)
18
+ * - OidcAuthProvider, one per entry (ngdpbase.auth.oidc.providers; the old
19
+ * ngdpbase.auth.google-oidc.* keys are read as the `google` entry, #1743)
20
+ * - CloudflareAccessAuthProvider (ngdpbase.auth.cloudflare-access.enabled)
21
+ * - AuthentikBearerAuthProvider (ngdpbase.auth.authentik-bearer.enabled)
22
+ * - AgentTokenAuthProvider (ngdpbase.auth.agent-token.enabled)
23
+ *
24
+ * Addons register through the same method, so the contributed path is the one
25
+ * exercised on every boot rather than a second, less-travelled one.
26
+ *
27
+ * Future providers (see #421):
28
+ * - TotpAuthProvider
29
+ *
30
+ * @see {@link https://github.com/jwilleke/ngdpbase/issues/396}
31
+ */
32
+ import { CONSENT_PATH } from '../types/Consent.js';
33
+ import BaseManager from './BaseManager.js';
34
+ import { PasswordAuthProvider } from '../providers/PasswordAuthProvider.js';
35
+ import { MagicLinkAuthProvider, EMAIL_FACTOR } from '../providers/MagicLinkAuthProvider.js';
36
+ import { dropKeyCopiesOfGrant, dropKeyCopy, hasUnlockedKey, hasUserKeys, heldKeyCopy, lockPrivateStores, openKeyCopy, personKeyOf, sealKekCopyFor } from '../utils/privateStoreUnlock.js';
37
+ import { isLockbox } from '../utils/shareLockbox.js';
38
+ import { OidcAuthProvider } from '../providers/OidcAuthProvider.js';
39
+ import { resolveEgressPolicy } from '../http/egressPolicy.js';
40
+ import { CloudflareAccessAuthProvider } from '../providers/CloudflareAccessAuthProvider.js';
41
+ import { AuthentikBearerAuthProvider } from '../providers/AuthentikBearerAuthProvider.js';
42
+ import { OidcBearerAuthProvider } from '../providers/OidcBearerAuthProvider.js';
43
+ import { AgentTokenAuthProvider } from '../providers/AgentTokenAuthProvider.js';
44
+ import logger from '../utils/logger.js';
45
+ import LocaleUtils from '../utils/LocaleUtils.js';
46
+ import { describeBrowser } from '../utils/browserDescription.js';
47
+ import { approximateLocation } from '../utils/ipLocation.js';
48
+ import { createHash, createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
49
+ import FileCredentialsProvider from '../providers/FileCredentialsProvider.js';
50
+ import { PasskeyAuthProvider, relyingPartyFrom } from '../providers/PasskeyAuthProvider.js';
51
+ import { CREDENTIALS_KEY_ENV } from '../providers/BaseCredentialsProvider.js';
52
+ import { recordAuditEvent } from '../utils/auditEvents.js';
53
+ import { actorOf } from '../context/ActorContext.js';
54
+ import { proxyAttribution } from '../utils/proxyClaim.js';
55
+ import { lackingScopes, requireScopeList, VAULT_PROXY_PERMISSION } from '../utils/delegation.js';
56
+ import { AUDIT_EVENT } from '../utils/auditEventNames.js';
57
+ import { enabledEntries } from '../utils/configFiles.js';
58
+ import { envRefName } from '../utils/configPaths.js';
59
+ import { describeScopes, grantablePermissionNames } from '../utils/permissionCatalog.js';
60
+ import { endSession } from '../utils/sessionEnd.js';
61
+ import { generateCsrfToken } from '../middleware/csrf.js';
62
+ import { sessionGenerationOf } from '../utils/sessionGeneration.js';
63
+ import { accountBindingOf } from '../utils/sessionAccount.js';
64
+ import { DEFAULT_SESSION_MAX_AGE_MS, sessionCapMs } from '../utils/sessionLimits.js';
65
+ import { safeRedirect } from '../utils/safeRedirect.js';
66
+ import { deriveUsername } from '../utils/deriveUsername.js';
67
+ import { safeRegistrationMessage, UserCreateError } from '../utils/userCreateError.js';
68
+ import { sameUsername } from '../utils/username.js';
69
+ import { isEmail } from '../types/guards.js';
70
+ import { jobContextFromRequestWithReason } from '../context/JobContext.js';
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';
90
+ /** A factor as offered: its entry lowered to what the provider's code gives. */
91
+ /**
92
+ * Providers that count toward a sign-in level only once the account has
93
+ * enrolled one (#1690): the provider id, and the credential kind it needs.
94
+ */
95
+ const ENROLLED_FACTORS = { passkey: 'passkey' };
96
+ /**
97
+ * The factor type each RFC 8176 `amr` value gives. Values not listed give no
98
+ * type: `user` (a presence test), `mfa`, `mca`, `rba`, `geo`, `wia` describe a
99
+ * method or a signal, not a factor someone holds.
100
+ */
101
+ const FACTOR_TYPE = {
102
+ pwd: 'know', pin: 'know', kba: 'know',
103
+ otp: 'have', sms: 'have', tel: 'have', swk: 'have', hwk: 'have', sc: 'have', email: 'have', push: 'have',
104
+ fpt: 'are', face: 'are', iris: 'are', retina: 'are', vbm: 'are'
105
+ };
106
+ /** `amr` values that never lift a sign-in's AAL (SP 800-63B §5.1.3.1). */
107
+ const NEVER_LIFTS = new Set(['email']);
108
+ const PHR_RANK = { phr: 1, phrh: 2 };
109
+ /**
110
+ * The `amr` values an outside provider's report is read for (#1633): those
111
+ * registered by RFC 8176 §2. Anything else (`email`, `push`, a provider's own
112
+ * word) is recorded in the audit record but never counts.
113
+ */
114
+ const RFC8176_AMR = new Set([
115
+ 'face', 'fpt', 'geo', 'hwk', 'iris', 'kba', 'mca', 'mfa', 'otp', 'pin',
116
+ 'pwd', 'rba', 'retina', 'sc', 'sms', 'swk', 'tel', 'user', 'vbm', 'wia'
117
+ ]);
118
+ /**
119
+ * Outside `acr` values that state AAL2 or higher by NIST SP 800-63B (#1633):
120
+ * the identifiers SP 800-63C §6 assigns, and the level each states. A trusted
121
+ * provider's report is taken as given, so `…/aal/3` is AAL3.
122
+ */
123
+ const OUTSIDE_ACR_AAL = {
124
+ 'http://idmanagement.gov/ns/assurance/aal/2': 2,
125
+ 'http://idmanagement.gov/ns/assurance/aal/3': 3
126
+ };
127
+ /** Bounds on what an outside report may put in an audit record. */
128
+ const REPORTED_MAX_AMR = 16;
129
+ const REPORTED_MAX_LENGTH = 256;
130
+ /**
131
+ * Keep the well-formed part of a report: `amr` only when it is an array (its
132
+ * strings), `acr` only when it is a string. A crafted `amr` of another type is
133
+ * ignored (`[]`), never coerced.
134
+ */
135
+ function reportedValues(raw, trusted) {
136
+ const amr = Array.isArray(raw.amr)
137
+ ? raw.amr.filter((v) => typeof v === 'string').slice(0, REPORTED_MAX_AMR).map(v => v.slice(0, REPORTED_MAX_LENGTH))
138
+ : [];
139
+ return typeof raw.acr === 'string'
140
+ ? { amr, acr: raw.acr.slice(0, REPORTED_MAX_LENGTH), trusted }
141
+ : { amr, trusted };
142
+ }
143
+ /** The longest name a credential may carry. */
144
+ export const CREDENTIAL_LABEL_MAX = 60;
145
+ /**
146
+ * A credential's name, tidied: whitespace collapsed, at most 60 characters.
147
+ * Empty is refused — two passkeys both called "Passkey" cannot be told apart
148
+ * when one has to be removed (operator, 2026-10-04).
149
+ */
150
+ export function credentialLabel(label) {
151
+ const tidy = typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().slice(0, CREDENTIAL_LABEL_MAX).trim() : '';
152
+ if (!tidy)
153
+ throw new Error('Give it a name, so you can tell it apart from your others');
154
+ return tidy;
155
+ }
156
+ /**
157
+ * The approval sentence (#1532, decided wording plus the IP and place):
158
+ * "A sign-in request for Willeke Family was sent at 2 Oct 2026, 14:02 EDT
159
+ * from Chrome on Windows at IP address 81.2.69.160, near Columbus, United
160
+ * States." `strong` marks the site and time, `text` every other value; for
161
+ * HTML both escape.
162
+ */
163
+ export function approvalSentence(about, strong = (v) => v, text = (v) => v) {
164
+ const from = [about.device ? text(about.device) : null, about.ip ? `IP address ${text(about.ip)}` : null].filter(Boolean).join(' at ');
165
+ return `A sign-in request for ${strong(about.site)} was sent at ${strong(about.when)}`
166
+ + (from ? ` from ${from}` : '')
167
+ + (about.location ? `, near ${text(about.location)}` : '')
168
+ + '.';
169
+ }
170
+ const PENDING_SIGN_IN_TTL_MS = 10 * 60_000;
171
+ const APPROVAL_RESEND_MS = 60_000;
172
+ const ENROL_TTL_MS = 30 * 60_000;
173
+ /** j***@example.com — enough to recognise, not enough to harvest. */
174
+ export function maskEmail(address) {
175
+ const [local, domain] = address.split('@');
176
+ if (!domain)
177
+ return '***';
178
+ return `${local.slice(0, 1)}***@${domain}`;
179
+ }
180
+ /** Kinds that can start a sign-in on their own — what "a way in" counts (#1524, #1743). */
181
+ const WAY_IN_KINDS = new Set(['passkey', 'email', 'identity']);
182
+ /**
183
+ * Rows of the credentials store that are not sign-in methods (#1762, #2049):
184
+ * a proxy grant and an invitation. The credential methods neither list nor
185
+ * change one; each is read and written only through its own methods.
186
+ */
187
+ const NOT_SIGN_IN_METHODS = new Set(['proxy', 'invitation']);
188
+ const OIDC_PRESETS = {
189
+ google: { issuer: 'https://accounts.google.com', displayName: 'Google' },
190
+ microsoft: { issuer: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', displayName: 'Microsoft' },
191
+ apple: { issuer: 'https://appleid.apple.com', displayName: 'Apple', signedClientSecret: true, responseMode: 'form_post', emailVerifiedMayBeString: true }
192
+ };
193
+ /** The icon a provider's button shows when its entry sets none (#1781): by preset, else a generic sign-in icon. */
194
+ const DEFAULT_PROVIDER_ICONS = {
195
+ google: { kind: 'builtin', id: 'google' },
196
+ microsoft: { kind: 'fa', className: 'fab fa-microsoft' },
197
+ apple: { kind: 'fa', className: 'fab fa-apple' }
198
+ };
199
+ const GENERIC_PROVIDER_ICON = { kind: 'fa', className: 'fas fa-right-to-bracket' };
200
+ const FONT_AWESOME_ICON = /^(fa[srb]? )?fa-[a-z0-9-]+$/;
201
+ /**
202
+ * Resolve an entry's `icon` (#1781). Accepted: an `https://` address, a site
203
+ * path (starts with one `/`, no `\`), or a Font Awesome name, normalised to
204
+ * its full class (`fa-users` → `fas fa-users`). Anything else — `http:`,
205
+ * `javascript:`, `data:`, a relative path — is refused with a warning naming
206
+ * the entry, and the default shown. Unset: the preset's icon, else generic.
207
+ */
208
+ export function resolveOutsideProviderIcon(name, icon, preset, warn) {
209
+ const fallback = (preset && DEFAULT_PROVIDER_ICONS[preset]) || GENERIC_PROVIDER_ICON;
210
+ if (icon === undefined || icon === null || icon === '')
211
+ return fallback;
212
+ if (typeof icon === 'string') {
213
+ if (FONT_AWESOME_ICON.test(icon))
214
+ return { kind: 'fa', className: icon.includes(' ') ? icon : `fas ${icon}` };
215
+ const plain = !/[\s\\]/.test(icon);
216
+ if (plain && icon.startsWith('/') && !icon.startsWith('//'))
217
+ return { kind: 'image', src: icon };
218
+ if (plain && icon.startsWith('https://') && URL.canParse(icon))
219
+ return { kind: 'image', src: icon };
220
+ }
221
+ warn(`[AuthManager] Outside sign-in provider '${name}': its icon must be an https:// address, a site path starting with /, or a Font Awesome name such as fa-users; showing the default icon (#1781)`);
222
+ return fallback;
223
+ }
224
+ /** A registered outside provider, as views are given it. */
225
+ const asOutsideProvider = (p) => ({ id: p.id, name: p.name, displayName: p.displayName, icon: p.icon });
226
+ /** Short: a shared browser should not keep someone else's identity waiting for long (#1743). */
227
+ const PENDING_CONNECTION_TTL_MS = 5 * 60_000;
228
+ /** #2073: how long the link confirming a new email lasts, in minutes. */
229
+ const EMAIL_CHANGE_TTL_KEY = 'ngdpbase.auth.email-change.ttl-minutes';
230
+ /** #2073: the least time between two email-change links for one account, in seconds. */
231
+ const EMAIL_CHANGE_COOLDOWN_KEY = 'ngdpbase.auth.email-change.cooldown-seconds';
232
+ /** #2073: the most email-change links one account is sent in a window, and the window, in minutes. */
233
+ const EMAIL_CHANGE_MAX_KEY = 'ngdpbase.auth.email-change.max-per-window';
234
+ const EMAIL_CHANGE_WINDOW_KEY = 'ngdpbase.auth.email-change.window-minutes';
235
+ /** #2073: an outside sign-in's verified email already belongs to one account here; it is connected only by signing in to that account. */
236
+ const OUTSIDE_EMAIL_HELD = 'An account here already uses that email. Sign in to it another way, then connect this sign-in.';
237
+ /** The setting that turns proxy access on (#1762); false by default. */
238
+ export const PROXY_ACCESS_ENABLED_KEY = 'ngdpbase.auth.proxy-access.enabled';
239
+ /** The permission to make a proxy grant on your own account (#1762); step-up. */
240
+ export const PROXY_GRANT_PERMISSION = 'proxy-grant';
241
+ /** Where a signed-in person chooses whom to act for, and switches back (#1762 slice 4). */
242
+ export const ACTING_FOR_PATH = '/account/acting-for';
243
+ /** Where an invitation link opens (#2049): `GET` shows it, `POST` uses it. */
244
+ export const INVITATION_PATH = '/invitation';
245
+ /** Where an invited user sets up their sign-in after the link signs them in (#2049). */
246
+ export const ACCOUNT_SETUP_PATH = '/account/setup';
247
+ /** How many days an invitation link stays usable (#2049); read on every call. */
248
+ export const INVITATION_TTL_KEY = 'ngdpbase.auth.invitation.ttl-days';
249
+ const DEFAULT_INVITATION_TTL_DAYS = 7;
250
+ /** An `invitation` row's view, or null when its terms do not read. */
251
+ function invitationOf(row) {
252
+ if (row.kind !== 'invitation')
253
+ return null;
254
+ try {
255
+ const t = JSON.parse(row.secret);
256
+ if (typeof t.createdBy !== 'string' || typeof t.expiresAt !== 'string')
257
+ return null;
258
+ return { id: row.id, username: row.username, createdBy: t.createdBy, createdAt: row.createdAt, expiresAt: t.expiresAt };
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
264
+ /** Whether an invitation is still usable at `now`; an end date that does not read counts as passed. */
265
+ function invitationLive(inv, now) {
266
+ const end = Date.parse(inv.expiresAt);
267
+ return Number.isFinite(end) && end > now;
268
+ }
269
+ /** What an invitation link's token is stored as: its SHA-256. The token is 32 random bytes, so a fast hash is enough. */
270
+ function invitationTokenHash(token) {
271
+ return createHash('sha256').update(token, 'utf8').digest('hex');
272
+ }
273
+ /** A `proxy` row's grant, or null when its terms do not read. */
274
+ function proxyGrantOf(row) {
275
+ if (row.kind !== 'proxy')
276
+ return null;
277
+ try {
278
+ const t = JSON.parse(row.secret);
279
+ if (typeof t.proxy !== 'string' || typeof t.createdBy !== 'string' ||
280
+ !Array.isArray(t.permissions) || !t.permissions.every(p => typeof p === 'string') ||
281
+ !(t.expiresAt === null || typeof t.expiresAt === 'string') || (t.vault !== 'none' && t.vault !== 'never' && t.vault !== 'copy'))
282
+ return null;
283
+ return { id: row.id, owner: row.username, proxy: t.proxy, permissions: [...t.permissions], createdBy: t.createdBy, createdAt: row.createdAt, expiresAt: t.expiresAt, vault: t.vault };
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ /** The key copy a `proxy` row carries (#1762 slice 5), or null: none, or one that does not read. */
290
+ function proxyKeyCopyOf(row) {
291
+ if (row?.kind !== 'proxy')
292
+ return null;
293
+ try {
294
+ const t = JSON.parse(row.secret);
295
+ return t.vault === 'copy' && isLockbox(t.keyCopy) ? t.keyCopy : null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ /** Whether a grant is still in force at `now`. An end date that does not read counts as passed: unreadable fails closed. */
302
+ function proxyGrantLive(grant, now) {
303
+ if (grant.expiresAt === null)
304
+ return true;
305
+ const end = Date.parse(grant.expiresAt);
306
+ return Number.isFinite(end) && end > now;
307
+ }
308
+ /** Whether a refusal in `mode` is answered by sending the browser to a page: a page, or a file a page links to (#2145). */
309
+ export function answeredWithPage(mode) {
310
+ return mode === 'page' || mode === 'download';
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
+ }
397
+ /**
398
+ * Where to return after re-authenticating: this page for a page GET, and the
399
+ * page the person was on for anything else — a POST (the form is filled in
400
+ * again; a POST body is not replayed), a script's request for data (#1738) or
401
+ * a file download (#2145). Always a path on this site.
402
+ */
403
+ export function reauthReturnTo(req, mode = 'page') {
404
+ if (req.method === 'GET' && mode === 'page')
405
+ return safeRedirect(req.originalUrl || '/');
406
+ const referer = req.get('referer');
407
+ if (!referer)
408
+ return '/';
409
+ try {
410
+ const url = new URL(referer);
411
+ return safeRedirect(url.pathname + url.search);
412
+ }
413
+ catch {
414
+ return '/';
415
+ }
416
+ }
417
+ class AuthManager extends BaseManager {
418
+ providers;
419
+ factorEntries;
420
+ /** Built on first use and dropped on registration, since add-ons register late. */
421
+ factorCache;
422
+ /** The credentials store (#1524); null when it could not be opened. */
423
+ credentials = null;
424
+ /** `ngdpbase.auth.credentials.file`, resolved at start: what a backup copies (#1876). */
425
+ credentialsFile = null;
426
+ constructor(engine) {
427
+ super(engine);
428
+ this.providers = new Map();
429
+ this.factorEntries = [{ authproviderid: 'password' }];
430
+ this.factorCache = null;
431
+ }
432
+ async initialize(config = {}) {
433
+ await super.initialize(config);
434
+ const configManager = this.engine.getManager('ConfigurationManager');
435
+ // Register password provider if enabled (default: true)
436
+ if (configManager?.getProperty('ngdpbase.auth.password.enabled', true) !== false) {
437
+ this.registerProvider(new PasswordAuthProvider(this.engine));
438
+ }
439
+ // Register magic-link provider if enabled
440
+ if (configManager?.getProperty('ngdpbase.auth.magic-link.enabled', false)) {
441
+ const emailManager = this.engine.getManager('EmailManager');
442
+ if (!emailManager) {
443
+ logger.error('[AuthManager] EmailManager not available — magic-link provider not registered');
444
+ }
445
+ else if (!configManager.isBaseUrlExplicit()) {
446
+ // #642 Iteration 3: refuse to register if base-url is the unconfigured default.
447
+ // Magic-link tokens are credentials embedded in URLs — emitting them
448
+ // pointing at the localhost default leaks the credential to anyone
449
+ // who can intercept the email.
450
+ logger.error('[AuthManager] Magic-link provider NOT registered: ngdpbase.application.base-url ' +
451
+ 'is not explicitly configured. Set it in custom config or via NGDPBASE_BASE_URL ' +
452
+ 'before enabling magic-link auth. (#642)');
453
+ }
454
+ else {
455
+ const ttlMinutes = configManager.getProperty('ngdpbase.auth.magic-link.ttl-minutes', 15);
456
+ this.registerProvider(new MagicLinkAuthProvider(this.engine, {
457
+ ttlMs: ttlMinutes * 60_000,
458
+ mailProvider: emailManager
459
+ }));
460
+ logger.info(`[AuthManager] magic-link transport=${emailManager.getProviderName()} ttl=${ttlMinutes}min`);
461
+ }
462
+ }
463
+ // Register Cloudflare Access provider if enabled (#649)
464
+ if (configManager?.getProperty('ngdpbase.auth.cloudflare-access.enabled', false)) {
465
+ const teamDomain = configManager.getProperty('ngdpbase.auth.cloudflare-access.team-domain', '');
466
+ const applicationAud = configManager.getProperty('ngdpbase.auth.cloudflare-access.application-aud', '');
467
+ if (!teamDomain || !applicationAud) {
468
+ logger.error('[AuthManager] Cloudflare Access provider NOT registered: ngdpbase.auth.cloudflare-access.team-domain ' +
469
+ 'and ngdpbase.auth.cloudflare-access.application-aud must both be set in custom config before enabling. (#649)');
470
+ }
471
+ else {
472
+ const cfConfig = {
473
+ teamDomain,
474
+ applicationAud,
475
+ defaultRole: configManager.getProperty('ngdpbase.auth.cloudflare-access.default-role', 'reader'),
476
+ groupMap: configManager.getProperty('ngdpbase.auth.cloudflare-access.group-map', {})
477
+ };
478
+ this.registerProvider(new CloudflareAccessAuthProvider(this.engine, cfConfig));
479
+ logger.info(`[AuthManager] cloudflare-access team=${teamDomain}`);
480
+ }
481
+ }
482
+ // Register Authentik bearer provider if enabled (#818). Verification-only:
483
+ // ngdpbase trusts Authentik-issued OAuth JWTs against the configured JWKS.
484
+ // No client secret is needed here — the secret lives with the agent that
485
+ // mints the token. Requires issuer + jwks-url + audience to be set.
486
+ if (configManager?.getProperty('ngdpbase.auth.authentik-bearer.enabled', false)) {
487
+ const issuer = configManager.getProperty('ngdpbase.auth.authentik-bearer.issuer', '');
488
+ const jwksUrl = configManager.getProperty('ngdpbase.auth.authentik-bearer.jwks-url', '');
489
+ const audience = configManager.getProperty('ngdpbase.auth.authentik-bearer.audience', '');
490
+ if (!issuer || !jwksUrl || !audience) {
491
+ logger.error('[AuthManager] Authentik bearer provider NOT registered: ngdpbase.auth.authentik-bearer.issuer, ' +
492
+ '.jwks-url and .audience must all be set in custom config before enabling. (#818)');
493
+ }
494
+ else {
495
+ const authentikConfig = {
496
+ issuer,
497
+ jwksUrl,
498
+ audience,
499
+ defaultRole: configManager.getProperty('ngdpbase.auth.authentik-bearer.default-role', 'reader'),
500
+ groupMap: configManager.getProperty('ngdpbase.auth.authentik-bearer.group-map', {})
501
+ };
502
+ this.registerProvider(new AuthentikBearerAuthProvider(this.engine, authentikConfig));
503
+ logger.info(`[AuthManager] authentik-bearer issuer=${issuer}`);
504
+ }
505
+ }
506
+ // Register the in-app agent token provider (#946). Unlike authentik-bearer
507
+ // this needs no external IdP — users mint their own delegated credentials —
508
+ // so it registers whenever enabled, with no further required config. Both
509
+ // bearer providers may be active at once; the middleware tries each.
510
+ if (configManager?.getProperty('ngdpbase.auth.agent-token.enabled', false)) {
511
+ this.registerProvider(new AgentTokenAuthProvider(this.engine));
512
+ }
513
+ // #1576: access tokens from this instance's own OpenID Connect provider,
514
+ // for apps a person allowed. Verified by OidcManager when a request
515
+ // arrives, so registering ahead of it is safe; with the provider off,
516
+ // every token simply fails to verify.
517
+ if (configManager?.getProperty('oidc-auth-server.enabled', false) === true) {
518
+ this.registerProvider(new OidcBearerAuthProvider(this.engine));
519
+ }
520
+ this.factorEntries = this.readFactorEntries(configManager);
521
+ this.factorCache = null;
522
+ await this.openCredentialsStore(configManager);
523
+ this.registerPasskeys(configManager);
524
+ this.registerOidcProviders(configManager);
525
+ this.warnIfEmailSecondFactorsDropped();
526
+ logger.info(`[AuthManager] Initialized — factors: [${this.factorEntries.map(e => e.authproviderid).join(', ')}]`);
527
+ }
528
+ /**
529
+ * `ngdpbase.auth.factors`, or the retired flat `required-factors` list
530
+ * converted to entries when a site still sets only that (#1523).
531
+ */
532
+ readFactorEntries(configManager) {
533
+ if (!configManager)
534
+ return [{ authproviderid: 'password' }];
535
+ const legacy = configManager.getCustomProperty('ngdpbase.auth.required-factors');
536
+ const custom = configManager.getCustomProperty('ngdpbase.auth.factors');
537
+ const legacyIds = Array.isArray(legacy) ? legacy.filter((id) => typeof id === 'string') : null;
538
+ if (legacyIds) {
539
+ logger.warn('[AuthManager] ngdpbase.auth.required-factors is retired — ' +
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' }]);
550
+ if (!Array.isArray(entries)) {
551
+ logger.error('[AuthManager] ngdpbase.auth.factors is not a list — offering password only. (#1523)');
552
+ return [{ authproviderid: 'password' }];
553
+ }
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)`);
592
+ }
593
+ /**
594
+ * The outside OpenID Connect providers (#1743): one `OidcAuthProvider` per
595
+ * enabled entry of `ngdpbase.auth.oidc.providers`, plus the old
596
+ * `ngdpbase.auth.google-oidc.*` keys as the `google` entry when the map has
597
+ * none. Connected identities live in the credentials store, so without it
598
+ * no outside sign-in can work and none is registered. An entry that cannot
599
+ * work (no issuer, no client id, a secret that is not an environment
600
+ * reference, nowhere to come back to) is refused with the reason.
601
+ */
602
+ registerOidcProviders(configManager) {
603
+ if (!configManager)
604
+ return;
605
+ // #1889: unresolved, since getProperty now resolves a `$NAME` inside the
606
+ // map, and the rule here is that a client secret IS such a reference.
607
+ const raw = configManager.getUnresolvedProperty?.('ngdpbase.auth.oidc.providers', {});
608
+ const entries = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
609
+ // The old keys: their secret is read through the configuration, which resolves a `$NAME` reference itself.
610
+ let legacy = null;
611
+ if (configManager.getProperty('ngdpbase.auth.google-oidc.enabled', false) === true) {
612
+ if (entries.google) {
613
+ logger.warn('[AuthManager] ngdpbase.auth.google-oidc.* is ignored: ngdpbase.auth.oidc.providers has a google entry (#1743)');
614
+ }
615
+ else {
616
+ entries.google = { preset: 'google', 'client-id': configManager.getProperty('ngdpbase.auth.google-oidc.client-id', ''), enabled: true };
617
+ legacy = {
618
+ secret: configManager.getProperty('ngdpbase.auth.google-oidc.client-secret', ''),
619
+ callbackUrl: configManager.getProperty('ngdpbase.auth.google-oidc.callback-url', '')
620
+ };
621
+ }
622
+ }
623
+ const enabled = Object.entries(entries).filter(([, e]) => e && typeof e === 'object' && e.enabled === true);
624
+ if (enabled.length === 0)
625
+ return;
626
+ if (!this.credentials) {
627
+ logger.warn('[AuthManager] Outside sign-in providers NOT registered: the credentials store, which keeps connected identities, is not available (#1743)');
628
+ return;
629
+ }
630
+ const egress = resolveEgressPolicy((key, fallback) => configManager.getProperty(key, fallback));
631
+ for (const [name, entry] of enabled) {
632
+ const refuse = (why) => { logger.error(`[AuthManager] Outside sign-in provider '${name}' NOT registered: ${why} (#1743)`); };
633
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
634
+ refuse('its name must be lowercase letters, digits and hyphens, as it is part of a URL');
635
+ continue;
636
+ }
637
+ const preset = entry.preset && entry.preset !== 'none' ? OIDC_PRESETS[entry.preset] : undefined;
638
+ if (entry.preset && entry.preset !== 'none' && !preset) {
639
+ refuse(`preset '${entry.preset}' is not available yet`);
640
+ continue;
641
+ }
642
+ const issuer = entry.issuer || preset?.issuer;
643
+ if (!issuer) {
644
+ refuse('set its issuer, or a preset');
645
+ continue;
646
+ }
647
+ const clientId = entry['client-id'];
648
+ if (!clientId) {
649
+ refuse('set its client-id');
650
+ continue;
651
+ }
652
+ const fromLegacy = name === 'google' ? legacy : null;
653
+ const clientSecret = fromLegacy
654
+ ? fromLegacy.secret
655
+ : preset?.signedClientSecret ? this.signingKeyFrom(entry, refuse) : this.secretFromEnvironment(entry['client-secret'], 'client-secret', refuse);
656
+ if (!clientSecret) {
657
+ if (fromLegacy)
658
+ refuse('set ngdpbase.auth.google-oidc.client-secret');
659
+ continue;
660
+ }
661
+ const redirectUri = fromLegacy?.callbackUrl || (configManager.isBaseUrlExplicit()
662
+ ? `${configManager.getBaseURL().replace(/\/$/, '')}/auth/oauth/${name}/callback`
663
+ : '');
664
+ if (!redirectUri) {
665
+ refuse('ngdpbase.application.base-url is not set explicitly, so there is no address for the provider to send people back to');
666
+ continue;
667
+ }
668
+ const store = this.credentials;
669
+ this.registerProvider(new OidcAuthProvider({
670
+ name, displayName: entry['display-name'] || preset?.displayName || name,
671
+ icon: resolveOutsideProviderIcon(name, entry.icon, preset ? entry.preset : undefined, m => logger.warn(m)), issuer, clientId, clientSecret, redirectUri, policy: egress.policy,
672
+ ...(preset?.responseMode ? { responseMode: preset.responseMode } : {}),
673
+ ...(preset?.emailVerifiedMayBeString ? { emailVerifiedMayBeString: true } : {})
674
+ }, {
675
+ find: (subject) => store.findBySubject('identity', subject),
676
+ used: (id, at, email) => store.touch(id, at, email || undefined)
677
+ }));
678
+ logger.info(`[AuthManager] outside sign-in '${name}' issuer=${issuer}`);
679
+ }
680
+ }
681
+ /** A secret from its environment reference (`$NAME`); never the secret written into configuration. */
682
+ secretFromEnvironment(ref, field, refuse) {
683
+ const name = envRefName(ref);
684
+ if (!name) {
685
+ refuse(`${field} must be an environment reference ($NAME), never the secret itself`);
686
+ return '';
687
+ }
688
+ const value = process.env[name] ?? '';
689
+ if (!value)
690
+ refuse(`the environment variable ${name} named by its ${field} is not set`);
691
+ return value;
692
+ }
693
+ /**
694
+ * Apple's client secret is signed by the site (#1743 slice 4b): the team id,
695
+ * the key's id, and the EC P-256 private key Apple issued (the `.p8` file's
696
+ * PEM, from an environment reference; `\n` escapes are accepted for a
697
+ * one-line variable). Null, with the reason refused, when any is missing or
698
+ * the key is not one Apple signs with.
699
+ */
700
+ signingKeyFrom(entry, refuse) {
701
+ const teamId = entry['team-id'];
702
+ const keyId = entry['key-id'];
703
+ if (!teamId || !keyId) {
704
+ refuse('set its team-id and key-id');
705
+ return null;
706
+ }
707
+ const pem = this.secretFromEnvironment(entry['private-key'], 'private-key', refuse);
708
+ if (!pem)
709
+ return null;
710
+ let privateKey;
711
+ try {
712
+ privateKey = createPrivateKey(pem.replace(/\\n/g, '\n'));
713
+ }
714
+ catch {
715
+ refuse('its private-key is not a PEM private key');
716
+ return null;
717
+ }
718
+ if (privateKey.asymmetricKeyType !== 'ec' || privateKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
719
+ refuse('its private-key must be an EC P-256 key (the .p8 file Apple issues)');
720
+ return null;
721
+ }
722
+ return { teamId, keyId, privateKey };
723
+ }
724
+ outsideProvider(providerId) {
725
+ const p = this.providers.get(providerId);
726
+ return p instanceof OidcAuthProvider ? p : null;
727
+ }
728
+ /**
729
+ * Whether this enabled outside provider POSTs its callback (`form_post`,
730
+ * Apple) — the only callbacks the relay in front of the session and CSRF
731
+ * accepts as a POST (#1743 slice 4b).
732
+ */
733
+ callbackIsPosted(providerId) {
734
+ return this.outsideProvider(providerId)?.callbackIsPosted ?? false;
735
+ }
736
+ /**
737
+ * Can a visitor with no account get one by an outside sign-in (#1743)?
738
+ * True when at least one enabled provider may create accounts, by the one
739
+ * rule the callback applies (`outsideMayCreateAccounts`). The header's
740
+ * Register button counts it as open sign-up. Read when asked.
741
+ */
742
+ outsideSignUpOpen() {
743
+ return [...this.providers.values()].some(p => p instanceof OidcAuthProvider && this.outsideMayCreateAccounts(p));
744
+ }
745
+ /** The outside sign-in providers configured and enabled now, in registration order (#1743). */
746
+ outsideProviders() {
747
+ return [...this.providers.values()]
748
+ .filter((p) => p instanceof OidcAuthProvider)
749
+ .map(asOutsideProvider);
750
+ }
751
+ /** The enabled provider an identity row came from, by its issuer; null when none is. */
752
+ providerOfIdentity(subject) {
753
+ for (const p of this.providers.values()) {
754
+ if (p instanceof OidcAuthProvider && subject.startsWith(`${p.issuer}#`))
755
+ return p;
756
+ }
757
+ return null;
758
+ }
759
+ /** Whether a credential row can start a sign-in now: an outside identity only while its provider is enabled. */
760
+ isWayIn(row) {
761
+ return WAY_IN_KINDS.has(row.kind) && (row.kind !== 'identity' || this.providerOfIdentity(row.subject) !== null);
762
+ }
763
+ /**
764
+ * `username`'s connected outside identities (#1743), for its owner: each
765
+ * with the provider's email and whether that provider can sign in now.
766
+ */
767
+ async connectedIdentities(ctx, username) {
768
+ if (!(await this.mayManageCredentials(ctx, username, { viewOnly: true })))
769
+ throw new Error('Permission denied');
770
+ return this.requireCredentialsStore().list(username).filter(r => r.kind === 'identity').map(r => {
771
+ const p = this.providerOfIdentity(r.subject);
772
+ return {
773
+ id: r.id,
774
+ label: r.label,
775
+ email: r.secret,
776
+ createdAt: r.createdAt,
777
+ ...(r.lastUsedAt ? { lastUsedAt: r.lastUsedAt } : {}),
778
+ provider: p ? asOutsideProvider(p) : null
779
+ };
780
+ });
781
+ }
782
+ /**
783
+ * Begin connecting an outside identity to the signed-in person's own
784
+ * account (#1743): an `account-security` change, so step-up applies.
785
+ * Returns the provider's URL to send the browser to.
786
+ */
787
+ async startConnect(ctx, username, providerId, flow) {
788
+ if (ctx.username !== username || !(await this.mayManageCredentials(ctx, username)))
789
+ throw new Error('Permission denied');
790
+ const provider = this.outsideProvider(providerId);
791
+ if (!provider)
792
+ throw new Error('That sign-in provider is not available');
793
+ if (!(await this.userMayUseProvider(username, providerId)))
794
+ throw new Error('This account cannot sign in with that provider');
795
+ return provider.startFlow({ redirect: flow.redirect ?? '/profile', deviceState: flow.deviceState, connectFor: username });
796
+ }
797
+ /** The account a flow is connecting an identity to, or null for a sign-in (or an unknown flow) (#1743). */
798
+ flowConnectsFor(providerId, state) {
799
+ return this.outsideProvider(providerId)?.getFlowConnectFor(state) ?? null;
800
+ }
801
+ /**
802
+ * Finish connecting an outside identity to `username` (#1743). The flow
803
+ * must be the one this person started to connect; the identity is the
804
+ * issuer plus subject, and belongs to exactly one account, so one already
805
+ * connected elsewhere is refused. Audited as a credential added.
806
+ */
807
+ async connectIdentity(ctx, username, providerId, credentials, attempt = {}) {
808
+ if (ctx.username !== username || !(await this.mayManageCredentials(ctx, username)))
809
+ throw new Error('Permission denied');
810
+ const provider = this.outsideProvider(providerId);
811
+ if (!provider)
812
+ return 'refused';
813
+ const identity = await provider.verifyIdentity(credentials, username);
814
+ if (!identity || !credentials.state || !provider.consumeToken(credentials.state)) {
815
+ await this.auditCheck(attempt, username, providerId, 'failure', `connect: ${providerId} refused`);
816
+ return 'refused';
817
+ }
818
+ return this.attachIdentity(ctx, username, provider, identity, 'connected from the profile', attempt);
819
+ }
820
+ /**
821
+ * Add an outside identity to `username` — the one place a connection is
822
+ * made (#1743): from the profile, at the next sign-in after the email step,
823
+ * and when an account is created for it. The identity belongs to exactly
824
+ * one account, so one already connected elsewhere is refused. Audited as a
825
+ * credential added, saying how. The caller has decided the person may.
826
+ */
827
+ async attachIdentity(actor, username, provider, identity, how, attempt) {
828
+ const existing = this.requireCredentialsStore().findBySubject('identity', identity.subject);
829
+ if (existing) {
830
+ if (existing.username === username)
831
+ return 'already-yours';
832
+ await this.auditCheck(attempt, username, provider.id, 'failure', `connect: ${provider.id} identity is connected to another account`);
833
+ return 'connected-elsewhere';
834
+ }
835
+ await this.storeCredential(actor, username, { kind: 'identity', subject: identity.subject, secret: identity.email, label: credentialLabel(provider.displayName) }, how);
836
+ return 'connected';
837
+ }
838
+ /**
839
+ * Finish an outside sign-in's callback (#1743): the one decision about what
840
+ * a verified outside identity does here. Connected to an account: that
841
+ * account signs in. Connected to none: an account is created for it when
842
+ * the instance, the provider and the registration policy all allow it and
843
+ * the provider verified the email; otherwise (or when that email already
844
+ * belongs to an account) it is held in the session as a pending connection
845
+ * until the person signs in another way and confirms it. Email never signs
846
+ * anyone in.
847
+ */
848
+ async finishOutsideSignIn(req, providerId, credentials, attempt = {}) {
849
+ const provider = this.outsideProvider(providerId);
850
+ const state = credentials.state;
851
+ if (!provider || !state)
852
+ return { outcome: 'refused' };
853
+ const redirect = provider.getFlowRedirect(state);
854
+ const result = await this.authenticate(providerId, credentials, attempt);
855
+ if (result.success) {
856
+ // #1021: the state is good for one sign-in; a replayed callback must not open a second session.
857
+ if (!provider.consumeToken(state))
858
+ return { outcome: 'replayed' };
859
+ // #1532: a provider's sign-in at AAL1 is a password elsewhere; the second step applies as after one here.
860
+ if (this.needsSecondStep(result)) {
861
+ const binding = randomBytes(24).toString('base64url');
862
+ const handle = await this.beginTwoStep(result, { binding, ip: req.ip, userAgent: req.get?.('user-agent'), location: await this.approximateLocation(req) });
863
+ return { outcome: 'second-step', handle, binding, next: safeRedirect(redirect) };
864
+ }
865
+ const done = await this.completeSignIn(req, result, { method: providerId, returnTo: redirect });
866
+ return { outcome: 'signed-in', next: done.next ?? safeRedirect(redirect) };
867
+ }
868
+ const identity = provider.unconnectedIdentity(state);
869
+ if (!identity || !provider.consumeToken(state))
870
+ return { outcome: 'refused' };
871
+ const hold = (error) => {
872
+ req.session.pendingConnection = {
873
+ providerId, subject: identity.subject, email: identity.email, displayName: provider.displayName,
874
+ expiresAt: Date.now() + PENDING_CONNECTION_TTL_MS
875
+ };
876
+ return error ? { outcome: 'connect-first', error } : { outcome: 'connect-first' };
877
+ };
878
+ // #2073: a verified email accounts here already hold. Several share it:
879
+ // the person chooses which account, every time, and nothing is connected
880
+ // or remembered. One: the email step, as before — an email alone never
881
+ // connects an identity to an account. Either way no account is created.
882
+ if (identity.email) {
883
+ const holders = await this.engine.getManager('UserManager')?.getUsersByEmail(identity.email) ?? [];
884
+ const active = holders.filter((u) => u.isActive !== false);
885
+ if (active.length > 1) {
886
+ delete req.session.pendingConnection;
887
+ req.session.outsideChoice = {
888
+ providerId, email: identity.email, choices: active.map((u) => u.username), displayName: provider.displayName,
889
+ reported: identity.reported, returnTo: redirect, expiresAt: Date.now() + PENDING_CONNECTION_TTL_MS
890
+ };
891
+ return { outcome: 'choose-account' };
892
+ }
893
+ if (holders.length > 0)
894
+ return hold(this.outsideMayCreateAccounts(provider) ? OUTSIDE_EMAIL_HELD : undefined);
895
+ }
896
+ if (!identity.email || !this.outsideMayCreateAccounts(provider))
897
+ return hold();
898
+ const created = await this.createAccountFor(req, provider, identity.email);
899
+ if ('error' in created)
900
+ return hold(created.error);
901
+ delete req.session.pendingConnection;
902
+ const attached = await this.attachIdentity({ username: created.username, ipAddress: req.ip }, created.username, provider, identity, 'connected when the account was created for it', attempt)
903
+ .catch((err) => { logger.warn(`[AuthManager] could not connect ${provider.id} to the new account ${created.username}:`, err); return 'refused'; });
904
+ if (attached !== 'connected') {
905
+ // Never leave an account with no way in: the one created a moment ago goes, by the one deletion path.
906
+ await this.removeCreatedAccount(created.username, provider);
907
+ return { outcome: 'refused' };
908
+ }
909
+ const reported = reportedValues(identity.reported, this.trustsOutsideMfa(provider));
910
+ const done = await this.completeSignIn(req, { success: true, username: created.username, provider: providerId, factors: this.satisfiedFactors(providerId, reported), reported }, { method: providerId, returnTo: redirect });
911
+ // #1603: an account made from an outside sign-in is asked to accept the terms at once.
912
+ return { outcome: 'created', next: done.next ?? safeRedirect(redirect) };
913
+ }
914
+ /**
915
+ * May an outside sign-in create an account (#1743)? All three must allow
916
+ * it: the instance ceiling `ngdpbase.auth.oidc.auto-provision`, the
917
+ * provider's own `auto-provision` in `ngdpbase.auth.oidc.providers`, and
918
+ * `ngdpbase.application.registration`, the policy for whether any path may
919
+ * create an account. Read when asked, so a change applies at once.
920
+ * {@link outsideSignUpOpen} asks the same rule for every enabled provider.
921
+ */
922
+ outsideMayCreateAccounts(provider) {
923
+ const cm = this.engine.getManager('ConfigurationManager');
924
+ if (!cm)
925
+ return false;
926
+ if (cm.getProperty('ngdpbase.auth.oidc.auto-provision', false) !== true)
927
+ return false;
928
+ if (this.oidcEntry(provider)?.['auto-provision'] !== true)
929
+ return false;
930
+ return (cm.getProperty('ngdpbase.application.registration', true) ?? true) !== false;
931
+ }
932
+ /**
933
+ * Does the admin trust this outside provider's own second factor (#1633)?
934
+ * Its entry's `trust-mfa` in `ngdpbase.auth.oidc.providers`, off unless set
935
+ * to true; read when asked, so a change applies at the next sign-in. The old
936
+ * `ngdpbase.auth.google-oidc.*` keys have no such switch: off.
937
+ */
938
+ trustsOutsideMfa(provider) {
939
+ return this.oidcEntry(provider)?.['trust-mfa'] === true;
940
+ }
941
+ /** The provider's entry in `ngdpbase.auth.oidc.providers` as configured now, or null. */
942
+ oidcEntry(provider) {
943
+ const cm = this.engine.getManager('ConfigurationManager');
944
+ const entries = cm?.getUnresolvedProperty?.('ngdpbase.auth.oidc.providers', {});
945
+ const entry = entries?.[provider.name];
946
+ return entry && typeof entry === 'object' ? entry : null;
947
+ }
948
+ /**
949
+ * Create the account for an outside identity's verified email (#1743), by
950
+ * the path magic-link auto-provisioning takes: a derived username,
951
+ * `UserManager.createUser` with registration's default roles (the provider
952
+ * gives none), no password (`isExternal`), the system principal acting with
953
+ * the provider named as the reason. `createUser` records the creation. An
954
+ * email that already belongs to an account creates nothing: the safe
955
+ * registration message is returned instead.
956
+ */
957
+ async createAccountFor(req, provider, email) {
958
+ const userManager = this.engine.getManager('UserManager');
959
+ if (!userManager)
960
+ return { error: safeRegistrationMessage(null) };
961
+ try {
962
+ const username = await deriveUsername(email, userManager);
963
+ await userManager.createUser({
964
+ username,
965
+ email,
966
+ displayName: username,
967
+ password: '',
968
+ isExternal: true,
969
+ isActive: true
970
+ }, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine), ipAddress: req.ip }, `provisioned by ${provider.id}`));
971
+ logger.info(`[AuthManager] created ${username} for an outside sign-in through ${provider.id} (#1743)`);
972
+ return { username };
973
+ }
974
+ catch (err) {
975
+ logger.warn(`[AuthManager] no account created for an outside sign-in through ${provider.id}: ${err.message}`);
976
+ return { error: safeRegistrationMessage(err) };
977
+ }
978
+ }
979
+ /** Delete an account created for an outside sign-in that could not be finished (#1743), through UserManager, which closes its access. */
980
+ async removeCreatedAccount(username, provider) {
981
+ try {
982
+ await this.engine.getManager('UserManager')?.deleteUser(username, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine) }, `created for ${provider.id}, but its identity could not be connected`));
983
+ }
984
+ catch (err) {
985
+ logger.error(`[AuthManager] the account ${username}, created for ${provider.id}, could not be removed after its identity failed to connect:`, err);
986
+ }
987
+ }
988
+ /**
989
+ * The accounts an outside sign-in to a shared email offers (#2073), for the
990
+ * page asking which; null when none is waiting, and an expired one is dropped.
991
+ */
992
+ outsideChoice(req) {
993
+ const choice = req.session?.outsideChoice;
994
+ if (!choice)
995
+ return null;
996
+ if (!(choice.expiresAt > Date.now())) {
997
+ delete req.session.outsideChoice;
998
+ return null;
999
+ }
1000
+ return { displayName: choice.displayName, choices: [...choice.choices] };
1001
+ }
1002
+ /**
1003
+ * Finish an outside sign-in to a shared email (#2073): `account` must be one
1004
+ * the sign-in offered, still hold that email, be active, and be allowed to
1005
+ * use the provider. The choice is used once, connects nothing and is not
1006
+ * remembered; the next sign-in asks again. A sign-in at AAL1 takes the
1007
+ * second step, as any outside sign-in does (#1532).
1008
+ */
1009
+ async finishOutsideChoice(req, account) {
1010
+ const choice = req.session?.outsideChoice;
1011
+ if (req.session)
1012
+ delete req.session.outsideChoice;
1013
+ if (!choice || !(choice.expiresAt > Date.now()) || !choice.choices.includes(account))
1014
+ return { outcome: 'refused' };
1015
+ const provider = this.outsideProvider(choice.providerId);
1016
+ if (!provider)
1017
+ return { outcome: 'refused' };
1018
+ const holders = await this.engine.getManager('UserManager')?.getUsersByEmail(choice.email) ?? [];
1019
+ if (!holders.some((u) => u.username === account && u.isActive !== false))
1020
+ return { outcome: 'refused' };
1021
+ if (!(await this.userMayUseProvider(account, choice.providerId))) {
1022
+ logger.warn(`[AuthManager] User ${account} not allowed to use provider: ${choice.providerId}`);
1023
+ return { outcome: 'refused' };
1024
+ }
1025
+ const reported = reportedValues(choice.reported, this.trustsOutsideMfa(provider));
1026
+ const result = { success: true, username: account, provider: choice.providerId, factors: this.satisfiedFactors(choice.providerId, reported), reported };
1027
+ const next = safeRedirect(choice.returnTo);
1028
+ if (this.needsSecondStep(result)) {
1029
+ const binding = randomBytes(24).toString('base64url');
1030
+ const handle = await this.beginTwoStep(result, { binding, ip: req.ip, userAgent: req.get?.('user-agent'), location: await this.approximateLocation(req) });
1031
+ return { outcome: 'second-step', handle, binding, next };
1032
+ }
1033
+ const done = await this.completeSignIn(req, result, { method: choice.providerId, returnTo: next });
1034
+ return { outcome: 'signed-in', next: done.next ?? next };
1035
+ }
1036
+ /**
1037
+ * The person's pending connection, for the sign-in page's notice and the
1038
+ * confirmation page; an expired one is dropped (#1743).
1039
+ */
1040
+ pendingConnection(req) {
1041
+ const pending = req.session?.pendingConnection;
1042
+ if (!pending)
1043
+ return null;
1044
+ if (!(pending.expiresAt > Date.now())) {
1045
+ delete req.session.pendingConnection;
1046
+ return null;
1047
+ }
1048
+ return { displayName: pending.displayName, email: pending.email };
1049
+ }
1050
+ /** The person cancelled the pending connection (#1743). */
1051
+ dropPendingConnection(req) {
1052
+ if (req.session)
1053
+ delete req.session.pendingConnection;
1054
+ }
1055
+ /**
1056
+ * Connect the pending identity to the signed-in person's own account, on
1057
+ * their confirmation (#1743). Never automatic: on a shared browser the
1058
+ * pending identity may be someone else's, and connecting it would let that
1059
+ * person sign in here as this account. Checked again now: signed in, not
1060
+ * expired, the provider enabled and allowed for the account, and the
1061
+ * identity not connected elsewhere. Returns where to send the person, with
1062
+ * what happened.
1063
+ */
1064
+ async confirmPendingConnection(req, ctx) {
1065
+ const pending = req.session?.pendingConnection;
1066
+ const username = req.session?.username;
1067
+ if (!ctx.isAuthenticated || typeof username !== 'string' || !username || ctx.username !== username)
1068
+ throw new Error('Permission denied');
1069
+ this.dropPendingConnection(req);
1070
+ if (!pending || !(pending.expiresAt > Date.now())) {
1071
+ return `/profile?error=${encodeURIComponent('That connection waited too long and was not made. Connect it from Sign-in methods instead.')}`;
1072
+ }
1073
+ const provider = this.outsideProvider(pending.providerId);
1074
+ let outcome = 'refused';
1075
+ if (provider && pending.subject.startsWith(`${provider.issuer}#`) && (await this.userMayUseProvider(username, provider.id))) {
1076
+ try {
1077
+ outcome = await this.attachIdentity({ username, ipAddress: ctx.ipAddress ?? req.ip }, username, provider, pending, 'confirmed after sign-in', { ipAddress: req.ip, userAgent: req.get?.('user-agent'), purpose: 're-check' });
1078
+ }
1079
+ catch (err) {
1080
+ logger.warn(`[AuthManager] could not connect a pending ${pending.providerId} identity to ${username}:`, err);
1081
+ }
1082
+ }
1083
+ const name = pending.displayName;
1084
+ const message = {
1085
+ connected: `success=${encodeURIComponent(`Your ${name} account is connected. You can now sign in with it.`)}`,
1086
+ 'already-yours': `success=${encodeURIComponent(`Your ${name} account was already connected.`)}`,
1087
+ 'connected-elsewhere': `error=${encodeURIComponent(`Your ${name} account was not connected: it is already connected to another account here.`)}`,
1088
+ refused: `error=${encodeURIComponent(`Your ${name} account could not be connected.`)}`
1089
+ }[outcome];
1090
+ return `/profile?${message}`;
1091
+ }
1092
+ /** The person declined the pending connection: drop it, and return where their sign-in was going (#1743). */
1093
+ declinePendingConnection(req) {
1094
+ const returnTo = req.session?.pendingConnection?.returnTo;
1095
+ this.dropPendingConnection(req);
1096
+ return safeRedirect(returnTo);
1097
+ }
1098
+ /**
1099
+ * Open the credentials store (#1524). Without the key nothing can be
1100
+ * verified, so the store stays closed and the manager says so; boot itself
1101
+ * never runs without the key, because bootstrap-env generates it.
1102
+ */
1103
+ async openCredentialsStore(configManager) {
1104
+ this.credentials = null;
1105
+ const key = process.env[CREDENTIALS_KEY_ENV] ?? '';
1106
+ // #1876, #1985: a staged site restore put the backed-up store in place in initialize(), before this reads it.
1107
+ const file = this.configuredCredentialsFile(configManager);
1108
+ if (!file)
1109
+ return;
1110
+ this.credentialsFile = file;
1111
+ if (!key) {
1112
+ this.markDegraded(`${CREDENTIALS_KEY_ENV} is not set, so the credentials store is closed`, CREDENTIALS_KEY_ENV);
1113
+ return;
1114
+ }
1115
+ const store = new FileCredentialsProvider(file, key);
1116
+ try {
1117
+ await store.initialize((rejected) => this.raiseRejectedCredentials(store.location(), rejected));
1118
+ }
1119
+ catch (err) {
1120
+ this.markDegraded(`The credentials store ${file} could not be read: ${err.message}`, 'ngdpbase.auth.credentials.file');
1121
+ return;
1122
+ }
1123
+ this.credentials = store;
1124
+ }
1125
+ /**
1126
+ * Passkeys (#448), registered only when they can work: the credentials store
1127
+ * is open, `ngdpbase.auth.passkey.enabled` is not false, and base-url is set
1128
+ * explicitly and is a secure context. The relying party is that host — never
1129
+ * the request — so a passkey made here works only on that hostname.
1130
+ */
1131
+ registerPasskeys(configManager) {
1132
+ if (!configManager || configManager.getProperty('ngdpbase.auth.passkey.enabled', true) === false)
1133
+ return;
1134
+ if (!this.credentials) {
1135
+ logger.warn('[AuthManager] Passkeys NOT registered: the credentials store is not available (#448)');
1136
+ return;
1137
+ }
1138
+ if (!configManager.isBaseUrlExplicit()) {
1139
+ logger.info('[AuthManager] Passkeys off: ngdpbase.application.base-url is not set explicitly, and a passkey is tied to that host (#448)');
1140
+ return;
1141
+ }
1142
+ const rp = relyingPartyFrom(configManager.getBaseURL(), String(configManager.getProperty('ngdpbase.application-name', 'ngdpbase')));
1143
+ if (!rp) {
1144
+ logger.warn(`[AuthManager] Passkeys off: base-url ${configManager.getBaseURL()} is not https (or localhost), where browsers allow WebAuthn (#448)`);
1145
+ return;
1146
+ }
1147
+ const store = this.credentials;
1148
+ this.registerProvider(new PasskeyAuthProvider(rp, {
1149
+ find: (credentialId) => store.findBySubject('passkey', credentialId),
1150
+ used: (id, at, secret) => store.touch(id, at, secret)
1151
+ }));
1152
+ logger.info(`[AuthManager] passkeys tied to ${rp.rpID}`);
1153
+ }
1154
+ passkeyProvider() {
1155
+ const p = this.providers.get('passkey');
1156
+ return p instanceof PasskeyAuthProvider ? p : null;
1157
+ }
1158
+ /** The host passkeys are tied to, or null when passkeys are off (#448). */
1159
+ passkeyRelyingParty() {
1160
+ return this.passkeyProvider()?.relyingParty() ?? null;
1161
+ }
1162
+ /** Options for `username` to enrol a passkey (#448); the caller keeps `challenge` for verify. */
1163
+ /**
1164
+ * May this account sign in with `providerId`? Its `allowedAuthMethods`, when
1165
+ * set, list the providers it may use; absent or empty means any. The one
1166
+ * answer for signing in and for enrolling (#1664): a method the account
1167
+ * cannot sign in with is never offered or enrolled.
1168
+ */
1169
+ async userMayUseProvider(username, providerId) {
1170
+ const user = await this.engine.getManager('UserManager')?.getUser(username);
1171
+ return allowsProvider(user?.allowedAuthMethods, providerId);
1172
+ }
1173
+ async passkeyRegistrationOptions(ctx, username, displayName) {
1174
+ if (!(await this.mayManageCredentials(ctx, username)))
1175
+ throw new Error('Permission denied');
1176
+ if (!(await this.userMayUseProvider(username, 'passkey')))
1177
+ throw new Error('This account cannot sign in with a passkey');
1178
+ const provider = this.passkeyProvider();
1179
+ if (!provider)
1180
+ throw new Error('Passkeys are not available on this site');
1181
+ const existing = this.requireCredentialsStore().list(username).filter(c => c.kind === 'passkey');
1182
+ return provider.registrationOptions(username, displayName, existing);
1183
+ }
1184
+ /**
1185
+ * Verify an enrolment against the challenge the caller kept, and store the
1186
+ * passkey (#448). Returns the credential id, or null when it does not verify.
1187
+ */
1188
+ async passkeyRegister(ctx, username, response, expectedChallenge, label) {
1189
+ if (!(await this.mayManageCredentials(ctx, username)))
1190
+ throw new Error('Permission denied');
1191
+ if (!(await this.userMayUseProvider(username, 'passkey')))
1192
+ throw new Error('This account cannot sign in with a passkey');
1193
+ credentialLabel(label); // refuse before the challenge is spent on a verification
1194
+ const provider = this.passkeyProvider();
1195
+ if (!provider)
1196
+ throw new Error('Passkeys are not available on this site');
1197
+ const verified = await provider.verifyRegistration(response, expectedChallenge);
1198
+ if (!verified)
1199
+ return null;
1200
+ return this.addCredential(ctx, username, { kind: 'passkey', subject: verified.subject, secret: verified.secret, label: credentialLabel(label) });
1201
+ }
1202
+ /** Options for signing in with a passkey (#448); anyone may ask. */
1203
+ async passkeyAuthenticationOptions() {
1204
+ const provider = this.passkeyProvider();
1205
+ if (!provider)
1206
+ throw new Error('Passkeys are not available on this site');
1207
+ return provider.authenticationOptions();
1208
+ }
1209
+ /**
1210
+ * A row that does not verify was planted or damaged (#1524). It has already
1211
+ * been dropped; this makes it impossible to miss: a critical security event,
1212
+ * an escalating admin notification, and a degraded AuthManager until the
1213
+ * store is put right.
1214
+ */
1215
+ raiseRejectedCredentials(file, rejected) {
1216
+ const summary = rejected.map(r => `${r.row.kind ?? '?'} ${r.row.id ?? '?'} for ${r.row.username ?? '?'} (${r.reason})`).join('; ');
1217
+ const message = `${rejected.length} credential row(s) in ${file} failed verification and were ignored: ${summary}. ` +
1218
+ 'A row that does not verify was written without the instance key — treat it as an attempt to plant a way into an account. ' +
1219
+ 'The rows are kept, unchanged. If you know they are genuine (the credentials key was lost or changed), an operator can re-sign them with scripts/retrust-credentials.ts (#1633).';
1220
+ logger.error(`🚨 [AuthManager] SECURITY: ${message}`);
1221
+ void recordAuditEvent(this.engine.getManager('AuditManager'), {
1222
+ eventType: AUDIT_EVENT.SECURITY_EVENT,
1223
+ user: 'system',
1224
+ ipAddress: undefined,
1225
+ action: 'credential-rejected',
1226
+ result: 'deny',
1227
+ severity: 'high', // the highest AuditSeverity; the notification escalates
1228
+ resource: file,
1229
+ resourceType: 'credentials-store',
1230
+ reason: message,
1231
+ metadata: { securityEventType: 'credential-rejected', rows: rejected.map(r => ({ id: r.row.id, username: r.row.username, kind: r.row.kind, reason: r.reason })) }
1232
+ });
1233
+ const nm = this.engine.getManager('NotificationManager');
1234
+ nm?.notifyAdmins({
1235
+ level: 'error',
1236
+ title: 'Security alert: credential rows rejected',
1237
+ message
1238
+ }).catch(() => { });
1239
+ this.markDegraded(message, 'ngdpbase.auth.credentials.file');
1240
+ }
1241
+ /**
1242
+ * Whether `ctx` may act on `username`'s credentials: their own under
1243
+ * `profile-manage`, anyone else's under `user-edit` (#1524). No PDP, no answer,
1244
+ * no access.
1245
+ */
1246
+ async mayManageCredentials(ctx, username, opts = {}) {
1247
+ const pdp = this.engine.getManager('PolicyDecisionPoint');
1248
+ if (!pdp || !ctx.isAuthenticated)
1249
+ return false;
1250
+ const action = ctx.username === username ? 'account-security' : 'user-edit';
1251
+ // #1738: viewing needs the permission, not a fresh sign-in.
1252
+ if (opts.viewOnly && typeof pdp.holds === 'function')
1253
+ return pdp.holds(ctx, action);
1254
+ return pdp.permits(ctx, action);
1255
+ }
1256
+ /**
1257
+ * Trust again the credential rows that failed verification (#1633): the
1258
+ * operator's deliberate repair after the credentials key was lost or
1259
+ * changed, so people keep their passkeys and verified addresses. Run from
1260
+ * `scripts/retrust-credentials.ts`, never from a request — a web button
1261
+ * that re-signs whatever is in the file would let anyone who can write the
1262
+ * file plant a credential and have it blessed. Recorded as a security event
1263
+ * naming every row and the reason.
1264
+ */
1265
+ async retrustRejectedCredentials(reason, ctx) {
1266
+ const store = this.requireCredentialsStore();
1267
+ const trusted = await store.retrustQuarantined();
1268
+ const remaining = store.quarantined().length;
1269
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
1270
+ eventType: AUDIT_EVENT.SECURITY_EVENT,
1271
+ ...actorOf(ctx),
1272
+ ipAddress: undefined,
1273
+ action: 'credential-retrusted',
1274
+ result: 'success',
1275
+ severity: 'high',
1276
+ resource: store.location(),
1277
+ resourceType: 'credentials-store',
1278
+ reason,
1279
+ metadata: { securityEventType: 'credential-retrusted', rows: trusted.map(r => ({ id: r.id, username: r.username, kind: r.kind })), remaining }
1280
+ });
1281
+ return { trusted: trusted.length, remaining };
1282
+ }
1283
+ requireCredentialsStore() {
1284
+ if (!this.credentials)
1285
+ throw new Error('The credentials store is not available (#1524)');
1286
+ return this.credentials;
1287
+ }
1288
+ /**
1289
+ * `username`'s credentials, without their secrets (#1524). Reading the list
1290
+ * is not a change, so it asks whether the person HOLDS the permission and
1291
+ * leaves out the fresh-sign-in rule (#1738): a stale sign-in that was
1292
+ * refused here showed its owner no passkeys at all, and they believed them
1293
+ * lost. Adding, renaming and removing still ask for a fresh sign-in.
1294
+ */
1295
+ async listCredentials(ctx, username) {
1296
+ if (!(await this.mayManageCredentials(ctx, username, { viewOnly: true })))
1297
+ throw new Error('Permission denied');
1298
+ // #1762: a proxy grant is not a sign-in method; it is listed and ended through the proxy-grant methods only.
1299
+ return this.requireCredentialsStore().list(username).filter(r => !NOT_SIGN_IN_METHODS.has(r.kind)).map(({ secret: _secret, ...view }) => view);
1300
+ }
1301
+ /** Enrol a credential for `username`; returns its id. Audited (#1524). */
1302
+ async addCredential(ctx, username, input) {
1303
+ if (!(await this.mayManageCredentials(ctx, username)))
1304
+ throw new Error('Permission denied');
1305
+ if (input.kind === 'proxy')
1306
+ throw new Error('A proxy grant is made with grantProxy (#1762)');
1307
+ if (input.kind === 'invitation')
1308
+ throw new Error('An invitation is sent with inviteUser (#2049)');
1309
+ return this.storeCredential(ctx, username, input);
1310
+ }
1311
+ /** Store and audit a credential the caller has already decided may be added; `how` says how it happened. */
1312
+ async storeCredential(actor, username, input, how) {
1313
+ const store = this.requireCredentialsStore();
1314
+ const record = {
1315
+ id: randomUUID(),
1316
+ username,
1317
+ kind: input.kind,
1318
+ subject: input.subject,
1319
+ secret: input.secret,
1320
+ label: input.label,
1321
+ createdAt: new Date().toISOString()
1322
+ };
1323
+ await store.add(record);
1324
+ this.auditCredentialChange(actor, username, 'credential-add', record, how);
1325
+ return record.id;
1326
+ }
1327
+ /**
1328
+ * Remove one of `username`'s credentials. Refused when it is their last way
1329
+ * in: no password and no other credential that can start a sign-in (#1524).
1330
+ */
1331
+ async removeCredential(ctx, username, id) {
1332
+ if (!(await this.mayManageCredentials(ctx, username)))
1333
+ throw new Error('Permission denied');
1334
+ const store = this.requireCredentialsStore();
1335
+ const row = store.get(id);
1336
+ if (!row || row.username !== username || NOT_SIGN_IN_METHODS.has(row.kind))
1337
+ return false;
1338
+ if (WAY_IN_KINDS.has(row.kind)) {
1339
+ const others = store.list(username).filter(r => r.id !== id && this.isWayIn(r)).length;
1340
+ if (others === 0 && !(await this.hasPassword(username))) {
1341
+ throw new Error('This is the last way into the account; add another before removing it');
1342
+ }
1343
+ }
1344
+ const removed = await store.remove(id);
1345
+ if (removed)
1346
+ this.auditCredentialChange(ctx, username, 'credential-remove', row);
1347
+ return removed;
1348
+ }
1349
+ /**
1350
+ * The one deactivation step (#1765): "this account can no longer sign in".
1351
+ * Disable and delete both come through here, from UserManager, so nothing
1352
+ * an account holds is left reachable by one path and not the other.
1353
+ *
1354
+ * - Both: every session of the account ends, destroyed in the live session
1355
+ * store (#1937); a session the store could not reach is still refused on
1356
+ * its next request (`checkSessionAccount`).
1357
+ * - Disable: its agent tokens, app grants and shares are suspended by the
1358
+ * live owner-is-active checks that refuse them, and its credentials stay,
1359
+ * so turning the account back on restores them.
1360
+ * - Delete: its agent tokens, app grants and shares are revoked, and its
1361
+ * credentials-store rows (passkeys, verified addresses, devices) are
1362
+ * removed. A row names its account by username, so a row left behind
1363
+ * would sign in to a new account later created with the same name.
1364
+ *
1365
+ * The account change itself is audited by UserManager (user-delete, or the
1366
+ * isActive change); each revocation is audited by its owner, and each removed
1367
+ * credential here (`credential-remove`). Delete also ends every proxy grant
1368
+ * the account is on, either side — the grants it made and the grants naming
1369
+ * it as proxy (#1762), each recorded as `proxy-revoke` — whether or not
1370
+ * proxy access is on. Disable leaves them, as it leaves tokens: acting under
1371
+ * a grant must refuse it live while either account is inactive (#1762
1372
+ * slice 2). The encrypted store is left to survivorship (#1545).
1373
+ */
1374
+ async closeAccess(username, mode, ctx) {
1375
+ const step = async (what, run) => {
1376
+ try {
1377
+ await run();
1378
+ }
1379
+ catch (err) {
1380
+ logger.error(`[AuthManager] closeAccess(${username}, ${mode}): ${what} failed:`, err);
1381
+ }
1382
+ };
1383
+ // #1937: in the live session store, each ended through endSession with its reason.
1384
+ const reason = mode === 'delete' ? 'account-deleted' : 'account-disabled';
1385
+ await step('sessions', async () => this.engine.getManager('UserManager')?.deleteUserSessions(username, reason, { ipAddress: ctx.ipAddress }));
1386
+ if (mode !== 'delete')
1387
+ return;
1388
+ const by = ctx.username ?? 'system';
1389
+ // #1764: the same path a person's own Revoke takes, one kind per step so one failing store does not stop the other.
1390
+ for (const [what, kind] of [['agent tokens', 'token'], ['app grants', 'app']]) {
1391
+ await step(what, async () => this.endDelegationsWhere(ctx, username, kind, () => true));
1392
+ }
1393
+ await step('shares', async () => {
1394
+ const shares = this.engine.getManager('ShareManager');
1395
+ for (const sh of shares?.list(username) ?? [])
1396
+ if (!sh.revokedAt)
1397
+ await shares?.revoke(sh.id, by);
1398
+ });
1399
+ await step('proxy grants', async () => {
1400
+ if (!this.credentials)
1401
+ return;
1402
+ for (const row of this.credentials.listKind('proxy')) {
1403
+ const grant = proxyGrantOf(row);
1404
+ if (grant && (grant.owner === username || grant.proxy === username))
1405
+ await this.endProxyGrant(ctx, grant, 'account-deleted');
1406
+ }
1407
+ });
1408
+ // #2049: an invitation to the account stops working, recorded as cancelled.
1409
+ await step('invitations', async () => {
1410
+ if (!this.credentials)
1411
+ return;
1412
+ for (const row of this.credentials.list(username).filter(r => r.kind === 'invitation'))
1413
+ await this.endInvitation(ctx, row, 'account-deleted');
1414
+ });
1415
+ // #1603: what the account accepted goes with it; the audit log keeps consent-withdraw.
1416
+ await step('consent records', async () => this.engine.getManager('ConsentManager')?.removeAccount(username, ctx));
1417
+ await step('credentials', async () => {
1418
+ if (!this.credentials)
1419
+ return;
1420
+ for (const row of this.credentials.list(username)) {
1421
+ // A proxy row is ended above, as a grant; one left here did not read and is removed as a row.
1422
+ if (await this.credentials.remove(row.id))
1423
+ this.auditCredentialChange(ctx, username, 'credential-remove', row);
1424
+ }
1425
+ });
1426
+ }
1427
+ /**
1428
+ * `username`'s agent tokens and the apps and devices they approved, as one
1429
+ * list (#1764). Reading it is not a change, so, like `listCredentials`, it
1430
+ * asks whether the caller holds the permission (their own:
1431
+ * `account-security`; anyone else's: `user-edit`) without a fresh sign-in.
1432
+ */
1433
+ async listDelegations(ctx, username) {
1434
+ if (!(await this.mayManageCredentials(ctx, username, { viewOnly: true })))
1435
+ throw new Error('Permission denied');
1436
+ const rows = [...(await this.delegationsOf(username, 'token')), ...(await this.delegationsOf(username, 'app'))];
1437
+ // #1764: what each may do, in the words the person reads.
1438
+ return rows.map(d => ({ ...d, can: this.describeScopes(d.scopes) }));
1439
+ }
1440
+ /**
1441
+ * Revoke one of `username`'s agent tokens or approved apps (#1764): the one
1442
+ * door every Revoke goes through — the profile list, `DELETE /api/tokens/:id`,
1443
+ * the admin user page — and `closeAccess`. Their own needs `account-security`
1444
+ * (so a fresh sign-in); anyone else's `user-edit`. False when no such live
1445
+ * item is theirs, so another person's is never revoked. Each is audited by
1446
+ * its owner, by who revoked it: `token-revoke` / `oidcgrant-revoke`.
1447
+ */
1448
+ async revokeDelegation(ctx, username, kind, id) {
1449
+ if (!(await this.mayManageCredentials(ctx, username)))
1450
+ throw new Error('Permission denied');
1451
+ return this.endDelegation(username, kind, id, ctx);
1452
+ }
1453
+ /**
1454
+ * Scope and permission names in plain words (#1764): the permission
1455
+ * catalog's `label` for each (`ngdpbase.permissions.definitions`, add-on
1456
+ * permissions merged in), the sign-in scopes' own lines, and anything
1457
+ * declared nowhere by its name. The one lookup: the "My tokens and devices"
1458
+ * list and the app consent page both read it.
1459
+ */
1460
+ describeScopes(names) {
1461
+ const cm = this.engine.getManager('ConfigurationManager');
1462
+ return describeScopes(names, cm?.getProperty('ngdpbase.permissions.definitions', {}));
1463
+ }
1464
+ /** One kind of `username`'s live delegated credentials, as list rows. */
1465
+ async delegationsOf(username, kind) {
1466
+ if (kind === 'token') {
1467
+ const tokens = this.engine.getManager('AgentTokenManager')?.listForOwner(username) ?? [];
1468
+ return tokens.filter(t => !t.revokedAt).map(t => ({
1469
+ kind, id: t.id, name: t.name, device: false, renewal: 'fixed', scopes: t.scopes,
1470
+ createdAt: t.createdAt, expiresAt: t.expiresAt, renewedAt: null, lastUsedAt: t.lastUsedAt,
1471
+ approvedBy: t.approvedBy ?? null, proxyGrantId: t.proxyGrantId ?? null
1472
+ }));
1473
+ }
1474
+ const apps = (await this.engine.getManager('OidcManager')?.listGrants(username)) ?? [];
1475
+ return apps.map(a => ({
1476
+ kind, id: a.grantId, name: a.name, device: a.device, renewal: a.refreshable ? 'refreshable' : 'fixed',
1477
+ scopes: a.scopes, createdAt: a.approvedAt, expiresAt: a.expiresAt, renewedAt: a.renewedAt, lastUsedAt: null,
1478
+ approvedBy: a.approvedBy ?? null, proxyGrantId: a.proxyGrantId ?? null
1479
+ }));
1480
+ }
1481
+ /**
1482
+ * End one delegated credential the caller has already decided may be ended;
1483
+ * only `username`'s own. Recorded by its owner as done by `ctx`: under a
1484
+ * proxy grant, naming both people (#1762 slice 6).
1485
+ */
1486
+ async endDelegation(username, kind, id, ctx) {
1487
+ if (kind === 'token') {
1488
+ const tokens = this.engine.getManager('AgentTokenManager');
1489
+ const token = tokens?.getById(id);
1490
+ if (!tokens || !token || token.owner !== username || token.revokedAt)
1491
+ return false;
1492
+ return tokens.revoke(id, ctx);
1493
+ }
1494
+ if (kind === 'app')
1495
+ return (await this.engine.getManager('OidcManager')?.revokeGrant(username, id, ctx)) ?? false;
1496
+ return false;
1497
+ }
1498
+ /**
1499
+ * End each of `username`'s live delegated credentials of one kind that
1500
+ * `which` picks: the one loop for an account closed (all of them) and a
1501
+ * proxy grant ended (those approved under it, #1762 slice 6).
1502
+ */
1503
+ async endDelegationsWhere(ctx, username, kind, which) {
1504
+ for (const d of await this.delegationsOf(username, kind)) {
1505
+ if (which(d))
1506
+ await this.endDelegation(username, kind, d.id, ctx);
1507
+ }
1508
+ }
1509
+ // ── Proxy access (#1762) ─────────────────────────────────────────────
1510
+ /** Whether proxy access is on (`ngdpbase.auth.proxy-access.enabled`), read on every call. */
1511
+ proxyAccessEnabled() {
1512
+ return this.engine.getManager('ConfigurationManager')?.getProperty(PROXY_ACCESS_ENABLED_KEY, false) === true;
1513
+ }
1514
+ requireProxyAccess() {
1515
+ if (!this.proxyAccessEnabled())
1516
+ throw new Error('Proxy access is turned off on this site');
1517
+ return this.requireCredentialsStore();
1518
+ }
1519
+ /**
1520
+ * Whether `ctx` may make or end a grant on `owner`'s account (#1762): their
1521
+ * own under `proxy-grant`, which asks for a fresh sign-in; anyone else's also
1522
+ * under `user-edit`, so an administrator acting for someone is fresh too.
1523
+ * `viewOnly` (a listing) asks whether the permission is held, without the
1524
+ * fresh sign-in, as `mayManageCredentials` does.
1525
+ */
1526
+ async mayManageProxyGrants(ctx, owner, opts = {}) {
1527
+ const pdp = this.engine.getManager('PolicyDecisionPoint');
1528
+ if (!pdp || !ctx.isAuthenticated || !ctx.username)
1529
+ return false;
1530
+ if (opts.viewOnly)
1531
+ return pdp.holds(ctx, ctx.username === owner ? PROXY_GRANT_PERMISSION : 'user-edit');
1532
+ const own = await pdp.permits(ctx, 'proxy-grant');
1533
+ // Someone else's also needs user-edit; proxy-grant above brings the fresh sign-in either way.
1534
+ return ctx.username === owner ? own : own && pdp.permits(ctx, 'user-edit');
1535
+ }
1536
+ /**
1537
+ * Let `proxy` act for `owner` within `permissions` (#1762 slice 1: recorded
1538
+ * only; no request is affected yet). Refused while proxy access is off.
1539
+ *
1540
+ * - The owner makes it under `proxy-grant`; an administrator makes it for
1541
+ * them under `user-edit` too (see `mayManageProxyGrants`).
1542
+ * - Delegate, not grant: every permission must be one the owner holds now,
1543
+ * the check agent tokens make (`lackingScopes`). Any permission they hold
1544
+ * may be included (operator, 2026-10-12), `proxy-grant` among them, which
1545
+ * lets the proxy give others access too (`mayRedelegate`).
1546
+ * - An administrator's grant never carries vault access: no key copy
1547
+ * (`vault: 'never'`), and never `vault-proxy` (#1762 slice 7).
1548
+ * - `expiresAt` is optional; without it the grant lasts until revoked.
1549
+ *
1550
+ * Recorded first, as `proxy-grant` (refuse on failure): `user` is the owner,
1551
+ * `actor` who made it.
1552
+ */
1553
+ async grantProxy(ctx, input) {
1554
+ const store = this.requireProxyAccess();
1555
+ const owner = typeof input?.owner === 'string' ? input.owner.trim() : '';
1556
+ const proxy = typeof input?.proxy === 'string' ? input.proxy.trim() : '';
1557
+ if (!owner || !proxy)
1558
+ throw new Error('Name the person represented and the person acting for them');
1559
+ if (!(await this.mayManageProxyGrants(ctx, owner)))
1560
+ throw new Error('Permission denied');
1561
+ if (owner === proxy)
1562
+ throw new Error('A person cannot act for themselves');
1563
+ const { pdp, pip, ownerSubject } = await this.proxyOwnerSubject(ctx, owner);
1564
+ if (!(await pip.subjectFor(proxy)))
1565
+ throw new Error(`There is no active account ${proxy}`);
1566
+ const permissions = requireScopeList(input.permissions);
1567
+ // Vault access is added only by the person themselves (operator, 2026-10-12): an administrator's grant never carries it.
1568
+ if (ctx.username !== owner && permissions.includes(VAULT_PROXY_PERMISSION)) {
1569
+ throw new Error('A grant made for someone else can never include access to their vaults');
1570
+ }
1571
+ // Nor does a proxy's, re-delegating: it would hand on the owner's vault key (#1762 slice 5).
1572
+ if (ctx.viaProxy && permissions.includes(VAULT_PROXY_PERMISSION)) {
1573
+ throw new Error(`Only ${owner} can give access to their vaults`);
1574
+ }
1575
+ // Held, not fresh: the owner's own step-up is not the question when an administrator makes it.
1576
+ const lacking = await lackingScopes(permissions, p => pdp.holds(ownerSubject, p));
1577
+ if (lacking.length > 0) {
1578
+ throw new Error(`${owner} does not hold ${lacking.join(', ')}. A grant can only carry permissions the person represented has.`);
1579
+ }
1580
+ const now = Date.now();
1581
+ let expiresAt = null;
1582
+ if (input.expiresAt !== undefined && input.expiresAt !== null) {
1583
+ const end = typeof input.expiresAt === 'string' ? Date.parse(input.expiresAt) : NaN;
1584
+ if (!Number.isFinite(end) || end <= now)
1585
+ throw new Error('The end date must be a date in the future');
1586
+ expiresAt = new Date(end).toISOString();
1587
+ }
1588
+ // #1762 slice 5: vault access to an encrypted store carries a copy of the owner's key, sealed to the proxy.
1589
+ const keyCopy = ctx.username === owner && permissions.includes(VAULT_PROXY_PERMISSION) ? await this.sealVaultCopy(ctx, owner, proxy) : null;
1590
+ const grant = {
1591
+ id: randomUUID(), owner, proxy, permissions, createdBy: ctx.username, createdAt: new Date(now).toISOString(), expiresAt,
1592
+ vault: ctx.username !== owner ? 'never' : keyCopy ? 'copy' : 'none'
1593
+ };
1594
+ const terms = { proxy, permissions, createdBy: grant.createdBy, expiresAt, vault: grant.vault, ...(keyCopy ? { keyCopy } : {}) };
1595
+ // Recorded before it exists (the token-mint ordering): a grant nobody knows about is the worst case.
1596
+ await this.auditProxy(AUDIT_EVENT.PROXY_GRANT, ctx, grant);
1597
+ await store.add({ id: grant.id, username: owner, kind: 'proxy', subject: grant.id, secret: JSON.stringify(terms), label: '', createdAt: grant.createdAt });
1598
+ return this.proxyGrantView(grant);
1599
+ }
1600
+ /** Where private stores live; undefined when this site has no page directory. */
1601
+ pagesDirectory() {
1602
+ return this.engine.getManager('ConfigurationManager')?.getResolvedDataPath?.('ngdpbase.page.provider.filesystem.storagedir', './data/pages');
1603
+ }
1604
+ /**
1605
+ * Why `ctx` cannot give access to their own vaults now, in plain words, or
1606
+ * null when they can (#1762 slice 5). The give form shows it beside the
1607
+ * "vaults" choice; grantProxy refuses with the same words.
1608
+ *
1609
+ * - Only the person themselves, signed in as themselves: not an
1610
+ * administrator, not a proxy re-delegating.
1611
+ * - A person with an encrypted store must have it unlocked, so the grant
1612
+ * can seal a copy of its key. A person with none needs nothing unlocked:
1613
+ * the grant opens only their unencrypted vaults.
1614
+ */
1615
+ async vaultGrantBlocked(ctx, owner) {
1616
+ if (ctx.username !== owner || ctx.viaProxy)
1617
+ return `Only ${owner} can give access to their vaults.`;
1618
+ const pagesDirectory = this.pagesDirectory();
1619
+ if (!pagesDirectory || !(await hasUserKeys(pagesDirectory, owner)))
1620
+ return null;
1621
+ return hasUnlockedKey(ctx) ? null : 'Unlock your private store first: access to your encrypted vaults can only be given while it is open.';
1622
+ }
1623
+ /**
1624
+ * The copy of the owner's vault key a grant to `proxy` carries (#1762 slice
1625
+ * 5), sealed to the proxy's public key while the owner's store is unlocked;
1626
+ * null when the owner has no encrypted store (nothing to copy). Refused, in
1627
+ * plain words, when the owner's store is locked or the proxy has no key
1628
+ * pair yet — a grant never promises vault access it cannot give.
1629
+ */
1630
+ async sealVaultCopy(ctx, owner, proxy) {
1631
+ const blocked = await this.vaultGrantBlocked(ctx, owner);
1632
+ if (blocked)
1633
+ throw new Error(blocked);
1634
+ const pagesDirectory = this.pagesDirectory();
1635
+ if (!pagesDirectory || !(await hasUserKeys(pagesDirectory, owner)))
1636
+ return null;
1637
+ const proxyKey = await personKeyOf(pagesDirectory, proxy);
1638
+ if (!proxyKey) {
1639
+ throw new Error(`${proxy} cannot be given access to your encrypted vaults yet: they need a private store of their own, unlocked at least once. Give access without vaults, or ask them to set one up first.`);
1640
+ }
1641
+ const copy = await sealKekCopyFor(ctx, proxyKey);
1642
+ if (!copy)
1643
+ throw new Error('Unlock your private store first: access to your encrypted vaults can only be given while it is open.');
1644
+ return copy;
1645
+ }
1646
+ /** The decision points and the represented person's live subject (`ctx` itself for one's own), as grantProxy and its form ask them. */
1647
+ async proxyOwnerSubject(ctx, owner) {
1648
+ const pdp = this.engine.getManager('PolicyDecisionPoint');
1649
+ const pip = this.engine.getManager('PolicyInformationPoint');
1650
+ if (!pdp || !pip)
1651
+ throw new Error('Cannot grant: permissions cannot be verified');
1652
+ const ownerSubject = ctx.username === owner ? ctx : await pip.subjectFor(owner);
1653
+ if (!ownerSubject)
1654
+ throw new Error(`There is no active account ${owner}`);
1655
+ return { pdp, pip, ownerSubject };
1656
+ }
1657
+ /**
1658
+ * What a grant on `owner`'s account may carry, in plain words (#1762 slice
1659
+ * 7): every grantable catalog permission the owner holds now — the same
1660
+ * `holds` question grantProxy asks — so the give form offers nothing
1661
+ * grantProxy would refuse. An administrator's form for someone else, or a
1662
+ * proxy's acting for them, never offers `vault-proxy`; the owner's own form
1663
+ * offers it and says why it cannot be chosen now (`vaultGrantBlocked`).
1664
+ * Asked like a listing (held, not fresh); refused
1665
+ * while proxy access is off.
1666
+ */
1667
+ async proxyGrantOptions(ctx, owner) {
1668
+ this.requireProxyAccess();
1669
+ if (!(await this.mayManageProxyGrants(ctx, owner, { viewOnly: true })))
1670
+ throw new Error('Permission denied');
1671
+ const { pdp, ownerSubject } = await this.proxyOwnerSubject(ctx, owner);
1672
+ const cm = this.engine.getManager('ConfigurationManager');
1673
+ const offered = [];
1674
+ for (const name of grantablePermissionNames(cm?.getProperty('ngdpbase.permissions.definitions', {}))) {
1675
+ if ((ctx.username !== owner || ctx.viaProxy) && name === VAULT_PROXY_PERMISSION)
1676
+ continue;
1677
+ if (await pdp.holds(ownerSubject, name))
1678
+ offered.push(name);
1679
+ }
1680
+ return this.describeScopes(offered);
1681
+ }
1682
+ /**
1683
+ * End a proxy grant (#1762). The owner under `proxy-grant`; anyone else
1684
+ * under `user-edit` too, so another person's grant is never ended by someone
1685
+ * without both. False when there is no such grant. Recorded as
1686
+ * `proxy-revoke`, first. Refused while proxy access is off.
1687
+ */
1688
+ async revokeProxy(ctx, grantId) {
1689
+ const store = this.requireProxyAccess();
1690
+ const row = typeof grantId === 'string' ? store.get(grantId) : null;
1691
+ const grant = row ? proxyGrantOf(row) : null;
1692
+ if (!grant)
1693
+ return false;
1694
+ if (!(await this.mayManageProxyGrants(ctx, grant.owner)))
1695
+ throw new Error('Permission denied');
1696
+ return this.endProxyGrant(ctx, grant, 'revoked');
1697
+ }
1698
+ /**
1699
+ * The live proxy grants on one side (#1762): those `asOwner` made, or those
1700
+ * naming `asProxy`. One's own needs `proxy-grant` held; anyone else's
1701
+ * `user-edit`, as `listDelegations`. A grant past its end date is left out.
1702
+ * Refused while proxy access is off.
1703
+ */
1704
+ async listProxyGrants(ctx, side) {
1705
+ const store = this.requireProxyAccess();
1706
+ const who = side?.asOwner ?? side?.asProxy;
1707
+ if (typeof who !== 'string' || !who)
1708
+ throw new Error('Say whose grants: asOwner or asProxy');
1709
+ if (!(await this.mayManageProxyGrants(ctx, who, { viewOnly: true })))
1710
+ throw new Error('Permission denied');
1711
+ return this.liveGrantsOn(store, side).map(g => this.proxyGrantView(g));
1712
+ }
1713
+ /** The live grants on one side, unchecked: each caller has already decided who may see them. */
1714
+ liveGrantsOn(store, side, now = Date.now()) {
1715
+ const who = side.asOwner ?? side.asProxy;
1716
+ return store.listKind('proxy')
1717
+ .map(proxyGrantOf)
1718
+ .filter((g) => g !== null && proxyGrantLive(g, now) && (side.asOwner !== undefined ? g.owner === who : g.proxy === who));
1719
+ }
1720
+ /**
1721
+ * The grant with this id if it is in force right now, else null (#1762
1722
+ * slice 2): the PDP's live re-read on every decision under `viaProxy`.
1723
+ * Null when proxy access is off, when there is no such row (revoked, or the
1724
+ * account closed), when its terms do not read, or when its end date has
1725
+ * passed — each fails closed. Not a door: it answers the decision point,
1726
+ * never a person, so it asks no permission.
1727
+ */
1728
+ liveProxyGrant(grantId, now = Date.now()) {
1729
+ if (!this.proxyAccessEnabled())
1730
+ return null;
1731
+ const grant = this.proxyGrantRecord(grantId);
1732
+ return grant && proxyGrantLive(grant, now) ? grant : null;
1733
+ }
1734
+ /** The grant row with this id as it stands — live or not, whether proxy access is on or off — or null. */
1735
+ proxyGrantRecord(grantId) {
1736
+ if (!this.credentials || typeof grantId !== 'string' || !grantId)
1737
+ return null;
1738
+ const row = this.credentials.get(grantId);
1739
+ return row ? proxyGrantOf(row) : null;
1740
+ }
1741
+ // ── Acting for someone (#1762 slice 4) ───────────────────────────────
1742
+ /**
1743
+ * The person signed in on this interactive session, or null. Never a
1744
+ * bearer request: an agent token or an app can never act for anyone, so it
1745
+ * can never choose to either. `session.username` stays the person who
1746
+ * signed in while they act for someone; the subject changes, not the
1747
+ * session.
1748
+ */
1749
+ sessionPerson(req) {
1750
+ if (req.bearerAuth)
1751
+ return null;
1752
+ const s = req.session;
1753
+ return s?.isAuthenticated === true && typeof s.username === 'string' && s.username ? s.username : null;
1754
+ }
1755
+ /**
1756
+ * The request's subject when the session acts for someone (#1762 slice 4):
1757
+ * the one place it is built, called from the session middleware in app.ts
1758
+ * with the session's own subject (`self`). Returns `self` unchanged when the
1759
+ * session acts for nobody.
1760
+ *
1761
+ * When `session.actingFor` names a grant, the grant is re-read live on this
1762
+ * request: in force, proxy access on, naming the session's person as proxy,
1763
+ * and the represented account active. The subject is then the represented
1764
+ * person — their username and live roles — with `viaProxy` naming the
1765
+ * actor, the grant and its permissions; the PDP bounds every decision by
1766
+ * the grant and by what the actor holds. The sign-in is the actor's
1767
+ * (`signIn`, `aalCap`): the represented person's roles are held at the
1768
+ * level the actor signed in at, and step-up asks the actor's own fresh
1769
+ * sign-in. It carries no private-store handle: the session's key bag is the
1770
+ * actor's own.
1771
+ *
1772
+ * Anything else — a grant revoked or expired mid-session, an inactive
1773
+ * account, proxy access turned off, a subject that came with a token or a
1774
+ * share — drops the session back to the person themselves: `actingFor` is
1775
+ * cleared and the drop recorded as `proxy-switch`. A request never acts on
1776
+ * a dead grant.
1777
+ */
1778
+ async subjectActingFor(req, self) {
1779
+ const grantId = req.session?.actingFor;
1780
+ if (grantId === undefined)
1781
+ return self;
1782
+ const person = this.sessionPerson(req);
1783
+ const grant = this.liveProxyGrant(String(grantId));
1784
+ const usable = grant !== null && person !== null && self.username === person && grant.proxy === person &&
1785
+ !self.viaToken && !self.viaShare && !self.viaProxy;
1786
+ const pip = this.engine.getManager('PolicyInformationPoint');
1787
+ const represented = usable ? await pip?.subjectFor(grant.owner) : null;
1788
+ if (!grant || !usable || !represented) {
1789
+ delete req.session.actingFor;
1790
+ // #1762 slice 5: a dead grant's key copy goes with it.
1791
+ dropKeyCopy(req.session?.privateStoreHandle);
1792
+ const why = this.proxyAccessEnabled() ? 'grant-ended' : 'proxy-access-off';
1793
+ logger.info(`[proxy] ${person ?? self.username} no longer acts under grant ${String(grantId)} (${why}); back to themselves`);
1794
+ await this.auditActingFor(req, person ?? self.username, String(grantId), (grant ?? this.proxyGrantRecord(grantId))?.owner ?? null, why);
1795
+ return self;
1796
+ }
1797
+ const keyHandle = await this.openVaultCopy(req, grant, person, self.privateStoreHandle);
1798
+ return {
1799
+ ...represented,
1800
+ roles: this.rolesAtSignIn(represented.roles, self.signIn?.aal ?? 1, self.aalCap).kept,
1801
+ ...(self.aalCap !== undefined ? { aalCap: self.aalCap } : {}),
1802
+ ...(self.signIn ? { signIn: self.signIn } : {}),
1803
+ ipAddress: self.ipAddress,
1804
+ viaProxy: { kind: 'proxy', grantId: grant.id, actor: person, scopes: [...grant.permissions], ...(keyHandle ? { keyHandle } : {}) }
1805
+ };
1806
+ }
1807
+ /**
1808
+ * Open the grant's key copy into the actor's own bag (#1762 slice 5), once,
1809
+ * when the grant carries `vault-proxy` and a copy and the actor's own store
1810
+ * is unlocked; recorded as `proxy-switch` `vault-opened`, naming both
1811
+ * people. Returns the handle the subject's claim carries to reach it, or
1812
+ * undefined: no copy is held then, and a copy held for another grant is
1813
+ * wiped. The copy is tried again when the actor unlocks their store later.
1814
+ */
1815
+ async openVaultCopy(req, grant, person, handle) {
1816
+ if (!handle)
1817
+ return undefined;
1818
+ const held = heldKeyCopy(handle);
1819
+ if (held?.grantId === grant.id && held.owner === grant.owner)
1820
+ return handle;
1821
+ if (held)
1822
+ dropKeyCopy(handle);
1823
+ const pagesDirectory = this.pagesDirectory();
1824
+ if (grant.vault !== 'copy' || !grant.permissions.includes(VAULT_PROXY_PERMISSION) || !pagesDirectory)
1825
+ return undefined;
1826
+ const copy = proxyKeyCopyOf(this.credentials?.get(grant.id));
1827
+ if (!copy)
1828
+ return undefined;
1829
+ if (!(await openKeyCopy({ handle, actor: person, grantId: grant.id, owner: grant.owner, copy, pagesDirectory })))
1830
+ return undefined;
1831
+ await this.auditActingFor(req, person, grant.id, grant.owner, 'vault-opened');
1832
+ return handle;
1833
+ }
1834
+ /**
1835
+ * Whom the signed-in person may act for (#1762 slice 4): themselves, and
1836
+ * each person a live grant names them proxy for. Null when proxy access is
1837
+ * off or this is not an interactive signed-in session — the choice page
1838
+ * does not exist then.
1839
+ */
1840
+ actingForChoices(req) {
1841
+ const person = this.sessionPerson(req);
1842
+ if (!this.proxyAccessEnabled() || !person || !this.credentials)
1843
+ return null;
1844
+ const current = req.session.actingFor;
1845
+ return {
1846
+ self: person,
1847
+ actingFor: typeof current === 'string' ? current : null,
1848
+ grants: this.liveGrantsOn(this.credentials, { asProxy: person }).map(g => this.proxyGrantView(g))
1849
+ };
1850
+ }
1851
+ /**
1852
+ * Switch whom this session acts for (#1762 slice 4): a grant id, or null to
1853
+ * act as yourself. The authority is the grant itself — live, naming the
1854
+ * session's person as proxy, the represented account active — and the
1855
+ * session's own sign-in; an agent token or an app never switches. Switching
1856
+ * back is always allowed. Each change is recorded as `proxy-switch`. False
1857
+ * when the switch is refused; the session is unchanged then.
1858
+ */
1859
+ async switchActingFor(req, grantId) {
1860
+ const person = this.sessionPerson(req);
1861
+ if (!person)
1862
+ return false;
1863
+ const current = typeof req.session.actingFor === 'string' ? req.session.actingFor : null;
1864
+ if (grantId !== null) {
1865
+ const grant = this.liveProxyGrant(grantId);
1866
+ const pip = this.engine.getManager('PolicyInformationPoint');
1867
+ if (!grant || grant.proxy !== person || !(await pip?.subjectFor(grant.owner)))
1868
+ return false;
1869
+ if (current === grant.id)
1870
+ return true;
1871
+ }
1872
+ if (current !== null) {
1873
+ delete req.session.actingFor;
1874
+ // #1762 slice 5: the represented person's keys go with the switch.
1875
+ dropKeyCopy(req.session.privateStoreHandle);
1876
+ await this.auditActingFor(req, person, current, this.proxyGrantRecord(current)?.owner ?? null, 'switched-back');
1877
+ }
1878
+ if (grantId !== null) {
1879
+ req.session.actingFor = grantId;
1880
+ await this.auditActingFor(req, person, grantId, this.proxyGrantRecord(grantId)?.owner ?? null, 'chosen');
1881
+ }
1882
+ return true;
1883
+ }
1884
+ /**
1885
+ * A `proxy-switch` record (#1762 slice 4): `user` the person represented,
1886
+ * `actor` the person signed in — the attribution every proxy row carries.
1887
+ * When the grant's record is already gone (revoked), the represented person
1888
+ * is not known and the row names the signed-in person alone.
1889
+ */
1890
+ async auditActingFor(req, person, grantId, owner, change) {
1891
+ const { metadata: proxyMeta, ...proxy } = proxyAttribution(owner ? { kind: 'proxy', grantId, actor: person, scopes: [] } : null);
1892
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
1893
+ eventType: AUDIT_EVENT.PROXY_SWITCH,
1894
+ user: owner ?? person,
1895
+ ...proxy,
1896
+ ipAddress: req.ip,
1897
+ action: 'proxy-switch',
1898
+ result: 'success',
1899
+ severity: 'medium',
1900
+ resource: grantId,
1901
+ resourceType: 'proxy-grant',
1902
+ reason: change,
1903
+ metadata: { ...proxyMeta, proxyGrantId: grantId }
1904
+ }, (err) => logger.warn(`[AuthManager] Audit record failed for proxy-switch (${change}) of ${grantId}:`, err));
1905
+ }
1906
+ proxyGrantView(grant) {
1907
+ return { ...grant, permissions: [...grant.permissions], can: this.describeScopes(grant.permissions), mayRedelegate: grant.permissions.includes(PROXY_GRANT_PERMISSION) };
1908
+ }
1909
+ /**
1910
+ * End a grant the caller has already decided may be ended: recorded first,
1911
+ * then the tokens, apps and devices approved under it are revoked (#1762
1912
+ * slice 6) — while the grant still stands, since a token under a grant no
1913
+ * longer in force is no longer listed as live — and then it is removed.
1914
+ */
1915
+ async endProxyGrant(ctx, grant, why) {
1916
+ await this.auditProxy(AUDIT_EVENT.PROXY_REVOKE, ctx, grant, why);
1917
+ for (const kind of ['token', 'app']) {
1918
+ try {
1919
+ await this.endDelegationsWhere(ctx, grant.owner, kind, d => d.proxyGrantId === grant.id);
1920
+ }
1921
+ catch (err) {
1922
+ // Ending the grant still goes ahead: each credential checks the grant is in force at use, so none outlives it.
1923
+ logger.error(`[AuthManager] ending proxy grant ${grant.id}: revoking the ${kind}s approved under it failed:`, err);
1924
+ }
1925
+ }
1926
+ // #1762 slice 5: the row holds the sealed key copy, so removing it deletes the copy; any opened from it is wiped now.
1927
+ const removed = await this.requireCredentialsStore().remove(grant.id);
1928
+ dropKeyCopiesOfGrant(grant.id);
1929
+ return removed;
1930
+ }
1931
+ /** A proxy-grant record (#1762): `user` the person represented, `actor` who acted. */
1932
+ async auditProxy(eventType, ctx, grant, reason) {
1933
+ const who = actorOf(ctx);
1934
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
1935
+ eventType,
1936
+ user: grant.owner,
1937
+ // The person who made or ended the grant: under a proxy grant (a proxy
1938
+ // re-delegating, #1762), the proxy, not the person they act for.
1939
+ actor: who.actor ?? who.user,
1940
+ ipAddress: who.ipAddress,
1941
+ action: eventType,
1942
+ result: 'success',
1943
+ severity: 'high',
1944
+ resource: grant.id,
1945
+ resourceType: 'proxy-grant',
1946
+ ...(reason ? { reason } : {}),
1947
+ metadata: { ...who.metadata, proxy: grant.proxy, permissions: grant.permissions, expiresAt: grant.expiresAt, vault: grant.vault, createdBy: grant.createdBy }
1948
+ }, (err) => logger.warn(`[AuthManager] Audit record failed for ${eventType} of ${grant.id}:`, err));
1949
+ }
1950
+ /** Rename one of `username`'s credentials. The name is required, as at enrolment. */
1951
+ async renameCredential(ctx, username, id, label) {
1952
+ if (!(await this.mayManageCredentials(ctx, username)))
1953
+ throw new Error('Permission denied');
1954
+ const name = credentialLabel(label);
1955
+ const store = this.requireCredentialsStore();
1956
+ const row = store.get(id);
1957
+ if (!row || row.username !== username || NOT_SIGN_IN_METHODS.has(row.kind))
1958
+ return false;
1959
+ const renamed = await store.relabel(id, name);
1960
+ if (renamed)
1961
+ this.auditCredentialChange(ctx, username, 'credential-rename', { ...row, label: name });
1962
+ return renamed;
1963
+ }
1964
+ /** Whether the account signs in with a password — UserManager is the door that sees the hash (#1524). */
1965
+ async hasPassword(username) {
1966
+ const userManager = this.engine.getManager('UserManager');
1967
+ return (await userManager?.hasPassword(username)) ?? false;
1968
+ }
1969
+ auditCredentialChange(ctx, username, action, row, how) {
1970
+ // #1762: a credential changed under a proxy grant names the proxy too.
1971
+ const { metadata: proxyMeta, ...proxy } = proxyAttribution(ctx.viaProxy);
1972
+ void recordAuditEvent(this.engine.getManager('AuditManager'), {
1973
+ eventType: AUDIT_EVENT.USER_EDIT,
1974
+ user: ctx.username,
1975
+ ...proxy,
1976
+ ipAddress: ctx.ipAddress,
1977
+ action,
1978
+ result: 'success',
1979
+ severity: 'medium',
1980
+ resource: username,
1981
+ resourceType: 'user',
1982
+ metadata: { credentialId: row.id, kind: row.kind, label: row.label, ...(how ? { how } : {}), ...proxyMeta }
1983
+ });
1984
+ }
1985
+ /**
1986
+ * The factors offered, in configuration order (#1523).
1987
+ *
1988
+ * An entry counts only when it is enabled and its provider is registered and
1989
+ * declares itself a factor; listed but unavailable is never offered. Each
1990
+ * value is the lower of the entry and the provider's code: an entry that
1991
+ * overstates is lowered to the truth, with a warning naming the key.
1992
+ */
1993
+ getFactors() {
1994
+ if (this.factorCache)
1995
+ return this.factorCache;
1996
+ const factors = [];
1997
+ for (const entry of this.factorEntries) {
1998
+ if (entry.enabled === false)
1999
+ continue;
2000
+ const declared = this.providers.get(entry.authproviderid)?.factor;
2001
+ if (!declared)
2002
+ continue;
2003
+ factors.push(this.lowered(entry, declared));
2004
+ }
2005
+ // #1743: an outside provider with no entry is still a sign-in method, at what its code declares (AAL1).
2006
+ for (const p of this.providers.values()) {
2007
+ if (p instanceof OidcAuthProvider && !this.factorEntries.some(e => e.authproviderid === p.id)) {
2008
+ factors.push(this.lowered({ authproviderid: p.id }, p.factor));
2009
+ }
2010
+ }
2011
+ this.factorCache = factors;
2012
+ return factors;
2013
+ }
2014
+ lowered(entry, declared) {
2015
+ const id = entry.authproviderid;
2016
+ const overstated = (what) => {
2017
+ logger.warn(`[AuthManager] ngdpbase.auth.factors entry '${id}' overstates ${what} — lowered to what the provider gives. (#1523)`);
2018
+ };
2019
+ // "trust" here is inert (#1633): an outside provider's own second factor is
2020
+ // trusted by its entry's `trust-mfa` in ngdpbase.auth.oidc.providers.
2021
+ const configured = entry.amr?.filter(v => v !== 'trust');
2022
+ let amr = declared.amr;
2023
+ if (configured && configured.length > 0) {
2024
+ if (configured.every(v => declared.amr.includes(v)))
2025
+ amr = configured;
2026
+ else
2027
+ overstated(`amr [${configured.join(', ')}]`);
2028
+ }
2029
+ let aal = declared.aal;
2030
+ if (entry.aal !== undefined) {
2031
+ if (entry.aal > declared.aal)
2032
+ overstated(`aal ${entry.aal}`);
2033
+ else
2034
+ aal = entry.aal;
2035
+ }
2036
+ let acr = declared.acr;
2037
+ if (entry.acr !== undefined) {
2038
+ if (!acr || PHR_RANK[entry.acr] > PHR_RANK[acr])
2039
+ overstated(`acr ${entry.acr}`);
2040
+ else
2041
+ acr = entry.acr;
2042
+ }
2043
+ const primary = entry.primary === undefined ? declared.primary : entry.primary && declared.primary;
2044
+ if (entry.primary && !declared.primary)
2045
+ overstated('primary');
2046
+ return acr ? { provider: id, primary, amr, aal, acr } : { provider: id, primary, amr, aal };
2047
+ }
2048
+ /**
2049
+ * What a set of satisfied factors amounts to (#1523), by NIST SP 800-63B.
2050
+ *
2051
+ * - `aal` is the strongest single factor's, lifted to AAL2 when the factors
2052
+ * cover two or more distinct types — email excepted: it never lifts a
2053
+ * sign-in above AAL1 (§5.1.3.1), though it still counts towards `mfa`.
2054
+ * - `acr` is `phrh`, then `phr`, then the AAL — what we claim, never more.
2055
+ * - `mfa` is two or more distinct types, which is what we require.
2056
+ */
2057
+ assess(factors) {
2058
+ const amr = [...new Set(factors.flatMap(f => f.amr))];
2059
+ const typesOf = (values) => new Set(values.map(v => FACTOR_TYPE[v]).filter((t) => t !== undefined));
2060
+ let aal = factors.reduce((max, f) => (f.aal > max ? f.aal : max), 0);
2061
+ const lifting = factors.filter(f => !f.amr.some(v => NEVER_LIFTS.has(v)));
2062
+ if (aal < 2 && typesOf(lifting.flatMap(f => f.amr)).size >= 2)
2063
+ aal = 2;
2064
+ const strongest = factors.reduce((best, f) => (f.acr && (!best || PHR_RANK[f.acr] > PHR_RANK[best]) ? f.acr : best), undefined);
2065
+ const acr = strongest ?? (aal >= 3 ? 'aal3' : aal === 2 ? 'aal2' : 'aal1');
2066
+ return { amr, aal, acr, mfa: typesOf(amr).size >= 2 };
2067
+ }
2068
+ /**
2069
+ * Authenticate using the specified provider — the one door (#1758).
2070
+ *
2071
+ * A sign-in method (a provider listed in `ngdpbase.auth.factors`) has its
2072
+ * failures and its re-checks audited here, with where they came from; a
2073
+ * successful sign-in is recorded, and counted on the account, when it
2074
+ * completes (`completeSignIn`), once with every factor it took. A request
2075
+ * credential (agent token, app token, gateway header) is checked on every
2076
+ * API request and is not audited per request.
2077
+ */
2078
+ async authenticate(providerId, credentials, attempt = {}) {
2079
+ const provider = this.providers.get(providerId);
2080
+ if (!provider) {
2081
+ logger.debug(`[AuthManager] Unknown provider: ${providerId}`);
2082
+ return { success: false };
2083
+ }
2084
+ // #1523: a sign-in method has a factor entry; a delegated credential has none.
2085
+ const factor = this.getFactors().find(f => f.provider === providerId);
2086
+ const purpose = attempt.purpose ?? 'sign-in';
2087
+ const refused = async (username) => {
2088
+ if (factor)
2089
+ await this.auditCheck(attempt, username, providerId, 'failure', `${purpose === 're-check' ? 're-check: ' : ''}${providerId} refused`);
2090
+ return { success: false };
2091
+ };
2092
+ try {
2093
+ const verified = await provider.verify(credentials);
2094
+ if (!verified)
2095
+ return await refused(typeof credentials.username === 'string' ? credentials.username : undefined);
2096
+ // #1882: a sign-in method names the stored account, never the typed casing.
2097
+ const username = factor ? await this.storedUsername(verified.username) : verified.username;
2098
+ if (!username)
2099
+ return await refused(verified.username);
2100
+ const result = { ...verified, username };
2101
+ if (!(await this.userMayUseProvider(result.username, providerId))) {
2102
+ logger.warn(`[AuthManager] User ${result.username} not allowed to use provider: ${providerId}`);
2103
+ return await refused(result.username);
2104
+ }
2105
+ // #1633: an outside provider's report, kept for the audit record and mapped by trust.
2106
+ const outside = this.outsideProvider(providerId);
2107
+ const reported = outside && result.reported ? reportedValues(result.reported, this.trustsOutsideMfa(outside)) : undefined;
2108
+ const factors = this.satisfiedFactors(providerId, reported);
2109
+ // A re-check's success is recorded here; a sign-in's when it completes
2110
+ // (completeSignIn), once, with every factor it took.
2111
+ if (factor && purpose === 're-check')
2112
+ await this.auditCheck(attempt, result.username, providerId, 'success', `re-check: ${providerId}`);
2113
+ // #946: pass through a token provider's viaToken detail, if any.
2114
+ // #1048: read directly — `AuthResult` now declares the field, so the
2115
+ // compiler checks both ends instead of a cast asserting one of them.
2116
+ // #1576: and a bearer credential's sign-in level, when it carries one.
2117
+ return {
2118
+ success: true,
2119
+ username: result.username,
2120
+ provider: providerId,
2121
+ factors,
2122
+ ...(result.viaToken ? { viaToken: result.viaToken } : {}),
2123
+ ...(result.aal !== undefined ? { aal: result.aal } : {}),
2124
+ ...(reported ? { reported } : {})
2125
+ };
2126
+ }
2127
+ catch (err) {
2128
+ logger.error(`[AuthManager] Error authenticating via ${providerId}:`, err);
2129
+ return await refused(typeof credentials.username === 'string' ? credentials.username : undefined);
2130
+ }
2131
+ }
2132
+ /**
2133
+ * #1523: say how — the provider, and the factor it satisfied with its time.
2134
+ * A delegated credential is never a factor, so it records none. An outside
2135
+ * provider the admin trusts counts for what it reported (#1633,
2136
+ * `outsideLevel`); step-up reads the same factor, so it uses the same level.
2137
+ */
2138
+ satisfiedFactors(providerId, reported) {
2139
+ const factor = this.getFactors().find(f => f.provider === providerId);
2140
+ if (!factor)
2141
+ return [];
2142
+ const { amr, aal, acr } = reported?.trusted ? this.outsideLevel(reported) : factor;
2143
+ return [{ provider: providerId, amr, aal, ...(acr ? { acr } : {}), at: new Date().toISOString() }];
2144
+ }
2145
+ /**
2146
+ * What an outside provider's own sign-in counts for here (#1633) — the one
2147
+ * mapping, for every outside provider, by NIST SP 800-63B and RFC 8176.
2148
+ * Untrusted: AAL1 with no `amr`, whatever was reported. Trusted (operator,
2149
+ * 2026-10-12): the report is taken as given, to whatever level it shows.
2150
+ * The reported RFC 8176 `amr` values count:
2151
+ *
2152
+ * - AAL3, phishing-resistant on hardware (`phrh`): a hardware-bound key
2153
+ * (`hwk`) with a second factor or user verification — `mfa`, `user`, or a
2154
+ * second factor type by the same rule `assess` applies to this site's own
2155
+ * factors (`hwk` + `pin`, `hwk` + `fpt`, `hwk` + `pwd`; not `hwk` + `otp`).
2156
+ * This site's passkey is phishing-resistant the same way, through its
2157
+ * factor's `acr`, which `assess` carries to the sign-in.
2158
+ * - AAL2: multi-factor — `mfa`, or two factor types by `assess`.
2159
+ * - The `acr`, when it is an SP 800-63C identifier, raises the level to the
2160
+ * one it states (`…/aal/2`, `…/aal/3`); it is never phishing resistance.
2161
+ * - AAL1 otherwise.
2162
+ */
2163
+ outsideLevel(reported) {
2164
+ if (!reported?.trusted)
2165
+ return { amr: [], aal: 1 };
2166
+ const amr = [...new Set(reported.amr.filter(v => RFC8176_AMR.has(v)))];
2167
+ const types = this.assess([{ amr, aal: 1 }]);
2168
+ const multiFactor = amr.includes('mfa') || types.aal >= 2;
2169
+ const hardwareVerified = amr.includes('hwk') && (amr.includes('mfa') || amr.includes('user') || types.mfa);
2170
+ const fromAmr = hardwareVerified ? 3 : multiFactor ? 2 : 1;
2171
+ const fromAcr = (reported.acr !== undefined ? OUTSIDE_ACR_AAL[reported.acr] : undefined) ?? 1;
2172
+ const aal = fromAmr > fromAcr ? fromAmr : fromAcr;
2173
+ return hardwareVerified ? { amr, aal, acr: 'phrh' } : { amr, aal };
2174
+ }
2175
+ /** The audit record of one sign-in method's check (#1758): written here, never by a route. */
2176
+ async auditCheck(attempt, username, method, result, reason, reported) {
2177
+ try {
2178
+ const audit = this.engine.getManager('AuditManager');
2179
+ await audit?.logAuthentication?.({ username: username ?? 'unknown', ipAddress: attempt.ipAddress, userAgent: attempt.userAgent, loginMethod: method, ...(reported ? { reported } : {}) }, result, reason);
2180
+ }
2181
+ catch (err) {
2182
+ logger.warn('[AuthManager] audit record failed for an authentication check:', err);
2183
+ }
2184
+ }
2185
+ /** Each role's `required-aal`, and whether the operator set it, read through the catalogue's owner (#1523, #448). */
2186
+ requiredLevels() {
2187
+ const roleManager = this.engine.getManager('RoleManager');
2188
+ const all = roleManager?.roleRequiredAal?.() ?? {};
2189
+ const operator = roleManager?.operatorRequiredAal?.() ?? {};
2190
+ return Object.entries(all).map(([role, aal]) => ({ role, aal, operatorSet: operator[role] === aal }));
2191
+ }
2192
+ /** The highest level the available factors reach together; 0 without a primary factor (#1523). */
2193
+ reachableAal() {
2194
+ const factors = this.getFactors();
2195
+ return factors.some(f => f.primary) ? this.assess(factors).aal : 0;
2196
+ }
2197
+ /**
2198
+ * A role's level as enforced (#448): its `required-aal`, except that a
2199
+ * SHIPPED default no available factor reaches counts as what is reachable —
2200
+ * an upgrade never locks an instance out. An operator's own level is never
2201
+ * lowered; the boot refuses it instead (`checkRequiredAal`).
2202
+ */
2203
+ effectiveLevels() {
2204
+ const reachable = this.reachableAal();
2205
+ return new Map(this.requiredLevels().map(({ role, aal, operatorSet }) => [role, (!operatorSet && aal > reachable ? Math.max(reachable, 1) : aal)]));
2206
+ }
2207
+ /**
2208
+ * The highest level an account's own allowed sign-in methods reach, never
2209
+ * below 1 (#1690). Operator rule (2026-10-08): whenever an account has no
2210
+ * way to reach AAL2, AAL1 is what its roles require. The site-wide factors,
2211
+ * narrowed to the account's `allowedAuthMethods` (none listed = all).
2212
+ */
2213
+ async aalCapFor(username) {
2214
+ return (await this.aalReachFor(username)).now;
2215
+ }
2216
+ /**
2217
+ * What an account can reach now, and what it could reach once it enrols
2218
+ * the factors it is allowed but has not set up (#1690). A factor that has
2219
+ * to be enrolled — a passkey — counts toward `now` only when the account
2220
+ * has one (operator, 2026-10-08: allowed but not enrolled is no way to
2221
+ * AAL2 yet). Both are at least 1. The gap between them is what the account
2222
+ * is told, on every page, to close.
2223
+ */
2224
+ async aalReachFor(username) {
2225
+ const user = await this.engine.getManager('UserManager')?.getUser(username);
2226
+ const allowed = user?.allowedAuthMethods;
2227
+ const offered = this.getFactors().filter(f => allowsProvider(allowed, f.provider));
2228
+ const reach = (factors) => Math.max(factors.some(f => f.primary) ? this.assess(factors).aal : 0, 1);
2229
+ const usable = offered.filter(f => {
2230
+ const kind = ENROLLED_FACTORS[f.provider];
2231
+ return kind === undefined || this.hasCredential(username, kind);
2232
+ });
2233
+ return { now: reach(usable), ifEnrolled: reach(offered) };
2234
+ }
2235
+ /**
2236
+ * The roles an account holds at a lower level than they require only
2237
+ * because it has not enrolled a factor it is allowed (#1690): it acts with
2238
+ * them, and is told on every page to add the factor.
2239
+ */
2240
+ rolesAwaitingEnrolment(roles, reach) {
2241
+ const levels = this.effectiveLevels();
2242
+ return roles.filter(r => {
2243
+ const level = levels.get(r) ?? 0;
2244
+ return level > reach.now && level <= reach.ifEnrolled;
2245
+ });
2246
+ }
2247
+ /** A role level, lowered to what the account can reach when its cap is known (#1690). */
2248
+ capped(level, cap) {
2249
+ return (cap !== undefined && level > cap ? cap : level);
2250
+ }
2251
+ /**
2252
+ * The level a sign-in must reach for someone holding `roles`: the highest
2253
+ * effective level among them (#1523), never above the account's own cap
2254
+ * when it is known (#1690).
2255
+ */
2256
+ requiredAalFor(roles, cap) {
2257
+ const levels = this.effectiveLevels();
2258
+ return roles.reduce((max, r) => {
2259
+ const aal = levels.get(r);
2260
+ if (aal === undefined)
2261
+ return max;
2262
+ const level = this.capped(aal, cap);
2263
+ return level > max ? level : max;
2264
+ }, 0);
2265
+ }
2266
+ /**
2267
+ * Which of `roles` a session signed in at `signInAal` may act with (#448,
2268
+ * decided 2026-10-03): a role whose effective level is above it steps down
2269
+ * for this session — the person acts without it until they sign in strongly
2270
+ * enough. Roles without a level are always kept, so a signed-in person keeps
2271
+ * `profile-manage` and can reach their profile to enrol.
2272
+ */
2273
+ rolesAtSignIn(roles, signInAal, cap) {
2274
+ const levels = this.effectiveLevels();
2275
+ const kept = [];
2276
+ const steppedDown = [];
2277
+ // #1690: never more than the account can reach — no way to AAL2 means AAL1.
2278
+ for (const role of roles)
2279
+ (this.capped(levels.get(role) ?? 0, cap) > signInAal ? steppedDown : kept).push(role);
2280
+ return { kept, steppedDown };
2281
+ }
2282
+ /** Every role whose configured level the available factors cannot reach, described (#1523). */
2283
+ unreachableRequiredAal() {
2284
+ const reachable = this.reachableAal();
2285
+ const offered = this.getFactors().map(f => `${f.provider} (AAL${f.aal})`).join(', ') || 'none';
2286
+ return this.requiredLevels()
2287
+ .filter(({ aal }) => aal > reachable)
2288
+ .map(({ role, aal, operatorSet }) => `role '${role}' requires AAL${aal} (${operatorSet ? 'set in app-custom-config.json' : 'shipped default'}), but the available sign-in factors reach AAL${reachable} — ngdpbase.auth.factors offers: ${offered}`);
2289
+ }
2290
+ /**
2291
+ * The start-up check, once add-ons have registered their providers (#1523,
2292
+ * #448). Returns the problems that must refuse the boot: a level the operator
2293
+ * set that no factor reaches. A shipped default that cannot be reached instead
2294
+ * marks AuthManager degraded, naming what to do; those roles act at what is
2295
+ * reachable meanwhile.
2296
+ */
2297
+ checkRequiredAal() {
2298
+ const reachable = this.reachableAal();
2299
+ const unreachable = this.requiredLevels().filter(({ aal }) => aal > reachable);
2300
+ const shipped = unreachable.filter(u => !u.operatorSet);
2301
+ if (shipped.length > 0) {
2302
+ const roles = shipped.map(u => `${u.role} (AAL${u.aal})`).join(', ');
2303
+ this.markNeedsAttention(`${roles} require a stronger sign-in than this site offers, so they act at AAL${Math.max(reachable, 1)} for now. Set ngdpbase.application.base-url (https) so passkeys can be enabled.`, 'ngdpbase.auth.factors');
2304
+ }
2305
+ const descriptions = this.unreachableRequiredAal();
2306
+ return unreachable.flatMap((u, i) => (u.operatorSet ? [descriptions[i]] : []));
2307
+ }
2308
+ /** Whether `username` has enrolled a credential of `kind` — for the step-down banner (#448). */
2309
+ hasCredential(username, kind) {
2310
+ return Boolean(this.credentials?.list(username).some(c => c.kind === kind));
2311
+ }
2312
+ /**
2313
+ * Step-up is AuthManager's (#1758, decided 2026-10-11): the freshness rule
2314
+ * (`stepUpNeeded`), the answer to a refused request, re-authentication and
2315
+ * its audit. The PDP stays the decider and asks `stepUpNeeded`.
2316
+ *
2317
+ * The answer for a person who holds `permission` but whose sign-in is not
2318
+ * fresh enough, with the `reauth-prompt` recorded. A delegated credential is
2319
+ * refused outright: it can never give a fresh factor.
2320
+ */
2321
+ async askForFreshSignIn(req, userContext, permission, mode) {
2322
+ const user = (userContext ?? {});
2323
+ const delegated = Boolean(user.viaToken || user.viaShare);
2324
+ await this.recordReauth(req, AUDIT_EVENT.REAUTH_PROMPT, 'failure', permission, delegated ? 'delegated credential cannot re-authenticate' : 'fresh sign-in asked for');
2325
+ if (delegated)
2326
+ return { permission, delegated, error: 'This needs a fresh sign-in by the person; a token cannot give one' };
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);
2336
+ // #2145: what the prompt was for, so its success is recorded against it and,
2337
+ // when returning does not redo the action, the page says it can be done now.
2338
+ if (req.session) {
2339
+ req.session.pendingStepUp = replay
2340
+ ? { permission, next, replays: true, replay }
2341
+ : { permission, next, replays: req.method === 'GET' && mode === 'page' };
2342
+ // Saved before the answer goes out: the browser follows a redirect on its
2343
+ // headers, before express-session's own save at the end lands, and the
2344
+ // prompt's request would read (and write back) the session without it.
2345
+ await new Promise((resolve) => { if (typeof req.session.save === 'function')
2346
+ req.session.save(() => resolve());
2347
+ else
2348
+ resolve(); });
2349
+ }
2350
+ return {
2351
+ permission,
2352
+ delegated,
2353
+ error: 'A fresh sign-in is needed',
2354
+ reauth: `/auth/reauth?next=${encodeURIComponent(next)}${prompt ? `&prompt=${prompt}` : ''}`
2355
+ };
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
+ }
2370
+ /** Record a step-up event: a prompt, a success or a failure (#1525). */
2371
+ async recordReauth(req, eventType, result, permission, detail) {
2372
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
2373
+ eventType,
2374
+ user: typeof req.session?.username === 'string' ? req.session.username : 'anonymous',
2375
+ ipAddress: req.ip,
2376
+ action: eventType,
2377
+ result,
2378
+ severity: eventType === AUDIT_EVENT.REAUTH_FAILURE ? 'medium' : 'low',
2379
+ resource: permission,
2380
+ resourceType: 'permission',
2381
+ metadata: { detail }
2382
+ }, (err) => logger.warn(`[step-up] audit record failed for ${eventType}:`, err));
2383
+ }
2384
+ /**
2385
+ * A re-authentication completes (#1525, #1758): the session's sign-in record
2386
+ * gains the fresh factor, and the success is recorded. The route saves the
2387
+ * session and answers, sending the browser to `next`.
2388
+ *
2389
+ * #2145: when `next` is where the pending prompt returns, the success is
2390
+ * recorded against the permission it was for (otherwise, a prompt opened
2391
+ * directly, against the method), and when going there does not redo the
2392
+ * action (a form POST, a script's request, a download) the page there says
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.
2397
+ */
2398
+ async completeReauth(req, result, method, next, prompt) {
2399
+ const record = this.reauthenticated(req.session.signIn, result);
2400
+ if (record)
2401
+ req.session.signIn = record;
2402
+ if (record)
2403
+ settleStepUpPrompt(req, prompt, 'confirmed');
2404
+ const pending = req.session.pendingStepUp;
2405
+ delete req.session.pendingStepUp;
2406
+ const answered = pending && pending.next === next ? pending : undefined;
2407
+ if (answered && !answered.replays)
2408
+ req.session.stepUpConfirmed = new URL(next, 'http://site.invalid').pathname;
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'}`);
2424
+ return record;
2425
+ }
2426
+ /**
2427
+ * Whether this request's page says "Confirmed — you can do it now" (#2145):
2428
+ * the first page the browser opens after a re-authentication that could not
2429
+ * redo the action, when it is the page the prompt returned to. Any page
2430
+ * opened clears it, so it is said once.
2431
+ */
2432
+ takeStepUpNotice(req) {
2433
+ const path = req.session?.stepUpConfirmed;
2434
+ if (typeof path !== 'string')
2435
+ return false;
2436
+ if (req.method !== 'GET' || !(req.get('accept') ?? '').includes('text/html'))
2437
+ return false;
2438
+ delete req.session.stepUpConfirmed;
2439
+ return req.path === path;
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
+ }
2514
+ /**
2515
+ * The one sign-in completion step (#1758): every way a person finishes
2516
+ * signing in — a password, a passkey, a second factor, an email link, an
2517
+ * outside provider — ends here, so none can forget a part.
2518
+ *
2519
+ * - A new session (#1043; a session already signed in on it ends, #1626),
2520
+ * with its sign-in time and a cookie as long as `ngdpbase.session.max-age`,
2521
+ * the cap from that time (#1767).
2522
+ * - The person, `isAuthenticated`, how they signed in (`session.signIn`,
2523
+ * #1523) and the account's password-change generation (#1482).
2524
+ * - Their private stores: a handle already unlocked (a two-step sign-in
2525
+ * unlocked at the password), or the password handed to the vault code
2526
+ * through the registered unlocker (#1594 adds a passkey's PRF output).
2527
+ * - One audit record of the completed sign-in, naming how, and the count on
2528
+ * the account. `authenticate()` records failures and re-checks; a
2529
+ * successful sign-in is recorded here, once, when it is complete.
2530
+ * - A pending outside identity (#1743): carried across the new session,
2531
+ * never connected here. The person is sent to confirm it (`next`,
2532
+ * `/login/connect/confirm`); ignored, it expires. An expired one is
2533
+ * dropped.
2534
+ * - Whom to act for (#1762 slice 4): a person a live proxy grant names is
2535
+ * sent to choose (`next`, `ACTING_FOR_PATH`); they act as themselves
2536
+ * until they choose someone. The new session never carries a choice.
2537
+ *
2538
+ * The route saves the session and answers, going to `next` when it is set.
2539
+ */
2540
+ async completeSignIn(req, result, how) {
2541
+ if (!result.username)
2542
+ throw new Error('completeSignIn needs a signed-in username');
2543
+ const username = await this.storedUsername(result.username);
2544
+ if (!username)
2545
+ throw new Error('completeSignIn: no stored account has that username (#1882)');
2546
+ result = { ...result, username };
2547
+ const pending = req.session?.pendingConnection;
2548
+ await this.regenerate(req);
2549
+ req.session.username = username;
2550
+ req.session.isAuthenticated = true;
2551
+ this.startSessionLifetime(req);
2552
+ const record = this.signInRecord(result);
2553
+ if (record)
2554
+ req.session.signIn = record;
2555
+ else
2556
+ delete req.session.signIn;
2557
+ await this.stampSessionGeneration(req, username);
2558
+ if (how.unlockWith && !this.vaultUnlocker && !this.warnedNoUnlocker) {
2559
+ // Never silent: a sign-in that should open the person's stores and can't.
2560
+ this.warnedNoUnlocker = true;
2561
+ logger.warn('[AuthManager] a sign-in brought unlock material but no vault unlocker is registered; private stores stay locked (#1758)');
2562
+ }
2563
+ const handle = how.privateStoreHandle
2564
+ ?? (how.unlockWith && this.vaultUnlocker ? await this.vaultUnlocker(username, how.unlockWith.password) : undefined);
2565
+ if (handle)
2566
+ req.session.privateStoreHandle = handle;
2567
+ await this.auditCheck({ ipAddress: req.ip, userAgent: req.get?.('user-agent'), purpose: 'sign-in' }, username, how.method, 'success', `signed in: ${how.method}`, result.reported);
2568
+ try {
2569
+ await this.engine.getManager('UserManager')?.recordSignIn?.(username);
2570
+ }
2571
+ catch (err) {
2572
+ logger.warn(`[AuthManager] could not count the sign-in for ${username}:`, err);
2573
+ }
2574
+ let next;
2575
+ if (pending && pending.expiresAt > Date.now()) {
2576
+ req.session.pendingConnection = { ...pending, ...(how.returnTo !== undefined ? { returnTo: safeRedirect(how.returnTo) } : {}) };
2577
+ next = '/login/connect/confirm';
2578
+ }
2579
+ else if (this.proxyAccessEnabled() && this.credentials && this.liveGrantsOn(this.credentials, { asProxy: username }).length > 0) {
2580
+ // #1762 slice 4: a person someone has let act for them is asked whom to act
2581
+ // for, then sent on to where they were going. Acting as themselves is the
2582
+ // default: a sign-in never starts acting for anyone unasked.
2583
+ next = `${ACTING_FOR_PATH}?returnTo=${encodeURIComponent(safeRedirect(how.returnTo))}`;
2584
+ }
2585
+ // #1603: terms they have not accepted at their current version come first,
2586
+ // then on to wherever they were going. The session is held to the prompt
2587
+ // (consentPending, the consent gate) until they accept or sign out.
2588
+ if (await this.termsPendingFor(username)) {
2589
+ req.session.consentPending = true;
2590
+ return { next: `${CONSENT_PATH}?returnTo=${encodeURIComponent(next ?? safeRedirect(how.returnTo))}` };
2591
+ }
2592
+ return next ? { next } : {};
2593
+ }
2594
+ /**
2595
+ * Whether `username` has core documents to accept (#1603), asked of
2596
+ * ConsentManager. A store that cannot be read asks nothing, loudly: a
2597
+ * broken consent folder must not lock everyone out.
2598
+ */
2599
+ async termsPendingFor(username) {
2600
+ const consent = this.engine.getManager('ConsentManager');
2601
+ if (!consent)
2602
+ return false;
2603
+ try {
2604
+ return (await consent.pendingFor(username)).length > 0;
2605
+ }
2606
+ catch (err) {
2607
+ logger.error(`[AuthManager] could not tell whether ${username} has terms to accept, so they are not asked this time (#1603):`, err);
2608
+ return false;
2609
+ }
2610
+ }
2611
+ /**
2612
+ * Start the signed-in session's lifetime (#1767), on the new session after
2613
+ * regeneration: `signedInAt`, from which `ngdpbase.session.max-age` caps it
2614
+ * (checked per request in app.ts, `checkSessionLimits`), and a cookie that
2615
+ * lasts that long, so the cookie never ends the session before the cap.
2616
+ * Only a sign-in writes it; a re-authentication (`completeReauth`) does not.
2617
+ */
2618
+ startSessionLifetime(req) {
2619
+ req.session.signedInAt = Date.now();
2620
+ const cap = sessionCapMs(this.engine.getManager('ConfigurationManager')?.getProperty('ngdpbase.session.max-age', DEFAULT_SESSION_MAX_AGE_MS));
2621
+ if (req.session.cookie)
2622
+ req.session.cookie.maxAge = cap;
2623
+ }
2624
+ /**
2625
+ * Stamp the session with the account's password-change generation (#1482):
2626
+ * at every sign-in, and on the session that changed the password, so that
2627
+ * one stays signed in while every other session of the account ends. Also
2628
+ * binds the session to the account itself (`accountCreatedAt`, #1937).
2629
+ */
2630
+ async stampSessionGeneration(req, username) {
2631
+ if (!req.session)
2632
+ return;
2633
+ const account = await this.engine.getManager('UserManager')?.getUser(username);
2634
+ req.session.sessionGeneration = sessionGenerationOf(account);
2635
+ // #1937: and to this account, so a later account of the same name never inherits the session.
2636
+ req.session.accountCreatedAt = accountBindingOf(account);
2637
+ }
2638
+ /**
2639
+ * The vault code's unlocker (#1758, #1594): given who signed in and their
2640
+ * password, unlock their private stores and return the session's handle.
2641
+ * Registered by whoever owns the vault code (today the routes, #1382);
2642
+ * sign-in completion and a two-step sign-in hand it the material, so no
2643
+ * route unlocks stores itself.
2644
+ */
2645
+ setVaultUnlocker(unlock) {
2646
+ this.vaultUnlocker = unlock;
2647
+ }
2648
+ /** Unlock `username`'s stores with their password through the registered unlocker, or none. */
2649
+ async unlockVaults(username, password) {
2650
+ return this.vaultUnlocker ? this.vaultUnlocker(username, password) : undefined;
2651
+ }
2652
+ vaultUnlocker = null;
2653
+ warnedNoUnlocker = false;
2654
+ /**
2655
+ * The stored spelling of a username a sign-in brought (#1882), or null when
2656
+ * no account has it. Lookups compare normalized (#1436), so "ADMIN" finds
2657
+ * admin's record; the session, the audit record, the roles and the vault
2658
+ * paths must carry the stored name, never the typed variant, which is a
2659
+ * roleless twin. The one rule, asked by every door a sign-in passes:
2660
+ * `authenticate`, `beginTwoStep` and `completeSignIn`.
2661
+ */
2662
+ async storedUsername(username) {
2663
+ if (!username)
2664
+ return null;
2665
+ const account = await this.engine.getManager('UserManager')?.getUser(username);
2666
+ return typeof account?.username === 'string' && account.username !== '' ? account.username : null;
2667
+ }
2668
+ /** A fresh session for a sign-in (#1043); the one already on it ends first (#1626, #1670). */
2669
+ regenerate(req) {
2670
+ endSession(this.engine, req.session, 'superseded', { ipAddress: req.ip, userAgent: req.get?.('user-agent') });
2671
+ return new Promise((resolve) => {
2672
+ if (typeof req.session?.regenerate !== 'function') {
2673
+ resolve();
2674
+ return;
2675
+ }
2676
+ req.session.regenerate((err) => {
2677
+ if (err)
2678
+ logger.error('Error regenerating session on sign-in (#1043):', err);
2679
+ else
2680
+ req.session.csrfToken = generateCsrfToken();
2681
+ resolve();
2682
+ });
2683
+ });
2684
+ }
2685
+ /**
2686
+ * What a successful sign-in amounts to, for the session (#1523): its provider,
2687
+ * the factors it satisfied with their times, and their assessment. Null for a
2688
+ * failed result or a delegated credential, which never starts a session.
2689
+ */
2690
+ signInRecord(result) {
2691
+ if (!result.success || !result.provider || result.viaToken)
2692
+ return null;
2693
+ const factors = result.factors ?? [];
2694
+ return { provider: result.provider, factors, ...this.assess(factors), at: new Date().toISOString() };
2695
+ }
2696
+ /**
2697
+ * Step-up (#1525): which permissions ask for a fresh factor, and how fresh.
2698
+ * Which: every permission entry marked `step-up: true` in
2699
+ * `ngdpbase.permissions.definitions` — the permission entry is the one
2700
+ * declaration of its action (#1638). How fresh:
2701
+ * `ngdpbase.auth.step-up.max-age-minutes`; missing, malformed or 0 means no
2702
+ * step-up. A `permissions` list still set under `ngdpbase.auth.step-up` (the
2703
+ * shape before #1638) is honoured as well, and said once.
2704
+ */
2705
+ stepUpPolicy() {
2706
+ const cm = this.engine.getManager('ConfigurationManager');
2707
+ const raw = cm?.getProperty('ngdpbase.auth.step-up', null);
2708
+ const minutes = Number(raw?.['max-age-minutes']);
2709
+ if (!Number.isFinite(minutes) || minutes <= 0)
2710
+ return { maxAgeMs: 0, permissions: new Set() };
2711
+ const definitions = cm?.getProperty('ngdpbase.permissions.definitions', {});
2712
+ const flagged = Object.entries(definitions ?? {}).filter(([, d]) => d?.['step-up'] === true).map(([name]) => name);
2713
+ const legacy = enabledEntries(raw?.permissions);
2714
+ if (legacy.length > 0 && !this.warnedLegacyStepUp) {
2715
+ this.warnedLegacyStepUp = true;
2716
+ logger.warn(`[AuthManager] ngdpbase.auth.step-up.permissions is retired (#1638): mark the permission "step-up": true in ngdpbase.permissions.definitions instead. Honoured for now: ${legacy.join(', ')}`);
2717
+ }
2718
+ return { maxAgeMs: minutes * 60_000, permissions: new Set([...flagged, ...legacy]) };
2719
+ }
2720
+ warnedLegacyStepUp = false;
2721
+ /**
2722
+ * Whether `permission` needs a fresher factor than `signIn` holds (#1525).
2723
+ * Fresh: a factor satisfied within the step-up window that reaches the
2724
+ * level the person's roles require (at least 1 — a known device, aal 0,
2725
+ * never counts). A delegated credential has no sign-in of its own, so it
2726
+ * never satisfies step-up.
2727
+ */
2728
+ stepUpNeeded(permission, signIn, roles, delegated, now = Date.now(), cap) {
2729
+ const policy = this.stepUpPolicy();
2730
+ if (!policy.permissions.has(permission))
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) {
2745
+ if (delegated)
2746
+ return false;
2747
+ const need = Math.max(this.requiredAalFor(roles, cap), 1);
2748
+ const since = now - maxAgeMs;
2749
+ return (signIn?.factors ?? []).some((f) => f.aal >= need && Date.parse(f.at) >= since);
2750
+ }
2751
+ /**
2752
+ * A session's sign-in after a re-authentication (#1525): the new factors
2753
+ * join the old, the assessment is redone over all of them, and the sign-in
2754
+ * time moves to now — this is a fresh authentication. The provider stays
2755
+ * the one that started the session. Null when the result is no sign-in.
2756
+ */
2757
+ reauthenticated(signIn, result) {
2758
+ const fresh = this.signInRecord(result);
2759
+ if (!fresh)
2760
+ return null;
2761
+ if (!signIn)
2762
+ return fresh;
2763
+ const factors = [...signIn.factors, ...fresh.factors];
2764
+ return { provider: signIn.provider, factors, ...this.assess(factors), at: fresh.at };
2765
+ }
2766
+ // ── Invitations (#2049) ───────────────────────────────────────────────
2767
+ //
2768
+ // An account made without a password gets a one-time email link that signs
2769
+ // the new user in once, at AAL1 as an email link, and sends them to set up
2770
+ // a password or passkey (ACCOUNT_SETUP_PATH), through the terms prompt
2771
+ // `completeSignIn` already applies. Unlike a magic link it is persisted: a
2772
+ // signed `invitation` row of the credentials store, holding only the
2773
+ // token's SHA-256, so it survives a restart and a reader of the file learns
2774
+ // no link. Not a sign-in method a user picks (no factor entry, no
2775
+ // provider): AuthManager checks the token itself, as it does an approval
2776
+ // link, and spends the row before the session exists.
2777
+ /** How long an invitation link stays usable: `ngdpbase.auth.invitation.ttl-days`, read on every call. */
2778
+ invitationTtlMs() {
2779
+ const days = Number(this.engine.getManager('ConfigurationManager')?.getProperty(INVITATION_TTL_KEY, DEFAULT_INVITATION_TTL_DAYS));
2780
+ return (Number.isFinite(days) && days > 0 ? days : DEFAULT_INVITATION_TTL_DAYS) * 86_400_000;
2781
+ }
2782
+ /**
2783
+ * Why no invitation can be sent on this site now, in plain words for the
2784
+ * administrator, or null when one can: email set up and working, an
2785
+ * explicit site address for the link, and the credentials store open.
2786
+ */
2787
+ invitationsUnavailable() {
2788
+ const mail = this.engine.getManager('EmailManager');
2789
+ if (!mail?.isEnabled())
2790
+ return 'Email is not set up on this site, so no invitation can be sent.';
2791
+ if (mail.isDeliveryFailing())
2792
+ return 'Email is not working on this site right now, so no invitation can be sent.';
2793
+ if (!this.engine.getManager('ConfigurationManager')?.isBaseUrlExplicit()) {
2794
+ return 'The site\'s own address is not set, so no invitation link can be made.';
2795
+ }
2796
+ if (!this.credentials)
2797
+ return 'The credentials store is not open on this site, so no invitation can be kept.';
2798
+ return null;
2799
+ }
2800
+ /** Sending, resending and cancelling an invitation is creating an account's way in: `user-create` (#2049). */
2801
+ async mayInvite(ctx) {
2802
+ const pdp = this.engine.getManager('PolicyDecisionPoint');
2803
+ return !!pdp && !!ctx && pdp.permits(ctx, 'user-create');
2804
+ }
2805
+ /** The live and expired invitation rows of the store, or none while it is closed. */
2806
+ invitationRows(username) {
2807
+ if (!this.credentials)
2808
+ return [];
2809
+ return username === undefined
2810
+ ? this.credentials.listKind('invitation')
2811
+ : this.credentials.list(username).filter(r => r.kind === 'invitation');
2812
+ }
2813
+ /**
2814
+ * Why `username` cannot be invited, in plain words, or null when they can:
2815
+ * an active account of this site, with an email address, no password, and
2816
+ * never signed in. An account with a way in needs no invitation.
2817
+ */
2818
+ async notInvitable(username) {
2819
+ const users = this.engine.getManager('UserManager');
2820
+ const account = await users?.getUser(username);
2821
+ if (!account)
2822
+ return `There is no account ${username}.`;
2823
+ if (account.isActive === false)
2824
+ return 'The account is turned off.';
2825
+ if (account.isExternal)
2826
+ return 'The account signs in through another service, so it needs no invitation.';
2827
+ if (typeof account.email !== 'string' || account.email.trim() === '')
2828
+ return 'The account has no email address to send the invitation to.';
2829
+ if (await users?.hasPassword(username))
2830
+ return 'The account already has a password.';
2831
+ if (account.lastLogin || (account.loginCount ?? 0) > 0)
2832
+ return 'The account has already signed in.';
2833
+ return null;
2834
+ }
2835
+ /**
2836
+ * Send `username` an invitation (#2049): an email with a one-time link to
2837
+ * set up their sign-in. Needs `user-create` (throws `Permission denied`).
2838
+ * A new invitation replaces any earlier one, whose link stops working
2839
+ * (resend). Recorded first, as `invitation-send` (refuse on failure), so no
2840
+ * link exists that the audit log does not know of. When email cannot be
2841
+ * sent the answer says so and nothing is kept; a send that fails removes
2842
+ * the link again, recorded as `invitation-cancel` with reason `not-sent`.
2843
+ * The message goes through EmailManager with the caller's context.
2844
+ */
2845
+ async inviteUser(ctx, username) {
2846
+ if (!(await this.mayInvite(ctx)))
2847
+ throw new Error('Permission denied');
2848
+ await this.sweepInvitations();
2849
+ const unavailable = this.invitationsUnavailable();
2850
+ if (unavailable)
2851
+ return { outcome: 'email-unavailable', reason: unavailable };
2852
+ const refused = await this.notInvitable(username);
2853
+ if (refused)
2854
+ return { outcome: 'not-invitable', reason: refused };
2855
+ const account = await this.engine.getManager('UserManager')?.getUser(username);
2856
+ const address = String(account?.email ?? '').trim();
2857
+ // Resend: the earlier link stops working before the new one exists.
2858
+ for (const row of this.invitationRows(username))
2859
+ await this.endInvitation(ctx, row, 'replaced');
2860
+ const token = randomBytes(32).toString('base64url');
2861
+ const now = Date.now();
2862
+ const createdBy = actorOf(ctx).actor ?? actorOf(ctx).user;
2863
+ const terms = { createdBy, expiresAt: new Date(now + this.invitationTtlMs()).toISOString() };
2864
+ const row = {
2865
+ id: randomUUID(), username, kind: 'invitation', subject: invitationTokenHash(token),
2866
+ secret: JSON.stringify(terms), label: '', createdAt: new Date(now).toISOString()
2867
+ };
2868
+ const invitation = invitationOf(row);
2869
+ // The message is made before the link exists, so nothing below can fail between storing it and sending it but the send.
2870
+ const site = this.engine.getManager('ConfigurationManager')?.getApplicationName() ?? 'ngdpbase';
2871
+ const url = `${this.linkBase()}${INVITATION_PATH}?t=${token}`;
2872
+ const until = await this.localTimeFor(username, new Date(terms.expiresAt));
2873
+ const htmlEscape = this.escapeHtml();
2874
+ await this.auditInvitation(AUDIT_EVENT.INVITATION_SEND, ctx, invitation);
2875
+ await this.requireCredentialsStore().add(row);
2876
+ try {
2877
+ const mail = this.engine.getManager('EmailManager');
2878
+ await mail.sendTo(ctx, address, `You are invited to ${site}`, [
2879
+ `An account has been made for you on ${site}, with the username ${username}.`,
2880
+ '',
2881
+ 'Open this link to choose how you sign in (a password or a passkey) and finish setting up your account:',
2882
+ '',
2883
+ url,
2884
+ '',
2885
+ `The link works once, until ${until}. If you were not expecting this, you can ignore this email.`
2886
+ ].join('\n'), [
2887
+ `<p>An account has been made for you on <strong>${htmlEscape(site)}</strong>, with the username <strong>${htmlEscape(username)}</strong>.</p>`,
2888
+ `<p><a href="${htmlEscape(url)}">Set up your sign-in</a>: choose a password or a passkey and finish setting up your account.</p>`,
2889
+ `<p>The link works once, until ${htmlEscape(until)}. If you were not expecting this, you can ignore this email.</p>`
2890
+ ].join('\n'));
2891
+ }
2892
+ catch {
2893
+ // #1916: EmailManager reported the failure; here the link goes, and the administrator is told.
2894
+ await this.endInvitation(ctx, row, 'not-sent');
2895
+ return { outcome: 'send-failed', reason: 'The invitation email could not be sent, so no invitation is waiting. You can set a password for the account by hand instead.' };
2896
+ }
2897
+ return { outcome: 'sent', invitation };
2898
+ }
2899
+ /**
2900
+ * Cancel `username`'s invitation (#2049): its link stops working. Needs
2901
+ * `user-create` (throws `Permission denied`). Recorded first, as
2902
+ * `invitation-cancel` (refuse on failure). False when none was waiting.
2903
+ */
2904
+ async cancelInvitation(ctx, username) {
2905
+ if (!(await this.mayInvite(ctx)))
2906
+ throw new Error('Permission denied');
2907
+ let cancelled = false;
2908
+ for (const row of this.invitationRows(username))
2909
+ cancelled = (await this.endInvitation(ctx, row, 'cancelled')) || cancelled;
2910
+ return cancelled;
2911
+ }
2912
+ /**
2913
+ * The invitations waiting to be used, for the administrator's user list
2914
+ * (#2049). Reading is not a change: it asks whether the caller holds
2915
+ * `user-read`. Expired ones are removed first, each recorded as
2916
+ * `invitation-expire`.
2917
+ */
2918
+ async listInvitations(ctx) {
2919
+ const pdp = this.engine.getManager('PolicyDecisionPoint');
2920
+ if (!pdp || !(await pdp.holds(ctx, 'user-read')))
2921
+ throw new Error('Permission denied');
2922
+ await this.sweepInvitations();
2923
+ return this.invitationRows().map(invitationOf).filter((v) => v !== null);
2924
+ }
2925
+ /** The live invitation a link's token names, or null: unknown, used, or expired (an expired one is removed, recorded). */
2926
+ async liveInvitation(token) {
2927
+ if (typeof token !== 'string' || token === '' || !this.credentials)
2928
+ return null;
2929
+ const row = this.credentials.findBySubject('invitation', invitationTokenHash(token));
2930
+ const invitation = row ? invitationOf(row) : null;
2931
+ if (!row || !invitation)
2932
+ return null;
2933
+ if (!invitationLive(invitation, Date.now())) {
2934
+ await this.expireInvitation(row, invitation);
2935
+ return null;
2936
+ }
2937
+ return { row, invitation };
2938
+ }
2939
+ /**
2940
+ * Who an invitation link is for, to show before it is used (#2049), or
2941
+ * null. Opening the link changes nothing (mail scanners open links): the
2942
+ * page's button posts to `useInvitation`, behind the session's CSRF token.
2943
+ */
2944
+ async invitationFor(token) {
2945
+ const live = await this.liveInvitation(token);
2946
+ if (!live)
2947
+ return null;
2948
+ const account = await this.engine.getManager('UserManager')?.getUser(live.row.username);
2949
+ if (!account || account.isActive === false)
2950
+ return null;
2951
+ return { username: live.row.username, displayName: String(account.displayName || live.row.username), expiresAt: live.invitation.expiresAt };
2952
+ }
2953
+ /**
2954
+ * Use an invitation link (#2049): spend it, then sign its user in once
2955
+ * through `completeSignIn` (a new session; the terms prompt first when
2956
+ * terms are waiting), at AAL1 as an email link, so a role above that
2957
+ * steps down as for any such sign-in. Returns where to go next
2958
+ * (`ACCOUNT_SETUP_PATH`, or the terms prompt on the way there), or null
2959
+ * when the link is unknown, used, expired, or its account is turned off.
2960
+ *
2961
+ * The row's removal is the single-use gate: the store deletes in memory
2962
+ * before it writes, so of two requests with one link exactly one sees
2963
+ * true. Recorded as `invitation-use`; the sign-in itself as
2964
+ * `authentication-success`, method `invitation`.
2965
+ */
2966
+ async useInvitation(req, token) {
2967
+ const live = await this.liveInvitation(token);
2968
+ if (!live)
2969
+ return null;
2970
+ const account = await this.engine.getManager('UserManager')?.getUser(live.row.username);
2971
+ if (!account || account.isActive === false)
2972
+ return null;
2973
+ if (!(await this.requireCredentialsStore().remove(live.row.id)))
2974
+ return null;
2975
+ await this.recordInvitationUse(req, live.invitation);
2976
+ const result = {
2977
+ success: true,
2978
+ username: live.row.username,
2979
+ provider: 'invitation',
2980
+ factors: [{ provider: 'invitation', ...EMAIL_FACTOR, at: new Date().toISOString() }]
2981
+ };
2982
+ const completed = await this.completeSignIn(req, result, { method: 'invitation', returnTo: ACCOUNT_SETUP_PATH });
2983
+ return completed.next ?? ACCOUNT_SETUP_PATH;
2984
+ }
2985
+ /** Remove every invitation past its end date, each recorded as `invitation-expire`. */
2986
+ async sweepInvitations() {
2987
+ const now = Date.now();
2988
+ for (const row of this.invitationRows()) {
2989
+ const invitation = invitationOf(row);
2990
+ if (!invitation || !invitationLive(invitation, now))
2991
+ await this.expireInvitation(row, invitation);
2992
+ }
2993
+ }
2994
+ async expireInvitation(row, invitation) {
2995
+ if (!(await this.credentials?.remove(row.id)))
2996
+ return;
2997
+ const ctx = systemContext(this.engine, `invitation ${row.id} for ${row.username} passed its end date`);
2998
+ await this.auditInvitation(AUDIT_EVENT.INVITATION_EXPIRE, ctx, invitation ?? { id: row.id, username: row.username, createdBy: '', createdAt: row.createdAt, expiresAt: '' });
2999
+ }
3000
+ /** End one invitation the caller may end: recorded first (`invitation-cancel`, refuse), then removed. */
3001
+ async endInvitation(ctx, row, why) {
3002
+ const invitation = invitationOf(row) ?? { id: row.id, username: row.username, createdBy: '', createdAt: row.createdAt, expiresAt: '' };
3003
+ await this.auditInvitation(AUDIT_EVENT.INVITATION_CANCEL, ctx, invitation, why);
3004
+ return (await this.credentials?.remove(row.id)) ?? false;
3005
+ }
3006
+ /** An invitation record: `user` who acted (the sender, the canceller, the system), `resource` the account invited. Never the token. */
3007
+ async auditInvitation(eventType, ctx, invitation, reason) {
3008
+ const who = actorOf(ctx);
3009
+ // #2041: the invited account is the owner, named by identity @id; a name with no account as written.
3010
+ const owner = (await this.engine.getManager('IdentityManager')
3011
+ ?.identityIdOf(systemContext(this.engine, 'record an invitation: the invited account identity @id (#2041)'), invitation.username)) ?? invitation.username;
3012
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
3013
+ eventType,
3014
+ ...who,
3015
+ action: eventType,
3016
+ result: 'success',
3017
+ severity: 'medium',
3018
+ resource: invitation.username,
3019
+ resourceType: 'user',
3020
+ ...(reason ? { reason } : {}),
3021
+ metadata: { ...who.metadata, invitationId: invitation.id, owner, ownerUser: invitation.username, expiresAt: invitation.expiresAt, createdBy: invitation.createdBy }
3022
+ }, (err) => logger.warn(`[AuthManager] Audit record failed for ${eventType} of invitation ${invitation.id}:`, err));
3023
+ }
3024
+ /** `invitation-use`: the invited user, before any session exists, from the request's address and browser. */
3025
+ async recordInvitationUse(req, invitation) {
3026
+ // #2041: the invited account is the owner, named by identity @id; a name with no account as written.
3027
+ const owner = (await this.engine.getManager('IdentityManager')
3028
+ ?.identityIdOf(systemContext(this.engine, 'record an invitation use: the invited account identity @id (#2041)'), invitation.username)) ?? invitation.username;
3029
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
3030
+ eventType: AUDIT_EVENT.INVITATION_USE,
3031
+ user: invitation.username,
3032
+ ipAddress: req.ip,
3033
+ action: AUDIT_EVENT.INVITATION_USE,
3034
+ result: 'success',
3035
+ severity: 'medium',
3036
+ resource: invitation.username,
3037
+ resourceType: 'user',
3038
+ metadata: { invitationId: invitation.id, owner, ownerUser: invitation.username, createdBy: invitation.createdBy, userAgent: req.get?.('user-agent') }
3039
+ }, (err) => logger.warn(`[AuthManager] Audit record failed for invitation-use of ${invitation.id}:`, err));
3040
+ }
3041
+ // ── Two-step sign-in (#1523) ──────────────────────────────────────────
3042
+ pendingSignIns = new Map();
3043
+ approvalTokens = new Map();
3044
+ enrolTokens = new Map();
3045
+ /** #2073: links confirming a user's new email, by token; one waiting per account. In memory: a restart drops them. */
3046
+ emailChangeTokens = new Map();
3047
+ /** #2073: when each account's email-change links were sent, for the cooldown and the cap. In memory. */
3048
+ emailChangeSends = new Map();
3049
+ /** Mail can carry an email-link factor: a mail transport and an explicit base-url for the link. */
3050
+ mailForLinks() {
3051
+ const configManager = this.engine.getManager('ConfigurationManager');
3052
+ const mail = this.engine.getManager('EmailManager');
3053
+ return mail && configManager?.isBaseUrlExplicit() ? mail : null;
3054
+ }
3055
+ linkBase() {
3056
+ return (this.engine.getManager('ConfigurationManager')?.getBaseURL() ?? '').replace(/\/$/, '');
3057
+ }
3058
+ /** Whether an email link can be a second factor on this site now (#1523): mail and an explicit base-url. */
3059
+ emailLinksAvailable() {
3060
+ return this.mailForLinks() !== null;
3061
+ }
3062
+ /**
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.
3077
+ */
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) {
3098
+ const rows = this.credentials?.list(username) ?? [];
3099
+ const out = [];
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
+ }
3105
+ }
3106
+ }
3107
+ return out;
3108
+ }
3109
+ /**
3110
+ * Whether a successful first step needs a second (#1532, operator
3111
+ * 2026-10-22): everyone who arrives with only a password behind them — a
3112
+ * password typed here, or an outside provider's sign-in at AAL1 — when the
3113
+ * account has a second factor it can use now. Never after an email link
3114
+ * (the second factor would go to the same inbox), and never after a sign-in
3115
+ * already at AAL2 or above (a passkey, a provider whose second factor the
3116
+ * admin trusts).
3117
+ */
3118
+ needsSecondStep(result) {
3119
+ const record = result.success && result.username ? this.signInRecord(result) : null;
3120
+ if (!record || record.aal >= 2 || record.amr.includes('email'))
3121
+ return false;
3122
+ return this.secondFactorsFor(result.username, { first: result }).length > 0;
3123
+ }
3124
+ /**
3125
+ * Roughly where `req` came from, for the approval message (#1532):
3126
+ * Cloudflare's location headers when `ngdpbase.server.behind-cloudflare`,
3127
+ * else the GeoLite2 City file at `ngdpbase.server.geolite2-city-file`, else
3128
+ * null. Read when asked, so a change applies at the next sign-in. The IP is
3129
+ * Express's `req.ip`, which honours `ngdpbase.server.trust-proxy`.
3130
+ */
3131
+ async approximateLocation(req) {
3132
+ const cm = this.engine.getManager('ConfigurationManager');
3133
+ const file = cm?.getProperty('ngdpbase.server.geolite2-city-file', '');
3134
+ try {
3135
+ return await approximateLocation(req.ip, (name) => req.get?.(name), {
3136
+ behindCloudflare: cm?.getProperty('ngdpbase.server.behind-cloudflare', false) === true,
3137
+ geoliteFile: typeof file === 'string' ? file.trim() : ''
3138
+ });
3139
+ }
3140
+ catch (err) {
3141
+ logger.warn('[AuthManager] could not tell where a sign-in came from (#1532):', err);
3142
+ return null;
3143
+ }
3144
+ }
3145
+ /** Hold a sign-in whose first factor passed, until a second factor completes it. Returns its handle. */
3146
+ async beginTwoStep(first, ctx) {
3147
+ if (!first.success || !first.username)
3148
+ throw new Error('No first factor to continue from');
3149
+ const username = await this.storedUsername(first.username);
3150
+ if (!username)
3151
+ throw new Error('beginTwoStep: no stored account has that username (#1882)');
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));
3156
+ this.sweepPending();
3157
+ const handle = randomBytes(24).toString('base64url');
3158
+ const now = Date.now();
3159
+ this.pendingSignIns.set(handle, {
3160
+ handle,
3161
+ id: randomUUID(),
3162
+ username,
3163
+ first,
3164
+ binding: ctx.binding,
3165
+ requestedAt: now,
3166
+ expiresAt: now + PENDING_SIGN_IN_TTL_MS,
3167
+ requestIp: ctx.ip,
3168
+ requestUserAgent: ctx.userAgent,
3169
+ requestLocation: ctx.location ?? null,
3170
+ privateStoreHandle: ctx.privateStoreHandle,
3171
+ requiredAal
3172
+ });
3173
+ // #1532: an expiry is recorded when it happens, not when someone next looks.
3174
+ setTimeout(() => this.expirePending(handle), PENDING_SIGN_IN_TTL_MS + 1).unref?.();
3175
+ return handle;
3176
+ }
3177
+ /** The pending sign-in, for the browser that started it; null when gone, expired, or another browser. */
3178
+ pendingSignIn(handle, binding) {
3179
+ if (!handle)
3180
+ return null;
3181
+ const p = this.pendingSignIns.get(handle);
3182
+ if (!p)
3183
+ return null;
3184
+ if (Date.now() > p.expiresAt) {
3185
+ this.expirePending(handle);
3186
+ return null;
3187
+ }
3188
+ return binding && p.binding === binding ? p : null;
3189
+ }
3190
+ /**
3191
+ * Record one step of a sign-in's approval (#1532): every record carries the
3192
+ * pending sign-in's `id` (never its handle or the link token), the account
3193
+ * it is for, and who took the step — the IP and browser of the request, the
3194
+ * opener or the presser. `user` is the account signing in: there is no
3195
+ * session yet, so no proxy (whom to act for is chosen after sign-in).
3196
+ */
3197
+ async recordApproval(eventType, p, result, from, metadata = {}) {
3198
+ const severity = eventType === AUDIT_EVENT.SIGNINAPPROVAL_DENY ? 'high'
3199
+ : eventType === AUDIT_EVENT.SIGNINAPPROVAL_SENDFAIL || eventType === AUDIT_EVENT.SIGNINAPPROVAL_THROTTLE ? 'medium'
3200
+ : 'low';
3201
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
3202
+ eventType,
3203
+ user: p.username,
3204
+ ipAddress: from?.ip,
3205
+ action: eventType,
3206
+ result,
3207
+ severity,
3208
+ resource: p.id,
3209
+ resourceType: 'sign-in',
3210
+ metadata: {
3211
+ pendingSignIn: p.id,
3212
+ channel: 'email',
3213
+ ...(from?.userAgent ? { userAgent: from.userAgent, device: describeBrowser(from.userAgent) } : {}),
3214
+ ...metadata
3215
+ }
3216
+ }, (err) => logger.warn(`[two-step] audit record failed for ${eventType}:`, err));
3217
+ }
3218
+ /**
3219
+ * HTML escaping for the approval message and page (#1532): PluginManager's
3220
+ * formatter (#1820), the one escapeHtml. Its absence is a wiring fault, not
3221
+ * a reason to send a value unescaped.
3222
+ */
3223
+ escapeHtml() {
3224
+ const formatters = this.engine.getManager('PluginManager')?.formatters;
3225
+ if (!formatters)
3226
+ throw new Error('PluginManager is not available to escape the sign-in approval (#1532)');
3227
+ return (v) => formatters.escapeHtml(v);
3228
+ }
3229
+ /** Where the sign-in asked from, as the request, send and expiry records say it. */
3230
+ static askedFrom(p) {
3231
+ return { ip: p.requestIp, userAgent: p.requestUserAgent };
3232
+ }
3233
+ /**
3234
+ * Email a one-time approval link for the pending sign-in (#1523, #1532).
3235
+ * The link only opens the approval page; the button there approves, so a
3236
+ * mail scanner opening links approves nothing. Limited to one per minute.
3237
+ * Each outcome is recorded: sent (`signinapproval-request`), too soon
3238
+ * (`-throttle`), or not sent (`-sendfail`).
3239
+ */
3240
+ async sendEmailApproval(handle, binding) {
3241
+ const p = this.pendingSignIn(handle, binding);
3242
+ if (!p)
3243
+ return 'unavailable';
3244
+ const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
3245
+ const row = (this.credentials?.list(p.username) ?? []).find((c) => c.kind === 'email');
3246
+ const from = AuthManager.askedFrom(p);
3247
+ if (!mail || !row) {
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 });
3250
+ return 'unavailable';
3251
+ }
3252
+ if (p.lastSentAt && Date.now() - p.lastSentAt < APPROVAL_RESEND_MS) {
3253
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_THROTTLE, p, 'deny', from, { lastSentAt: new Date(p.lastSentAt).toISOString() });
3254
+ return 'too-soon';
3255
+ }
3256
+ const token = randomBytes(32).toString('base64url');
3257
+ this.approvalTokens.set(token, { handle, expiresAt: p.expiresAt });
3258
+ const previousSentAt = p.lastSentAt;
3259
+ p.lastSentAt = Date.now();
3260
+ const url = `${this.linkBase()}/login/approve?t=${token}`;
3261
+ const about = await this.approvalContext(p);
3262
+ const escapeHtml = this.escapeHtml();
3263
+ // #1532: the decided wording — which site, when (the person's own time
3264
+ // zone), from what browser, IP address and roughly where; one link, no
3265
+ // code. The page carries the buttons. Every value is escaped in the HTML.
3266
+ const sent = await mail.send({
3267
+ to: row.subject,
3268
+ subject: `Approve or deny a sign-in to ${about.site}`,
3269
+ text: [
3270
+ approvalSentence(about),
3271
+ `Was this you? Open to approve or deny: ${url}`,
3272
+ '',
3273
+ 'The link works once, for the next 10 minutes.'
3274
+ ].join('\n'),
3275
+ html: [
3276
+ `<p>${approvalSentence(about, (v) => `<strong>${escapeHtml(v)}</strong>`, escapeHtml)}</p>`,
3277
+ `<p>Was this you? <a href="${escapeHtml(url)}">Open to approve or deny</a></p>`,
3278
+ '<p>The link works once, for the next 10 minutes.</p>'
3279
+ ].join('\n')
3280
+ }).then(() => true, () => false);
3281
+ // #1916: a send that fails is reported by EmailManager; here the person
3282
+ // waiting is told, and nothing says a link is on its way.
3283
+ if (!sent) {
3284
+ this.approvalTokens.delete(token);
3285
+ p.lastSentAt = previousSentAt;
3286
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_SENDFAIL, p, 'error', from, { reason: 'the message could not be sent' });
3287
+ return 'failed';
3288
+ }
3289
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_REQUEST, p, 'success', from, { location: about.location, browser: about.device });
3290
+ return 'sent';
3291
+ }
3292
+ /**
3293
+ * What the approval page shows about the waiting sign-in; null for an
3294
+ * unknown or used link. Opening the page changes nothing, but it is
3295
+ * recorded (`signinapproval-open`) with who opened it (#1532): a mail
3296
+ * scanner opens links too.
3297
+ */
3298
+ async approvalDetails(token, opener) {
3299
+ const entry = this.approvalTokens.get(token);
3300
+ if (!entry || Date.now() > entry.expiresAt)
3301
+ return null;
3302
+ const p = this.pendingSignIns.get(entry.handle);
3303
+ if (!p || p.approved || p.denied)
3304
+ return null;
3305
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_OPEN, p, 'success', opener);
3306
+ const about = await this.approvalContext(p);
3307
+ const escapeHtml = this.escapeHtml();
3308
+ return {
3309
+ username: p.username, requestedAt: p.requestedAt, userAgent: p.requestUserAgent, ...about,
3310
+ sentenceHtml: approvalSentence(about, (v) => `<strong>${escapeHtml(v)}</strong>`, escapeHtml)
3311
+ };
3312
+ }
3313
+ /**
3314
+ * `at` as `username` reads it: in their own time zone (their `timezone`
3315
+ * preference, else `ngdpbase.default.timezone`), locale and clock, with the
3316
+ * zone named (#1532). For a message sent to them, where no page script can
3317
+ * show it in local time; the approval request and an invitation (#2049).
3318
+ */
3319
+ async localTimeFor(username, at) {
3320
+ const cm = this.engine.getManager('ConfigurationManager');
3321
+ const prefs = (await this.engine.getManager('UserManager')?.getUser(username))?.preferences;
3322
+ const zone = [prefs?.timezone, cm?.getProperty('ngdpbase.default.timezone', 'UTC')].find((z) => typeof z === 'string' && LocaleUtils.isValidTimezone(z)) ?? 'UTC';
3323
+ const opts = {
3324
+ day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: zone, timeZoneName: 'short',
3325
+ ...(prefs?.timeFormat === '12h' || prefs?.timeFormat === '24h' ? { hour12: prefs.timeFormat === '12h' } : {})
3326
+ };
3327
+ try {
3328
+ return at.toLocaleString(LocaleUtils.normalizeLocale(typeof prefs?.locale === 'string' ? prefs.locale : 'en-US'), opts);
3329
+ }
3330
+ catch {
3331
+ return at.toLocaleString('en-US', opts);
3332
+ }
3333
+ }
3334
+ /**
3335
+ * What an approval request says about a waiting sign-in (#1532), one source
3336
+ * for the message and the page: the site's name, when it was asked in the
3337
+ * account holder's own time zone (their preference, else the site default),
3338
+ * the browser and device it came from (null when the header names
3339
+ * neither), its IP address, and roughly where (null when no source says).
3340
+ */
3341
+ async approvalContext(p) {
3342
+ const cm = this.engine.getManager('ConfigurationManager');
3343
+ return {
3344
+ site: cm?.getApplicationName() ?? 'ngdpbase',
3345
+ when: await this.localTimeFor(p.username, new Date(p.requestedAt)),
3346
+ device: describeBrowser(p.requestUserAgent),
3347
+ ...(p.requestIp ? { ip: p.requestIp } : {}),
3348
+ location: p.requestLocation ?? null
3349
+ };
3350
+ }
3351
+ /** Approve the waiting sign-in from its link (the button press). Single use; returns the username, or null. */
3352
+ async approveByToken(token, presser) {
3353
+ const entry = this.approvalTokens.get(token);
3354
+ this.approvalTokens.delete(token);
3355
+ if (!entry || Date.now() > entry.expiresAt)
3356
+ return null;
3357
+ const p = this.pendingSignIns.get(entry.handle);
3358
+ if (!p || p.approved || p.denied || Date.now() > p.expiresAt)
3359
+ return null;
3360
+ p.approved = { provider: 'email-link', ...EMAIL_FACTOR, at: new Date().toISOString() };
3361
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_APPROVE, p, 'success', presser);
3362
+ return p.username;
3363
+ }
3364
+ /** Refuse the waiting sign-in from its link (Deny). Returns the username, or null. */
3365
+ async denyByToken(token, presser) {
3366
+ const entry = this.approvalTokens.get(token);
3367
+ this.approvalTokens.delete(token);
3368
+ if (!entry)
3369
+ return null;
3370
+ const p = this.pendingSignIns.get(entry.handle);
3371
+ if (!p || p.approved)
3372
+ return null;
3373
+ p.denied = true;
3374
+ if (p.privateStoreHandle)
3375
+ lockPrivateStores(p.privateStoreHandle);
3376
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_DENY, p, 'deny', presser);
3377
+ return p.username;
3378
+ }
3379
+ /**
3380
+ * The finished sign-in, once its second factor is satisfied — both factors,
3381
+ * assessed together. Single use: the pending entry is gone after this.
3382
+ */
3383
+ completeTwoStep(handle, binding) {
3384
+ const p = this.pendingSignIn(handle, binding);
3385
+ if (!p?.approved || p.denied)
3386
+ return null;
3387
+ this.pendingSignIns.delete(handle);
3388
+ for (const [token, entry] of this.approvalTokens)
3389
+ if (entry.handle === handle)
3390
+ this.approvalTokens.delete(token);
3391
+ return {
3392
+ result: { ...p.first, factors: [...(p.first.factors ?? []), p.approved] },
3393
+ privateStoreHandle: p.privateStoreHandle
3394
+ };
3395
+ }
3396
+ /**
3397
+ * The waiting browser gave up the sign-in (Cancel, #1532): dropped, its
3398
+ * keys locked, and recorded as `signinapproval-abandon`.
3399
+ */
3400
+ async abandonTwoStep(handle, binding, from) {
3401
+ const p = this.pendingSignIn(handle, binding);
3402
+ if (!p)
3403
+ return;
3404
+ this.dropPending(handle);
3405
+ await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_ABANDON, p, 'failure', from, { state: p.denied ? 'denied' : p.approved ? 'approved' : 'waiting' });
3406
+ }
3407
+ /** Drop a pending sign-in and lock any keys it held. */
3408
+ dropPending(handle) {
3409
+ const p = this.pendingSignIns.get(handle);
3410
+ if (!p)
3411
+ return;
3412
+ if (p.privateStoreHandle)
3413
+ lockPrivateStores(p.privateStoreHandle);
3414
+ this.pendingSignIns.delete(handle);
3415
+ for (const [token, entry] of this.approvalTokens)
3416
+ if (entry.handle === handle)
3417
+ this.approvalTokens.delete(token);
3418
+ }
3419
+ /** A pending sign-in past its time: dropped, and recorded once as `signinapproval-expire` (#1532). */
3420
+ expirePending(handle) {
3421
+ const p = this.pendingSignIns.get(handle);
3422
+ if (!p || Date.now() <= p.expiresAt)
3423
+ return;
3424
+ this.dropPending(handle);
3425
+ void this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_EXPIRE, p, 'failure', AuthManager.askedFrom(p), {
3426
+ state: p.denied ? 'denied' : p.approved ? 'approved' : 'waiting',
3427
+ messageSent: Boolean(p.lastSentAt)
3428
+ });
3429
+ }
3430
+ sweepPending() {
3431
+ const now = Date.now();
3432
+ for (const [handle, p] of this.pendingSignIns)
3433
+ if (now > p.expiresAt)
3434
+ this.expirePending(handle);
3435
+ for (const [token, e] of this.approvalTokens)
3436
+ if (now > e.expiresAt)
3437
+ this.approvalTokens.delete(token);
3438
+ for (const [token, e] of this.enrolTokens)
3439
+ if (now > e.expiresAt)
3440
+ this.enrolTokens.delete(token);
3441
+ }
3442
+ /**
3443
+ * Start enrolling the account's email address as a second factor (#1523).
3444
+ * The address must prove it receives mail first: a factor that cannot be
3445
+ * satisfied would lock its owner out.
3446
+ */
3447
+ async startEmailFactorEnrolment(ctx, username) {
3448
+ if (!(await this.mayManageCredentials(ctx, username)))
3449
+ throw new Error('Permission denied');
3450
+ const mail = this.emailSecondFactorAvailable() ? this.mailForLinks() : null;
3451
+ if (!mail)
3452
+ return 'unavailable';
3453
+ const user = await this.engine.getManager('UserManager')?.getUser(username);
3454
+ const address = typeof user?.email === 'string' ? user.email.trim() : '';
3455
+ if (!address)
3456
+ return 'no-email';
3457
+ if ((this.credentials?.list(username) ?? []).some((c) => c.kind === 'email' && c.subject === address))
3458
+ return 'already';
3459
+ this.sweepPending();
3460
+ const token = randomBytes(32).toString('base64url');
3461
+ this.enrolTokens.set(token, { username, address, expiresAt: Date.now() + ENROL_TTL_MS });
3462
+ const url = `${this.linkBase()}/profile/second-factor/email/confirm?t=${token}`;
3463
+ await mail.send({
3464
+ to: address,
3465
+ subject: 'Confirm your email as a second factor',
3466
+ text: ['Open this link to use this address to approve your sign-ins:', '', url, '', 'It works once, for 30 minutes. If you did not ask for this, ignore it.'].join('\n'),
3467
+ html: `<p><a href="${url}">Use this address to approve your sign-ins</a></p><p>It works once, for 30 minutes. If you did not ask for this, ignore it.</p>`
3468
+ });
3469
+ return 'sent';
3470
+ }
3471
+ /** Finish enrolling from the emailed link, by the same signed-in person. Single use. */
3472
+ async confirmEmailFactorEnrolment(ctx, token) {
3473
+ const entry = this.enrolTokens.get(token);
3474
+ this.enrolTokens.delete(token);
3475
+ if (!entry || Date.now() > entry.expiresAt || entry.username !== ctx.username)
3476
+ return false;
3477
+ await this.addCredential(ctx, entry.username, { kind: 'email', subject: entry.address, secret: '', label: 'Email: approve sign-ins' });
3478
+ return true;
3479
+ }
3480
+ /**
3481
+ * A user asks to change their own email (#2073, operator 2026-10-25): it
3482
+ * changes only when they click a link sent to the new address. Until then
3483
+ * the account keeps its old email. The link is single use, lasts
3484
+ * `ngdpbase.auth.email-change.ttl-minutes` (60), is bound to the account and
3485
+ * the new address, and replaces any link still waiting for that account. The
3486
+ * old address, when there is one, is told a change was asked, with no link.
3487
+ * Recorded as `emailchange-request`, naming the account, never an address.
3488
+ *
3489
+ * Only the account itself asks (its own context, signed in or just
3490
+ * registered). `unavailable`: this site cannot send email now, so nothing
3491
+ * is sent and an administrator can change it instead. `failed`: the send
3492
+ * failed and no link is waiting. `unchanged`: the address is the one the
3493
+ * account already has. `throttled`: the account asked too soon after its
3494
+ * last link (`ngdpbase.auth.email-change.cooldown-seconds`, 60) or too often
3495
+ * in the window (`ngdpbase.auth.email-change.max-per-window` links per
3496
+ * `ngdpbase.auth.email-change.window-minutes`, 5 per 60); no mail is sent.
3497
+ */
3498
+ async requestEmailChange(ctx, username, newEmail, via) {
3499
+ if (!ctx || !sameUsername(ctx.username, username))
3500
+ throw new Error('Permission denied');
3501
+ const address = typeof newEmail === 'string' ? newEmail.trim() : '';
3502
+ if (!isEmail(address))
3503
+ throw new Error('requestEmailChange: not an email address');
3504
+ const userManager = this.engine.getManager('UserManager');
3505
+ const account = await userManager?.getUser(username);
3506
+ if (!account)
3507
+ throw new Error(`requestEmailChange: no account ${username}`);
3508
+ const current = typeof account.email === 'string' ? account.email.trim() : '';
3509
+ if (current.toLowerCase() === address.toLowerCase())
3510
+ return 'unchanged';
3511
+ const mail = this.mailForLinks();
3512
+ if (!mail || mail.isDeliveryFailing?.())
3513
+ return 'unavailable';
3514
+ const cm = this.engine.getManager('ConfigurationManager');
3515
+ const setting = (key, fallback) => {
3516
+ const value = Number(cm?.getProperty(key, fallback));
3517
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
3518
+ };
3519
+ const now = Date.now();
3520
+ const windowMs = setting(EMAIL_CHANGE_WINDOW_KEY, 60) * 60_000;
3521
+ const recent = (this.emailChangeSends.get(account.username) ?? []).filter((at) => now - at < windowMs);
3522
+ const last = recent.at(-1);
3523
+ if ((last !== undefined && now - last < setting(EMAIL_CHANGE_COOLDOWN_KEY, 60) * 1000) || recent.length >= setting(EMAIL_CHANGE_MAX_KEY, 5)) {
3524
+ this.emailChangeSends.set(account.username, recent);
3525
+ logger.info(`[email change] ${account.username} asked again too soon or too often; no link sent (#2073)`);
3526
+ return 'throttled';
3527
+ }
3528
+ for (const [t, entry] of this.emailChangeTokens) {
3529
+ if (entry.username === account.username || Date.now() > entry.expiresAt)
3530
+ this.emailChangeTokens.delete(t);
3531
+ }
3532
+ const ttlMinutes = Number(this.engine.getManager('ConfigurationManager')?.getProperty(EMAIL_CHANGE_TTL_KEY, 60)) || 60;
3533
+ const token = randomBytes(32).toString('base64url');
3534
+ this.emailChangeTokens.set(token, { username: account.username, address, expiresAt: Date.now() + ttlMinutes * 60_000 });
3535
+ const site = this.engine.getManager('ConfigurationManager')?.getApplicationName() ?? 'ngdpbase';
3536
+ const url = `${this.linkBase()}/account/email/confirm?t=${token}`;
3537
+ const htmlEscape = this.escapeHtml();
3538
+ try {
3539
+ await mail.send({
3540
+ to: address,
3541
+ subject: `Confirm your new email for ${site}`,
3542
+ text: [`You asked to use this address for your account ${account.username} on ${site}. Open this link to confirm it:`, '', url, '', `It works once, for ${ttlMinutes} minutes. Opening it does not sign you in. If you did not ask for this, ignore it and nothing changes.`].join('\n'),
3543
+ html: `<p>You asked to use this address for your account <strong>${htmlEscape(account.username)}</strong> on ${htmlEscape(site)}.</p><p><a href="${url}">Confirm this address</a></p><p>It works once, for ${ttlMinutes} minutes. Opening it does not sign you in. If you did not ask for this, ignore it and nothing changes.</p>`
3544
+ });
3545
+ }
3546
+ catch {
3547
+ // EmailManager has reported the failure; no link is waiting.
3548
+ this.emailChangeTokens.delete(token);
3549
+ return 'failed';
3550
+ }
3551
+ this.emailChangeSends.set(account.username, [...recent, now]);
3552
+ if (current) {
3553
+ await mail.send({
3554
+ to: current,
3555
+ subject: `Your email for ${site} is being changed`,
3556
+ text: `Someone signed in as ${account.username} on ${site} asked to change its email to another address. It changes only if that address confirms it. If this was not you, change your password and tell the site administrator.`,
3557
+ html: `<p>Someone signed in as <strong>${htmlEscape(account.username)}</strong> on ${htmlEscape(site)} asked to change its email to another address. It changes only if that address confirms it.</p><p>If this was not you, change your password and tell the site administrator.</p>`
3558
+ }).catch(() => undefined);
3559
+ }
3560
+ const who = actorOf(ctx);
3561
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
3562
+ eventType: AUDIT_EVENT.EMAILCHANGE_REQUEST, ...who, action: 'emailchange-request', result: 'success', severity: 'medium',
3563
+ resource: account.username, resourceType: 'user', metadata: { ...who.metadata, username: account.username, via }
3564
+ }, (err) => logger.warn('[email change] audit record failed for emailchange-request:', err));
3565
+ return 'sent';
3566
+ }
3567
+ /** The new address waiting to be confirmed for `username` (#2073), for the profile to show; null when none. */
3568
+ pendingEmailChange(username) {
3569
+ for (const entry of this.emailChangeTokens.values()) {
3570
+ if (sameUsername(entry.username, username) && Date.now() <= entry.expiresAt)
3571
+ return entry.address;
3572
+ }
3573
+ return null;
3574
+ }
3575
+ /** What a confirmation link would change (#2073), for its page; null for an unknown, used or expired link. */
3576
+ emailChangeOffer(token) {
3577
+ const entry = typeof token === 'string' ? this.emailChangeTokens.get(token) : undefined;
3578
+ if (!entry || Date.now() > entry.expiresAt)
3579
+ return null;
3580
+ return { username: entry.username, address: entry.address };
3581
+ }
3582
+ /**
3583
+ * Confirm a new email from its emailed link (#2073). Single use: the link is
3584
+ * spent first. The rules are checked again now: the account still exists,
3585
+ * is on, and is not a shared account whose email only an administrator
3586
+ * changes; and UserManager's own (an administrator's email is never
3587
+ * shared). Saved through UserManager under the system context naming the
3588
+ * account (the link is the authority), which keeps the identity record in
3589
+ * step. Nobody is signed in. Recorded as `emailchange-confirm`. A user who
3590
+ * had no email yet (just registered) is then offered their contact record,
3591
+ * as at sign-up (#1930).
3592
+ */
3593
+ async confirmEmailChange(token) {
3594
+ const entry = typeof token === 'string' ? this.emailChangeTokens.get(token) : undefined;
3595
+ if (!entry || !this.emailChangeTokens.delete(token) || Date.now() > entry.expiresAt)
3596
+ return { outcome: 'expired' };
3597
+ const userManager = this.engine.getManager('UserManager');
3598
+ const account = await userManager?.getUser(entry.username);
3599
+ if (!userManager || !account || account.isActive === false || account.profileLocked) {
3600
+ return { outcome: 'refused', message: 'This account cannot change its email now. An administrator can change it.' };
3601
+ }
3602
+ const ctx = systemContext(this.engine, `confirm the new email of account ${entry.username} by the link sent to it (#2073)`);
3603
+ try {
3604
+ await userManager.updateUser(entry.username, { email: entry.address }, ctx);
3605
+ }
3606
+ catch (err) {
3607
+ if (err instanceof UserCreateError)
3608
+ return { outcome: 'refused', message: safeRegistrationMessage(err) };
3609
+ throw err;
3610
+ }
3611
+ const who = actorOf(ctx);
3612
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
3613
+ eventType: AUDIT_EVENT.EMAILCHANGE_CONFIRM, ...who, action: 'emailchange-confirm', result: 'success', severity: 'medium',
3614
+ resource: entry.username, resourceType: 'user', metadata: { ...who.metadata, username: entry.username }
3615
+ }, (err) => logger.warn('[email change] audit record failed for emailchange-confirm:', err));
3616
+ if (!(typeof account.email === 'string' && account.email.trim())) {
3617
+ await this.engine.getManager('IdentityManager')
3618
+ ?.offerSignUpLink(ctx, entry.username).catch(() => undefined);
3619
+ }
3620
+ return { outcome: 'changed', username: entry.username };
3621
+ }
3622
+ /**
3623
+ * Initiate a challenge-based auth flow (magic link email, OAuth redirect).
3624
+ */
3625
+ async initiate(providerId, context) {
3626
+ const provider = this.providers.get(providerId);
3627
+ if (!provider?.initiate) {
3628
+ logger.debug(`[AuthManager] Provider ${providerId} has no initiate()`);
3629
+ return;
3630
+ }
3631
+ await provider.initiate(context);
3632
+ }
3633
+ /**
3634
+ * Consume a single-use token, returning whether this caller consumed it.
3635
+ *
3636
+ * #1021: the return value is the single-use gate — a caller must not create
3637
+ * a session when it is false, because that means another request consumed
3638
+ * the token first. A provider with no `consumeToken` has no single-use
3639
+ * semantics to enforce, so it reports true rather than blocking sign-in.
3640
+ */
3641
+ consumeToken(providerId, token) {
3642
+ const provider = this.providers.get(providerId);
3643
+ if (!provider)
3644
+ return false;
3645
+ if (!provider.consumeToken)
3646
+ return true;
3647
+ return provider.consumeToken(token);
3648
+ }
3649
+ /**
3650
+ * Register an authentication provider (#1050).
3651
+ *
3652
+ * The built-ins call this during `initialize()`, so the path an addon uses is
3653
+ * the path exercised on every boot rather than a second, less-travelled one.
3654
+ * Config gating stays with the caller: this method decides nothing about
3655
+ * whether a provider *should* be active, only that it is.
3656
+ *
3657
+ * ## Duplicate ids: first registration wins
3658
+ *
3659
+ * Deliberate, and the reason is security rather than tidiness. Last-wins
3660
+ * would let an addon replace the built-in `password` provider with its own
3661
+ * `verify()` — an auth bypass contributed by a config change. Throwing would
3662
+ * be safe but lets one bad addon take the whole instance down at boot, and a
3663
+ * refused provider is a smaller failure than a site that will not start. So:
3664
+ * keep the incumbent, warn loudly, carry on.
3665
+ *
3666
+ * ## Late registration is allowed
3667
+ *
3668
+ * AuthManager initializes before AddonsManager (WikiEngine), so an addon
3669
+ * registering during its own startup is registering late by definition. Such
3670
+ * a provider is absent from `getFactors()` until it registers and cannot serve a request
3671
+ * that arrives first. Both are acceptable; neither is silent.
3672
+ *
3673
+ * ## There is no unregisterProvider()
3674
+ *
3675
+ * Not an oversight. Withdrawing a provider would not invalidate the sessions
3676
+ * already established through it, so "disabled" would mean "no new logins"
3677
+ * while existing ones continued — which reads as revocation without being it.
3678
+ * Honest session revocation is a larger feature than this seam, and pretending
3679
+ * otherwise here would be worse than the gap.
3680
+ *
3681
+ * @param provider the provider to register
3682
+ * @param source attribution for logs — an addon name, or 'built-in'
3683
+ * @returns true if registered; false if rejected as malformed or duplicate
3684
+ */
3685
+ registerProvider(provider, source = 'built-in') {
3686
+ // An addon can pass anything. A provider with no id would be unreachable;
3687
+ // one with no verify() would throw inside authenticate() on a live request,
3688
+ // which is a worse place to find out than boot.
3689
+ if (!provider || typeof provider.id !== 'string' || provider.id.trim() === '') {
3690
+ logger.error(`[AuthManager] Rejected provider from ${source}: missing id`);
3691
+ return false;
3692
+ }
3693
+ if (typeof provider.verify !== 'function') {
3694
+ logger.error(`[AuthManager] Rejected provider '${provider.id}' from ${source}: no verify()`);
3695
+ return false;
3696
+ }
3697
+ if (this.providers.has(provider.id)) {
3698
+ logger.warn(`[AuthManager] Provider '${provider.id}' is already registered — ` +
3699
+ `keeping the existing one, ignoring the registration from ${source}`);
3700
+ return false;
3701
+ }
3702
+ this.providers.set(provider.id, provider);
3703
+ this.factorCache = null;
3704
+ // info, not debug: which auth providers are live is a security-relevant
3705
+ // fact about a running instance, and the boot log is where an operator
3706
+ // checks it. Callers add a second line only when they carry config detail
3707
+ // worth seeing (transport, issuer, team) — never a bare repeat of the id.
3708
+ logger.info(`[AuthManager] Registered provider: ${provider.id} (${source})`);
3709
+ return true;
3710
+ }
3711
+ /**
3712
+ * Begin a redirect-based flow; returns the URL to send the browser to (#1049).
3713
+ *
3714
+ * Throws when the provider is absent or cannot start a flow, deliberately
3715
+ * and unlike {@link getFlowRedirect} below. There is no sensible fallback for
3716
+ * "where should the browser go" — returning `'/'` would bounce the user back
3717
+ * to the page they just left with no error to explain it, which reads as the
3718
+ * button being broken. Callers guard with `isEnabled()` first.
3719
+ */
3720
+ async startFlow(providerId, context = {}) {
3721
+ const provider = this.providers.get(providerId);
3722
+ if (!provider?.startFlow) {
3723
+ throw new Error(`Auth provider '${providerId}' cannot start a redirect flow`);
3724
+ }
3725
+ // #1743: a sign-in flow never connects; connecting is startConnect's.
3726
+ const signIn = { ...context };
3727
+ delete signIn.connectFor;
3728
+ return provider.startFlow(signIn);
3729
+ }
3730
+ /**
3731
+ * Where the user was headed before the flow began, keyed by the flow's own
3732
+ * handle — a magic-link token, an OAuth state nonce (#1049).
3733
+ *
3734
+ * Must be called BEFORE `consumeToken()`, which deletes the entry.
3735
+ *
3736
+ * Falls back to `'/'` rather than throwing, deliberately and unlike
3737
+ * {@link startFlow} above: losing the destination costs the user a redirect
3738
+ * to the front page, while failing the sign-in over it would cost them the
3739
+ * login itself.
3740
+ */
3741
+ getFlowRedirect(providerId, handle) {
3742
+ const provider = this.providers.get(providerId);
3743
+ return provider?.getFlowRedirect?.(handle) ?? '/';
3744
+ }
3745
+ /**
3746
+ * The device-state recorded when this flow was initiated (#1022), or null.
3747
+ *
3748
+ * Must be called BEFORE `consumeToken()`, which deletes the entry.
3749
+ *
3750
+ * Null means "nothing to compare against" — a provider that does not bind,
3751
+ * or a handle issued before binding existed. It never means "did not match":
3752
+ * that distinction is made in `evaluateDeviceBinding`, and collapsing the two
3753
+ * here would make an unbindable flow look like an attack.
3754
+ */
3755
+ getDeviceState(providerId, handle) {
3756
+ const provider = this.providers.get(providerId);
3757
+ return provider?.getDeviceState?.(handle) ?? null;
3758
+ }
3759
+ /**
3760
+ * The accounts an emailed link to a shared address offers (#2073), by
3761
+ * username, or null when the link names one account. Read without consuming.
3762
+ */
3763
+ getAccountChoices(providerId, handle) {
3764
+ return this.providers.get(providerId)?.accountChoices?.(handle) ?? null;
3765
+ }
3766
+ /**
3767
+ * Create the account behind a first-time credential (#1026, #1049).
3768
+ *
3769
+ * Must be called before `authenticate(providerId, …)` on the completing
3770
+ * request, because the account has to exist before a session names it.
3771
+ *
3772
+ * Three outcomes, and the caller must tell them apart:
3773
+ * - `true` — account created, or already existed
3774
+ * - `false` — the provider tried and could not; treat as a failed sign-in
3775
+ * - `undefined` — nothing to provision here, which is not a failure
3776
+ *
3777
+ * The previous `provisionMagicLinkUser` returned `false` for a missing
3778
+ * provider, conflating "no such capability" with "tried and failed". The end
3779
+ * state was the same only because `authenticate()` then failed anyway.
3780
+ */
3781
+ async provisionIfNew(providerId, handle) {
3782
+ const provider = this.providers.get(providerId);
3783
+ if (!provider?.provisionIfNew)
3784
+ return undefined;
3785
+ return provider.provisionIfNew(handle);
3786
+ }
3787
+ /** Returns true if the provider is registered. */
3788
+ isEnabled(providerId) {
3789
+ return this.providers.has(providerId);
3790
+ }
3791
+ /** Returns all registered providers (for admin UI). */
3792
+ getProviders() {
3793
+ return Array.from(this.providers.values());
3794
+ }
3795
+ /** `ngdpbase.auth.credentials.file`, resolved: the credentials store. */
3796
+ configuredCredentialsFile(configManager = this.engine.getManager('ConfigurationManager')) {
3797
+ return configManager?.getResolvedDataPath?.('ngdpbase.auth.credentials.file', './data/users/credentials.json') ?? null;
3798
+ }
3799
+ /**
3800
+ * The provider list. The credentials store (#1876) — passkeys, TOTP,
3801
+ * verified email and phone, devices, linked identities and proxy grants —
3802
+ * is a declared file (`backupFolders`), which BackupManager writes beside
3803
+ * the document as `data.credentials`.
3804
+ */
3805
+ async backup() {
3806
+ return { managerName: 'AuthManager', timestamp: new Date().toISOString(), data: { providers: Array.from(this.providers.keys()) } };
3807
+ }
3808
+ /**
3809
+ * The credentials store, as its own file beside the backup document,
3810
+ * __always encrypted under `NGDPBASE_BACKUP_KEY`__
3811
+ * (`<stem>.credentials.tar.enc`; #1876, declared since #1985). Without the
3812
+ * key it is left out and the backup is reported incomplete; it is never in
3813
+ * a backup unencrypted. A backup that left it out stages nothing: the store
3814
+ * on the site stays, and the restore's notification names it as not
3815
+ * restored. The rows stay signed with `NGDPBASE_CREDENTIALS_KEY`, which is
3816
+ * not in the backup: a site restored under another credentials key holds
3817
+ * them in quarantine (#1633).
3818
+ */
3819
+ backupFolders() {
3820
+ return [{
3821
+ part: 'credentials',
3822
+ file: this.credentialsFile ?? this.configuredCredentialsFile(),
3823
+ what: 'credentials store',
3824
+ requireKey: true,
3825
+ whenAbsent: JSON.stringify({ version: 1, rows: [] }),
3826
+ optional: true
3827
+ }];
3828
+ }
3829
+ }
3830
+ export default AuthManager;