@crombolo/server 0.13.2 → 0.14.4

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/src/config.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { hostname, tmpdir } from 'node:os'
2
2
  import { join, resolve } from 'node:path'
3
3
  import type { DeliveryApiOptions } from '@crombolo/api-delivery'
4
- import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
4
+ import {
5
+ DEFAULT_BACKOFFICE_PATH,
6
+ DEFAULT_PASSWORD_CONFIGURATION,
7
+ isValidPassword,
8
+ } from '@crombolo/core'
5
9
  import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
6
10
  import type { AssistantConfig } from './assistant.ts'
7
11
  import type { ServerBundle } from './bundles.ts'
8
12
  import type { EmailPort } from './email.ts'
9
13
  import type { GitConfig } from './git-routes.ts'
14
+ import { checkIdentityProviders, type IdentityProvider } from './identity-providers.ts'
10
15
  import type { MediaStore } from './media-store.ts'
11
16
  import type { SchemaStore } from './schema-store.ts'
12
17
 
@@ -29,6 +34,65 @@ export type UserLinkSender = (message: {
29
34
  message: string | null
30
35
  }) => Promise<void>
31
36
 
37
+ export interface RemoteSettings {
38
+ enabled: boolean
39
+ /** The environment this server is, as the remote protocol names it. */
40
+ environment: string
41
+ /** A push here replaces something people rely on: it needs its own permission. */
42
+ protected: boolean
43
+ /** A push to a protected environment waits for another person's approval. */
44
+ approvals: boolean
45
+ /**
46
+ * The largest push file or media file a client may upload. Every other route
47
+ * keeps `maxRequestBodyBytes`, checked per request, since Bun's own limit is
48
+ * the server's.
49
+ */
50
+ maxUploadBytes: number
51
+ }
52
+
53
+ /** The prefix every API client id carries, as the Management API requires. */
54
+ export const API_CLIENT_ID_PREFIX = 'crombolo-back-office-'
55
+
56
+ /** Refuses a seeded API client that the Management API would refuse, naming the variable. */
57
+ function checkApiClient(client: CromboloConfig['apiClient']): void {
58
+ if (!client) return
59
+ if (!client.id || !client.secret)
60
+ throw new Error(
61
+ 'CROMBOLO_API_CLIENT_ID and CROMBOLO_API_CLIENT_SECRET go together: set both, or neither.',
62
+ )
63
+ if (
64
+ !client.id.startsWith(API_CLIENT_ID_PREFIX) ||
65
+ client.id.length <= API_CLIENT_ID_PREFIX.length
66
+ )
67
+ throw new Error(`CROMBOLO_API_CLIENT_ID must start with "${API_CLIENT_ID_PREFIX}".`)
68
+ if (!isValidPassword(client.secret))
69
+ throw new Error(
70
+ `CROMBOLO_API_CLIENT_SECRET must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Whether this site runs on the developer's own machine: development, and an
76
+ * application URL that is unset or names localhost. What `localAdmin` needs.
77
+ */
78
+ export function runsOnLocalhost(config: CromboloConfig): boolean {
79
+ if (!config.development) return false
80
+ if (!config.applicationUrl) return true
81
+ try {
82
+ const host = new URL(config.applicationUrl).hostname
83
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1'
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** The body limit to listen with: raised for remote uploads when the remote is on. */
90
+ export function listeningBodyLimit(config: CromboloConfig): number {
91
+ return config.remote.enabled
92
+ ? Math.max(config.maxRequestBodyBytes, config.remote.maxUploadBytes)
93
+ : config.maxRequestBodyBytes
94
+ }
95
+
32
96
  export interface CromboloConfig {
33
97
  port: number
34
98
  /** The largest request body the server will read, media uploads included. */
@@ -73,6 +137,20 @@ export interface CromboloConfig {
73
137
  adminPassword: string | undefined
74
138
  /** Guards "Set up your new site". Chosen by the owner; never printed, logged or shown. */
75
139
  setupCode: string | undefined
140
+ /**
141
+ * The API client an unattended install seeds (story 034), so a pipeline can
142
+ * administer a site whose backoffice only takes an identity provider. Seeded
143
+ * once and never rotated from here; the secret is never printed or logged.
144
+ * With it set, no password administrator is seeded unless `adminPassword` is
145
+ * — or `localAdmin` is, on a site running on localhost.
146
+ */
147
+ apiClient: { id: string; secret: string } | undefined
148
+ /**
149
+ * Seed the password administrator even with `apiClient` set, so local
150
+ * development keeps printing a sign-in. Honoured only on localhost
151
+ * (`runsOnLocalhost`); ignored, with a warning, anywhere else.
152
+ */
153
+ localAdmin: boolean
76
154
  /** Seals secrets kept in the database, such as a webhook's headers; never stored. */
77
155
  secretKey: string | undefined
78
156
  /**
@@ -232,6 +310,23 @@ export interface CromboloConfig {
232
310
  memberSessionMinutes: number
233
311
  /** Failed sign-ins that lock a member out; Umbraco's default is 5. 0 never locks out. */
234
312
  maxFailedPasswordAttempts: number
313
+ /**
314
+ * Identity providers members may sign in with on the site: `entra()`,
315
+ * `awsIdentityCenter()`, `oidc()`. Separate from the backoffice's, so staff
316
+ * and members can sign in through different ones.
317
+ */
318
+ memberIdentityProviders: readonly IdentityProvider[]
319
+ /** Identity providers backoffice users may sign in with. `allowLocalLogin: false` makes them the only way in. */
320
+ backofficeIdentityProviders: readonly IdentityProvider[]
321
+ /**
322
+ * Answering the remote protocol (story 107): a developer's `crombolo` pulls a
323
+ * copy of this site and pushes work back. Off unless `enabled`. This server
324
+ * offers one environment, itself, named `environment`; `protected` makes a
325
+ * push need the protected-push permission, and `approvals` makes it wait for
326
+ * someone else with that permission to approve it, which needs
327
+ * `CROMBOLO_SECRET_KEY`. docs/remote-protocol.md.
328
+ */
329
+ remote: RemoteSettings
235
330
  /**
236
331
  * Redirects the site declares itself, built with `redirect()`. They are synced
237
332
  * into the database at boot, so the backoffice lists them beside the tracked
@@ -418,6 +513,14 @@ export function loadConfig(
418
513
  sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
419
514
  adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
420
515
  adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
516
+ apiClient:
517
+ Bun.env.CROMBOLO_API_CLIENT_ID || Bun.env.CROMBOLO_API_CLIENT_SECRET
518
+ ? {
519
+ id: Bun.env.CROMBOLO_API_CLIENT_ID ?? '',
520
+ secret: Bun.env.CROMBOLO_API_CLIENT_SECRET ?? '',
521
+ }
522
+ : undefined,
523
+ localAdmin: Bun.env.CROMBOLO_LOCAL_ADMIN === 'true',
421
524
  setupCode: Bun.env.CROMBOLO_SETUP_CODE,
422
525
  secretKey: Bun.env.CROMBOLO_SECRET_KEY,
423
526
  secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
@@ -502,6 +605,16 @@ export function loadConfig(
502
605
  maxFailedPasswordAttempts: Number(
503
606
  Bun.env.CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS ?? DEFAULTS.maxFailedPasswordAttempts,
504
607
  ),
608
+ // No environment variable: a provider is a preset, which is code.
609
+ memberIdentityProviders: [],
610
+ backofficeIdentityProviders: [],
611
+ remote: {
612
+ enabled: Bun.env.CROMBOLO_REMOTE_ENABLED === 'true',
613
+ environment: Bun.env.CROMBOLO_REMOTE_ENVIRONMENT || 'production',
614
+ protected: Bun.env.CROMBOLO_REMOTE_PROTECTED !== 'false',
615
+ approvals: Bun.env.CROMBOLO_REMOTE_APPROVALS === 'true',
616
+ maxUploadBytes: Number(Bun.env.CROMBOLO_REMOTE_MAX_UPLOAD_GB ?? 50) * 1024 ** 3,
617
+ },
505
618
  redirects: [],
506
619
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
507
620
  applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
@@ -529,8 +642,16 @@ export function loadConfig(
529
642
  assistant: undefined,
530
643
  }
531
644
  const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
645
+ checkIdentityProviders(merged.memberIdentityProviders, 'member')
646
+ checkIdentityProviders(merged.backofficeIdentityProviders, 'backoffice')
532
647
  // Field by field, so a site setting its title suffix keeps the environment's switches.
533
648
  merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
649
+ merged.remote = { ...fromEnv.remote, ...stripUndefined(overrides.remote ?? {}) }
650
+ checkApiClient(merged.apiClient)
651
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(merged.remote.environment))
652
+ throw new Error(
653
+ `remote.environment "${merged.remote.environment}" must be lower case letters, digits and dashes.`,
654
+ )
534
655
  merged.deliveryApi = {
535
656
  ...fromEnv.deliveryApi,
536
657
  ...stripUndefined(overrides.deliveryApi ?? {}),
@@ -618,4 +739,4 @@ export function describeDatabase(
618
739
  }
619
740
  }
620
741
 
621
- export const VERSION = '0.13.2'
742
+ export const VERSION = '0.14.4'
package/src/database.ts CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  seedContent,
22
22
  seedIdentity,
23
23
  } from '@crombolo/data'
24
+ import { ensureApiClient } from './api-client.ts'
24
25
  import { type BundleChain, migrateBundles, pendingBundleSteps } from './bundle-tables.ts'
25
- import type { CromboloConfig } from './config.ts'
26
+ import { type CromboloConfig, runsOnLocalhost } from './config.ts'
27
+ import { logger } from './logging.ts'
26
28
 
27
29
  /** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
28
30
  export class UpgradePendingError extends Error {
@@ -127,17 +129,28 @@ export async function installDatabase(
127
129
  await migrateBundles(db, chains, { by: config.nodeId })
128
130
 
129
131
  // In production a password is never made up: the pipeline supplies one, or
130
- // the owner sets the site up in the browser (docs/operations.md).
131
- const settingUp = !config.development && !config.adminPassword
132
- const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
132
+ // the owner sets the site up in the browser (docs/operations.md). With an API
133
+ // client to seed (story 034) a machine administers the site instead, and no
134
+ // password administrator exists unless asked for — `adminPassword`, or
135
+ // `localAdmin` on the developer's own machine.
136
+ const localAdmin = config.localAdmin && runsOnLocalhost(config)
137
+ if (config.localAdmin && !localAdmin)
138
+ logger('install').warning(
139
+ 'CROMBOLO_LOCAL_ADMIN is ignored: this site does not run on localhost',
140
+ )
141
+ const machineOnly = config.apiClient !== undefined && !config.adminPassword && !localAdmin
142
+ const settingUp = !machineOnly && !config.development && !config.adminPassword
143
+ const password = config.adminPassword || crypto.randomUUID().replaceAll('-', '').slice(0, 20)
133
144
  const seeded = await seedIdentity(db, {
134
- admin: settingUp
135
- ? undefined
136
- : { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
145
+ admin:
146
+ settingUp || machineOnly
147
+ ? undefined
148
+ : { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
137
149
  hashPassword,
138
150
  passwordConfig: passwordConfigJson(),
139
151
  })
140
152
  if (seeded && settingUp) await markSetupPending(db)
153
+ if (config.apiClient) await ensureApiClient(db, config.apiClient)
141
154
 
142
155
  await seedContent(db)
143
156
  // A database from an older version gains the built-ins it lacks.
@@ -146,5 +159,5 @@ export async function installDatabase(
146
159
  await ensureSystemMemberTypes(db)
147
160
  await ensureSystemContentTypes(db)
148
161
 
149
- return seeded && !settingUp && !config.adminPassword ? password : undefined
162
+ return seeded && !settingUp && !machineOnly && !config.adminPassword ? password : undefined
150
163
  }
@@ -39,7 +39,7 @@ const tokenError = (error: string, description: string, status = 400) =>
39
39
  { error, error_description: description },
40
40
  {
41
41
  status,
42
- headers: status === 401 ? { 'www-authenticate': 'Basic realm="Umbraco"' } : undefined,
42
+ headers: status === 401 ? { 'www-authenticate': 'Basic realm="Crombolo"' } : undefined,
43
43
  },
44
44
  )
45
45
 
@@ -83,7 +83,7 @@ export interface DeliveryMemberSecurity {
83
83
  export function createDeliveryMemberSecurity(options: {
84
84
  db: Db
85
85
  settings: DeliveryApiSettings
86
- members: Pick<MemberAuth, 'resolve' | 'signOutCookie'>
86
+ members: Pick<MemberAuth, 'resolve' | 'signOutCookie' | 'externalSignInUrl'>
87
87
  }): DeliveryMemberSecurity {
88
88
  const { db, settings } = options
89
89
  const tokens = new DeliveryMemberTokenRepository(db)
@@ -175,6 +175,15 @@ export function createDeliveryMemberSecurity(options: {
175
175
 
176
176
  const signedIn = await options.members.resolve(request)
177
177
  if (!signedIn) {
178
+ // `identity_provider` names a member provider, as Umbraco's
179
+ // `UmbracoMembers.{id}` scheme or by its id: straight there, then back here.
180
+ const named = q('identity_provider').replace(/^UmbracoMembers\./i, '')
181
+ if (named) {
182
+ const external = options.members.externalSignInUrl(named, `${url.pathname}${url.search}`)
183
+ if (!external)
184
+ return local('invalid_request', "The specified 'identity_provider' is not configured.")
185
+ return new Response(null, { status: 302, headers: { location: external } })
186
+ }
178
187
  if (!code.loginPage)
179
188
  return local(
180
189
  'login_required',
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Signing in through an external identity provider (story 031), for members on
3
+ * the site and users in the backoffice alike: the browser leaves for the
4
+ * provider and comes back to a callback, which verifies who signed in, finds or
5
+ * links or creates their account, makes their groups follow the provider, and
6
+ * ends exactly as a password sign-in does. docs/operations.md.
7
+ *
8
+ * GET {base}/{provider}?returnUrl=… leave for the provider
9
+ * GET {base}/{provider}/callback come back from it
10
+ */
11
+ import {
12
+ OidcClient,
13
+ OidcError,
14
+ type OidcIdentity,
15
+ readCookie,
16
+ readFlowState,
17
+ serializeCookie,
18
+ signFlowState,
19
+ } from '@crombolo/auth'
20
+ import { localReturnUrl } from '@crombolo/core'
21
+ import {
22
+ type Db,
23
+ type ExternalLoginAudience,
24
+ ExternalLoginRepository,
25
+ ensureKeyValue,
26
+ externalLoginProvider,
27
+ } from '@crombolo/data'
28
+ import type { IdentityProvider } from './identity-providers.ts'
29
+
30
+ /** A member or user, as far as signing in is concerned. */
31
+ export interface ExternalAccount {
32
+ key: string
33
+ email: string
34
+ /** Member group names, or user group aliases. */
35
+ groups: readonly string[]
36
+ }
37
+
38
+ /** What a first sign-in through a provider comes to. Pure. */
39
+ export type SignInDecision =
40
+ | { kind: 'signIn'; ownerKey: string }
41
+ | { kind: 'link'; ownerKey: string }
42
+ | { kind: 'create' }
43
+ | { kind: 'refuse'; reason: string }
44
+
45
+ /**
46
+ * Who a provider's sign-in is, given who is already linked to it and who has
47
+ * its email. An account already linked signs in. Otherwise an account with the
48
+ * same email is linked only when the provider allows it and vouches for the
49
+ * email; it is never merged otherwise. Nobody at all is created, when allowed.
50
+ */
51
+ export function decideSignIn(input: {
52
+ linked: string | undefined
53
+ sameEmail: string | undefined
54
+ identity: Pick<OidcIdentity, 'email' | 'emailVerified'>
55
+ provider: Pick<IdentityProvider, 'autoCreate' | 'linkByEmail' | 'emailsVerified'> & {
56
+ name: string
57
+ }
58
+ }): SignInDecision {
59
+ const { linked, sameEmail, identity, provider } = input
60
+ if (linked) return { kind: 'signIn', ownerKey: linked }
61
+ if (!identity.email)
62
+ return { kind: 'refuse', reason: `${provider.name} did not say what your email address is.` }
63
+ if (sameEmail) {
64
+ const verified = identity.emailVerified || provider.emailsVerified
65
+ if (provider.linkByEmail && verified) return { kind: 'link', ownerKey: sameEmail }
66
+ return {
67
+ kind: 'refuse',
68
+ reason:
69
+ 'An account with this email address already exists. Sign in the way you usually do, or ask for the two to be linked.',
70
+ }
71
+ }
72
+ if (!provider.autoCreate)
73
+ return { kind: 'refuse', reason: `No account here is linked to your ${provider.name} sign-in.` }
74
+ return { kind: 'create' }
75
+ }
76
+
77
+ /**
78
+ * An account's groups once the provider's claim is applied: the groups the
79
+ * mapping manages are made to match the claim, and every other group is kept.
80
+ * Pure.
81
+ */
82
+ export function syncedGroups(
83
+ current: readonly string[],
84
+ claimValues: readonly string[],
85
+ map: Readonly<Record<string, string>>,
86
+ ): string[] {
87
+ const managed = new Set(Object.values(map).map((g) => g.toLowerCase()))
88
+ const wanted = claimValues.flatMap((value) => (map[value] ? [map[value]] : []))
89
+ const kept = current.filter((group) => !managed.has(group.toLowerCase()))
90
+ const out: string[] = []
91
+ for (const group of [...kept, ...wanted])
92
+ if (!out.some((g) => g.toLowerCase() === group.toLowerCase())) out.push(group)
93
+ return out
94
+ }
95
+
96
+ /** What one audience — members or users — does at each step. */
97
+ export interface ExternalSignInAudience {
98
+ audience: ExternalLoginAudience
99
+ providers: readonly IdentityProvider[]
100
+ /** Where the routes answer: `{base}/{provider}` and `{base}/{provider}/callback`. */
101
+ basePath: string
102
+ byKey(key: string): Promise<ExternalAccount | undefined>
103
+ byEmail(email: string): Promise<ExternalAccount | undefined>
104
+ create(
105
+ identity: OidcIdentity,
106
+ provider: IdentityProvider,
107
+ groups: string[],
108
+ ): Promise<ExternalAccount>
109
+ setGroups(account: ExternalAccount, groups: string[]): Promise<void>
110
+ /** Signs the account in: the cookies to set, or why it may not. */
111
+ signIn(
112
+ account: ExternalAccount,
113
+ request: Request,
114
+ ): Promise<{ cookies: string[] } | { refused: string }>
115
+ /** Where a sign-in goes back to when it fails, with the reason to show. */
116
+ failed(returnUrl: string, reason: string): string
117
+ }
118
+
119
+ export interface ExternalSignIn {
120
+ /** The provider URL to start a sign-in, for a link or button. */
121
+ startUrl(providerId: string, returnUrl?: string): string
122
+ handle(request: Request, pathname: string): Promise<Response | undefined>
123
+ }
124
+
125
+ const FLOW_KEY = 'crombolo/externalLoginKey'
126
+
127
+ export function createExternalSignIn(
128
+ db: Db,
129
+ audience: ExternalSignInAudience,
130
+ options: {
131
+ applicationUrl: string
132
+ secureCookies: boolean
133
+ log?: (message: string) => void
134
+ /** The provider as a sign-in uses it, with an administrator's overrides on top (story 033). */
135
+ resolve?: (provider: IdentityProvider) => Promise<IdentityProvider>
136
+ },
137
+ ): ExternalSignIn {
138
+ const logins = new ExternalLoginRepository(db)
139
+ // One client per effective configuration, so discovery stays cached until a
140
+ // setting changes, and a changed one is used at once.
141
+ const clients = new Map<string, OidcClient>()
142
+ const clientFor = (provider: IdentityProvider) => {
143
+ const key = JSON.stringify(provider.oidc)
144
+ let client = clients.get(key)
145
+ if (!client) {
146
+ client = new OidcClient(provider.oidc)
147
+ clients.set(key, client)
148
+ }
149
+ return client
150
+ }
151
+ const byId = new Map(audience.providers.map((provider) => [provider.oidc.id, provider]))
152
+ let key: Promise<string> | undefined
153
+ const secret = () => {
154
+ key ??= ensureKeyValue(db, FLOW_KEY, () => crypto.randomUUID())
155
+ return key
156
+ }
157
+ const flowCookie = `crombolo-external-${audience.audience}`
158
+ const originOf = (request: Request) =>
159
+ (options.applicationUrl || new URL(request.url).origin).replace(/\/$/, '')
160
+ const callbackUrl = (request: Request, id: string) =>
161
+ `${originOf(request)}${audience.basePath}/${id}/callback`
162
+ const redirect = (location: string, cookies: string[] = []) => {
163
+ const headers = new Headers({ location })
164
+ for (const cookie of cookies) headers.append('set-cookie', cookie)
165
+ return new Response(null, { status: 302, headers })
166
+ }
167
+ // Lax (as every cookie here is), not Strict: the provider sends the browser
168
+ // back with a top-level GET, which a Strict cookie would not ride along on.
169
+ const flowCookieValue = (value: string, maxAgeSeconds: number) =>
170
+ serializeCookie({
171
+ name: flowCookie,
172
+ value,
173
+ secure: options.secureCookies,
174
+ maxAgeSeconds,
175
+ path: audience.basePath,
176
+ })
177
+
178
+ const start = async (request: Request, provider: IdentityProvider, client: OidcClient) => {
179
+ const returnUrl = localReturnUrl(new URL(request.url).searchParams.get('returnUrl')) ?? '/'
180
+ try {
181
+ const { url, flow } = await client.start(callbackUrl(request, provider.oidc.id), returnUrl)
182
+ return redirect(url, [flowCookieValue(await signFlowState(await secret(), flow), 600)])
183
+ } catch (error) {
184
+ options.log?.(`${provider.oidc.name} sign-in could not start: ${(error as Error).message}`)
185
+ return redirect(
186
+ audience.failed(returnUrl, `${provider.oidc.name} is not reachable right now.`),
187
+ )
188
+ }
189
+ }
190
+
191
+ const finish = async (request: Request, provider: IdentityProvider, client: OidcClient) => {
192
+ const clear = flowCookieValue('', 0)
193
+ const flow = await readFlowState(await secret(), readCookie(request, flowCookie))
194
+ if (!flow || flow.provider !== provider.oidc.id)
195
+ return redirect(
196
+ audience.failed('/', 'That sign-in took too long, or was not started here. Try again.'),
197
+ [clear],
198
+ )
199
+ const back = (reason: string) => redirect(audience.failed(flow.returnUrl, reason), [clear])
200
+ let identity: OidcIdentity
201
+ try {
202
+ identity = await client.finish(
203
+ new URL(request.url),
204
+ flow,
205
+ callbackUrl(request, provider.oidc.id),
206
+ )
207
+ } catch (error) {
208
+ if (!(error instanceof OidcError)) throw error
209
+ options.log?.(error.message)
210
+ return back(`Signing in with ${provider.oidc.name} did not work.`)
211
+ }
212
+
213
+ const loginProvider = externalLoginProvider(audience.audience, provider.oidc.id)
214
+ const linked = await logins.find(loginProvider, identity.subject)
215
+ // A link to an account that has since been deleted counts for nothing.
216
+ const linkedAccount = linked ? await audience.byKey(linked.ownerKey) : undefined
217
+ const sameEmail =
218
+ !linkedAccount && identity.email ? await audience.byEmail(identity.email) : undefined
219
+ const decision = decideSignIn({
220
+ linked: linkedAccount?.key,
221
+ sameEmail: sameEmail?.key,
222
+ identity,
223
+ provider: { ...provider, name: provider.oidc.name },
224
+ })
225
+ let account: ExternalAccount
226
+ switch (decision.kind) {
227
+ case 'refuse':
228
+ return back(decision.reason)
229
+ case 'signIn':
230
+ account = linkedAccount as ExternalAccount
231
+ break
232
+ case 'link':
233
+ account = sameEmail as ExternalAccount
234
+ await logins.link(account.key, loginProvider, identity.subject)
235
+ break
236
+ case 'create': {
237
+ const groups = syncedGroups(provider.defaultGroups, identity.groups, provider.groupMap)
238
+ account = await audience.create(identity, provider, groups)
239
+ await logins.link(account.key, loginProvider, identity.subject)
240
+ break
241
+ }
242
+ }
243
+ if (decision.kind !== 'create' && Object.keys(provider.groupMap).length > 0) {
244
+ const groups = syncedGroups(account.groups, identity.groups, provider.groupMap)
245
+ if (groups.join('\n') !== account.groups.join('\n')) await audience.setGroups(account, groups)
246
+ }
247
+ const signedIn = await audience.signIn(account, request)
248
+ if ('refused' in signedIn) return back(signedIn.refused)
249
+ return redirect(flow.returnUrl, [clear, ...signedIn.cookies])
250
+ }
251
+
252
+ return {
253
+ startUrl(providerId, returnUrl) {
254
+ const query = returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''
255
+ return `${audience.basePath}/${providerId}${query}`
256
+ },
257
+ async handle(request, pathname) {
258
+ if (!pathname.startsWith(`${audience.basePath}/`)) return undefined
259
+ const [id, step, ...rest] = pathname.slice(audience.basePath.length + 1).split('/')
260
+ const declared = id ? byId.get(id) : undefined
261
+ if (!declared || rest.length > 0) return undefined
262
+ if (request.method !== 'GET') return new Response(null, { status: 405 })
263
+ const provider = options.resolve ? await options.resolve(declared) : declared
264
+ const client = clientFor(provider)
265
+ if (step === undefined || step === '') return start(request, provider, client)
266
+ if (step === 'callback') return finish(request, provider, client)
267
+ return undefined
268
+ },
269
+ }
270
+ }