@jwilleke/ngdpbase-types 5.39.0 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  2. package/lib/src/context/ApiContext.d.ts +3 -2
  3. package/lib/src/context/ApiContext.js +4 -2
  4. package/lib/src/ice/fileHandling.d.ts +6 -1
  5. package/lib/src/ice/givenName.d.ts +19 -0
  6. package/lib/src/ice/runExport.d.ts +41 -0
  7. package/lib/src/ice/runImport.d.ts +37 -8
  8. package/lib/src/ice/shims/csv.d.ts +24 -0
  9. package/lib/src/ice/shims/export.d.ts +23 -0
  10. package/lib/src/ice/shims/index.d.ts +3 -1
  11. package/lib/src/ice/shims/svg.d.ts +27 -0
  12. package/lib/src/ice/shims/takeout.d.ts +83 -0
  13. package/lib/src/ice/types.d.ts +301 -5
  14. package/lib/src/ice/vet.d.ts +19 -0
  15. package/lib/src/managers/AttachmentManager.d.ts +100 -36
  16. package/lib/src/managers/AuthManager.d.ts +152 -12
  17. package/lib/src/managers/AuthManager.js +3830 -0
  18. package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
  19. package/lib/src/managers/BackupManager.d.ts +24 -0
  20. package/lib/src/managers/BackupManager.js +48 -1
  21. package/lib/src/managers/ExportManager.d.ts +16 -0
  22. package/lib/src/managers/ExportManager.js +25 -0
  23. package/lib/src/managers/FileManager.d.ts +182 -0
  24. package/lib/src/managers/ImportManager.d.ts +279 -16
  25. package/lib/src/managers/MediaManager.d.ts +2 -0
  26. package/lib/src/managers/PageManager.d.ts +53 -0
  27. package/lib/src/managers/SearchManager.d.ts +4 -0
  28. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  29. package/lib/src/managers/ValidationManager.d.ts +5 -2
  30. package/lib/src/middleware/csrf.js +99 -0
  31. package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
  32. package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
  33. package/lib/src/providers/BaseCredentialsProvider.js +21 -0
  34. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  35. package/lib/src/providers/BasePageProvider.d.ts +8 -0
  36. package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
  37. package/lib/src/providers/FileCredentialsProvider.js +188 -0
  38. package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
  39. package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
  40. package/lib/src/providers/OidcAuthProvider.js +350 -0
  41. package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
  42. package/lib/src/providers/PasskeyAuthProvider.js +147 -0
  43. package/lib/src/providers/PasswordAuthProvider.js +44 -0
  44. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  45. package/lib/src/security/pageActions.js +43 -0
  46. package/lib/src/security/permissions.generated.d.ts +7 -3
  47. package/lib/src/types/File.d.ts +90 -0
  48. package/lib/src/types/Provider.d.ts +15 -0
  49. package/lib/src/types/express.d.ts +7 -1
  50. package/lib/src/types/guards.js +366 -0
  51. package/lib/src/types/linkedom.d.ts +2 -0
  52. package/lib/src/utils/atomicWrite.js +129 -0
  53. package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
  54. package/lib/src/utils/auditEventNames.generated.js +12 -0
  55. package/lib/src/utils/auditEvents.d.ts +15 -0
  56. package/lib/src/utils/auditEvents.js +17 -1
  57. package/lib/src/utils/bip39English.js +2 -0
  58. package/lib/src/utils/browserDescription.js +28 -0
  59. package/lib/src/utils/delegation.js +79 -0
  60. package/lib/src/utils/deriveUsername.js +38 -0
  61. package/lib/src/utils/emailForLog.js +28 -0
  62. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  63. package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
  64. package/lib/src/utils/instanceEnvBackup.js +13 -0
  65. package/lib/src/utils/ipLocation.js +106 -0
  66. package/lib/src/utils/magicLinkDeviceState.js +97 -0
  67. package/lib/src/utils/pageUrl.d.ts +1 -0
  68. package/lib/src/utils/passwordHash.js +150 -0
  69. package/lib/src/utils/permissionCatalog.js +102 -0
  70. package/lib/src/utils/privateStoreAccess.d.ts +25 -0
  71. package/lib/src/utils/privateStoreAccess.js +22 -1
  72. package/lib/src/utils/privateStoreCatalogs.js +57 -0
  73. package/lib/src/utils/privateStoreCrypto.js +266 -0
  74. package/lib/src/utils/privateStoreDoor.d.ts +10 -0
  75. package/lib/src/utils/privateStoreDoor.js +298 -0
  76. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  77. package/lib/src/utils/privateStoreMeta.js +123 -0
  78. package/lib/src/utils/privateStorePath.d.ts +37 -1
  79. package/lib/src/utils/privateStorePath.js +60 -3
  80. package/lib/src/utils/privateStoreTakeout.js +149 -0
  81. package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
  82. package/lib/src/utils/privateStoreUnlock.js +499 -0
  83. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  84. package/lib/src/utils/safeRedirect.js +55 -0
  85. package/lib/src/utils/sessionAccount.js +55 -0
  86. package/lib/src/utils/sessionEnd.d.ts +1 -0
  87. package/lib/src/utils/sessionEnd.js +42 -0
  88. package/lib/src/utils/sessionGeneration.js +29 -0
  89. package/lib/src/utils/sessionIdle.d.ts +4 -0
  90. package/lib/src/utils/sessionIdle.js +72 -0
  91. package/lib/src/utils/sessionLimits.js +72 -0
  92. package/lib/src/utils/sessionSecurity.js +122 -0
  93. package/lib/src/utils/shareLockbox.js +101 -0
  94. package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
  95. package/lib/src/utils/sharedFolderAccess.js +80 -0
  96. package/lib/src/utils/userCreateError.js +82 -0
  97. package/lib/src/utils/username.d.ts +2 -0
  98. package/lib/src/utils/username.js +105 -0
  99. package/package.json +2 -1
@@ -0,0 +1,333 @@
1
+ /**
2
+ * MagicLinkAuthProvider — passwordless authentication via one-time email links.
3
+ *
4
+ * Registered by AuthManager when `ngdpbase.auth.magic-link.enabled` is true.
5
+ *
6
+ * Flow:
7
+ * 1. initiate() — look up user by email, generate token, send link via mail transport
8
+ * 2. verify() — validate token (exists + not expired), return AuthResult
9
+ * 3. consumeToken() — delete token; its return value gates the session (#1021)
10
+ *
11
+ * `verify()` being side-effect free is load-bearing, not incidental (#1019): the
12
+ * route layer calls it twice, once on the GET that renders the sign-in
13
+ * confirmation page and again on the POST that completes the login. Only the
14
+ * POST consumes. Moving consumption into verify() would reintroduce the bug
15
+ * where a mail scanner's link pre-fetch burns the token before the user clicks.
16
+ *
17
+ * Tokens are in-memory only — lost on restart, acceptable for the default 15-min TTL.
18
+ *
19
+ * A shared address (#2073): two or more accounts may hold one email. The link
20
+ * then names no account; the person opening it chooses one of the accounts on
21
+ * the address, and the link alone is enough for any of them (the mailbox is
22
+ * the shared trust, operator 2026-10-25).
23
+ *
24
+ * @see AuthManager
25
+ * @see BaseAuthProvider
26
+ */
27
+ import * as crypto from 'crypto';
28
+ import { jobContextFromRequestWithReason } from '../context/JobContext.js';
29
+ import { systemPrincipalOf } from '../context/bootActions.js';
30
+ import { deriveUsername } from '../utils/deriveUsername.js';
31
+ import logger from '../utils/logger.js';
32
+ import { emailDomain, errorForLog } from '../utils/emailForLog.js';
33
+ /**
34
+ * What an email link gives, in either role — starting a sign-in or confirming
35
+ * one as a second factor (#1523: one email-link factor). Email is never
36
+ * phishing-resistant and never above AAL1 (SP 800-63B §5.1.3.1).
37
+ */
38
+ export const EMAIL_FACTOR = { amr: ['email'], aal: 1 };
39
+ export class MagicLinkAuthProvider {
40
+ engine;
41
+ config;
42
+ id = 'magic-link';
43
+ displayName = 'Magic Link';
44
+ /** Email is never phishing-resistant and never above AAL1: SP 800-63B §5.1.3.1 bars it as out-of-band (#1523). */
45
+ factor = { ...EMAIL_FACTOR, primary: true };
46
+ /** token → entry */
47
+ tokens;
48
+ /** email → timestamp of last request (rate-limit) */
49
+ rateLimitMap;
50
+ constructor(engine, config) {
51
+ this.engine = engine;
52
+ this.config = config;
53
+ this.tokens = new Map();
54
+ this.rateLimitMap = new Map();
55
+ }
56
+ /**
57
+ * Initiate the magic-link flow: look up the user, generate a token, send the email.
58
+ * Silently succeeds if the email is not registered — prevents user enumeration.
59
+ */
60
+ async initiate(context) {
61
+ try {
62
+ const email = context.email?.trim().toLowerCase();
63
+ if (!email)
64
+ return;
65
+ // Rate limit: 1 request per email per 60 seconds
66
+ if (this.isRateLimited(email)) {
67
+ logger.debug(`[MagicLinkAuthProvider] Rate limited: an address at ${emailDomain(email)}`);
68
+ return;
69
+ }
70
+ const userManager = this.engine.getManager('UserManager');
71
+ if (!userManager)
72
+ return;
73
+ // #2073: an address may be shared; the accounts on it that may sign in.
74
+ const holders = await userManager.getUsersByEmail(email);
75
+ const active = holders.filter((u) => u.isActive !== false);
76
+ const choices = active.length > 1 ? active.map((u) => u.username) : undefined;
77
+ const user = choices ? undefined : holders.length === 1 ? holders[0] : active[0];
78
+ if (!user && !choices && (holders.length > 0 || !this.isAutoProvisionEnabled())) {
79
+ // Silent — do not reveal whether email is registered
80
+ logger.debug(`[MagicLinkAuthProvider] No user for an address at ${emailDomain(email)} (silent)`);
81
+ return;
82
+ }
83
+ // #1026: with auto-provision on, an unknown email still gets a link — but
84
+ // the account is NOT created here. It is created when the link is
85
+ // verified, so an address nobody controls never becomes an account and a
86
+ // spray of requests at other people's addresses leaves nothing behind.
87
+ // The caller-visible response is identical either way, which is what
88
+ // keeps the anti-enumeration property intact.
89
+ const isNewUser = !user && !choices;
90
+ const username = choices
91
+ ? ''
92
+ : user
93
+ ? user.username
94
+ : await deriveUsername(email, userManager);
95
+ const token = crypto.randomBytes(32).toString('hex');
96
+ const expiresAt = Date.now() + this.config.ttlMs;
97
+ this.tokens.set(token, {
98
+ username,
99
+ email,
100
+ ...(choices ? { choices } : {}),
101
+ redirect: context.redirect || '/',
102
+ expiresAt,
103
+ isNewUser,
104
+ // #1022: undefined when the caller did not supply one, which reads as
105
+ // 'unknown' at redemption rather than as a mismatch.
106
+ deviceState: context.deviceState
107
+ });
108
+ this.rateLimitMap.set(email, Date.now());
109
+ // #642 Iteration 3: derive baseUrl from canonical config at runtime.
110
+ // AuthManager.initialize() already refused to register this provider
111
+ // unless ngdpbase.application.base-url is explicit, so getBaseURL()
112
+ // here always returns the operator-configured value.
113
+ const configManager = this.engine.getManager('ConfigurationManager');
114
+ const baseUrl = (configManager?.getBaseURL() ?? '').replace(/\/$/, '');
115
+ const verifyUrl = `${baseUrl}/auth/magic-link/verify?token=${token}`;
116
+ const ttlMinutes = Math.round(this.config.ttlMs / 60_000);
117
+ // #1916: a failed send is reported once, by EmailManager (the mail
118
+ // provider here); logging its error again would repeat it.
119
+ const sent = await this.config.mailProvider.send({
120
+ to: email,
121
+ subject: 'Your login link',
122
+ text: [
123
+ 'Click the link below to log in.',
124
+ '',
125
+ verifyUrl,
126
+ '',
127
+ `This link expires in ${ttlMinutes} minutes and can only be used once.`,
128
+ '',
129
+ 'If you did not request this link, you can ignore this email.'
130
+ ].join('\n'),
131
+ html: [
132
+ '<p>Click the link below to log in.</p>',
133
+ `<p><a href="${verifyUrl}">${verifyUrl}</a></p>`,
134
+ `<p>This link expires in ${ttlMinutes} minutes and can only be used once.</p>`,
135
+ '<p>If you did not request this link, you can ignore this email.</p>'
136
+ ].join('\n')
137
+ }).then(() => true, () => false);
138
+ if (!sent)
139
+ return;
140
+ this.cleanupExpired();
141
+ logger.info(`[MagicLinkAuthProvider] Link sent to an address at ${emailDomain(email)} ` +
142
+ (choices ? `for ${choices.length} accounts sharing it (the person chooses one)` : user ? `for user ${user.username}` : '(new account — provisioned on verify)'));
143
+ }
144
+ catch (err) {
145
+ logger.error(`[MagicLinkAuthProvider] Error in initiate: ${errorForLog(err)}`);
146
+ }
147
+ }
148
+ /**
149
+ * Verify a token. Returns AuthResult on success, null if invalid or expired.
150
+ * Does NOT consume the token — call consumeToken() after the session is created.
151
+ */
152
+ async verify(credentials) {
153
+ const { token } = credentials;
154
+ if (!token)
155
+ return null;
156
+ const entry = this.liveEntry(token);
157
+ if (!entry)
158
+ return null;
159
+ if (!entry.choices)
160
+ return { username: entry.username };
161
+ // #2073: a shared address — the account chosen must be one the link offered, still hold the address, and still be on.
162
+ const account = credentials.account;
163
+ if (!account || !entry.choices.includes(account))
164
+ return null;
165
+ const holders = await this.engine.getManager('UserManager')?.getUsersByEmail(entry.email) ?? [];
166
+ return holders.some((u) => u.username === account && u.isActive !== false) ? { username: account } : null;
167
+ }
168
+ /**
169
+ * The accounts a link to a shared address offers (#2073), by username, or
170
+ * null for a link to one account (or an unknown or expired one). Read
171
+ * without consuming, so the page asking which account can be shown.
172
+ */
173
+ accountChoices(token) {
174
+ return this.liveEntry(token)?.choices ?? null;
175
+ }
176
+ liveEntry(token) {
177
+ const entry = this.tokens.get(token);
178
+ if (!entry)
179
+ return null;
180
+ if (Date.now() > entry.expiresAt) {
181
+ this.tokens.delete(token);
182
+ return null;
183
+ }
184
+ return entry;
185
+ }
186
+ /**
187
+ * Delete the token and report whether THIS caller was the one that did.
188
+ *
189
+ * #1021: the return value is the single-use gate, not a courtesy. The route
190
+ * used to verify, then consume, with an `await` between the two — so two
191
+ * POSTs carrying the same token (a double-clicked confirmation button, a
192
+ * client retry, a prefetch racing the real submit) could both clear
193
+ * verification before either consumed, and both would then establish a
194
+ * session. #1019's CSRF-protected POST does not close that: both submits
195
+ * come from the same session with the same valid token.
196
+ *
197
+ * `Map.delete` returns true only for the call that found an entry, and it is
198
+ * synchronous so it cannot interleave. Exactly one caller can ever see true,
199
+ * which makes consumption — not verification — the thing a session is gated
200
+ * on.
201
+ */
202
+ consumeToken(token) {
203
+ return this.tokens.delete(token);
204
+ }
205
+ /**
206
+ * The requesting browser's state value for this token (#1022), or null.
207
+ *
208
+ * Must be read BEFORE consumeToken(), which deletes the entry — same
209
+ * constraint as getFlowRedirect below, and for the same reason.
210
+ */
211
+ getDeviceState(token) {
212
+ return this.tokens.get(token)?.deviceState ?? null;
213
+ }
214
+ /**
215
+ * Where the user was headed before the link was issued (#1049).
216
+ *
217
+ * Named for the capability rather than the mechanism: GoogleOIDCProvider
218
+ * answers the same question keyed by a state nonce, and the two used to be
219
+ * `getTokenRedirect` / `getStateRedirect`, which is why AuthManager needed a
220
+ * method per provider to ask it.
221
+ *
222
+ * Must be read BEFORE consumeToken(), which deletes the entry.
223
+ */
224
+ getFlowRedirect(token) {
225
+ return this.tokens.get(token)?.redirect || '/';
226
+ }
227
+ /**
228
+ * Create the account behind a new-user token (#1026).
229
+ *
230
+ * Called from the POST that completes sign-in, never from the GET — so a mail
231
+ * scanner pre-fetching the link cannot bring an account into existence, for
232
+ * the same reason it cannot consume the token (#1019).
233
+ *
234
+ * The account is created with `isExternal: true`, which stores an empty
235
+ * password hash. `UserManager.verifyPassword` refuses an empty stored hash
236
+ * outright (#1042), so these accounts cannot be logged into with a password
237
+ * by any input — magic link is structurally their only way in.
238
+ *
239
+ * Idempotent: a token whose account already exists is a no-op, so a double
240
+ * submit cannot fail the second time.
241
+ *
242
+ * @returns true if the token is usable (existing user, or newly created)
243
+ */
244
+ async provisionIfNew(token) {
245
+ const entry = this.tokens.get(token);
246
+ if (!entry)
247
+ return false;
248
+ if (!entry.isNewUser)
249
+ return true;
250
+ const userManager = this.engine.getManager('UserManager');
251
+ if (!userManager)
252
+ return false;
253
+ // Re-check: the name was derived when the link was requested and something
254
+ // else may have claimed it since. #2073: accounts made for the address
255
+ // meanwhile are not guessed between; the link is refused instead.
256
+ const existing = await userManager.getUsersByEmail(entry.email);
257
+ if (existing.length > 1)
258
+ return false;
259
+ if (existing.length === 1) {
260
+ entry.username = existing[0].username;
261
+ entry.isNewUser = false;
262
+ return true;
263
+ }
264
+ const username = await deriveUsername(entry.email, userManager);
265
+ const role = this.resolveDefaultRole();
266
+ let created;
267
+ try {
268
+ created = await userManager.createUser({
269
+ username,
270
+ email: entry.email,
271
+ displayName: username,
272
+ password: '',
273
+ roles: [role],
274
+ isExternal: true,
275
+ isActive: true
276
+ }, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine) }, 'provisioned by magic-link')); // #1204: provisioned, not registered; #1179: the system principal acts, the reason names the provider
277
+ }
278
+ catch (err) {
279
+ logger.error(`[MagicLinkAuthProvider] Failed to provision an account for an address at ${emailDomain(entry.email)}: ${errorForLog(err)}`);
280
+ return false;
281
+ }
282
+ entry.username = created.username; // #1882: the account as stored
283
+ entry.isNewUser = false;
284
+ logger.info(`[MagicLinkAuthProvider] Provisioned new user: ${username} (an address at ${emailDomain(entry.email)}) role=${role}`);
285
+ return true;
286
+ }
287
+ /** Whether an unknown email may create an account by verifying a link (#1026). */
288
+ isAutoProvisionEnabled() {
289
+ const configManager = this.engine.getManager('ConfigurationManager');
290
+ // `ngdpbase.application.registration` is the master override — when self-
291
+ // registration is off, no path may create an account, exactly as
292
+ // GoogleOIDCProvider treats it. The provider toggle sits below it.
293
+ const allowReg = configManager?.getProperty?.('ngdpbase.application.registration', true) ?? true;
294
+ if (!allowReg)
295
+ return false;
296
+ return configManager?.getProperty?.('ngdpbase.auth.magic-link.auto-provision', false) ?? false;
297
+ }
298
+ /**
299
+ * Role for auto-provisioned users. An unknown role name falls back to
300
+ * `reader` with a warning — a typo should degrade to read-only, not throw
301
+ * mid-signup or silently mint a more privileged account than intended.
302
+ */
303
+ resolveDefaultRole() {
304
+ const configManager = this.engine.getManager('ConfigurationManager');
305
+ const configured = configManager?.getProperty?.('ngdpbase.auth.magic-link.registration.default-role', 'reader') ?? 'reader';
306
+ if (configured === 'reader')
307
+ return 'reader';
308
+ const declared = configManager?.getProperty?.('ngdpbase.roles.definitions', {});
309
+ if (!Object.hasOwn(declared ?? {}, configured)) {
310
+ logger.warn(`[MagicLinkAuthProvider] Unknown role "${configured}" in ` +
311
+ 'ngdpbase.auth.magic-link.registration.default-role — falling back to "reader"');
312
+ return 'reader';
313
+ }
314
+ return configured;
315
+ }
316
+ isRateLimited(email) {
317
+ const last = this.rateLimitMap.get(email);
318
+ if (!last)
319
+ return false;
320
+ return Date.now() - last < 60_000;
321
+ }
322
+ cleanupExpired() {
323
+ const now = Date.now();
324
+ for (const [token, entry] of this.tokens) {
325
+ if (now > entry.expiresAt)
326
+ this.tokens.delete(token);
327
+ }
328
+ }
329
+ /** Exposed for testing */
330
+ getTokenCount() {
331
+ return this.tokens.size;
332
+ }
333
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * OidcAuthProvider — signing in through an outside OpenID Connect provider (#1743).
3
+ *
4
+ * One class, one registered instance per configured issuer (`oidc:<name>`),
5
+ * registered by AuthManager from `ngdpbase.auth.oidc.providers` (and the old
6
+ * `ngdpbase.auth.google-oidc.*` keys, read as the `google` entry). Routes never
7
+ * call it: AuthManager is the one door.
8
+ *
9
+ * What an outside sign-in may do (decided 2026-10-10, docs/planning/authentication.md):
10
+ * - The identity is the issuer plus the subject (`iss` + `sub`, OIDC Core §5.7),
11
+ * stored as a credentials-store row of kind `identity`. Only an identity
12
+ * already connected to an account signs anyone in. Email never matches or
13
+ * signs anyone in here.
14
+ * - It gives no roles: a provider's groups and roles claims are never read.
15
+ * - No access or refresh token is kept; nothing needs them.
16
+ *
17
+ * Each flow is protected as `GoogleOIDCProvider` was (#1630): a single-use
18
+ * `state` bound to the starting browser by an HTTP-only cookie, PKCE (S256),
19
+ * an OIDC `nonce`, and the ID token's signature, issuer, audience and expiry.
20
+ *
21
+ * Apple (slice 4b) differs in two ways handled here: its client secret is a
22
+ * JWT signed per exchange (`SignedClientSecret`), and it POSTs the callback
23
+ * (`responseMode: 'form_post'`), which `middleware/outsideCallbackRelay.ts`
24
+ * turns into the usual GET before the session and CSRF see it.
25
+ *
26
+ * Discovery (`/.well-known/openid-configuration`) and the issuer's keys are
27
+ * fetched through `guardedFetch`, like every outbound request (#1133), and
28
+ * cached. Flow state is in memory: a restart drops a sign-in in progress.
29
+ *
30
+ * @see AuthManager
31
+ * @see BaseAuthProvider
32
+ */
33
+ import * as crypto from 'crypto';
34
+ import { createLocalJWKSet, jwtVerify, SignJWT, errors as joseErrors } from 'jose';
35
+ import { guardedFetch } from '../http/guardedFetch.js';
36
+ import { evaluateDeviceBinding } from '../utils/magicLinkDeviceState.js';
37
+ import logger from '../utils/logger.js';
38
+ const FLOW_TTL_MS = 10 * 60_000;
39
+ const CACHE_TTL_MS = 60 * 60_000;
40
+ const MAX_DOC_BYTES = 512 * 1024;
41
+ const FETCH_TIMEOUT_MS = 10_000;
42
+ /** How long a signed client secret is valid; Apple allows up to six months, a code exchange needs seconds. */
43
+ const SIGNED_SECRET_TTL_S = 5 * 60;
44
+ /** Google's ID tokens may name their issuer without the scheme (Google's OIDC documentation). */
45
+ const ISSUER_ALIASES = {
46
+ 'https://accounts.google.com': ['accounts.google.com']
47
+ };
48
+ export class OidcAuthProvider {
49
+ config;
50
+ identities;
51
+ id;
52
+ displayName;
53
+ icon;
54
+ /**
55
+ * AAL1 with no `amr`: what is offered, and what a sign-in counts for unless
56
+ * the admin trusts this provider's own second factor (`trust-mfa`, #1633),
57
+ * when AuthManager maps the reported `amr` / `acr` instead
58
+ * (`outsideLevel`). Primary: it starts a sign-in on its own.
59
+ */
60
+ factor = { amr: [], aal: 1, primary: true };
61
+ flows = new Map();
62
+ discovery = null;
63
+ keys = null;
64
+ constructor(config, identities) {
65
+ this.config = config;
66
+ this.identities = identities;
67
+ this.id = `oidc:${config.name}`;
68
+ this.displayName = config.displayName;
69
+ this.icon = config.icon;
70
+ }
71
+ /** The configured issuer; an identity row's subject starts with it and `#`. */
72
+ get issuer() {
73
+ return this.config.issuer;
74
+ }
75
+ /** The entry's name, as in its URLs. */
76
+ get name() {
77
+ return this.config.name;
78
+ }
79
+ /** Whether the issuer POSTs the callback (`response_mode=form_post`) instead of redirecting to it. */
80
+ get callbackIsPosted() {
81
+ return this.config.responseMode === 'form_post';
82
+ }
83
+ /**
84
+ * Begin a sign-in, or a connection when `connectFor` names the signed-in
85
+ * account: keep the flow's state and return the provider's authorization URL.
86
+ */
87
+ async startFlow({ redirect = '/', deviceState, connectFor } = {}) {
88
+ const doc = await this.discover();
89
+ const state = crypto.randomBytes(16).toString('hex');
90
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
91
+ const oidcNonce = crypto.randomBytes(16).toString('hex');
92
+ this.sweep();
93
+ this.flows.set(state, {
94
+ redirect,
95
+ expiresAt: Date.now() + FLOW_TTL_MS,
96
+ deviceState,
97
+ codeVerifier,
98
+ oidcNonce,
99
+ ...(connectFor ? { connectFor } : {})
100
+ });
101
+ const url = new URL(doc.authorization_endpoint);
102
+ url.searchParams.set('response_type', 'code');
103
+ url.searchParams.set('client_id', this.config.clientId);
104
+ url.searchParams.set('redirect_uri', this.config.redirectUri);
105
+ url.searchParams.set('scope', 'openid email');
106
+ url.searchParams.set('state', state);
107
+ url.searchParams.set('nonce', oidcNonce);
108
+ url.searchParams.set('code_challenge', crypto.createHash('sha256').update(codeVerifier).digest('base64url'));
109
+ url.searchParams.set('code_challenge_method', 'S256');
110
+ if (this.config.responseMode)
111
+ url.searchParams.set('response_mode', this.config.responseMode);
112
+ return url.toString();
113
+ }
114
+ /** Where the person was headed when the flow began; read before `consumeToken`. */
115
+ getFlowRedirect(state) {
116
+ return this.flows.get(state)?.redirect ?? '/';
117
+ }
118
+ /** The account this flow is connecting an identity to, or null for a sign-in or an unknown flow. */
119
+ getFlowConnectFor(state) {
120
+ return this.flows.get(state)?.connectFor ?? null;
121
+ }
122
+ /**
123
+ * Finish a sign-in: the account whose connected identity this is, or null.
124
+ * A flow started to connect never signs in. An identity connected to no
125
+ * account is refused, and kept on the flow until it is consumed, so
126
+ * AuthManager can decide what happens next (`unconnectedIdentity`);
127
+ * nothing here says whether an account exists.
128
+ */
129
+ async verify(credentials) {
130
+ const identity = await this.redeem(credentials, null);
131
+ if (!identity)
132
+ return null;
133
+ const row = this.identities.find(identity.subject);
134
+ if (!row) {
135
+ logger.info(`[OidcAuthProvider:${this.config.name}] an identity connected to no account was refused`);
136
+ const flow = credentials.state ? this.flows.get(credentials.state) : undefined;
137
+ if (flow)
138
+ flow.unconnected = identity;
139
+ return null;
140
+ }
141
+ try {
142
+ await this.identities.used(row.id, new Date().toISOString(), identity.email);
143
+ }
144
+ catch (err) {
145
+ logger.warn(`[OidcAuthProvider:${this.config.name}] could not record the sign-in on the connection:`, err);
146
+ }
147
+ return { username: row.username, reported: identity.reported };
148
+ }
149
+ /**
150
+ * Finish a connection for `username`: the identity the provider vouched
151
+ * for, or null. Only a flow started to connect that same account counts.
152
+ */
153
+ async verifyIdentity(credentials, username) {
154
+ return this.redeem(credentials, username);
155
+ }
156
+ /**
157
+ * The verified identity a refused sign-in brought that no account has
158
+ * connected, or null; read before `consumeToken` (#1743 slice 3).
159
+ */
160
+ unconnectedIdentity(state) {
161
+ return this.flows.get(state)?.unconnected ?? null;
162
+ }
163
+ /** Spend the flow's state; true only for the caller that did (#1021). */
164
+ consumeToken(state) {
165
+ return this.flows.delete(state);
166
+ }
167
+ /**
168
+ * The state, the browser, the code exchange and the ID token, checked in
169
+ * that order. `connectFor` is the account a connection is for, or null for
170
+ * a sign-in; a flow of the other kind is refused.
171
+ */
172
+ async redeem(credentials, connectFor) {
173
+ const code = credentials.token;
174
+ const state = credentials.state;
175
+ const tag = `[OidcAuthProvider:${this.config.name}]`;
176
+ if (!code || !state)
177
+ return null;
178
+ const flow = this.flows.get(state);
179
+ if (!flow) {
180
+ logger.warn(`${tag} unknown or used state`);
181
+ return null;
182
+ }
183
+ if (Date.now() > flow.expiresAt) {
184
+ this.flows.delete(state);
185
+ logger.warn(`${tag} the sign-in took too long; its state expired`);
186
+ return null;
187
+ }
188
+ // #1630: the callback must come back to the browser that started the
189
+ // flow, or an attacker's own code and state could finish in a victim's
190
+ // browser (login CSRF). Always enforced: there is no cross-device case.
191
+ const binding = evaluateDeviceBinding({ stored: flow.deviceState, presented: credentials.deviceState, enforce: true });
192
+ if (!binding.allowed) {
193
+ this.flows.delete(state);
194
+ logger.warn(`${tag} callback from a different browser than the one that started (${binding.outcome})`);
195
+ return null;
196
+ }
197
+ if ((flow.connectFor ?? null) !== connectFor) {
198
+ logger.warn(`${tag} the flow was started for ${flow.connectFor ? 'a connection' : 'a sign-in'}, not this`);
199
+ return null;
200
+ }
201
+ let idToken;
202
+ try {
203
+ idToken = await this.exchange(code, flow.codeVerifier);
204
+ }
205
+ catch (err) {
206
+ logger.warn(`${tag} the code exchange failed: ${err.message}`);
207
+ return null;
208
+ }
209
+ let claims;
210
+ try {
211
+ claims = await this.verifyIdToken(idToken);
212
+ }
213
+ catch (err) {
214
+ logger.warn(`${tag} the ID token was refused: ${err.message}`);
215
+ return null;
216
+ }
217
+ if (claims.nonce !== flow.oidcNonce) {
218
+ logger.warn(`${tag} the ID token answers another sign-in (nonce)`);
219
+ return null;
220
+ }
221
+ if (Array.isArray(claims.aud) && claims.aud.length > 1 && claims.azp !== this.config.clientId) {
222
+ logger.warn(`${tag} the ID token was issued to another party (azp)`);
223
+ return null;
224
+ }
225
+ if (typeof claims.sub !== 'string' || claims.sub === '') {
226
+ logger.warn(`${tag} the ID token names no subject`);
227
+ return null;
228
+ }
229
+ // Only a verified address is kept; it never identifies anyone here. Apple
230
+ // may send the flag as a string; elsewhere only the boolean counts.
231
+ const verified = claims.email_verified === true || (this.config.emailVerifiedMayBeString === true && claims.email_verified === 'true');
232
+ const email = verified && typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
233
+ // #1633: how the provider says it signed the person in, passed on unread.
234
+ return { subject: `${this.config.issuer}#${claims.sub}`, email, reported: { amr: claims.amr, acr: claims.acr } };
235
+ }
236
+ /** Exchange the code for the ID token. No access or refresh token is kept. */
237
+ async exchange(code, codeVerifier) {
238
+ const doc = await this.discover();
239
+ const form = new URLSearchParams({
240
+ grant_type: 'authorization_code',
241
+ code,
242
+ redirect_uri: this.config.redirectUri,
243
+ code_verifier: codeVerifier
244
+ });
245
+ const headers = {
246
+ 'Content-Type': 'application/x-www-form-urlencoded',
247
+ Accept: 'application/json'
248
+ };
249
+ // RFC 6749 §2.3.1: Basic unless the issuer says it takes only the form;
250
+ // OIDC Discovery: an absent list means client_secret_basic.
251
+ const methods = doc.token_endpoint_auth_methods_supported;
252
+ const clientSecret = await this.clientSecret();
253
+ if (methods && !methods.includes('client_secret_basic') && methods.includes('client_secret_post')) {
254
+ form.set('client_id', this.config.clientId);
255
+ form.set('client_secret', clientSecret);
256
+ }
257
+ else {
258
+ const pair = `${encodeURIComponent(this.config.clientId)}:${encodeURIComponent(clientSecret)}`;
259
+ headers.Authorization = `Basic ${Buffer.from(pair).toString('base64')}`;
260
+ }
261
+ const res = await guardedFetch(doc.token_endpoint, {
262
+ policy: this.config.policy,
263
+ method: 'POST',
264
+ headers,
265
+ body: form.toString(),
266
+ maxBytes: MAX_DOC_BYTES,
267
+ timeoutMs: FETCH_TIMEOUT_MS
268
+ });
269
+ if (res.status !== 200)
270
+ throw new Error(`token endpoint answered ${res.status}`);
271
+ const body = JSON.parse(res.body.toString('utf8'));
272
+ if (typeof body.id_token !== 'string')
273
+ throw new Error('no id_token in the token response');
274
+ return body.id_token;
275
+ }
276
+ /** The client secret for one code exchange: the shared one, or a freshly signed JWT (Apple). */
277
+ async clientSecret() {
278
+ const secret = this.config.clientSecret;
279
+ if (typeof secret === 'string')
280
+ return secret;
281
+ const now = Math.floor(Date.now() / 1000);
282
+ return new SignJWT({})
283
+ .setProtectedHeader({ alg: 'ES256', kid: secret.keyId })
284
+ .setIssuer(secret.teamId)
285
+ .setSubject(this.config.clientId)
286
+ .setAudience(this.config.issuer)
287
+ .setIssuedAt(now)
288
+ .setExpirationTime(now + SIGNED_SECRET_TTL_S)
289
+ .sign(secret.privateKey);
290
+ }
291
+ /** Signature, issuer, audience and expiry; the keys are fetched again once if none matches (rotation). */
292
+ async verifyIdToken(idToken) {
293
+ const options = {
294
+ issuer: [this.config.issuer, ...(ISSUER_ALIASES[this.config.issuer] ?? [])],
295
+ audience: this.config.clientId,
296
+ clockTolerance: 60
297
+ };
298
+ try {
299
+ return (await jwtVerify(idToken, await this.keySet(false), options)).payload;
300
+ }
301
+ catch (err) {
302
+ if (!(err instanceof joseErrors.JWKSNoMatchingKey))
303
+ throw err;
304
+ return (await jwtVerify(idToken, await this.keySet(true), options)).payload;
305
+ }
306
+ }
307
+ async keySet(refresh) {
308
+ if (!refresh && this.keys && Date.now() - this.keys.at < CACHE_TTL_MS)
309
+ return this.keys.set;
310
+ const doc = await this.discover();
311
+ const jwks = await this.getJson(doc.jwks_uri);
312
+ if (!jwks || !Array.isArray(jwks.keys))
313
+ throw new Error('the issuer\'s key set is malformed');
314
+ this.keys = { set: createLocalJWKSet(jwks), at: Date.now() };
315
+ return this.keys.set;
316
+ }
317
+ /** The issuer's discovery document, cached; its `issuer` must be the configured one (OIDC Discovery §4.3). */
318
+ async discover() {
319
+ if (this.discovery && Date.now() - this.discovery.at < CACHE_TTL_MS)
320
+ return this.discovery.doc;
321
+ const url = `${this.config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
322
+ const doc = await this.getJson(url);
323
+ if (doc.issuer !== this.config.issuer) {
324
+ throw new Error(`the discovery document names issuer ${String(doc.issuer)}, not ${this.config.issuer}`);
325
+ }
326
+ for (const field of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
327
+ if (typeof doc[field] !== 'string')
328
+ throw new Error(`the discovery document has no ${field}`);
329
+ }
330
+ this.discovery = { doc: doc, at: Date.now() };
331
+ return this.discovery.doc;
332
+ }
333
+ async getJson(url) {
334
+ const res = await guardedFetch(url, {
335
+ policy: this.config.policy,
336
+ headers: { Accept: 'application/json' },
337
+ maxBytes: MAX_DOC_BYTES,
338
+ timeoutMs: FETCH_TIMEOUT_MS
339
+ });
340
+ if (res.status !== 200)
341
+ throw new Error(`${url} answered ${res.status}`);
342
+ return JSON.parse(res.body.toString('utf8'));
343
+ }
344
+ sweep() {
345
+ const now = Date.now();
346
+ for (const [state, flow] of this.flows)
347
+ if (now > flow.expiresAt)
348
+ this.flows.delete(state);
349
+ }
350
+ }