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