@crombolo/server 0.13.2 → 0.14.1

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * External identity providers (story 031): typed presets a site's config lists,
3
+ * one list for members and one for backoffice users. Each preset knows its
4
+ * provider's issuer, scopes and claims; the rest says what a first sign-in may
5
+ * do and how groups follow the account.
6
+ *
7
+ * ```ts
8
+ * defineConfig({
9
+ * backofficeIdentityProviders: [entra({ tenantId, clientId, clientSecret, groups: { map: { … } } })],
10
+ * memberIdentityProviders: [awsIdentityCenter({ issuer, clientId, clientSecret })],
11
+ * })
12
+ * ```
13
+ */
14
+ import type { OidcClaims, OidcProviderOptions } from '@crombolo/auth'
15
+
16
+ /** What every preset takes besides the provider's own settings. */
17
+ export interface IdentityProviderOptions {
18
+ /** The provider's id, in its URLs and in `external_login`; the preset's name by default. */
19
+ id?: string
20
+ /** What its sign-in button says: "Sign in with {name}". */
21
+ name?: string
22
+ /** Create a member or user for an account nobody has yet. On by default. */
23
+ autoCreate?: boolean
24
+ /**
25
+ * Link a first sign-in to an existing member or user with the same email,
26
+ * when the provider says the email is verified. Off by default: without it, an
27
+ * email already taken is refused rather than merged.
28
+ */
29
+ linkByEmail?: boolean
30
+ /**
31
+ * Treat every email this provider sends as verified. For a provider whose
32
+ * emails are the organisation's own, such as one Entra tenant, which sends no
33
+ * `email_verified` claim.
34
+ */
35
+ emailsVerified?: boolean
36
+ /**
37
+ * Groups that follow the account: claim values mapped to member groups (for
38
+ * members) or user group aliases (for users), made to match the claim on
39
+ * every sign-in. Groups outside the map are left as they are.
40
+ */
41
+ groups?: { claim?: string; map: Record<string, string> }
42
+ /** Groups a new member or user joins, besides the mapped ones. */
43
+ defaultGroups?: readonly string[]
44
+ /** Members only: the member type a new member is created as, by alias; `Member` by default. */
45
+ memberType?: string
46
+ }
47
+
48
+ /** Which preset made a provider: what the Entra bundle looks for. */
49
+ export type IdentityProviderKind = 'entra' | 'aws' | 'oidc'
50
+
51
+ /** A provider, ready for the config. */
52
+ export interface IdentityProvider {
53
+ kind: IdentityProviderKind
54
+ /** Entra only: the tenant its issuer is for, which an override may change. */
55
+ tenantId?: string
56
+ oidc: OidcProviderOptions
57
+ autoCreate: boolean
58
+ linkByEmail: boolean
59
+ emailsVerified: boolean
60
+ groupMap: Readonly<Record<string, string>>
61
+ defaultGroups: readonly string[]
62
+ memberType: string | undefined
63
+ }
64
+
65
+ const ID = /^[a-z0-9][a-z0-9-]*$/
66
+
67
+ /** Entra's issuer for a tenant. */
68
+ export const entraIssuer = (tenantId: string) =>
69
+ `https://login.microsoftonline.com/${tenantId}/v2.0`
70
+
71
+ function preset(
72
+ kind: IdentityProviderKind,
73
+ defaults: { id: string; name: string },
74
+ provider: { issuer: string; clientId: string; clientSecret?: string; scopes?: readonly string[] },
75
+ claims: OidcClaims,
76
+ options: IdentityProviderOptions,
77
+ ): IdentityProvider {
78
+ const id = options.id ?? defaults.id
79
+ if (!ID.test(id))
80
+ throw new Error(`An identity provider's id is letters, digits and dashes: "${id}" is not.`)
81
+ if (!provider.issuer) throw new Error(`The ${defaults.name} identity provider needs an issuer.`)
82
+ if (!provider.clientId)
83
+ throw new Error(`The ${defaults.name} identity provider needs a clientId.`)
84
+ return {
85
+ kind,
86
+ oidc: {
87
+ id,
88
+ name: options.name ?? defaults.name,
89
+ issuer: provider.issuer,
90
+ clientId: provider.clientId,
91
+ clientSecret: provider.clientSecret,
92
+ scopes: provider.scopes ?? ['openid', 'profile', 'email'],
93
+ // A group map with no claim named reads the usual `groups` claim.
94
+ claims: {
95
+ ...claims,
96
+ groups: options.groups?.claim ?? claims.groups ?? (options.groups ? 'groups' : undefined),
97
+ },
98
+ },
99
+ autoCreate: options.autoCreate ?? true,
100
+ linkByEmail: options.linkByEmail ?? false,
101
+ emailsVerified: options.emailsVerified ?? false,
102
+ groupMap: options.groups?.map ?? {},
103
+ defaultGroups: options.defaultGroups ?? [],
104
+ memberType: options.memberType,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Microsoft Entra ID, for one tenant. Register a web app with a client secret
110
+ * and the redirect URLs in `docs/operations.md`; for groups, add the `groups`
111
+ * claim to its token configuration.
112
+ */
113
+ export function entra(
114
+ options: IdentityProviderOptions & { tenantId: string; clientId: string; clientSecret?: string },
115
+ ): IdentityProvider {
116
+ if (!options.tenantId) throw new Error('The Entra identity provider needs a tenantId.')
117
+ const provider = preset(
118
+ 'entra',
119
+ { id: 'entra', name: 'Microsoft' },
120
+ {
121
+ issuer: entraIssuer(options.tenantId),
122
+ clientId: options.clientId,
123
+ clientSecret: options.clientSecret,
124
+ },
125
+ { email: 'email', name: 'name', groups: 'groups' },
126
+ options,
127
+ )
128
+ return { ...provider, tenantId: options.tenantId }
129
+ }
130
+
131
+ /**
132
+ * AWS IAM Identity Center, through the OpenID Connect application its
133
+ * instance issues; `issuer` is that instance's issuer URL.
134
+ */
135
+ export function awsIdentityCenter(
136
+ options: IdentityProviderOptions & { issuer: string; clientId: string; clientSecret?: string },
137
+ ): IdentityProvider {
138
+ return preset(
139
+ 'aws',
140
+ { id: 'aws', name: 'AWS' },
141
+ { issuer: options.issuer, clientId: options.clientId, clientSecret: options.clientSecret },
142
+ { email: 'email', name: 'name', groups: 'groups' },
143
+ options,
144
+ )
145
+ }
146
+
147
+ /** Any OpenID Connect provider, by its issuer: Google, Okta, Auth0, Keycloak and the like. */
148
+ export function oidc(
149
+ options: IdentityProviderOptions & {
150
+ id: string
151
+ name: string
152
+ issuer: string
153
+ clientId: string
154
+ clientSecret?: string
155
+ scopes?: readonly string[]
156
+ claims?: Partial<OidcClaims>
157
+ },
158
+ ): IdentityProvider {
159
+ return preset(
160
+ 'oidc',
161
+ { id: options.id, name: options.name },
162
+ {
163
+ issuer: options.issuer,
164
+ clientId: options.clientId,
165
+ clientSecret: options.clientSecret,
166
+ scopes: options.scopes,
167
+ },
168
+ { email: 'email', name: 'name', ...options.claims },
169
+ options,
170
+ )
171
+ }
172
+
173
+ /** Refuses two providers with one id in a list, which would share one set of URLs. */
174
+ export function checkIdentityProviders(
175
+ list: readonly IdentityProvider[],
176
+ label: string,
177
+ ): readonly IdentityProvider[] {
178
+ const seen = new Set<string>()
179
+ for (const provider of list) {
180
+ if (seen.has(provider.oidc.id))
181
+ throw new Error(`Two ${label} identity providers have the id "${provider.oidc.id}".`)
182
+ seen.add(provider.oidc.id)
183
+ }
184
+ return list
185
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export * from './email-postmark.ts'
34
34
  export * from './email-resend.ts'
35
35
  export * from './email-ses.ts'
36
36
  export * from './extensions-notice.ts'
37
+ export * from './external-sign-in.ts'
37
38
  export * from './file-intake.ts'
38
39
  export * from './form-files.ts'
39
40
  export * from './form-submit.ts'
@@ -43,6 +44,8 @@ export * from './forms.ts'
43
44
  export * from './git.ts'
44
45
  export * from './git-github.ts'
45
46
  export * from './git-routes.ts'
47
+ export * from './identity-provider-overrides.ts'
48
+ export * from './identity-providers.ts'
46
49
  export * from './imaging.ts'
47
50
  export * from './jobs.ts'
48
51
  export * from './listen.ts'
@@ -67,8 +70,13 @@ export * from './seo.ts'
67
70
  export * from './server.ts'
68
71
  export * from './server-events.ts'
69
72
  export * from './setup.ts'
73
+ export * from './site-file.ts'
74
+ export * from './site-replace.ts'
75
+ export * from './site-sections.ts'
70
76
  export * from './status.ts'
77
+ export * from './tar.ts'
71
78
  export * from './transfer-dashboard.ts'
79
+ export * from './two-factor.ts'
72
80
  export * from './udt.ts'
73
81
  export * from './upgrade-summary.ts'
74
82
  export * from './url-settings.ts'
@@ -156,6 +156,12 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
156
156
  if (!response.ok) throw new Error(`Azure GET ${key} failed with ${response.status}.`)
157
157
  return new Uint8Array(await response.arrayBuffer())
158
158
  },
159
+ stream: async () => {
160
+ const response = await send('GET', url)
161
+ if (!response.ok || !response.body)
162
+ throw new Error(`Azure GET ${key} failed with ${response.status}.`)
163
+ return response.body
164
+ },
159
165
  }
160
166
  },
161
167
 
@@ -61,6 +61,7 @@ export function s3MediaStore(options: S3MediaStoreOptions): MediaStore {
61
61
  etag: stat.etag.replaceAll('"', ''),
62
62
  contentType: stat.type || contentTypeFor(key),
63
63
  bytes: async () => new Uint8Array(await file.arrayBuffer()),
64
+ stream: async () => file.stream(),
64
65
  }
65
66
  } catch {
66
67
  // A missing object is a thrown NoSuchKey, not a null stat.
@@ -31,6 +31,8 @@ export interface StoredFile {
31
31
  etag: string
32
32
  contentType: string
33
33
  bytes(): Promise<Uint8Array>
34
+ /** The bytes as a stream, for a file too big to hold (story 104); `streamOf` falls back to `bytes()`. */
35
+ stream?(): Promise<ReadableStream<Uint8Array>>
34
36
  /** A path on this machine, when the store has one; lets the server stream it. */
35
37
  localPath?: string
36
38
  }
@@ -51,6 +53,24 @@ export interface MediaStore {
51
53
  publicUrl?(key: string): string | undefined
52
54
  }
53
55
 
56
+ /** A stored file's bytes in chunks: streamed where the store can, whole where it cannot. */
57
+ export async function* streamOf(file: StoredFile): AsyncIterable<Uint8Array> {
58
+ if (!file.stream) {
59
+ yield await file.bytes()
60
+ return
61
+ }
62
+ const reader = (await file.stream()).getReader()
63
+ try {
64
+ for (;;) {
65
+ const { done, value } = await reader.read()
66
+ if (done) return
67
+ yield value
68
+ }
69
+ } finally {
70
+ reader.releaseLock()
71
+ }
72
+ }
73
+
54
74
  /** Content types by extension, for stores that do not infer one. */
55
75
  const CONTENT_TYPES: Record<string, string> = {
56
76
  avif: 'image/avif',
@@ -139,6 +159,7 @@ export function fileSystemMediaStore(
139
159
  etag: `${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}`,
140
160
  contentType: file.type || contentTypeFor(key),
141
161
  bytes: () => file.bytes(),
162
+ stream: async () => file.stream(),
142
163
  localPath: path,
143
164
  }
144
165
  },
@@ -12,6 +12,9 @@
12
12
  * POST /umbraco/members/logout
13
13
  * POST /umbraco/members/register name, email, username, password, returnUrl?
14
14
  * GET /umbraco/members/current
15
+ * POST /umbraco/members/login/2fa code, returnUrl? the second step, when 2FA is on
16
+ * GET /umbraco/members/2fa the signed-in member's 2FA state
17
+ * POST /umbraco/members/2fa/setup | enable | disable turning it on and off (story 032)
15
18
  *
16
19
  * A form post is answered with a redirect, a JSON post with JSON, so the same
17
20
  * endpoint serves a plain `<form>` in a TSX template and a fetch from the page.
@@ -27,14 +30,17 @@ import {
27
30
  passwordConfigJson,
28
31
  readCookie,
29
32
  readMemberTicket,
33
+ readShortLived,
30
34
  serializeCookie,
31
35
  signMemberTicket,
36
+ signShortLived,
32
37
  verifyPassword,
33
38
  } from '@crombolo/auth'
34
39
  import {
35
40
  DEFAULT_PASSWORD_CONFIGURATION,
36
41
  isValidPassword,
37
42
  localReturnUrl,
43
+ memberMayOpen,
38
44
  ObjectTypes,
39
45
  } from '@crombolo/core'
40
46
  import {
@@ -45,14 +51,25 @@ import {
45
51
  type NodeSchemaState,
46
52
  PublicAccessRepository,
47
53
  } from '@crombolo/data'
48
- import type { AccessDecision, PublishedContent, RequestMember } from '@crombolo/render'
54
+ import {
55
+ type AccessDecision,
56
+ MEMBER_EXTERNAL_SIGN_IN_PATH,
57
+ type PublishedContent,
58
+ type RequestMember,
59
+ } from '@crombolo/render'
49
60
  import type { CromboloConfig } from './config.ts'
61
+ import { createExternalSignIn, type ExternalAccount } from './external-sign-in.ts'
62
+ import { IdentityProviderOverrides } from './identity-provider-overrides.ts'
63
+ import { MEMBER_TWO_FACTOR_PROVIDER, TwoFactorService } from './two-factor.ts'
50
64
 
51
65
  /** The key member tickets are signed with, generated once and shared by every node. */
52
66
  const SIGNING_KEY = 'crombolo/memberCookieKey'
53
67
 
54
68
  export const MEMBER_ROUTE_PREFIX = '/umbraco/members'
55
69
 
70
+ /** How long a member has to enter their code after their password. */
71
+ const TWO_FACTOR_PENDING_SECONDS = 5 * 60
72
+
56
73
  export interface MemberAuthOptions {
57
74
  /** The schema state this node runs at; a registration is refused without it. */
58
75
  nodeState?: NodeSchemaState
@@ -72,6 +89,11 @@ export interface MemberAuth {
72
89
  handle(request: Request, pathname: string): Promise<Response | undefined>
73
90
  /** The `Set-Cookie` value that ends a member's site session. */
74
91
  signOutCookie(): string
92
+ /**
93
+ * Where a "Sign in with …" link for a member identity provider goes, by its
94
+ * id; undefined for a provider the site does not configure.
95
+ */
96
+ externalSignInUrl(providerId: string, returnUrl?: string): string | undefined
75
97
  }
76
98
 
77
99
  const isFormPost = (request: Request) =>
@@ -149,6 +171,13 @@ export function createMemberAuth(
149
171
  return signingKey
150
172
  }
151
173
 
174
+ const twoFactor = new TwoFactorService(db, {
175
+ secretKey: config.secretKey,
176
+ issuer: config.siteName,
177
+ })
178
+ const pendingCookie = `${cookie}TwoFactor`
179
+ const flagged = (url: string, flags: string) => `${url}${url.includes('?') ? '&' : '?'}${flags}`
180
+
152
181
  const asRequestMember = (member: {
153
182
  key: string
154
183
  name: string
@@ -176,6 +205,93 @@ export function createMemberAuth(
176
205
  })
177
206
  }
178
207
 
208
+ /** Member group keys by name, for the names a registration or a provider names. */
209
+ const groupKeysFor = async (names: readonly string[]): Promise<string[]> => {
210
+ const keys: string[] = []
211
+ for (const groupName of names) {
212
+ const rows = await db.query<{ unique_id: string }>(
213
+ 'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
214
+ [groupName, ObjectTypes.MemberGroup],
215
+ )
216
+ if (rows[0]) keys.push(String(rows[0].unique_id))
217
+ }
218
+ return keys
219
+ }
220
+
221
+ const asAccount = (member: { key: string; email: string; roles: string[] }): ExternalAccount => ({
222
+ key: member.key,
223
+ email: member.email,
224
+ groups: member.roles,
225
+ })
226
+
227
+ const MEMBER_EXTERNAL_PATH = MEMBER_EXTERNAL_SIGN_IN_PATH
228
+ const external =
229
+ config.memberIdentityProviders.length === 0
230
+ ? undefined
231
+ : createExternalSignIn(
232
+ db,
233
+ {
234
+ audience: 'member',
235
+ providers: config.memberIdentityProviders,
236
+ basePath: MEMBER_EXTERNAL_PATH,
237
+ byKey: async (key) => {
238
+ const member = await members.byKey(key)
239
+ return member ? asAccount(member) : undefined
240
+ },
241
+ byEmail: async (email) => {
242
+ const member = await members.byEmail(email)
243
+ return member ? asAccount(member) : undefined
244
+ },
245
+ create: async (identity, provider, groups) => {
246
+ const alias = provider.memberType ?? config.memberRegistrationType
247
+ const type = await memberTypes.byAlias(alias)
248
+ if (!type) throw new Error(`The member type "${alias}" does not exist.`)
249
+ const email = identity.email as string
250
+ // The email is the username, as registration makes it, unless
251
+ // someone already signs in with that name.
252
+ const username = (await members.taken('username', email))
253
+ ? `${provider.oidc.id}-${identity.subject}`
254
+ : email
255
+ const created = await members.create(
256
+ {
257
+ key: crypto.randomUUID(),
258
+ contentTypeKey: type.key,
259
+ componentKey: null,
260
+ parentKey: null,
261
+ values: [],
262
+ variants: [{ culture: null, segment: null, name: identity.name || email }],
263
+ },
264
+ {
265
+ email,
266
+ username,
267
+ isApproved: true,
268
+ isLockedOut: false,
269
+ groupKeys: await groupKeysFor(groups),
270
+ },
271
+ )
272
+ return asAccount(created)
273
+ },
274
+ setGroups: async (account, groups) => {
275
+ await members.setGroups(account.key, await groupKeysFor(groups))
276
+ },
277
+ signIn: async (account) => {
278
+ const member = await members.byKey(account.key)
279
+ if (!member?.isApproved || member.isLockedOut)
280
+ return { refused: 'This account cannot sign in at the moment.' }
281
+ await members.recordLogin(member.key)
282
+ return { cookies: [await issue(member.key, member.securityStamp, false)] }
283
+ },
284
+ failed: (returnUrl, reason) =>
285
+ `${returnUrl}${returnUrl.includes('?') ? '&' : '?'}memberLoginFailed=1&memberLoginError=${encodeURIComponent(reason)}`,
286
+ },
287
+ {
288
+ applicationUrl: config.applicationUrl,
289
+ secureCookies: config.secureCookies,
290
+ resolve: (provider) =>
291
+ new IdentityProviderOverrides(db, config.secretKey).apply('member', provider),
292
+ },
293
+ )
294
+
179
295
  const resolve: MemberAuth['resolve'] = async (request) => {
180
296
  const ticket = await readMemberTicket(await secret(), readCookie(request, cookie))
181
297
  if (!ticket) return undefined
@@ -196,12 +312,7 @@ export function createMemberAuth(
196
312
  // branch they serve, or the substitution would never terminate.
197
313
  if (here === entry.loginNodeKey || here === entry.errorNodeKey) return { status: 'allowed' }
198
314
  if (!member) return { status: 'substitute', contentKey: entry.loginNodeKey }
199
- const allowed =
200
- entry.memberUserNames.some((name) => name.toLowerCase() === member.username.toLowerCase()) ||
201
- entry.memberGroupNames.some((name) =>
202
- member.roles.some((role) => role.toLowerCase() === name.toLowerCase()),
203
- )
204
- return allowed
315
+ return memberMayOpen(entry, { username: member.username, groups: member.roles })
205
316
  ? { status: 'allowed' }
206
317
  : { status: 'substitute', contentKey: entry.errorNodeKey }
207
318
  }
@@ -268,6 +379,28 @@ export function createMemberAuth(
268
379
  await hashPassword(input.password ?? ''),
269
380
  passwordConfigJson(),
270
381
  )
382
+ if ((await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER)) {
383
+ const pending = await signShortLived(
384
+ await secret(),
385
+ { sub: member.key, persist: input.rememberMe === 'true' ? 1 : 0 },
386
+ TWO_FACTOR_PENDING_SECONDS,
387
+ )
388
+ return answer(
389
+ request,
390
+ { ok: true, body: { twoFactorRequired: true } },
391
+ {
392
+ returnUrl: flagged(back, 'memberTwoFactorRequired=1'),
393
+ cookies: [
394
+ serializeCookie({
395
+ name: pendingCookie,
396
+ value: pending,
397
+ secure: config.secureCookies,
398
+ maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
399
+ }),
400
+ ],
401
+ },
402
+ )
403
+ }
271
404
  await members.recordLogin(member.key)
272
405
  return answer(
273
406
  request,
@@ -279,6 +412,87 @@ export function createMemberAuth(
279
412
  )
280
413
  }
281
414
 
415
+ /** The second step: the code from the app, or a recovery code, after the password. */
416
+ const verifyTwoFactor = async (request: Request): Promise<Response> => {
417
+ const input = await readInput(request)
418
+ const back = safeReturnUrl(input.returnUrl) ?? '/'
419
+ const pending = await readShortLived(await secret(), readCookie(request, pendingCookie))
420
+ const key = typeof pending?.sub === 'string' ? pending.sub : undefined
421
+ const member = key ? await members.byKey(key) : undefined
422
+ if (!member || member.isLockedOut || !member.isApproved)
423
+ return answer(
424
+ request,
425
+ { ok: false, status: 401, error: 'Sign in again.' },
426
+ {
427
+ returnUrl: flagged(back, 'memberLoginFailed=1'),
428
+ cookies: [expireCookie(pendingCookie, config.secureCookies)],
429
+ },
430
+ )
431
+ if (!(await twoFactor.verify(member.key, MEMBER_TWO_FACTOR_PROVIDER, input.code ?? ''))) {
432
+ await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
433
+ return answer(
434
+ request,
435
+ { ok: false, status: 400, error: 'That code is not right.' },
436
+ { returnUrl: flagged(back, 'memberTwoFactorRequired=1&memberLoginFailed=1') },
437
+ )
438
+ }
439
+ await members.recordLogin(member.key)
440
+ return answer(
441
+ request,
442
+ { ok: true, body: { member: asRequestMember(member) } },
443
+ {
444
+ returnUrl: back,
445
+ cookies: [
446
+ await issue(member.key, member.securityStamp, pending?.persist === 1),
447
+ expireCookie(pendingCookie, config.secureCookies),
448
+ ],
449
+ },
450
+ )
451
+ }
452
+
453
+ /** The signed-in member's own two-factor settings; JSON, as the recovery codes must be shown. */
454
+ const manageTwoFactor = async (request: Request, step: string): Promise<Response> => {
455
+ const member = await resolve(request)
456
+ if (!member) return Response.json({ error: 'Sign in first.' }, { status: 401 })
457
+ const refused = (message: string, status = 400) => Response.json({ error: message }, { status })
458
+ if (step === '') {
459
+ if (request.method !== 'GET') return new Response(null, { status: 405 })
460
+ return Response.json({
461
+ available: twoFactor.available,
462
+ enabled: (await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER),
463
+ recoveryCodesLeft: await twoFactor.recoveryCodesLeft(member.key),
464
+ })
465
+ }
466
+ if (request.method !== 'POST') return new Response(null, { status: 405 })
467
+ if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
468
+ const input = await readInput(request)
469
+ switch (step) {
470
+ case 'setup': {
471
+ const setup = twoFactor.setup(member.email || member.username)
472
+ return setup.ok ? Response.json(setup.value) : refused(setup.message)
473
+ }
474
+ case 'enable': {
475
+ const enabled = await twoFactor.enable(
476
+ member.key,
477
+ MEMBER_TWO_FACTOR_PROVIDER,
478
+ input.secret ?? '',
479
+ input.code ?? '',
480
+ )
481
+ return enabled.ok ? Response.json(enabled.value) : refused(enabled.message)
482
+ }
483
+ case 'disable': {
484
+ const disabled = await twoFactor.disable(
485
+ member.key,
486
+ MEMBER_TWO_FACTOR_PROVIDER,
487
+ input.code ?? '',
488
+ )
489
+ return disabled.ok ? Response.json({ ok: true }) : refused(disabled.message)
490
+ }
491
+ default:
492
+ return new Response(null, { status: 404 })
493
+ }
494
+ }
495
+
282
496
  const logout = async (request: Request): Promise<Response> => {
283
497
  const input = await readInput(request)
284
498
  return answer(
@@ -334,14 +548,7 @@ export function createMemberAuth(
334
548
  status: 400,
335
549
  error: `A password must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
336
550
  })
337
- const groupKeys: string[] = []
338
- for (const groupName of config.memberRegistrationGroups) {
339
- const rows = await db.query<{ unique_id: string }>(
340
- 'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
341
- [groupName, ObjectTypes.MemberGroup],
342
- )
343
- if (rows[0]) groupKeys.push(String(rows[0].unique_id))
344
- }
551
+ const groupKeys = await groupKeysFor(config.memberRegistrationGroups)
345
552
  const created = await members.create(
346
553
  {
347
554
  key: crypto.randomUUID(),
@@ -375,19 +582,29 @@ export function createMemberAuth(
375
582
  resolve,
376
583
  decide,
377
584
  signOutCookie: () => expireCookie(cookie, config.secureCookies),
585
+ externalSignInUrl: (providerId, returnUrl) =>
586
+ config.memberIdentityProviders.some((p) => p.oidc.id === providerId)
587
+ ? external?.startUrl(providerId, returnUrl)
588
+ : undefined,
378
589
  async handle(request, pathname) {
379
590
  if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
591
+ if (pathname.startsWith(`${MEMBER_EXTERNAL_PATH}/`))
592
+ return external?.handle(request, pathname) ?? undefined
380
593
  const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
381
594
  if (action === 'current') {
382
595
  if (request.method !== 'GET') return new Response(null, { status: 405 })
383
596
  const member = await resolve(request)
384
597
  return member ? Response.json({ member }) : Response.json({ member: null }, { status: 401 })
385
598
  }
599
+ if (action === '2fa' || action.startsWith('2fa/'))
600
+ return manageTwoFactor(request, action.slice(4))
386
601
  if (request.method !== 'POST') return new Response(null, { status: 405 })
387
602
  if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
388
603
  switch (action) {
389
604
  case 'login':
390
605
  return login(request)
606
+ case 'login/2fa':
607
+ return verifyTwoFactor(request)
391
608
  case 'logout':
392
609
  return logout(request)
393
610
  case 'register':