@jwilleke/ngdpbase-types 5.41.1 → 5.42.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 (34) hide show
  1. package/lib/src/managers/AddonsManager.d.ts +8 -0
  2. package/lib/src/managers/AttachmentManager.d.ts +59 -0
  3. package/lib/src/managers/AuditManager.d.ts +477 -0
  4. package/lib/src/managers/AuthManager.d.ts +247 -15
  5. package/lib/src/managers/AuthManager.js +588 -45
  6. package/lib/src/managers/ConsentManager.d.ts +15 -0
  7. package/lib/src/managers/ImportManager.d.ts +15 -0
  8. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -0
  9. package/lib/src/providers/BaseAuditProvider.d.ts +492 -0
  10. package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
  11. package/lib/src/providers/BaseCredentialsProvider.d.ts +2 -2
  12. package/lib/src/providers/BaseCredentialsProvider.js +1 -1
  13. package/lib/src/providers/BasicAttachmentProvider.d.ts +9 -0
  14. package/lib/src/providers/FileCredentialsProvider.js +1 -1
  15. package/lib/src/providers/NullAuditProvider.d.ts +82 -0
  16. package/lib/src/providers/TotpAuthProvider.d.ts +55 -0
  17. package/lib/src/providers/TotpAuthProvider.js +145 -0
  18. package/lib/src/types/Consent.d.ts +17 -4
  19. package/lib/src/types/Consent.js +11 -1
  20. package/lib/src/types/Provider.d.ts +6 -0
  21. package/lib/src/types/express.d.ts +22 -0
  22. package/lib/src/utils/auditChain.d.ts +83 -0
  23. package/lib/src/utils/auditHeadWitness.d.ts +97 -0
  24. package/lib/src/utils/auditLifecycle.d.ts +71 -0
  25. package/lib/src/utils/browserDescription.d.ts +18 -0
  26. package/lib/src/utils/browserDescription.js +12 -0
  27. package/lib/src/utils/postureRecord.d.ts +73 -0
  28. package/lib/src/utils/privateStorePath.d.ts +6 -0
  29. package/lib/src/utils/privateStorePath.js +8 -0
  30. package/lib/src/utils/securityPosture.d.ts +52 -0
  31. package/lib/src/utils/tlsConfig.d.ts +59 -0
  32. package/lib/src/utils/totpSeedCipher.d.ts +4 -0
  33. package/lib/src/utils/totpSeedCipher.js +45 -0
  34. package/package.json +5 -2
@@ -12,7 +12,8 @@
12
12
  * multi-factor flow, the per-role check and the boot refusal follow under #1523.
13
13
  *
14
14
  * Built-in providers, each gated on its own config key and all registered
15
- * through the public {@link AuthManager.registerProvider} (#1050):
15
+ * through registerCoreProvider(), which shares the checks of the public
16
+ * {@link AuthManager.registerProvider} (#1050):
16
17
  * - PasswordAuthProvider (unless ngdpbase.auth.password.enabled is false)
17
18
  * - MagicLinkAuthProvider (ngdpbase.auth.magic-link.enabled)
18
19
  * - OidcAuthProvider, one per entry (ngdpbase.auth.oidc.providers; the old
@@ -21,15 +22,16 @@
21
22
  * - AuthentikBearerAuthProvider (ngdpbase.auth.authentik-bearer.enabled)
22
23
  * - AgentTokenAuthProvider (ngdpbase.auth.agent-token.enabled)
23
24
  *
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.
25
+ * Addons register through registerProvider(), so the contributed path is the one
26
+ * exercised on every boot rather than a second, less-travelled one. Core's ids
27
+ * and the `oidc:` prefix are reserved for core there (#2223).
26
28
  *
27
29
  * Future providers (see #421):
28
30
  * - TotpAuthProvider
29
31
  *
30
32
  * @see {@link https://github.com/jwilleke/ngdpbase/issues/396}
31
33
  */
32
- import { CONSENT_PATH } from '../types/Consent.js';
34
+ import { CONSENT_PATH, REMEMBER_BROWSER_CONSENT_SCOPE } from '../types/Consent.js';
33
35
  import BaseManager from './BaseManager.js';
34
36
  import { PasswordAuthProvider } from '../providers/PasswordAuthProvider.js';
35
37
  import { MagicLinkAuthProvider, EMAIL_FACTOR } from '../providers/MagicLinkAuthProvider.js';
@@ -43,11 +45,13 @@ import { OidcBearerAuthProvider } from '../providers/OidcBearerAuthProvider.js';
43
45
  import { AgentTokenAuthProvider } from '../providers/AgentTokenAuthProvider.js';
44
46
  import logger from '../utils/logger.js';
45
47
  import LocaleUtils from '../utils/LocaleUtils.js';
46
- import { describeBrowser } from '../utils/browserDescription.js';
48
+ import { browserIdentity, describeBrowser, sameBrowser } from '../utils/browserDescription.js';
47
49
  import { approximateLocation } from '../utils/ipLocation.js';
48
- import { createHash, createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
50
+ import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, verify as verifySignature } from 'node:crypto';
49
51
  import FileCredentialsProvider from '../providers/FileCredentialsProvider.js';
50
52
  import { PasskeyAuthProvider, relyingPartyFrom } from '../providers/PasskeyAuthProvider.js';
53
+ import { TotpAuthProvider } from '../providers/TotpAuthProvider.js';
54
+ import QRCode from 'qrcode';
51
55
  import { CREDENTIALS_KEY_ENV } from '../providers/BaseCredentialsProvider.js';
52
56
  import { recordAuditEvent } from '../utils/auditEvents.js';
53
57
  import { actorOf } from '../context/ActorContext.js';
@@ -87,12 +91,25 @@ const allowsProvider = (allowed, providerId) => !allowed || allowed.length === 0
87
91
  * provider. The second step runs it itself, so it needs no registered provider.
88
92
  */
89
93
  const EMAIL_LINK_ENTRY = 'magic-link';
94
+ /**
95
+ * Every provider id core registers on some site (#1523 Q1), whether or not
96
+ * this one has it on. An entry naming one is never a typo: a provider that is
97
+ * off here, such as passkeys without an https base-url, is treated as off.
98
+ * `oidc:<name>` ids are known from `ngdpbase.auth.oidc.providers` instead.
99
+ */
100
+ const CORE_PROVIDER_IDS = [
101
+ 'password', 'magic-link', 'passkey', 'totp', 'cloudflare-access', 'authentik-bearer', 'agent-token', 'oidc-bearer'
102
+ ];
103
+ /** Ids only core may register (#2223): every core id, and any `oidc:<name>`. */
104
+ function isReservedProviderId(id) {
105
+ return CORE_PROVIDER_IDS.includes(id) || id.startsWith('oidc:');
106
+ }
90
107
  /** A factor as offered: its entry lowered to what the provider's code gives. */
91
108
  /**
92
109
  * Providers that count toward a sign-in level only once the account has
93
110
  * enrolled one (#1690): the provider id, and the credential kind it needs.
94
111
  */
95
- const ENROLLED_FACTORS = { passkey: 'passkey' };
112
+ const ENROLLED_FACTORS = { passkey: 'passkey', totp: 'totp' };
96
113
  /**
97
114
  * The factor type each RFC 8176 `amr` value gives. Values not listed give no
98
115
  * type: `user` (a presence test), `mfa`, `mca`, `rba`, `geo`, `wia` describe a
@@ -153,6 +170,28 @@ export function credentialLabel(label) {
153
170
  throw new Error('Give it a name, so you can tell it apart from your others');
154
171
  return tidy;
155
172
  }
173
+ const KNOWN_DEVICE_DEFAULT_DAYS = 30;
174
+ /** What a remembered browser signs: this, then the challenge. Separates the signature from any other use of the key. */
175
+ export const KNOWN_DEVICE_SIGNED_PREFIX = 'ngdpbase known device v1\n';
176
+ /** The same factors, in any order (#1523). */
177
+ function sameAmr(a, b) {
178
+ if (!a || a.length !== b.length)
179
+ return false;
180
+ const want = new Set(b);
181
+ return a.every((v) => want.has(v));
182
+ }
183
+ function parseDeviceSecret(secret) {
184
+ try {
185
+ const d = JSON.parse(secret);
186
+ return typeof d.publicKey === 'string' && typeof d.browser === 'string' && typeof d.os === 'string' && typeof d.platform === 'string' && typeof d.expiresAt === 'string'
187
+ ? { publicKey: d.publicKey, browser: d.browser, os: d.os, platform: d.platform, amr: Array.isArray(d.amr) ? d.amr.map(String) : [], expiresAt: d.expiresAt, ...(typeof d.generation === 'number' ? { generation: d.generation } : {}),
188
+ ...(typeof d.auditId === 'string' ? { auditId: d.auditId } : {}), ...(typeof d.signInMethod === 'string' ? { signInMethod: d.signInMethod } : {}) }
189
+ : null;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
156
195
  /**
157
196
  * The approval sentence (#1532, decided wording plus the IP and place):
158
197
  * "A sign-in request for Willeke Family was sent at 2 Oct 2026, 14:02 EDT
@@ -421,6 +460,12 @@ class AuthManager extends BaseManager {
421
460
  factorCache;
422
461
  /** The credentials store (#1524); null when it could not be opened. */
423
462
  credentials = null;
463
+ /**
464
+ * #421: TOTP, built for enrolment only. Not registered as a sign-in
465
+ * provider until the second-factor step can ask for it (slice 2b): offering
466
+ * it earlier would raise admins to AAL2 before they could use it.
467
+ */
468
+ totp = null;
424
469
  /** `ngdpbase.auth.credentials.file`, resolved at start: what a backup copies (#1876). */
425
470
  credentialsFile = null;
426
471
  constructor(engine) {
@@ -434,7 +479,7 @@ class AuthManager extends BaseManager {
434
479
  const configManager = this.engine.getManager('ConfigurationManager');
435
480
  // Register password provider if enabled (default: true)
436
481
  if (configManager?.getProperty('ngdpbase.auth.password.enabled', true) !== false) {
437
- this.registerProvider(new PasswordAuthProvider(this.engine));
482
+ this.registerCoreProvider(new PasswordAuthProvider(this.engine));
438
483
  }
439
484
  // Register magic-link provider if enabled
440
485
  if (configManager?.getProperty('ngdpbase.auth.magic-link.enabled', false)) {
@@ -453,7 +498,7 @@ class AuthManager extends BaseManager {
453
498
  }
454
499
  else {
455
500
  const ttlMinutes = configManager.getProperty('ngdpbase.auth.magic-link.ttl-minutes', 15);
456
- this.registerProvider(new MagicLinkAuthProvider(this.engine, {
501
+ this.registerCoreProvider(new MagicLinkAuthProvider(this.engine, {
457
502
  ttlMs: ttlMinutes * 60_000,
458
503
  mailProvider: emailManager
459
504
  }));
@@ -475,7 +520,7 @@ class AuthManager extends BaseManager {
475
520
  defaultRole: configManager.getProperty('ngdpbase.auth.cloudflare-access.default-role', 'reader'),
476
521
  groupMap: configManager.getProperty('ngdpbase.auth.cloudflare-access.group-map', {})
477
522
  };
478
- this.registerProvider(new CloudflareAccessAuthProvider(this.engine, cfConfig));
523
+ this.registerCoreProvider(new CloudflareAccessAuthProvider(this.engine, cfConfig));
479
524
  logger.info(`[AuthManager] cloudflare-access team=${teamDomain}`);
480
525
  }
481
526
  }
@@ -499,7 +544,7 @@ class AuthManager extends BaseManager {
499
544
  defaultRole: configManager.getProperty('ngdpbase.auth.authentik-bearer.default-role', 'reader'),
500
545
  groupMap: configManager.getProperty('ngdpbase.auth.authentik-bearer.group-map', {})
501
546
  };
502
- this.registerProvider(new AuthentikBearerAuthProvider(this.engine, authentikConfig));
547
+ this.registerCoreProvider(new AuthentikBearerAuthProvider(this.engine, authentikConfig));
503
548
  logger.info(`[AuthManager] authentik-bearer issuer=${issuer}`);
504
549
  }
505
550
  }
@@ -508,19 +553,20 @@ class AuthManager extends BaseManager {
508
553
  // so it registers whenever enabled, with no further required config. Both
509
554
  // bearer providers may be active at once; the middleware tries each.
510
555
  if (configManager?.getProperty('ngdpbase.auth.agent-token.enabled', false)) {
511
- this.registerProvider(new AgentTokenAuthProvider(this.engine));
556
+ this.registerCoreProvider(new AgentTokenAuthProvider(this.engine));
512
557
  }
513
558
  // #1576: access tokens from this instance's own OpenID Connect provider,
514
559
  // for apps a person allowed. Verified by OidcManager when a request
515
560
  // arrives, so registering ahead of it is safe; with the provider off,
516
561
  // every token simply fails to verify.
517
562
  if (configManager?.getProperty('oidc-auth-server.enabled', false) === true) {
518
- this.registerProvider(new OidcBearerAuthProvider(this.engine));
563
+ this.registerCoreProvider(new OidcBearerAuthProvider(this.engine));
519
564
  }
520
565
  this.factorEntries = this.readFactorEntries(configManager);
521
566
  this.factorCache = null;
522
567
  await this.openCredentialsStore(configManager);
523
568
  this.registerPasskeys(configManager);
569
+ this.openTotp(configManager);
524
570
  this.registerOidcProviders(configManager);
525
571
  this.warnIfEmailSecondFactorsDropped();
526
572
  logger.info(`[AuthManager] Initialized — factors: [${this.factorEntries.map(e => e.authproviderid).join(', ')}]`);
@@ -666,7 +712,7 @@ class AuthManager extends BaseManager {
666
712
  continue;
667
713
  }
668
714
  const store = this.credentials;
669
- this.registerProvider(new OidcAuthProvider({
715
+ this.registerCoreProvider(new OidcAuthProvider({
670
716
  name, displayName: entry['display-name'] || preset?.displayName || name,
671
717
  icon: resolveOutsideProviderIcon(name, entry.icon, preset ? entry.preset : undefined, m => logger.warn(m)), issuer, clientId, clientSecret, redirectUri, policy: egress.policy,
672
718
  ...(preset?.responseMode ? { responseMode: preset.responseMode } : {}),
@@ -1145,12 +1191,78 @@ class AuthManager extends BaseManager {
1145
1191
  return;
1146
1192
  }
1147
1193
  const store = this.credentials;
1148
- this.registerProvider(new PasskeyAuthProvider(rp, {
1194
+ this.registerCoreProvider(new PasskeyAuthProvider(rp, {
1149
1195
  find: (credentialId) => store.findBySubject('passkey', credentialId),
1150
1196
  used: (id, at, secret) => store.touch(id, at, secret)
1151
1197
  }));
1152
1198
  logger.info(`[AuthManager] passkeys tied to ${rp.rpID}`);
1153
1199
  }
1200
+ /** TOTP enrolment (#421), available whenever the credentials store is open. */
1201
+ openTotp(configManager) {
1202
+ this.totp = null;
1203
+ this.providers.delete('totp');
1204
+ const store = this.credentials;
1205
+ if (!store)
1206
+ return;
1207
+ const name = configManager?.getProperty('ngdpbase.application-name', 'ngdpbase');
1208
+ const issuer = typeof name === 'string' && name ? name : 'ngdpbase';
1209
+ this.totp = new TotpAuthProvider(process.env[CREDENTIALS_KEY_ENV] ?? '', issuer, {
1210
+ rows: (username) => store.list(username).filter(r => r.kind === 'totp'),
1211
+ used: (id, at, secret) => store.touch(id, at, secret)
1212
+ });
1213
+ // #421 slice 2b: registered, so its factor entry counts and the second step can ask for its code.
1214
+ this.registerCoreProvider(this.totp);
1215
+ }
1216
+ /** Whether an authenticator app can be enrolled here (#421). */
1217
+ totpAvailable() {
1218
+ return this.totp !== null;
1219
+ }
1220
+ /**
1221
+ * A new seed for `username` to add to an authenticator app (#421): the
1222
+ * manual key, the `otpauth://` URI and that URI as an SVG QR code. Nothing
1223
+ * is stored. `pending` is the seed sealed for this account: the caller keeps
1224
+ * that, never the seed, (in the session) until a code confirms it. Refused
1225
+ * while the account already has one.
1226
+ */
1227
+ async totpEnrolmentStart(ctx, username) {
1228
+ const totp = await this.totpEnrolGate(ctx, username);
1229
+ if (this.requireCredentialsStore().list(username).some(r => r.kind === 'totp')) {
1230
+ throw new Error('This account already has an authenticator app; remove it before adding another');
1231
+ }
1232
+ const { seed, uri } = totp.newEnrolment(username);
1233
+ const qrSvg = await QRCode.toString(uri, { type: 'svg', errorCorrectionLevel: 'M', margin: 2 });
1234
+ return { seed, pending: totp.sealPending(username, seed), uri, qrSvg };
1235
+ }
1236
+ /**
1237
+ * Confirm an enrolment with a code from the app and store the seed,
1238
+ * encrypted (#421). Returns the credential id, or null when the code does
1239
+ * not match, or when `pending` is not a seed `totpEnrolmentStart` sealed
1240
+ * for this account. The confirming code is spent: it cannot also sign
1241
+ * anyone in.
1242
+ */
1243
+ async totpEnrolmentConfirm(ctx, username, pending, code, label) {
1244
+ const totp = await this.totpEnrolGate(ctx, username);
1245
+ const name = credentialLabel(label || 'Authenticator app');
1246
+ if (this.requireCredentialsStore().list(username).some(r => r.kind === 'totp')) {
1247
+ throw new Error('This account already has an authenticator app; remove it before adding another');
1248
+ }
1249
+ const seed = totp.openPending(username, pending);
1250
+ if (!seed)
1251
+ return null;
1252
+ const sealed = totp.confirmEnrolment(username, seed, code);
1253
+ if (!sealed)
1254
+ return null;
1255
+ return this.storeCredential(ctx, username, { kind: 'totp', subject: randomUUID(), secret: sealed, label: name });
1256
+ }
1257
+ async totpEnrolGate(ctx, username) {
1258
+ if (!(await this.mayManageCredentials(ctx, username)))
1259
+ throw new Error('Permission denied');
1260
+ if (!(await this.userMayUseProvider(username, 'totp')))
1261
+ throw new Error('This account cannot sign in with an authenticator app');
1262
+ if (!this.totp)
1263
+ throw new Error('Authenticator apps are not available on this site');
1264
+ return this.totp;
1265
+ }
1154
1266
  passkeyProvider() {
1155
1267
  const p = this.providers.get('passkey');
1156
1268
  return p instanceof PasskeyAuthProvider ? p : null;
@@ -1295,6 +1407,7 @@ class AuthManager extends BaseManager {
1295
1407
  async listCredentials(ctx, username) {
1296
1408
  if (!(await this.mayManageCredentials(ctx, username, { viewOnly: true })))
1297
1409
  throw new Error('Permission denied');
1410
+ await this.pruneRememberedDevices(username);
1298
1411
  // #1762: a proxy grant is not a sign-in method; it is listed and ended through the proxy-grant methods only.
1299
1412
  return this.requireCredentialsStore().list(username).filter(r => !NOT_SIGN_IN_METHODS.has(r.kind)).map(({ secret: _secret, ...view }) => view);
1300
1413
  }
@@ -1306,6 +1419,8 @@ class AuthManager extends BaseManager {
1306
1419
  throw new Error('A proxy grant is made with grantProxy (#1762)');
1307
1420
  if (input.kind === 'invitation')
1308
1421
  throw new Error('An invitation is sent with inviteUser (#2049)');
1422
+ if (input.kind === 'totp')
1423
+ throw new Error('An authenticator app is enrolled with totpEnrolmentConfirm, which encrypts its seed (#421)');
1309
1424
  return this.storeCredential(ctx, username, input);
1310
1425
  }
1311
1426
  /** Store and audit a credential the caller has already decided may be added; `how` says how it happened. */
@@ -1344,6 +1459,9 @@ class AuthManager extends BaseManager {
1344
1459
  const removed = await store.remove(id);
1345
1460
  if (removed)
1346
1461
  this.auditCredentialChange(ctx, username, 'credential-remove', row);
1462
+ // Q5: removing a remembered browser withdraws the agreement to remember it.
1463
+ if (removed && row.kind === 'device')
1464
+ await this.withdrawRememberConsent(ctx, username, row.subject, 'removed');
1347
1465
  return removed;
1348
1466
  }
1349
1467
  /**
@@ -2172,14 +2290,22 @@ class AuthManager extends BaseManager {
2172
2290
  const aal = fromAmr > fromAcr ? fromAmr : fromAcr;
2173
2291
  return hardwareVerified ? { amr, aal, acr: 'phrh' } : { amr, aal };
2174
2292
  }
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) {
2293
+ /**
2294
+ * The audit record of one sign-in method's check (#1758): written here, never
2295
+ * by a route. Returns the record's id when it was recorded (#1523).
2296
+ */
2297
+ async auditCheck(attempt, username, method, result, reason, more = {}) {
2177
2298
  try {
2178
2299
  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);
2300
+ const recorded = await audit?.logAuthentication?.({
2301
+ username: username ?? 'unknown', ipAddress: attempt.ipAddress, userAgent: attempt.userAgent, loginMethod: method,
2302
+ ...(more.reported ? { reported: more.reported } : {}), ...(more.knownDevice ? { knownDevice: true } : {}), ...(more.amr ? { amr: more.amr } : {})
2303
+ }, result, reason);
2304
+ return typeof recorded?.id === 'string' ? recorded.id : undefined;
2180
2305
  }
2181
2306
  catch (err) {
2182
2307
  logger.warn('[AuthManager] audit record failed for an authentication check:', err);
2308
+ return undefined;
2183
2309
  }
2184
2310
  }
2185
2311
  /** Each role's `required-aal`, and whether the operator set it, read through the catalogue's owner (#1523, #448). */
@@ -2287,6 +2413,48 @@ class AuthManager extends BaseManager {
2287
2413
  .filter(({ aal }) => aal > reachable)
2288
2414
  .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
2415
  }
2416
+ /**
2417
+ * The start-up check of the `ngdpbase.auth.factors` ids, once add-ons have
2418
+ * registered their providers (#1523 Q1, operator 2026-10-31). An id no
2419
+ * provider could ever register, such as a typo, refuses the boot when its
2420
+ * entry is on, and only warns when it is `enabled: false`. Returns the
2421
+ * problems that must refuse the boot.
2422
+ */
2423
+ checkFactorIds() {
2424
+ const { refused, ignored } = this.unknownFactorIds(this.factorEntries);
2425
+ for (const problem of ignored)
2426
+ logger.warn(`[AuthManager] ${problem}. The entry is off, so it is ignored. (#1523)`);
2427
+ return refused;
2428
+ }
2429
+ /**
2430
+ * The same check for a value the configuration screen is about to save as
2431
+ * `ngdpbase.auth.factors` (#1523), so a typo is refused there rather than
2432
+ * at the next restart, which would refuse to start. `refused` names each
2433
+ * enabled unknown id; `ignored` each one whose entry is `enabled: false`.
2434
+ */
2435
+ checkFactorEntries(value) {
2436
+ const entries = (Array.isArray(value) ? value : [])
2437
+ .filter((e) => typeof e === 'object' && e !== null && typeof e.authproviderid === 'string')
2438
+ .map(e => ({ ...e, authproviderid: FACTOR_ALIASES[e.authproviderid] ?? e.authproviderid }));
2439
+ return this.unknownFactorIds(entries);
2440
+ }
2441
+ /** The entries whose id no provider could ever register, split by whether the entry is on. */
2442
+ unknownFactorIds(entries) {
2443
+ const raw = this.engine.getManager('ConfigurationManager')?.getUnresolvedProperty?.('ngdpbase.auth.oidc.providers', {});
2444
+ const oidcNames = raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw) : [];
2445
+ // `oidc:google` is also what the old ngdpbase.auth.google-oidc.* keys register.
2446
+ const known = new Set([...CORE_PROVIDER_IDS, 'oidc:google', ...oidcNames.map(n => `oidc:${n}`), ...this.providers.keys()]);
2447
+ const knownIds = [...known].sort().join(', ');
2448
+ const refused = [];
2449
+ const ignored = [];
2450
+ for (const entry of entries) {
2451
+ if (known.has(entry.authproviderid))
2452
+ continue;
2453
+ const problem = `ngdpbase.auth.factors entry '${entry.authproviderid}' names no provider. Known ids: ${knownIds}`;
2454
+ (entry.enabled === false ? ignored : refused).push(problem);
2455
+ }
2456
+ return { refused, ignored };
2457
+ }
2290
2458
  /**
2291
2459
  * The start-up check, once add-ons have registered their providers (#1523,
2292
2460
  * #448). Returns the problems that must refuse the boot: a level the operator
@@ -2564,7 +2732,7 @@ class AuthManager extends BaseManager {
2564
2732
  ?? (how.unlockWith && this.vaultUnlocker ? await this.vaultUnlocker(username, how.unlockWith.password) : undefined);
2565
2733
  if (handle)
2566
2734
  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);
2735
+ const auditId = await this.auditCheck({ ipAddress: req.ip, userAgent: req.get?.('user-agent'), purpose: 'sign-in' }, username, how.method, 'success', `signed in: ${how.method}`, { reported: result.reported, knownDevice: result.knownDevice, ...(record ? { amr: record.amr } : {}) });
2568
2736
  try {
2569
2737
  await this.engine.getManager('UserManager')?.recordSignIn?.(username);
2570
2738
  }
@@ -2587,9 +2755,9 @@ class AuthManager extends BaseManager {
2587
2755
  // (consentPending, the consent gate) until they accept or sign out.
2588
2756
  if (await this.termsPendingFor(username)) {
2589
2757
  req.session.consentPending = true;
2590
- return { next: `${CONSENT_PATH}?returnTo=${encodeURIComponent(next ?? safeRedirect(how.returnTo))}` };
2758
+ return { next: `${CONSENT_PATH}?returnTo=${encodeURIComponent(next ?? safeRedirect(how.returnTo))}`, ...(auditId ? { auditId } : {}) };
2591
2759
  }
2592
- return next ? { next } : {};
2760
+ return { ...(next ? { next } : {}), ...(auditId ? { auditId } : {}) };
2593
2761
  }
2594
2762
  /**
2595
2763
  * Whether `username` has core documents to accept (#1603), asked of
@@ -2691,7 +2859,7 @@ class AuthManager extends BaseManager {
2691
2859
  if (!result.success || !result.provider || result.viaToken)
2692
2860
  return null;
2693
2861
  const factors = result.factors ?? [];
2694
- return { provider: result.provider, factors, ...this.assess(factors), at: new Date().toISOString() };
2862
+ return { provider: result.provider, factors, ...this.assess(factors), ...(result.knownDevice ? { knownDevice: true } : {}), at: new Date().toISOString() };
2695
2863
  }
2696
2864
  /**
2697
2865
  * Step-up (#1525): which permissions ask for a fresh factor, and how fresh.
@@ -2720,9 +2888,9 @@ class AuthManager extends BaseManager {
2720
2888
  warnedLegacyStepUp = false;
2721
2889
  /**
2722
2890
  * 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
2891
+ * Fresh: the factors satisfied within the step-up window together reach
2892
+ * the level the person's roles require (at least 1 — a known device, aal 0,
2893
+ * never counts); a password and an authenticator code reach AAL2 (#421). A delegated credential has no sign-in of its own, so it
2726
2894
  * never satisfies step-up.
2727
2895
  */
2728
2896
  stepUpNeeded(permission, signIn, roles, delegated, now = Date.now(), cap) {
@@ -2746,7 +2914,11 @@ class AuthManager extends BaseManager {
2746
2914
  return false;
2747
2915
  const need = Math.max(this.requiredAalFor(roles, cap), 1);
2748
2916
  const since = now - maxAgeMs;
2749
- return (signIn?.factors ?? []).some((f) => f.aal >= need && Date.parse(f.at) >= since);
2917
+ // #421 slice 2c: the factors satisfied within the window, together — a
2918
+ // password and an authenticator code reach AAL2 as at sign-in. A known
2919
+ // device (aal 0) never counts.
2920
+ const fresh = (signIn?.factors ?? []).filter((f) => f.aal >= 1 && Date.parse(f.at) >= since);
2921
+ return fresh.length > 0 && this.assess(fresh).aal >= need;
2750
2922
  }
2751
2923
  /**
2752
2924
  * A session's sign-in after a re-authentication (#1525): the new factors
@@ -3090,9 +3262,9 @@ class AuthManager extends BaseManager {
3090
3262
  /**
3091
3263
  * The second factors `username` has enrolled and can use now (#1523), in
3092
3264
  * `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.
3265
+ * by `preference`. Only what the second step can run is offered — the
3266
+ * email link and an authenticator app (#421) — and one that is not truly
3267
+ * available (no mail transport) is not. Empty means the sign-in is one step, as before.
3096
3268
  */
3097
3269
  secondFactorsFor(username, signIn) {
3098
3270
  const rows = this.credentials?.list(username) ?? [];
@@ -3103,6 +3275,9 @@ class AuthManager extends BaseManager {
3103
3275
  out.push({ id: 'email-link', label: 'Email link', target: maskEmail(r.subject) });
3104
3276
  }
3105
3277
  }
3278
+ if (id === 'totp' && this.totp && rows.some((c) => c.kind === 'totp')) {
3279
+ out.push({ id: 'totp', label: 'Authenticator app', target: '' });
3280
+ }
3106
3281
  }
3107
3282
  return out;
3108
3283
  }
@@ -3152,7 +3327,13 @@ class AuthManager extends BaseManager {
3152
3327
  first = { ...first, username };
3153
3328
  // #1523 Q2: what this account's roles require, so the factors that reach it are offered first.
3154
3329
  const pip = this.engine.getManager('PolicyInformationPoint');
3155
- const requiredAal = this.requiredAalFor((await pip?.subjectFor(username))?.roles ?? [], await this.aalCapFor(username));
3330
+ const roles = (await pip?.subjectFor(username))?.roles ?? [];
3331
+ const requiredAal = this.requiredAalFor(roles, await this.aalCapFor(username));
3332
+ // #1523 slice 1c: the roles' declared levels, not lowered to what the site
3333
+ // or the account can reach (#1690), so an admin who cannot reach AAL2 yet
3334
+ // is still never let past the prompt by a remembered browser.
3335
+ const declared = new Map(this.requiredLevels().map(({ role, aal }) => [role, aal]));
3336
+ const rolesAal = roles.reduce((max, r) => Math.max(max, declared.get(r) ?? 0), 0);
3156
3337
  this.sweepPending();
3157
3338
  const handle = randomBytes(24).toString('base64url');
3158
3339
  const now = Date.now();
@@ -3168,7 +3349,8 @@ class AuthManager extends BaseManager {
3168
3349
  requestUserAgent: ctx.userAgent,
3169
3350
  requestLocation: ctx.location ?? null,
3170
3351
  privateStoreHandle: ctx.privateStoreHandle,
3171
- requiredAal
3352
+ requiredAal,
3353
+ rolesAal
3172
3354
  });
3173
3355
  // #1532: an expiry is recorded when it happens, not when someone next looks.
3174
3356
  setTimeout(() => this.expirePending(handle), PENDING_SIGN_IN_TTL_MS + 1).unref?.();
@@ -3361,6 +3543,62 @@ class AuthManager extends BaseManager {
3361
3543
  await this.recordApproval(AUDIT_EVENT.SIGNINAPPROVAL_APPROVE, p, 'success', presser);
3362
3544
  return p.username;
3363
3545
  }
3546
+ /**
3547
+ * The authenticator app's code for the waiting sign-in (#421 slice 2b). Its
3548
+ * check goes through `authenticate`, which records a wrong code as a failed
3549
+ * sign-in; a right one approves the pending sign-in, which `completeTwoStep`
3550
+ * then finishes with both factors. `unavailable` when the sign-in is gone,
3551
+ * already decided, or the account has no authenticator app offered here.
3552
+ * The caller throttles wrong codes as it does wrong passwords.
3553
+ */
3554
+ async approveByTotp(handle, binding, code, from = {}) {
3555
+ const p = this.pendingSignIn(handle, binding);
3556
+ if (!p || p.approved || p.denied)
3557
+ return 'unavailable';
3558
+ if (!this.secondFactorsFor(p.username, p).some((f) => f.id === 'totp'))
3559
+ return 'unavailable';
3560
+ const result = await this.authenticate('totp', { username: p.username, code }, { ipAddress: from.ip, userAgent: from.userAgent, purpose: 'sign-in' });
3561
+ const factor = result.factors?.[0];
3562
+ if (!result.success || result.username !== p.username || !factor)
3563
+ return 'wrong';
3564
+ // The code may have taken a moment; the sign-in may have been cancelled or expired meanwhile.
3565
+ if (!this.pendingSignIn(handle, binding) || p.approved || p.denied)
3566
+ return 'unavailable';
3567
+ p.approved = factor;
3568
+ return 'approved';
3569
+ }
3570
+ /**
3571
+ * Whether step-up offers `username` the authenticator app's code (#421
3572
+ * slice 2c): as at the second step, an app enrolled and its
3573
+ * `ngdpbase.auth.factors` entry enabled.
3574
+ */
3575
+ stepUpTotpOffered(username) {
3576
+ return this.secondFactorsFor(username).some((f) => f.id === 'totp');
3577
+ }
3578
+ /**
3579
+ * Step-up with the authenticator app (#421 slice 2c). The code is checked
3580
+ * through `authenticate`, as at the second step of sign-in, so a wrong one
3581
+ * is recorded as a failed re-check. With `password` (a role that needs
3582
+ * AAL2), the password is checked first, and the result carries both
3583
+ * factors, which together reach AAL2. `unavailable` when the app is not
3584
+ * offered here. The caller throttles wrong answers as it does wrong passwords.
3585
+ */
3586
+ async reauthenticateByTotp(username, code, password, attempt = {}) {
3587
+ if (!this.stepUpTotpOffered(username))
3588
+ return 'unavailable';
3589
+ const recheck = { ...attempt, purpose: 're-check' };
3590
+ const factors = [];
3591
+ if (password !== null) {
3592
+ const first = password ? await this.authenticate('password', { username, password }, recheck) : { success: false };
3593
+ if (!first.success || first.username !== username)
3594
+ return { success: false };
3595
+ factors.push(...(first.factors ?? []));
3596
+ }
3597
+ const otp = await this.authenticate('totp', { username, code }, recheck);
3598
+ if (!otp.success || otp.username !== username)
3599
+ return { success: false };
3600
+ return { ...otp, factors: [...factors, ...(otp.factors ?? [])] };
3601
+ }
3364
3602
  /** Refuse the waiting sign-in from its link (Deny). Returns the username, or null. */
3365
3603
  async denyByToken(token, presser) {
3366
3604
  const entry = this.approvalTokens.get(token);
@@ -3382,16 +3620,290 @@ class AuthManager extends BaseManager {
3382
3620
  */
3383
3621
  completeTwoStep(handle, binding) {
3384
3622
  const p = this.pendingSignIn(handle, binding);
3385
- if (!p?.approved || p.denied)
3623
+ if (!p || p.denied)
3624
+ return null;
3625
+ // #1523 slice 1c: a remembered browser completes it with only the factors
3626
+ // entered, and only while the person's roles still allow the skip.
3627
+ const known = !p.approved && Boolean(p.knownDevice) && this.knownDeviceMaySkip(p);
3628
+ if (!p.approved && !known)
3386
3629
  return null;
3387
3630
  this.pendingSignIns.delete(handle);
3388
3631
  for (const [token, entry] of this.approvalTokens)
3389
3632
  if (entry.handle === handle)
3390
3633
  this.approvalTokens.delete(token);
3634
+ if (known)
3635
+ return { result: { ...p.first, knownDevice: true }, privateStoreHandle: p.privateStoreHandle };
3391
3636
  return {
3392
3637
  result: { ...p.first, factors: [...(p.first.factors ?? []), p.approved] },
3393
- privateStoreHandle: p.privateStoreHandle
3638
+ privateStoreHandle: p.privateStoreHandle,
3639
+ // Offered only where the skip could ever apply; see rememberOffered.
3640
+ ...(p.remember && this.rememberOffered(p) ? { remember: p.remember } : {})
3641
+ };
3642
+ }
3643
+ // ── Known devices (#1523 slice 1c) ──────────────────────────────────────
3644
+ //
3645
+ // A remembered browser holds a non-extractable ECDSA P-256 key (WebCrypto)
3646
+ // in IndexedDB; the site keeps the public half as a `device` row. At the
3647
+ // second step it signs a one-time challenge. That skips the prompt, never
3648
+ // raises the level (Q4: no carry-forward), and only for a person whose roles
3649
+ // the factors entered already satisfy (operator, 2026-11-01).
3650
+ /** How many days a remembered browser stays remembered: `ngdpbase.auth.known-device.remember-days`; 0 turns remembering off. */
3651
+ rememberDays() {
3652
+ const cm = this.engine.getManager('ConfigurationManager');
3653
+ const days = Number(cm?.getProperty('ngdpbase.auth.known-device.remember-days', KNOWN_DEVICE_DEFAULT_DAYS));
3654
+ return Number.isFinite(days) && days > 0 ? Math.min(days, 400) : 0;
3655
+ }
3656
+ /**
3657
+ * Whether a remembered browser may skip this sign-in's second step: the
3658
+ * factors entered already reach the highest `required-aal` among the
3659
+ * person's roles (operator, 2026-11-01). A person whose roles need AAL2 is
3660
+ * always asked.
3661
+ */
3662
+ knownDeviceMaySkip(p) {
3663
+ if (this.rememberDays() === 0 || p.rolesAal === undefined)
3664
+ return false;
3665
+ const record = this.signInRecord(p.first);
3666
+ return Boolean(record) && record.aal >= Math.max(p.rolesAal, 1);
3667
+ }
3668
+ /** Whether "Remember this browser" is offered at this second step: only where it could ever skip one. */
3669
+ rememberOffered(p) {
3670
+ return this.knownDeviceMaySkip(p);
3671
+ }
3672
+ /** "Remember this browser" was ticked at the second step, having been shown `version` of its page. */
3673
+ noteRemember(handle, binding, version) {
3674
+ const p = this.pendingSignIn(handle, binding);
3675
+ if (p && this.rememberOffered(p) && /^[\w.-]{1,40}$/.test(version))
3676
+ p.remember = { version };
3677
+ }
3678
+ /**
3679
+ * When a remembered browser stops skipping the prompt: its stored expiry,
3680
+ * or its creation plus the current `remember-days`, whichever is sooner,
3681
+ * so lowering the setting shortens browsers already remembered.
3682
+ */
3683
+ deviceExpiry(row, device) {
3684
+ const created = Date.parse(row.createdAt);
3685
+ const now = Number.isFinite(created) ? created + this.rememberDays() * 86_400_000 : 0;
3686
+ return Math.min(Date.parse(device.expiresAt), now);
3687
+ }
3688
+ /**
3689
+ * `username`'s remembered browsers that have not expired. With the
3690
+ * account's password-change `generation`, also only those remembered
3691
+ * since the last password change (#1482).
3692
+ */
3693
+ rememberedDevices(username, now = Date.now(), generation) {
3694
+ const out = [];
3695
+ for (const row of this.credentials?.list(username) ?? []) {
3696
+ if (row.kind !== 'device')
3697
+ continue;
3698
+ const device = parseDeviceSecret(row.secret);
3699
+ if (!device || this.deviceExpiry(row, device) <= now)
3700
+ continue;
3701
+ if (generation !== undefined && (device.generation ?? 0) !== generation)
3702
+ continue;
3703
+ out.push({ row, device });
3704
+ }
3705
+ return out;
3706
+ }
3707
+ async accountGeneration(username) {
3708
+ const account = await this.engine.getManager('UserManager')?.getUser(username);
3709
+ return sessionGenerationOf(account);
3710
+ }
3711
+ /**
3712
+ * Drop `username`'s remembered browsers that no longer count: expired,
3713
+ * unreadable, or remembered before the last password change. Each is
3714
+ * recorded as `credential-remove` and its consent withdrawn. Returns the
3715
+ * ones that still count.
3716
+ */
3717
+ async pruneRememberedDevices(username, now = Date.now()) {
3718
+ if (!this.credentials)
3719
+ return [];
3720
+ const generation = await this.accountGeneration(username);
3721
+ const live = this.rememberedDevices(username, now, generation);
3722
+ const dead = this.credentials.list(username).filter((r) => r.kind === 'device' && !live.some((d) => d.row.id === r.id));
3723
+ for (const row of dead) {
3724
+ const device = parseDeviceSecret(row.secret);
3725
+ const why = device && this.deviceExpiry(row, device) > now ? 'password-changed' : 'expired';
3726
+ const ctx = systemContext(this.engine, `remembered browser ${row.id} of ${username}: ${why}`);
3727
+ if (!(await this.credentials.remove(row.id)))
3728
+ continue;
3729
+ this.auditCredentialChange(ctx, username, 'credential-remove', row, why);
3730
+ await this.withdrawRememberConsent(ctx, username, row.subject, why);
3731
+ }
3732
+ return live;
3733
+ }
3734
+ /** Withdraw the "remember this browser" consent bound to `deviceId`; a failure is logged, the browser stays removed. */
3735
+ async withdrawRememberConsent(ctx, username, deviceId, why) {
3736
+ const consent = this.engine.getManager('ConsentManager');
3737
+ try {
3738
+ await consent?.withdraw(username, REMEMBER_BROWSER_CONSENT_SCOPE, deviceId, ctx, why);
3739
+ }
3740
+ catch (err) {
3741
+ logger.error(`[AuthManager] remembered browser of ${username} removed (${why}), but its consent could not be withdrawn:`, err);
3742
+ }
3743
+ }
3744
+ /**
3745
+ * A one-time challenge for a remembered browser to sign, or null when the
3746
+ * skip cannot apply (the roles need more, or no browser is remembered).
3747
+ * A new challenge replaces the last.
3748
+ */
3749
+ knownDeviceChallenge(handle, binding) {
3750
+ const p = this.pendingSignIn(handle, binding);
3751
+ if (!p || p.approved || p.denied || !this.knownDeviceMaySkip(p))
3752
+ return null;
3753
+ if (this.rememberedDevices(p.username).length === 0)
3754
+ return null;
3755
+ p.deviceChallenge = randomBytes(32).toString('base64url');
3756
+ return p.deviceChallenge;
3757
+ }
3758
+ /**
3759
+ * A remembered browser's answer to the challenge (#1523 slice 1c). It
3760
+ * counts only when the device is this person's, has not expired, the
3761
+ * signature over the challenge verifies with its public key, and the
3762
+ * browser and system still match what it was issued to, and the audit
3763
+ * record its row names says the same as the row (#1523, operator
3764
+ * 2026-10-31). Any failure is recorded and the prompt stays. A row whose
3765
+ * audit record is missing or disagrees is also a security event; a row
3766
+ * remembered before rows named their record is removed, so the browser is
3767
+ * asked once and may be remembered again. The challenge is spent either way.
3768
+ */
3769
+ async proveKnownDevice(handle, binding, deviceId, signature, from) {
3770
+ const p = this.pendingSignIn(handle, binding);
3771
+ const challenge = p?.deviceChallenge;
3772
+ if (p)
3773
+ delete p.deviceChallenge;
3774
+ if (!p || !challenge || p.approved || p.denied || !this.knownDeviceMaySkip(p))
3775
+ return false;
3776
+ const fail = async (reason) => {
3777
+ await this.auditCheck({ ipAddress: from.ip, userAgent: from.userAgent, purpose: 'sign-in' }, p.username, 'known device', 'failure', `remembered browser not accepted: ${reason}`);
3778
+ return false;
3779
+ };
3780
+ const found = (await this.pruneRememberedDevices(p.username)).find((d) => d.row.subject === deviceId);
3781
+ if (!found)
3782
+ return fail('no remembered browser of this account with that id, or it expired');
3783
+ const now = browserIdentity(from.userAgent, from.platformHint);
3784
+ if (!now || !sameBrowser(now, found.device))
3785
+ return fail('a different browser or system from the one it was issued to');
3786
+ let ok;
3787
+ try {
3788
+ const key = createPublicKey({ key: Buffer.from(found.device.publicKey, 'base64url'), format: 'der', type: 'spki' });
3789
+ ok = verifySignature('sha256', Buffer.from(KNOWN_DEVICE_SIGNED_PREFIX + challenge, 'utf8'), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url'));
3790
+ }
3791
+ catch {
3792
+ ok = false;
3793
+ }
3794
+ if (!ok)
3795
+ return fail('the signature did not verify');
3796
+ // After the signature, so only the browser holding the key can raise this.
3797
+ const disagreement = await this.deviceRecordDisagreement(p.username, found.device);
3798
+ if (disagreement === 'no record named') {
3799
+ const ctx = systemContext(this.engine, `remembered browser ${found.row.id} of ${p.username}: remembered before rows named their sign-in record`);
3800
+ if (await this.credentials?.remove(found.row.id)) {
3801
+ this.auditCredentialChange(ctx, p.username, 'credential-remove', found.row, 'no-sign-in-record');
3802
+ await this.withdrawRememberConsent(ctx, p.username, found.row.subject, 'no-sign-in-record');
3803
+ }
3804
+ return fail('remembered before rows named their sign-in record; asked once, and it may be remembered again');
3805
+ }
3806
+ if (disagreement) {
3807
+ void recordAuditEvent(this.engine.getManager('AuditManager'), {
3808
+ eventType: AUDIT_EVENT.SECURITY_EVENT,
3809
+ user: p.username,
3810
+ ipAddress: from.ip,
3811
+ action: 'remembered-browser-record-mismatch',
3812
+ result: 'deny',
3813
+ severity: 'high',
3814
+ resource: found.row.id,
3815
+ resourceType: 'credential',
3816
+ reason: `remembered browser ${found.row.id} of ${p.username} treated as unknown: ${disagreement}`,
3817
+ metadata: { securityEventType: 'remembered-browser-record-mismatch', credentialId: found.row.id, auditId: found.device.auditId, userAgent: from.userAgent }
3818
+ });
3819
+ return fail(disagreement);
3820
+ }
3821
+ p.knownDevice = found.row.id;
3822
+ await this.credentials?.touch(found.row.id, new Date().toISOString());
3823
+ return true;
3824
+ }
3825
+ /**
3826
+ * How a remembered browser's row disagrees with the audit record it names
3827
+ * (#1523), or null when they agree: the record is the account's sign-in,
3828
+ * made the way the row says, with the same factors. `'no record named'` for
3829
+ * a row remembered before rows named one.
3830
+ */
3831
+ async deviceRecordDisagreement(username, device) {
3832
+ if (!device.auditId)
3833
+ return 'no record named';
3834
+ const audit = this.engine.getManager('AuditManager');
3835
+ const record = await audit?.authenticationRecord?.(device.auditId);
3836
+ if (!record)
3837
+ return `its sign-in record ${device.auditId} was not found`;
3838
+ if (record.user !== username)
3839
+ return `its sign-in record ${device.auditId} is another account's`;
3840
+ if (record.loginMethod !== device.signInMethod)
3841
+ return `its sign-in record ${device.auditId} names a different way of signing in`;
3842
+ if (!sameAmr(record.amr, device.amr))
3843
+ return `its sign-in record ${device.auditId} names different factors`;
3844
+ return null;
3845
+ }
3846
+ /**
3847
+ * Remember this browser for `ctx`'s person (#1523 slice 1c, Q5): the
3848
+ * consent record first, bound to the device id, then the `device` row with
3849
+ * the public key, the browser and system, and when it expires. Refused
3850
+ * when the key is not an ECDSA P-256 public key, the browser cannot be
3851
+ * told, the page changed since it was shown, or consent cannot be recorded.
3852
+ * The row names the audit record of the sign-in (`auditId`, from
3853
+ * `completeSignIn`), which must be readable and be this person's sign-in
3854
+ * with these factors; otherwise nothing is remembered (#1523).
3855
+ */
3856
+ async rememberBrowser(ctx, input) {
3857
+ const days = this.rememberDays();
3858
+ if (!this.credentials || days === 0)
3859
+ return null;
3860
+ const identity = browserIdentity(input.userAgent, input.platformHint);
3861
+ if (!identity)
3862
+ return null;
3863
+ const audit = this.engine.getManager('AuditManager');
3864
+ const signIn = input.auditId ? await audit?.authenticationRecord?.(input.auditId) : null;
3865
+ if (!signIn || signIn.user !== ctx.username || !sameAmr(signIn.amr, input.amr)) {
3866
+ logger.warn(`[AuthManager] ${ctx.username} asked to remember a browser, but the audit record of the sign-in could not be read or does not match; not remembered (#1523)`);
3867
+ return null;
3868
+ }
3869
+ let spki;
3870
+ try {
3871
+ const key = createPublicKey({ key: Buffer.from(input.publicKey, 'base64url'), format: 'der', type: 'spki' });
3872
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1')
3873
+ return null;
3874
+ spki = key.export({ format: 'der', type: 'spki' });
3875
+ }
3876
+ catch {
3877
+ return null;
3878
+ }
3879
+ const deviceId = createHash('sha256').update(spki).digest('base64url');
3880
+ // A key is made fresh for each remembering, so its id is new; one already
3881
+ // stored under it (another account's included) is never replaced.
3882
+ if (this.credentials.findBySubject('device', deviceId))
3883
+ return null;
3884
+ const consent = this.engine.getManager('ConsentManager');
3885
+ const doc = (await consent?.documents(REMEMBER_BROWSER_CONSENT_SCOPE))?.[0];
3886
+ if (!consent || !doc || String(doc.version) !== input.version)
3887
+ return null;
3888
+ try {
3889
+ await consent.accept(ctx, REMEMBER_BROWSER_CONSENT_SCOPE, [{ id: doc.id, version: String(doc.version) }], 'sign-in', deviceId);
3890
+ }
3891
+ catch (err) {
3892
+ logger.warn(`[AuthManager] ${ctx.username} asked to remember a browser, but the consent could not be recorded; not remembered:`, err);
3893
+ return null;
3894
+ }
3895
+ const now = new Date();
3896
+ const secret = {
3897
+ publicKey: spki.toString('base64url'),
3898
+ ...identity,
3899
+ amr: input.amr,
3900
+ expiresAt: new Date(now.getTime() + days * 86_400_000).toISOString(),
3901
+ generation: await this.accountGeneration(ctx.username),
3902
+ auditId: signIn.id,
3903
+ ...(signIn.loginMethod !== undefined ? { signInMethod: signIn.loginMethod } : {})
3394
3904
  };
3905
+ await this.storeCredential(ctx, ctx.username, { kind: 'device', subject: deviceId, secret: JSON.stringify(secret), label: `${identity.browser} on ${identity.os}` }, 'remember this browser');
3906
+ return { deviceId };
3395
3907
  }
3396
3908
  /**
3397
3909
  * The waiting browser gave up the sign-in (Cancel, #1532): dropped, its
@@ -3649,9 +4161,11 @@ class AuthManager extends BaseManager {
3649
4161
  /**
3650
4162
  * Register an authentication provider (#1050).
3651
4163
  *
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
4164
+ * The built-ins register during `initialize()` through registerCoreProvider(),
4165
+ * which shares every check below except the reserved ids, so the path an addon
4166
+ * uses is the path exercised on every boot rather than a second, less-travelled
4167
+ * one. Core ids and the `oidc:` prefix are reserved: an addon registering one is
4168
+ * refused, whether or not core registered it on this site (#2223). Config gating stays with the caller: this method decides nothing about
3655
4169
  * whether a provider *should* be active, only that it is.
3656
4170
  *
3657
4171
  * ## Duplicate ids: first registration wins
@@ -3679,33 +4193,62 @@ class AuthManager extends BaseManager {
3679
4193
  * otherwise here would be worse than the gap.
3680
4194
  *
3681
4195
  * @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
4196
+ * @param source attribution for logs: the add-on's name
4197
+ * @returns true if registered; false if rejected as reserved, malformed or duplicate
3684
4198
  */
3685
- registerProvider(provider, source = 'built-in') {
4199
+ registerProvider(provider, source = 'an add-on') {
4200
+ // #2223: core's ids and the oidc: prefix are core's alone, registered
4201
+ // through registerCoreProvider() in initialize(). An add-on claiming one
4202
+ // would bring its own verify() and AAL under a name the factors config
4203
+ // trusts, on a site where core left that id unregistered.
4204
+ // The id must be a plain own property, read once, and only that value is
4205
+ // checked and stored: a getter could pass the check as one id and register
4206
+ // as another.
4207
+ const idProperty = provider ? Object.getOwnPropertyDescriptor(provider, 'id') : undefined;
4208
+ if (provider && idProperty && !('value' in idProperty)) {
4209
+ logger.error(`[AuthManager] Rejected provider from ${source}: its id must be a plain property, not a getter`);
4210
+ return false;
4211
+ }
4212
+ const id = idProperty?.value;
4213
+ if (typeof id === 'string' && isReservedProviderId(id)) {
4214
+ logger.error(`[AuthManager] Rejected provider '${id}' from ${source}: core provider ids ` +
4215
+ `(${CORE_PROVIDER_IDS.join(', ')}) and the 'oidc:' prefix are reserved for core`);
4216
+ return false;
4217
+ }
4218
+ return this.addProvider(provider, id, source);
4219
+ }
4220
+ /**
4221
+ * Core's own registrations (#2223): the one way a reserved id gets in. Called
4222
+ * only from initialize() and the helpers it runs; add-ons get the AuthManager
4223
+ * through the engine and call registerProvider(), which refuses these ids.
4224
+ */
4225
+ registerCoreProvider(provider) {
4226
+ return this.addProvider(provider, provider?.id, 'built-in');
4227
+ }
4228
+ addProvider(provider, id, source) {
3686
4229
  // An addon can pass anything. A provider with no id would be unreachable;
3687
4230
  // one with no verify() would throw inside authenticate() on a live request,
3688
4231
  // which is a worse place to find out than boot.
3689
- if (!provider || typeof provider.id !== 'string' || provider.id.trim() === '') {
4232
+ if (!provider || typeof id !== 'string' || id.trim() === '') {
3690
4233
  logger.error(`[AuthManager] Rejected provider from ${source}: missing id`);
3691
4234
  return false;
3692
4235
  }
3693
4236
  if (typeof provider.verify !== 'function') {
3694
- logger.error(`[AuthManager] Rejected provider '${provider.id}' from ${source}: no verify()`);
4237
+ logger.error(`[AuthManager] Rejected provider '${id}' from ${source}: no verify()`);
3695
4238
  return false;
3696
4239
  }
3697
- if (this.providers.has(provider.id)) {
3698
- logger.warn(`[AuthManager] Provider '${provider.id}' is already registered — ` +
4240
+ if (this.providers.has(id)) {
4241
+ logger.warn(`[AuthManager] Provider '${id}' is already registered — ` +
3699
4242
  `keeping the existing one, ignoring the registration from ${source}`);
3700
4243
  return false;
3701
4244
  }
3702
- this.providers.set(provider.id, provider);
4245
+ this.providers.set(id, provider);
3703
4246
  this.factorCache = null;
3704
4247
  // info, not debug: which auth providers are live is a security-relevant
3705
4248
  // fact about a running instance, and the boot log is where an operator
3706
4249
  // checks it. Callers add a second line only when they carry config detail
3707
4250
  // worth seeing (transport, issuer, team) — never a bare repeat of the id.
3708
- logger.info(`[AuthManager] Registered provider: ${provider.id} (${source})`);
4251
+ logger.info(`[AuthManager] Registered provider: ${id} (${source})`);
3709
4252
  return true;
3710
4253
  }
3711
4254
  /**