@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External identity providers (story 031): typed presets a site's config lists,
|
|
3
|
+
* one list for members and one for backoffice users. Each preset knows its
|
|
4
|
+
* provider's issuer, scopes and claims; the rest says what a first sign-in may
|
|
5
|
+
* do and how groups follow the account.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* defineConfig({
|
|
9
|
+
* backofficeIdentityProviders: [entra({ tenantId, clientId, clientSecret, groups: { map: { … } } })],
|
|
10
|
+
* memberIdentityProviders: [awsIdentityCenter({ issuer, clientId, clientSecret })],
|
|
11
|
+
* })
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import type { OidcClaims, OidcProviderOptions } from '@crombolo/auth'
|
|
15
|
+
|
|
16
|
+
/** What every preset takes besides the provider's own settings. */
|
|
17
|
+
export interface IdentityProviderOptions {
|
|
18
|
+
/** The provider's id, in its URLs and in `external_login`; the preset's name by default. */
|
|
19
|
+
id?: string
|
|
20
|
+
/** What its sign-in button says: "Sign in with {name}". */
|
|
21
|
+
name?: string
|
|
22
|
+
/** Create a member or user for an account nobody has yet. On by default. */
|
|
23
|
+
autoCreate?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Link a first sign-in to an existing member or user with the same email,
|
|
26
|
+
* when the provider says the email is verified. Off by default: without it, an
|
|
27
|
+
* email already taken is refused rather than merged.
|
|
28
|
+
*/
|
|
29
|
+
linkByEmail?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Treat every email this provider sends as verified. For a provider whose
|
|
32
|
+
* emails are the organisation's own, such as one Entra tenant, which sends no
|
|
33
|
+
* `email_verified` claim.
|
|
34
|
+
*/
|
|
35
|
+
emailsVerified?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Groups that follow the account: claim values mapped to member groups (for
|
|
38
|
+
* members) or user group aliases (for users), made to match the claim on
|
|
39
|
+
* every sign-in. Groups outside the map are left as they are.
|
|
40
|
+
*/
|
|
41
|
+
groups?: { claim?: string; map: Record<string, string> }
|
|
42
|
+
/** Groups a new member or user joins, besides the mapped ones. */
|
|
43
|
+
defaultGroups?: readonly string[]
|
|
44
|
+
/** Members only: the member type a new member is created as, by alias; `Member` by default. */
|
|
45
|
+
memberType?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Which preset made a provider: what the Entra bundle looks for. */
|
|
49
|
+
export type IdentityProviderKind = 'entra' | 'aws' | 'oidc'
|
|
50
|
+
|
|
51
|
+
/** A provider, ready for the config. */
|
|
52
|
+
export interface IdentityProvider {
|
|
53
|
+
kind: IdentityProviderKind
|
|
54
|
+
/** Entra only: the tenant its issuer is for, which an override may change. */
|
|
55
|
+
tenantId?: string
|
|
56
|
+
oidc: OidcProviderOptions
|
|
57
|
+
autoCreate: boolean
|
|
58
|
+
linkByEmail: boolean
|
|
59
|
+
emailsVerified: boolean
|
|
60
|
+
groupMap: Readonly<Record<string, string>>
|
|
61
|
+
defaultGroups: readonly string[]
|
|
62
|
+
memberType: string | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ID = /^[a-z0-9][a-z0-9-]*$/
|
|
66
|
+
|
|
67
|
+
/** Entra's issuer for a tenant. */
|
|
68
|
+
export const entraIssuer = (tenantId: string) =>
|
|
69
|
+
`https://login.microsoftonline.com/${tenantId}/v2.0`
|
|
70
|
+
|
|
71
|
+
function preset(
|
|
72
|
+
kind: IdentityProviderKind,
|
|
73
|
+
defaults: { id: string; name: string },
|
|
74
|
+
provider: { issuer: string; clientId: string; clientSecret?: string; scopes?: readonly string[] },
|
|
75
|
+
claims: OidcClaims,
|
|
76
|
+
options: IdentityProviderOptions,
|
|
77
|
+
): IdentityProvider {
|
|
78
|
+
const id = options.id ?? defaults.id
|
|
79
|
+
if (!ID.test(id))
|
|
80
|
+
throw new Error(`An identity provider's id is letters, digits and dashes: "${id}" is not.`)
|
|
81
|
+
if (!provider.issuer) throw new Error(`The ${defaults.name} identity provider needs an issuer.`)
|
|
82
|
+
if (!provider.clientId)
|
|
83
|
+
throw new Error(`The ${defaults.name} identity provider needs a clientId.`)
|
|
84
|
+
return {
|
|
85
|
+
kind,
|
|
86
|
+
oidc: {
|
|
87
|
+
id,
|
|
88
|
+
name: options.name ?? defaults.name,
|
|
89
|
+
issuer: provider.issuer,
|
|
90
|
+
clientId: provider.clientId,
|
|
91
|
+
clientSecret: provider.clientSecret,
|
|
92
|
+
scopes: provider.scopes ?? ['openid', 'profile', 'email'],
|
|
93
|
+
// A group map with no claim named reads the usual `groups` claim.
|
|
94
|
+
claims: {
|
|
95
|
+
...claims,
|
|
96
|
+
groups: options.groups?.claim ?? claims.groups ?? (options.groups ? 'groups' : undefined),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
autoCreate: options.autoCreate ?? true,
|
|
100
|
+
linkByEmail: options.linkByEmail ?? false,
|
|
101
|
+
emailsVerified: options.emailsVerified ?? false,
|
|
102
|
+
groupMap: options.groups?.map ?? {},
|
|
103
|
+
defaultGroups: options.defaultGroups ?? [],
|
|
104
|
+
memberType: options.memberType,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Microsoft Entra ID, for one tenant. Register a web app with a client secret
|
|
110
|
+
* and the redirect URLs in `docs/operations.md`; for groups, add the `groups`
|
|
111
|
+
* claim to its token configuration.
|
|
112
|
+
*/
|
|
113
|
+
export function entra(
|
|
114
|
+
options: IdentityProviderOptions & { tenantId: string; clientId: string; clientSecret?: string },
|
|
115
|
+
): IdentityProvider {
|
|
116
|
+
if (!options.tenantId) throw new Error('The Entra identity provider needs a tenantId.')
|
|
117
|
+
const provider = preset(
|
|
118
|
+
'entra',
|
|
119
|
+
{ id: 'entra', name: 'Microsoft' },
|
|
120
|
+
{
|
|
121
|
+
issuer: entraIssuer(options.tenantId),
|
|
122
|
+
clientId: options.clientId,
|
|
123
|
+
clientSecret: options.clientSecret,
|
|
124
|
+
},
|
|
125
|
+
{ email: 'email', name: 'name', groups: 'groups' },
|
|
126
|
+
options,
|
|
127
|
+
)
|
|
128
|
+
return { ...provider, tenantId: options.tenantId }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* AWS IAM Identity Center, through the OpenID Connect application its
|
|
133
|
+
* instance issues; `issuer` is that instance's issuer URL.
|
|
134
|
+
*/
|
|
135
|
+
export function awsIdentityCenter(
|
|
136
|
+
options: IdentityProviderOptions & { issuer: string; clientId: string; clientSecret?: string },
|
|
137
|
+
): IdentityProvider {
|
|
138
|
+
return preset(
|
|
139
|
+
'aws',
|
|
140
|
+
{ id: 'aws', name: 'AWS' },
|
|
141
|
+
{ issuer: options.issuer, clientId: options.clientId, clientSecret: options.clientSecret },
|
|
142
|
+
{ email: 'email', name: 'name', groups: 'groups' },
|
|
143
|
+
options,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Any OpenID Connect provider, by its issuer: Google, Okta, Auth0, Keycloak and the like. */
|
|
148
|
+
export function oidc(
|
|
149
|
+
options: IdentityProviderOptions & {
|
|
150
|
+
id: string
|
|
151
|
+
name: string
|
|
152
|
+
issuer: string
|
|
153
|
+
clientId: string
|
|
154
|
+
clientSecret?: string
|
|
155
|
+
scopes?: readonly string[]
|
|
156
|
+
claims?: Partial<OidcClaims>
|
|
157
|
+
},
|
|
158
|
+
): IdentityProvider {
|
|
159
|
+
return preset(
|
|
160
|
+
'oidc',
|
|
161
|
+
{ id: options.id, name: options.name },
|
|
162
|
+
{
|
|
163
|
+
issuer: options.issuer,
|
|
164
|
+
clientId: options.clientId,
|
|
165
|
+
clientSecret: options.clientSecret,
|
|
166
|
+
scopes: options.scopes,
|
|
167
|
+
},
|
|
168
|
+
{ email: 'email', name: 'name', ...options.claims },
|
|
169
|
+
options,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Refuses two providers with one id in a list, which would share one set of URLs. */
|
|
174
|
+
export function checkIdentityProviders(
|
|
175
|
+
list: readonly IdentityProvider[],
|
|
176
|
+
label: string,
|
|
177
|
+
): readonly IdentityProvider[] {
|
|
178
|
+
const seen = new Set<string>()
|
|
179
|
+
for (const provider of list) {
|
|
180
|
+
if (seen.has(provider.oidc.id))
|
|
181
|
+
throw new Error(`Two ${label} identity providers have the id "${provider.oidc.id}".`)
|
|
182
|
+
seen.add(provider.oidc.id)
|
|
183
|
+
}
|
|
184
|
+
return list
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './adapters/public-access.ts'
|
|
|
12
12
|
export * from './adapters/redirects.ts'
|
|
13
13
|
export * from './adapters/references.ts'
|
|
14
14
|
export * from './adapters/template-files.ts'
|
|
15
|
+
export * from './api-client.ts'
|
|
15
16
|
export * from './backup.ts'
|
|
16
17
|
export * from './boot-failure.ts'
|
|
17
18
|
export * from './bundle-install.ts'
|
|
@@ -34,6 +35,7 @@ export * from './email-postmark.ts'
|
|
|
34
35
|
export * from './email-resend.ts'
|
|
35
36
|
export * from './email-ses.ts'
|
|
36
37
|
export * from './extensions-notice.ts'
|
|
38
|
+
export * from './external-sign-in.ts'
|
|
37
39
|
export * from './file-intake.ts'
|
|
38
40
|
export * from './form-files.ts'
|
|
39
41
|
export * from './form-submit.ts'
|
|
@@ -43,6 +45,8 @@ export * from './forms.ts'
|
|
|
43
45
|
export * from './git.ts'
|
|
44
46
|
export * from './git-github.ts'
|
|
45
47
|
export * from './git-routes.ts'
|
|
48
|
+
export * from './identity-provider-overrides.ts'
|
|
49
|
+
export * from './identity-providers.ts'
|
|
46
50
|
export * from './imaging.ts'
|
|
47
51
|
export * from './jobs.ts'
|
|
48
52
|
export * from './listen.ts'
|
|
@@ -60,6 +64,9 @@ export * from './no-nodes.ts'
|
|
|
60
64
|
export * from './oembed.ts'
|
|
61
65
|
export * from './ports.ts'
|
|
62
66
|
export * from './redirects.ts'
|
|
67
|
+
export * from './remote.ts'
|
|
68
|
+
export * from './remote-credentials.ts'
|
|
69
|
+
export * from './remote-media.ts'
|
|
63
70
|
export * from './schema.ts'
|
|
64
71
|
export * from './schema-store.ts'
|
|
65
72
|
export * from './secrets.ts'
|
|
@@ -67,8 +74,13 @@ export * from './seo.ts'
|
|
|
67
74
|
export * from './server.ts'
|
|
68
75
|
export * from './server-events.ts'
|
|
69
76
|
export * from './setup.ts'
|
|
77
|
+
export * from './site-file.ts'
|
|
78
|
+
export * from './site-replace.ts'
|
|
79
|
+
export * from './site-sections.ts'
|
|
70
80
|
export * from './status.ts'
|
|
81
|
+
export * from './tar.ts'
|
|
71
82
|
export * from './transfer-dashboard.ts'
|
|
83
|
+
export * from './two-factor.ts'
|
|
72
84
|
export * from './udt.ts'
|
|
73
85
|
export * from './upgrade-summary.ts'
|
|
74
86
|
export * from './url-settings.ts'
|
package/src/media-store-azure.ts
CHANGED
|
@@ -156,13 +156,19 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
|
|
|
156
156
|
if (!response.ok) throw new Error(`Azure GET ${key} failed with ${response.status}.`)
|
|
157
157
|
return new Uint8Array(await response.arrayBuffer())
|
|
158
158
|
},
|
|
159
|
+
stream: async () => {
|
|
160
|
+
const response = await send('GET', url)
|
|
161
|
+
if (!response.ok || !response.body)
|
|
162
|
+
throw new Error(`Azure GET ${key} failed with ${response.status}.`)
|
|
163
|
+
return response.body
|
|
164
|
+
},
|
|
159
165
|
}
|
|
160
166
|
},
|
|
161
167
|
|
|
162
168
|
async put(key, body, putOptions) {
|
|
163
169
|
if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
|
|
164
170
|
// Copied into a plain ArrayBuffer so `fetch` and the signature agree on its length.
|
|
165
|
-
const bytes = new Uint8Array(body instanceof
|
|
171
|
+
const bytes = new Uint8Array(body instanceof Blob ? await body.arrayBuffer() : body)
|
|
166
172
|
const response = await send('PUT', blobUrl(key), {
|
|
167
173
|
body: bytes,
|
|
168
174
|
headers: {
|
package/src/media-store-s3.ts
CHANGED
|
@@ -61,6 +61,7 @@ export function s3MediaStore(options: S3MediaStoreOptions): MediaStore {
|
|
|
61
61
|
etag: stat.etag.replaceAll('"', ''),
|
|
62
62
|
contentType: stat.type || contentTypeFor(key),
|
|
63
63
|
bytes: async () => new Uint8Array(await file.arrayBuffer()),
|
|
64
|
+
stream: async () => file.stream(),
|
|
64
65
|
}
|
|
65
66
|
} catch {
|
|
66
67
|
// A missing object is a thrown NoSuchKey, not a null stat.
|
package/src/media-store.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* `ab12cd34/photo.jpg` a placed file, served at `/media/ab12cd34/photo.jpg`
|
|
14
14
|
* `.temp/<uuid>/photo.jpg` an upload not yet placed, with `meta.json` beside it
|
|
15
15
|
* `.cache/ab/<hash>.webp` a processed image variant
|
|
16
|
+
* `.remote/<job>/site.tgz` a remote pull's or push's file (story 107), never public
|
|
16
17
|
*
|
|
17
18
|
* A site picks a store in `crombolo.config.ts` — `fileSystemMediaStore`,
|
|
18
19
|
* `s3MediaStore` or `azureMediaStore` — or leaves it to the environment, which
|
|
@@ -31,6 +32,8 @@ export interface StoredFile {
|
|
|
31
32
|
etag: string
|
|
32
33
|
contentType: string
|
|
33
34
|
bytes(): Promise<Uint8Array>
|
|
35
|
+
/** The bytes as a stream, for a file too big to hold (story 104); `streamOf` falls back to `bytes()`. */
|
|
36
|
+
stream?(): Promise<ReadableStream<Uint8Array>>
|
|
34
37
|
/** A path on this machine, when the store has one; lets the server stream it. */
|
|
35
38
|
localPath?: string
|
|
36
39
|
}
|
|
@@ -39,7 +42,7 @@ export interface MediaStore {
|
|
|
39
42
|
/** Named in the boot banner and in logs: `media/`, `s3://bucket`, … */
|
|
40
43
|
readonly description: string
|
|
41
44
|
get(key: string): Promise<StoredFile | undefined>
|
|
42
|
-
put(key: string, body: Uint8Array |
|
|
45
|
+
put(key: string, body: Uint8Array | Blob, options?: { contentType?: string }): Promise<void>
|
|
43
46
|
delete(key: string): Promise<void>
|
|
44
47
|
/** Every key under a prefix, which is how temporary uploads are swept. */
|
|
45
48
|
list(prefix: string): Promise<string[]>
|
|
@@ -51,6 +54,24 @@ export interface MediaStore {
|
|
|
51
54
|
publicUrl?(key: string): string | undefined
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** A stored file's bytes in chunks: streamed where the store can, whole where it cannot. */
|
|
58
|
+
export async function* streamOf(file: StoredFile): AsyncIterable<Uint8Array> {
|
|
59
|
+
if (!file.stream) {
|
|
60
|
+
yield await file.bytes()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const reader = (await file.stream()).getReader()
|
|
64
|
+
try {
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read()
|
|
67
|
+
if (done) return
|
|
68
|
+
yield value
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
reader.releaseLock()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
/** Content types by extension, for stores that do not infer one. */
|
|
55
76
|
const CONTENT_TYPES: Record<string, string> = {
|
|
56
77
|
avif: 'image/avif',
|
|
@@ -91,7 +112,8 @@ export function isSafeMediaKey(key: string): boolean {
|
|
|
91
112
|
part !== '' &&
|
|
92
113
|
part !== '.' &&
|
|
93
114
|
part !== '..' &&
|
|
94
|
-
(!part.startsWith('.') ||
|
|
115
|
+
(!part.startsWith('.') ||
|
|
116
|
+
(index === 0 && (part === '.temp' || part === '.cache' || part === '.remote'))),
|
|
95
117
|
)
|
|
96
118
|
}
|
|
97
119
|
|
|
@@ -139,6 +161,7 @@ export function fileSystemMediaStore(
|
|
|
139
161
|
etag: `${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}`,
|
|
140
162
|
contentType: file.type || contentTypeFor(key),
|
|
141
163
|
bytes: () => file.bytes(),
|
|
164
|
+
stream: async () => file.stream(),
|
|
142
165
|
localPath: path,
|
|
143
166
|
}
|
|
144
167
|
},
|