@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.
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@ import type { AssistantConfig } from './assistant.ts'
7
7
  import type { ServerBundle } from './bundles.ts'
8
8
  import type { EmailPort } from './email.ts'
9
9
  import type { GitConfig } from './git-routes.ts'
10
+ import { checkIdentityProviders, type IdentityProvider } from './identity-providers.ts'
10
11
  import type { MediaStore } from './media-store.ts'
11
12
  import type { SchemaStore } from './schema-store.ts'
12
13
 
@@ -232,6 +233,14 @@ export interface CromboloConfig {
232
233
  memberSessionMinutes: number
233
234
  /** Failed sign-ins that lock a member out; Umbraco's default is 5. 0 never locks out. */
234
235
  maxFailedPasswordAttempts: number
236
+ /**
237
+ * Identity providers members may sign in with on the site: `entra()`,
238
+ * `awsIdentityCenter()`, `oidc()`. Separate from the backoffice's, so staff
239
+ * and members can sign in through different ones.
240
+ */
241
+ memberIdentityProviders: readonly IdentityProvider[]
242
+ /** Identity providers backoffice users may sign in with. `allowLocalLogin: false` makes them the only way in. */
243
+ backofficeIdentityProviders: readonly IdentityProvider[]
235
244
  /**
236
245
  * Redirects the site declares itself, built with `redirect()`. They are synced
237
246
  * into the database at boot, so the backoffice lists them beside the tracked
@@ -502,6 +511,9 @@ export function loadConfig(
502
511
  maxFailedPasswordAttempts: Number(
503
512
  Bun.env.CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS ?? DEFAULTS.maxFailedPasswordAttempts,
504
513
  ),
514
+ // No environment variable: a provider is a preset, which is code.
515
+ memberIdentityProviders: [],
516
+ backofficeIdentityProviders: [],
505
517
  redirects: [],
506
518
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
507
519
  applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
@@ -529,6 +541,8 @@ export function loadConfig(
529
541
  assistant: undefined,
530
542
  }
531
543
  const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
544
+ checkIdentityProviders(merged.memberIdentityProviders, 'member')
545
+ checkIdentityProviders(merged.backofficeIdentityProviders, 'backoffice')
532
546
  // Field by field, so a site setting its title suffix keeps the environment's switches.
533
547
  merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
534
548
  merged.deliveryApi = {
@@ -618,4 +632,4 @@ export function describeDatabase(
618
632
  }
619
633
  }
620
634
 
621
- export const VERSION = '0.13.2'
635
+ export const VERSION = '0.14.1'
@@ -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
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Settings an administrator changed for an identity provider the config
3
+ * declares (story 033): kept in `key_value`, one row per provider and audience,
4
+ * with the client secret sealed by `CROMBOLO_SECRET_KEY`. The config's preset is
5
+ * the provider; these are only the fields changed, put on top of it at every
6
+ * sign-in, so a change reaches every node with nothing to invalidate.
7
+ */
8
+ import type { Db, ExternalLoginAudience } from '@crombolo/data'
9
+ import { entraIssuer, type IdentityProvider } from './identity-providers.ts'
10
+ import { openSecret, SECRET_KEY_VARIABLE, sealSecret, usableSecretKey } from './secrets.ts'
11
+
12
+ /** The fields an override may change. */
13
+ export interface ProviderOverrides {
14
+ name?: string
15
+ tenantId?: string
16
+ issuer?: string
17
+ clientId?: string
18
+ autoCreate?: boolean
19
+ linkByEmail?: boolean
20
+ emailsVerified?: boolean
21
+ groupMap?: Record<string, string>
22
+ defaultGroups?: string[]
23
+ }
24
+
25
+ /** What an administrator sends: the fields to change, and a client secret to set (`null` clears it). */
26
+ export interface ProviderOverrideInput extends ProviderOverrides {
27
+ clientSecret?: string | null
28
+ }
29
+
30
+ interface Stored extends ProviderOverrides {
31
+ clientSecretSealed?: string
32
+ }
33
+
34
+ const OVERRIDABLE: readonly (keyof ProviderOverrides)[] = [
35
+ 'name',
36
+ 'tenantId',
37
+ 'issuer',
38
+ 'clientId',
39
+ 'autoCreate',
40
+ 'linkByEmail',
41
+ 'emailsVerified',
42
+ 'groupMap',
43
+ 'defaultGroups',
44
+ ]
45
+
46
+ const keyOf = (audience: ExternalLoginAudience, id: string) =>
47
+ `crombolo:identity-provider:${audience}:${id}`
48
+
49
+ /** The provider the config declares, with an administrator's changes on top. Pure. */
50
+ export function effectiveProvider(
51
+ code: IdentityProvider,
52
+ overrides: ProviderOverrides & { clientSecret?: string },
53
+ ): IdentityProvider {
54
+ const tenantId = overrides.tenantId ?? code.tenantId
55
+ const issuer =
56
+ overrides.issuer ??
57
+ (code.kind === 'entra' && overrides.tenantId
58
+ ? entraIssuer(overrides.tenantId)
59
+ : code.oidc.issuer)
60
+ return {
61
+ ...code,
62
+ ...(tenantId ? { tenantId } : {}),
63
+ oidc: {
64
+ ...code.oidc,
65
+ name: overrides.name ?? code.oidc.name,
66
+ issuer,
67
+ clientId: overrides.clientId ?? code.oidc.clientId,
68
+ clientSecret: overrides.clientSecret ?? code.oidc.clientSecret,
69
+ },
70
+ autoCreate: overrides.autoCreate ?? code.autoCreate,
71
+ linkByEmail: overrides.linkByEmail ?? code.linkByEmail,
72
+ emailsVerified: overrides.emailsVerified ?? code.emailsVerified,
73
+ groupMap: overrides.groupMap ?? code.groupMap,
74
+ defaultGroups: overrides.defaultGroups ?? code.defaultGroups,
75
+ }
76
+ }
77
+
78
+ export class IdentityProviderOverrides {
79
+ #db: Db
80
+ #secretKey: string | undefined
81
+
82
+ constructor(db: Db, secretKey: string | undefined) {
83
+ this.#db = db
84
+ this.#secretKey = usableSecretKey(secretKey)
85
+ }
86
+
87
+ async #read(audience: ExternalLoginAudience, id: string): Promise<Stored> {
88
+ const rows = await this.#db.query<{ value: string | null }>(
89
+ 'SELECT value FROM key_value WHERE key = ?',
90
+ [keyOf(audience, id)],
91
+ )
92
+ try {
93
+ return rows[0]?.value ? (JSON.parse(rows[0].value) as Stored) : {}
94
+ } catch {
95
+ return {}
96
+ }
97
+ }
98
+
99
+ /** The fields changed, with whether a client secret is kept here; never the secret itself. */
100
+ async get(
101
+ audience: ExternalLoginAudience,
102
+ id: string,
103
+ ): Promise<{ overrides: ProviderOverrides; hasClientSecret: boolean }> {
104
+ const { clientSecretSealed, ...overrides } = await this.#read(audience, id)
105
+ return { overrides, hasClientSecret: Boolean(clientSecretSealed) }
106
+ }
107
+
108
+ /** A provider as a sign-in uses it: the config's, with the changes and a kept secret on top. */
109
+ async apply(audience: ExternalLoginAudience, code: IdentityProvider): Promise<IdentityProvider> {
110
+ const { clientSecretSealed, ...overrides } = await this.#read(audience, code.oidc.id)
111
+ if (Object.keys(overrides).length === 0 && !clientSecretSealed) return code
112
+ let clientSecret: string | undefined
113
+ if (clientSecretSealed && this.#secretKey) {
114
+ try {
115
+ clientSecret = openSecret(clientSecretSealed, this.#secretKey)
116
+ } catch {
117
+ clientSecret = undefined
118
+ }
119
+ }
120
+ return effectiveProvider(code, { ...overrides, clientSecret })
121
+ }
122
+
123
+ /** Changes some fields. An empty string or an empty value clears a field back to the config's. */
124
+ async update(
125
+ audience: ExternalLoginAudience,
126
+ id: string,
127
+ input: ProviderOverrideInput,
128
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
129
+ const stored = await this.#read(audience, id)
130
+ for (const field of OVERRIDABLE) {
131
+ if (!(field in input)) continue
132
+ const value = input[field]
133
+ if (value === undefined || value === null || value === '') delete stored[field]
134
+ else (stored as Record<string, unknown>)[field] = value
135
+ }
136
+ if (input.clientSecret === null || input.clientSecret === '') delete stored.clientSecretSealed
137
+ else if (typeof input.clientSecret === 'string') {
138
+ if (!this.#secretKey)
139
+ return {
140
+ ok: false,
141
+ error: `Keeping a client secret here needs ${SECRET_KEY_VARIABLE} set on the server.`,
142
+ }
143
+ stored.clientSecretSealed = sealSecret(input.clientSecret, this.#secretKey)
144
+ }
145
+ await this.#write(audience, id, stored)
146
+ return { ok: true }
147
+ }
148
+
149
+ /** Puts a provider back to what the config says. */
150
+ async reset(audience: ExternalLoginAudience, id: string): Promise<void> {
151
+ await this.#db.exec('DELETE FROM key_value WHERE key = ?', [keyOf(audience, id)])
152
+ }
153
+
154
+ async #write(audience: ExternalLoginAudience, id: string, stored: Stored): Promise<void> {
155
+ const key = keyOf(audience, id)
156
+ const now = this.#db.dialect.now()
157
+ if (Object.keys(stored).length === 0) {
158
+ await this.#db.exec('DELETE FROM key_value WHERE key = ?', [key])
159
+ return
160
+ }
161
+ const value = JSON.stringify(stored)
162
+ const updated = await this.#db.query<{ key: string }>(
163
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
164
+ [value, key],
165
+ )
166
+ if (updated.length === 0)
167
+ await this.#db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
168
+ key,
169
+ value,
170
+ ])
171
+ }
172
+ }
173
+
174
+ /** A provider as a bundle sees it: its effective settings and which of them were changed. */
175
+ export interface BundleIdentityProvider {
176
+ audience: ExternalLoginAudience
177
+ id: string
178
+ kind: IdentityProvider['kind']
179
+ name: string
180
+ tenantId: string | null
181
+ issuer: string
182
+ clientId: string
183
+ /** Whether there is a client secret, from the config or kept here. Never the secret. */
184
+ hasClientSecret: boolean
185
+ autoCreate: boolean
186
+ linkByEmail: boolean
187
+ emailsVerified: boolean
188
+ groupMap: Record<string, string>
189
+ defaultGroups: string[]
190
+ /** The fields an administrator changed, `clientSecret` among them when one is kept here. */
191
+ overridden: string[]
192
+ /** The path to register with the provider as the redirect URL, after the site's address. */
193
+ callbackPath: string
194
+ }
195
+
196
+ /** The `identityProviders` bundle capability (story 033): edit, never declare. */
197
+ export interface BundleIdentityProviders {
198
+ list(): Promise<BundleIdentityProvider[]>
199
+ update(
200
+ audience: ExternalLoginAudience,
201
+ id: string,
202
+ input: ProviderOverrideInput,
203
+ ): Promise<{ ok: true } | { ok: false; error: string }>
204
+ reset(audience: ExternalLoginAudience, id: string): Promise<boolean>
205
+ /** Whether the provider's discovery document answers, with what it says if not. */
206
+ test(audience: ExternalLoginAudience, id: string): Promise<{ ok: boolean; message: string }>
207
+ /** The groups a mapping can name: user groups by alias, member groups by name. */
208
+ groups(audience: ExternalLoginAudience): Promise<Array<{ value: string; name: string }>>
209
+ }
210
+
211
+ export function createBundleIdentityProviders(options: {
212
+ overrides: IdentityProviderOverrides
213
+ members: readonly IdentityProvider[]
214
+ backoffice: readonly IdentityProvider[]
215
+ memberCallbackPath: string
216
+ backofficeCallbackPath: string
217
+ groups(audience: ExternalLoginAudience): Promise<Array<{ value: string; name: string }>>
218
+ }): BundleIdentityProviders {
219
+ const declared = (audience: ExternalLoginAudience, id: string) =>
220
+ (audience === 'member' ? options.members : options.backoffice).find((p) => p.oidc.id === id)
221
+
222
+ const describe = async (
223
+ audience: ExternalLoginAudience,
224
+ code: IdentityProvider,
225
+ ): Promise<BundleIdentityProvider> => {
226
+ const effective = await options.overrides.apply(audience, code)
227
+ const { overrides, hasClientSecret } = await options.overrides.get(audience, code.oidc.id)
228
+ const base = audience === 'member' ? options.memberCallbackPath : options.backofficeCallbackPath
229
+ return {
230
+ audience,
231
+ id: code.oidc.id,
232
+ kind: code.kind,
233
+ name: effective.oidc.name,
234
+ tenantId: effective.tenantId ?? null,
235
+ issuer: effective.oidc.issuer,
236
+ clientId: effective.oidc.clientId,
237
+ hasClientSecret: Boolean(effective.oidc.clientSecret),
238
+ autoCreate: effective.autoCreate,
239
+ linkByEmail: effective.linkByEmail,
240
+ emailsVerified: effective.emailsVerified,
241
+ groupMap: { ...effective.groupMap },
242
+ defaultGroups: [...effective.defaultGroups],
243
+ overridden: [...Object.keys(overrides), ...(hasClientSecret ? ['clientSecret'] : [])],
244
+ callbackPath: `${base}/${code.oidc.id}/callback`,
245
+ }
246
+ }
247
+
248
+ return {
249
+ async list() {
250
+ return Promise.all([
251
+ ...options.members.map((p) => describe('member', p)),
252
+ ...options.backoffice.map((p) => describe('user', p)),
253
+ ])
254
+ },
255
+ async update(audience, id, input) {
256
+ if (!declared(audience, id))
257
+ return { ok: false, error: 'The config declares no such identity provider.' }
258
+ return options.overrides.update(audience, id, input)
259
+ },
260
+ async reset(audience, id) {
261
+ if (!declared(audience, id)) return false
262
+ await options.overrides.reset(audience, id)
263
+ return true
264
+ },
265
+ groups: (audience) => options.groups(audience),
266
+ async test(audience, id) {
267
+ const code = declared(audience, id)
268
+ if (!code) return { ok: false, message: 'The config declares no such identity provider.' }
269
+ const effective = await options.overrides.apply(audience, code)
270
+ const url = `${effective.oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
271
+ try {
272
+ const response = await fetch(url, { headers: { accept: 'application/json' } })
273
+ if (!response.ok) return { ok: false, message: `${url} answered ${response.status}.` }
274
+ const document = (await response.json()) as { authorization_endpoint?: unknown }
275
+ return typeof document.authorization_endpoint === 'string'
276
+ ? { ok: true, message: `${effective.oidc.name} answered at ${effective.oidc.issuer}.` }
277
+ : { ok: false, message: `${url} is not an OpenID Connect discovery document.` }
278
+ } catch (error) {
279
+ return { ok: false, message: `${url} could not be reached: ${(error as Error).message}` }
280
+ }
281
+ },
282
+ }
283
+ }