@jwilleke/ngdpbase-types 5.41.1 → 5.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/context/ApiContext.js +1 -1
- package/lib/src/context/BaseContext.d.ts +20 -20
- package/lib/src/context/BaseContext.js +41 -24
- package/lib/src/context/JobContext.d.ts +31 -0
- package/lib/src/context/JobContext.js +34 -1
- package/lib/src/managers/AddonsManager.d.ts +8 -28
- package/lib/src/managers/AgentTokenManager.d.ts +8 -0
- package/lib/src/managers/AttachmentManager.d.ts +59 -0
- package/lib/src/managers/AuditManager.d.ts +477 -0
- package/lib/src/managers/AuthManager.d.ts +247 -15
- package/lib/src/managers/AuthManager.js +588 -45
- package/lib/src/managers/ConfigurationManager.d.ts +15 -0
- package/lib/src/managers/ConsentManager.d.ts +15 -0
- package/lib/src/managers/ImportManager.d.ts +15 -0
- package/lib/src/managers/PageManager.d.ts +185 -23
- package/lib/src/managers/PolicyEvaluator.d.ts +99 -24
- package/lib/src/managers/ShareManager.d.ts +3 -2
- package/lib/src/managers/UserManager.d.ts +19 -5
- package/lib/src/managers/ValidationManager.d.ts +3 -1
- package/lib/src/providers/AgentTokenAuthProvider.js +1 -1
- package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -0
- package/lib/src/providers/BaseAuditProvider.d.ts +492 -0
- package/lib/src/providers/BaseAuthProvider.d.ts +4 -0
- package/lib/src/providers/BaseCredentialsProvider.d.ts +2 -2
- package/lib/src/providers/BaseCredentialsProvider.js +1 -1
- package/lib/src/providers/BasicAttachmentProvider.d.ts +9 -0
- package/lib/src/providers/FileCredentialsProvider.js +1 -1
- package/lib/src/providers/NullAuditProvider.d.ts +82 -0
- package/lib/src/providers/TotpAuthProvider.d.ts +55 -0
- package/lib/src/providers/TotpAuthProvider.js +145 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +57 -1
- package/lib/src/security/PolicyInformationPoint.d.ts +86 -6
- package/lib/src/security/permissions.generated.d.ts +8 -0
- package/lib/src/types/Consent.d.ts +17 -4
- package/lib/src/types/Consent.js +11 -1
- package/lib/src/types/Page.d.ts +0 -7
- package/lib/src/types/Policy.d.ts +67 -6
- package/lib/src/types/Policy.js +6 -0
- package/lib/src/types/Provider.d.ts +18 -0
- package/lib/src/types/User.d.ts +6 -0
- package/lib/src/types/express.d.ts +22 -0
- package/lib/src/utils/addonPageSync.d.ts +0 -12
- package/lib/src/utils/auditChain.d.ts +83 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
- package/lib/src/utils/auditEventNames.generated.js +2 -0
- package/lib/src/utils/auditEvents.d.ts +2 -0
- package/lib/src/utils/auditEvents.js +3 -1
- package/lib/src/utils/auditHeadWitness.d.ts +97 -0
- package/lib/src/utils/auditLifecycle.d.ts +71 -0
- package/lib/src/utils/browserDescription.d.ts +18 -0
- package/lib/src/utils/browserDescription.js +12 -0
- package/lib/src/utils/frontmatterAccess.d.ts +15 -24
- package/lib/src/utils/legacyStoreKeys.d.ts +16 -0
- package/lib/src/utils/pageWritePolicies.d.ts +55 -0
- package/lib/src/utils/permissionCatalog.d.ts +2 -0
- package/lib/src/utils/permissionCatalog.js +19 -0
- package/lib/src/utils/policyAttributes.d.ts +141 -0
- package/lib/src/utils/policyAttributes.js +219 -0
- package/lib/src/utils/postureRecord.d.ts +73 -0
- package/lib/src/utils/privateStoreAccess.d.ts +24 -4
- package/lib/src/utils/privateStoreAccess.js +19 -4
- package/lib/src/utils/privateStorePath.d.ts +6 -0
- package/lib/src/utils/privateStorePath.js +8 -0
- package/lib/src/utils/securityPosture.d.ts +52 -0
- package/lib/src/utils/tlsConfig.d.ts +59 -0
- package/lib/src/utils/totpSeedCipher.d.ts +4 -0
- package/lib/src/utils/totpSeedCipher.js +45 -0
- package/lib/src/utils/username.d.ts +13 -2
- package/lib/src/utils/username.js +18 -3
- 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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
2176
|
-
|
|
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?.({
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
3094
|
-
* email link — and one that is not truly
|
|
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
|
|
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
|
|
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
|
|
3653
|
-
*
|
|
3654
|
-
*
|
|
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
|
|
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 = '
|
|
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
|
|
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 '${
|
|
4237
|
+
logger.error(`[AuthManager] Rejected provider '${id}' from ${source}: no verify()`);
|
|
3695
4238
|
return false;
|
|
3696
4239
|
}
|
|
3697
|
-
if (this.providers.has(
|
|
3698
|
-
logger.warn(`[AuthManager] Provider '${
|
|
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(
|
|
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: ${
|
|
4251
|
+
logger.info(`[AuthManager] Registered provider: ${id} (${source})`);
|
|
3709
4252
|
return true;
|
|
3710
4253
|
}
|
|
3711
4254
|
/**
|