@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/package.json +12 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +261 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +15 -1
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +8 -0
- package/src/media-store-azure.ts +6 -0
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +21 -0
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +112 -7
- package/src/site-file.ts +258 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +155 -0
- package/src/two-factor.ts +138 -0
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.
|
|
635
|
+
export const VERSION = '0.14.1'
|
package/src/delivery-members.ts
CHANGED
|
@@ -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="
|
|
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
|
+
}
|