@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/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/api-client.ts +47 -0
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +278 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +123 -2
- package/src/database.ts +21 -8
- 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 +12 -0
- package/src/media-store-azure.ts +7 -1
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +25 -2
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/remote-credentials.ts +108 -0
- package/src/remote-media.ts +91 -0
- package/src/remote.ts +793 -0
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +195 -11
- package/src/site-file.ts +292 -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 +164 -0
- package/src/two-factor.ts +138 -0
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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
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:
|
|
135
|
-
|
|
136
|
-
|
|
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
|
}
|
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
|
+
}
|