@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/member-auth.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* POST /umbraco/members/logout
|
|
13
13
|
* POST /umbraco/members/register name, email, username, password, returnUrl?
|
|
14
14
|
* GET /umbraco/members/current
|
|
15
|
+
* POST /umbraco/members/login/2fa code, returnUrl? the second step, when 2FA is on
|
|
16
|
+
* GET /umbraco/members/2fa the signed-in member's 2FA state
|
|
17
|
+
* POST /umbraco/members/2fa/setup | enable | disable turning it on and off (story 032)
|
|
15
18
|
*
|
|
16
19
|
* A form post is answered with a redirect, a JSON post with JSON, so the same
|
|
17
20
|
* endpoint serves a plain `<form>` in a TSX template and a fetch from the page.
|
|
@@ -27,14 +30,17 @@ import {
|
|
|
27
30
|
passwordConfigJson,
|
|
28
31
|
readCookie,
|
|
29
32
|
readMemberTicket,
|
|
33
|
+
readShortLived,
|
|
30
34
|
serializeCookie,
|
|
31
35
|
signMemberTicket,
|
|
36
|
+
signShortLived,
|
|
32
37
|
verifyPassword,
|
|
33
38
|
} from '@crombolo/auth'
|
|
34
39
|
import {
|
|
35
40
|
DEFAULT_PASSWORD_CONFIGURATION,
|
|
36
41
|
isValidPassword,
|
|
37
42
|
localReturnUrl,
|
|
43
|
+
memberMayOpen,
|
|
38
44
|
ObjectTypes,
|
|
39
45
|
} from '@crombolo/core'
|
|
40
46
|
import {
|
|
@@ -45,14 +51,25 @@ import {
|
|
|
45
51
|
type NodeSchemaState,
|
|
46
52
|
PublicAccessRepository,
|
|
47
53
|
} from '@crombolo/data'
|
|
48
|
-
import
|
|
54
|
+
import {
|
|
55
|
+
type AccessDecision,
|
|
56
|
+
MEMBER_EXTERNAL_SIGN_IN_PATH,
|
|
57
|
+
type PublishedContent,
|
|
58
|
+
type RequestMember,
|
|
59
|
+
} from '@crombolo/render'
|
|
49
60
|
import type { CromboloConfig } from './config.ts'
|
|
61
|
+
import { createExternalSignIn, type ExternalAccount } from './external-sign-in.ts'
|
|
62
|
+
import { IdentityProviderOverrides } from './identity-provider-overrides.ts'
|
|
63
|
+
import { MEMBER_TWO_FACTOR_PROVIDER, TwoFactorService } from './two-factor.ts'
|
|
50
64
|
|
|
51
65
|
/** The key member tickets are signed with, generated once and shared by every node. */
|
|
52
66
|
const SIGNING_KEY = 'crombolo/memberCookieKey'
|
|
53
67
|
|
|
54
68
|
export const MEMBER_ROUTE_PREFIX = '/umbraco/members'
|
|
55
69
|
|
|
70
|
+
/** How long a member has to enter their code after their password. */
|
|
71
|
+
const TWO_FACTOR_PENDING_SECONDS = 5 * 60
|
|
72
|
+
|
|
56
73
|
export interface MemberAuthOptions {
|
|
57
74
|
/** The schema state this node runs at; a registration is refused without it. */
|
|
58
75
|
nodeState?: NodeSchemaState
|
|
@@ -72,6 +89,11 @@ export interface MemberAuth {
|
|
|
72
89
|
handle(request: Request, pathname: string): Promise<Response | undefined>
|
|
73
90
|
/** The `Set-Cookie` value that ends a member's site session. */
|
|
74
91
|
signOutCookie(): string
|
|
92
|
+
/**
|
|
93
|
+
* Where a "Sign in with …" link for a member identity provider goes, by its
|
|
94
|
+
* id; undefined for a provider the site does not configure.
|
|
95
|
+
*/
|
|
96
|
+
externalSignInUrl(providerId: string, returnUrl?: string): string | undefined
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
const isFormPost = (request: Request) =>
|
|
@@ -149,6 +171,13 @@ export function createMemberAuth(
|
|
|
149
171
|
return signingKey
|
|
150
172
|
}
|
|
151
173
|
|
|
174
|
+
const twoFactor = new TwoFactorService(db, {
|
|
175
|
+
secretKey: config.secretKey,
|
|
176
|
+
issuer: config.siteName,
|
|
177
|
+
})
|
|
178
|
+
const pendingCookie = `${cookie}TwoFactor`
|
|
179
|
+
const flagged = (url: string, flags: string) => `${url}${url.includes('?') ? '&' : '?'}${flags}`
|
|
180
|
+
|
|
152
181
|
const asRequestMember = (member: {
|
|
153
182
|
key: string
|
|
154
183
|
name: string
|
|
@@ -176,6 +205,93 @@ export function createMemberAuth(
|
|
|
176
205
|
})
|
|
177
206
|
}
|
|
178
207
|
|
|
208
|
+
/** Member group keys by name, for the names a registration or a provider names. */
|
|
209
|
+
const groupKeysFor = async (names: readonly string[]): Promise<string[]> => {
|
|
210
|
+
const keys: string[] = []
|
|
211
|
+
for (const groupName of names) {
|
|
212
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
213
|
+
'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
|
|
214
|
+
[groupName, ObjectTypes.MemberGroup],
|
|
215
|
+
)
|
|
216
|
+
if (rows[0]) keys.push(String(rows[0].unique_id))
|
|
217
|
+
}
|
|
218
|
+
return keys
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const asAccount = (member: { key: string; email: string; roles: string[] }): ExternalAccount => ({
|
|
222
|
+
key: member.key,
|
|
223
|
+
email: member.email,
|
|
224
|
+
groups: member.roles,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const MEMBER_EXTERNAL_PATH = MEMBER_EXTERNAL_SIGN_IN_PATH
|
|
228
|
+
const external =
|
|
229
|
+
config.memberIdentityProviders.length === 0
|
|
230
|
+
? undefined
|
|
231
|
+
: createExternalSignIn(
|
|
232
|
+
db,
|
|
233
|
+
{
|
|
234
|
+
audience: 'member',
|
|
235
|
+
providers: config.memberIdentityProviders,
|
|
236
|
+
basePath: MEMBER_EXTERNAL_PATH,
|
|
237
|
+
byKey: async (key) => {
|
|
238
|
+
const member = await members.byKey(key)
|
|
239
|
+
return member ? asAccount(member) : undefined
|
|
240
|
+
},
|
|
241
|
+
byEmail: async (email) => {
|
|
242
|
+
const member = await members.byEmail(email)
|
|
243
|
+
return member ? asAccount(member) : undefined
|
|
244
|
+
},
|
|
245
|
+
create: async (identity, provider, groups) => {
|
|
246
|
+
const alias = provider.memberType ?? config.memberRegistrationType
|
|
247
|
+
const type = await memberTypes.byAlias(alias)
|
|
248
|
+
if (!type) throw new Error(`The member type "${alias}" does not exist.`)
|
|
249
|
+
const email = identity.email as string
|
|
250
|
+
// The email is the username, as registration makes it, unless
|
|
251
|
+
// someone already signs in with that name.
|
|
252
|
+
const username = (await members.taken('username', email))
|
|
253
|
+
? `${provider.oidc.id}-${identity.subject}`
|
|
254
|
+
: email
|
|
255
|
+
const created = await members.create(
|
|
256
|
+
{
|
|
257
|
+
key: crypto.randomUUID(),
|
|
258
|
+
contentTypeKey: type.key,
|
|
259
|
+
componentKey: null,
|
|
260
|
+
parentKey: null,
|
|
261
|
+
values: [],
|
|
262
|
+
variants: [{ culture: null, segment: null, name: identity.name || email }],
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
email,
|
|
266
|
+
username,
|
|
267
|
+
isApproved: true,
|
|
268
|
+
isLockedOut: false,
|
|
269
|
+
groupKeys: await groupKeysFor(groups),
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
return asAccount(created)
|
|
273
|
+
},
|
|
274
|
+
setGroups: async (account, groups) => {
|
|
275
|
+
await members.setGroups(account.key, await groupKeysFor(groups))
|
|
276
|
+
},
|
|
277
|
+
signIn: async (account) => {
|
|
278
|
+
const member = await members.byKey(account.key)
|
|
279
|
+
if (!member?.isApproved || member.isLockedOut)
|
|
280
|
+
return { refused: 'This account cannot sign in at the moment.' }
|
|
281
|
+
await members.recordLogin(member.key)
|
|
282
|
+
return { cookies: [await issue(member.key, member.securityStamp, false)] }
|
|
283
|
+
},
|
|
284
|
+
failed: (returnUrl, reason) =>
|
|
285
|
+
`${returnUrl}${returnUrl.includes('?') ? '&' : '?'}memberLoginFailed=1&memberLoginError=${encodeURIComponent(reason)}`,
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
applicationUrl: config.applicationUrl,
|
|
289
|
+
secureCookies: config.secureCookies,
|
|
290
|
+
resolve: (provider) =>
|
|
291
|
+
new IdentityProviderOverrides(db, config.secretKey).apply('member', provider),
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
|
|
179
295
|
const resolve: MemberAuth['resolve'] = async (request) => {
|
|
180
296
|
const ticket = await readMemberTicket(await secret(), readCookie(request, cookie))
|
|
181
297
|
if (!ticket) return undefined
|
|
@@ -196,12 +312,7 @@ export function createMemberAuth(
|
|
|
196
312
|
// branch they serve, or the substitution would never terminate.
|
|
197
313
|
if (here === entry.loginNodeKey || here === entry.errorNodeKey) return { status: 'allowed' }
|
|
198
314
|
if (!member) return { status: 'substitute', contentKey: entry.loginNodeKey }
|
|
199
|
-
|
|
200
|
-
entry.memberUserNames.some((name) => name.toLowerCase() === member.username.toLowerCase()) ||
|
|
201
|
-
entry.memberGroupNames.some((name) =>
|
|
202
|
-
member.roles.some((role) => role.toLowerCase() === name.toLowerCase()),
|
|
203
|
-
)
|
|
204
|
-
return allowed
|
|
315
|
+
return memberMayOpen(entry, { username: member.username, groups: member.roles })
|
|
205
316
|
? { status: 'allowed' }
|
|
206
317
|
: { status: 'substitute', contentKey: entry.errorNodeKey }
|
|
207
318
|
}
|
|
@@ -268,6 +379,28 @@ export function createMemberAuth(
|
|
|
268
379
|
await hashPassword(input.password ?? ''),
|
|
269
380
|
passwordConfigJson(),
|
|
270
381
|
)
|
|
382
|
+
if ((await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER)) {
|
|
383
|
+
const pending = await signShortLived(
|
|
384
|
+
await secret(),
|
|
385
|
+
{ sub: member.key, persist: input.rememberMe === 'true' ? 1 : 0 },
|
|
386
|
+
TWO_FACTOR_PENDING_SECONDS,
|
|
387
|
+
)
|
|
388
|
+
return answer(
|
|
389
|
+
request,
|
|
390
|
+
{ ok: true, body: { twoFactorRequired: true } },
|
|
391
|
+
{
|
|
392
|
+
returnUrl: flagged(back, 'memberTwoFactorRequired=1'),
|
|
393
|
+
cookies: [
|
|
394
|
+
serializeCookie({
|
|
395
|
+
name: pendingCookie,
|
|
396
|
+
value: pending,
|
|
397
|
+
secure: config.secureCookies,
|
|
398
|
+
maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
|
|
399
|
+
}),
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
}
|
|
271
404
|
await members.recordLogin(member.key)
|
|
272
405
|
return answer(
|
|
273
406
|
request,
|
|
@@ -279,6 +412,87 @@ export function createMemberAuth(
|
|
|
279
412
|
)
|
|
280
413
|
}
|
|
281
414
|
|
|
415
|
+
/** The second step: the code from the app, or a recovery code, after the password. */
|
|
416
|
+
const verifyTwoFactor = async (request: Request): Promise<Response> => {
|
|
417
|
+
const input = await readInput(request)
|
|
418
|
+
const back = safeReturnUrl(input.returnUrl) ?? '/'
|
|
419
|
+
const pending = await readShortLived(await secret(), readCookie(request, pendingCookie))
|
|
420
|
+
const key = typeof pending?.sub === 'string' ? pending.sub : undefined
|
|
421
|
+
const member = key ? await members.byKey(key) : undefined
|
|
422
|
+
if (!member || member.isLockedOut || !member.isApproved)
|
|
423
|
+
return answer(
|
|
424
|
+
request,
|
|
425
|
+
{ ok: false, status: 401, error: 'Sign in again.' },
|
|
426
|
+
{
|
|
427
|
+
returnUrl: flagged(back, 'memberLoginFailed=1'),
|
|
428
|
+
cookies: [expireCookie(pendingCookie, config.secureCookies)],
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
if (!(await twoFactor.verify(member.key, MEMBER_TWO_FACTOR_PROVIDER, input.code ?? ''))) {
|
|
432
|
+
await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
|
|
433
|
+
return answer(
|
|
434
|
+
request,
|
|
435
|
+
{ ok: false, status: 400, error: 'That code is not right.' },
|
|
436
|
+
{ returnUrl: flagged(back, 'memberTwoFactorRequired=1&memberLoginFailed=1') },
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
await members.recordLogin(member.key)
|
|
440
|
+
return answer(
|
|
441
|
+
request,
|
|
442
|
+
{ ok: true, body: { member: asRequestMember(member) } },
|
|
443
|
+
{
|
|
444
|
+
returnUrl: back,
|
|
445
|
+
cookies: [
|
|
446
|
+
await issue(member.key, member.securityStamp, pending?.persist === 1),
|
|
447
|
+
expireCookie(pendingCookie, config.secureCookies),
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The signed-in member's own two-factor settings; JSON, as the recovery codes must be shown. */
|
|
454
|
+
const manageTwoFactor = async (request: Request, step: string): Promise<Response> => {
|
|
455
|
+
const member = await resolve(request)
|
|
456
|
+
if (!member) return Response.json({ error: 'Sign in first.' }, { status: 401 })
|
|
457
|
+
const refused = (message: string, status = 400) => Response.json({ error: message }, { status })
|
|
458
|
+
if (step === '') {
|
|
459
|
+
if (request.method !== 'GET') return new Response(null, { status: 405 })
|
|
460
|
+
return Response.json({
|
|
461
|
+
available: twoFactor.available,
|
|
462
|
+
enabled: (await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER),
|
|
463
|
+
recoveryCodesLeft: await twoFactor.recoveryCodesLeft(member.key),
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
467
|
+
if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
|
|
468
|
+
const input = await readInput(request)
|
|
469
|
+
switch (step) {
|
|
470
|
+
case 'setup': {
|
|
471
|
+
const setup = twoFactor.setup(member.email || member.username)
|
|
472
|
+
return setup.ok ? Response.json(setup.value) : refused(setup.message)
|
|
473
|
+
}
|
|
474
|
+
case 'enable': {
|
|
475
|
+
const enabled = await twoFactor.enable(
|
|
476
|
+
member.key,
|
|
477
|
+
MEMBER_TWO_FACTOR_PROVIDER,
|
|
478
|
+
input.secret ?? '',
|
|
479
|
+
input.code ?? '',
|
|
480
|
+
)
|
|
481
|
+
return enabled.ok ? Response.json(enabled.value) : refused(enabled.message)
|
|
482
|
+
}
|
|
483
|
+
case 'disable': {
|
|
484
|
+
const disabled = await twoFactor.disable(
|
|
485
|
+
member.key,
|
|
486
|
+
MEMBER_TWO_FACTOR_PROVIDER,
|
|
487
|
+
input.code ?? '',
|
|
488
|
+
)
|
|
489
|
+
return disabled.ok ? Response.json({ ok: true }) : refused(disabled.message)
|
|
490
|
+
}
|
|
491
|
+
default:
|
|
492
|
+
return new Response(null, { status: 404 })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
282
496
|
const logout = async (request: Request): Promise<Response> => {
|
|
283
497
|
const input = await readInput(request)
|
|
284
498
|
return answer(
|
|
@@ -334,14 +548,7 @@ export function createMemberAuth(
|
|
|
334
548
|
status: 400,
|
|
335
549
|
error: `A password must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
|
|
336
550
|
})
|
|
337
|
-
const groupKeys
|
|
338
|
-
for (const groupName of config.memberRegistrationGroups) {
|
|
339
|
-
const rows = await db.query<{ unique_id: string }>(
|
|
340
|
-
'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
|
|
341
|
-
[groupName, ObjectTypes.MemberGroup],
|
|
342
|
-
)
|
|
343
|
-
if (rows[0]) groupKeys.push(String(rows[0].unique_id))
|
|
344
|
-
}
|
|
551
|
+
const groupKeys = await groupKeysFor(config.memberRegistrationGroups)
|
|
345
552
|
const created = await members.create(
|
|
346
553
|
{
|
|
347
554
|
key: crypto.randomUUID(),
|
|
@@ -375,19 +582,29 @@ export function createMemberAuth(
|
|
|
375
582
|
resolve,
|
|
376
583
|
decide,
|
|
377
584
|
signOutCookie: () => expireCookie(cookie, config.secureCookies),
|
|
585
|
+
externalSignInUrl: (providerId, returnUrl) =>
|
|
586
|
+
config.memberIdentityProviders.some((p) => p.oidc.id === providerId)
|
|
587
|
+
? external?.startUrl(providerId, returnUrl)
|
|
588
|
+
: undefined,
|
|
378
589
|
async handle(request, pathname) {
|
|
379
590
|
if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
|
|
591
|
+
if (pathname.startsWith(`${MEMBER_EXTERNAL_PATH}/`))
|
|
592
|
+
return external?.handle(request, pathname) ?? undefined
|
|
380
593
|
const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
|
|
381
594
|
if (action === 'current') {
|
|
382
595
|
if (request.method !== 'GET') return new Response(null, { status: 405 })
|
|
383
596
|
const member = await resolve(request)
|
|
384
597
|
return member ? Response.json({ member }) : Response.json({ member: null }, { status: 401 })
|
|
385
598
|
}
|
|
599
|
+
if (action === '2fa' || action.startsWith('2fa/'))
|
|
600
|
+
return manageTwoFactor(request, action.slice(4))
|
|
386
601
|
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
387
602
|
if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
|
|
388
603
|
switch (action) {
|
|
389
604
|
case 'login':
|
|
390
605
|
return login(request)
|
|
606
|
+
case 'login/2fa':
|
|
607
|
+
return verifyTwoFactor(request)
|
|
391
608
|
case 'logout':
|
|
392
609
|
return logout(request)
|
|
393
610
|
case 'register':
|
package/src/ports.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ServerPort,
|
|
13
13
|
TemporaryFilePort,
|
|
14
14
|
UserItemPort,
|
|
15
|
+
UserTwoFactorPort,
|
|
15
16
|
} from '@crombolo/api-management'
|
|
16
17
|
import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
|
|
17
18
|
import {
|
|
@@ -84,6 +85,7 @@ import { createOEmbedService } from './oembed.ts'
|
|
|
84
85
|
import { createRedirectTracker } from './redirects.ts'
|
|
85
86
|
import { componentAliasesIn, type SchemaBoot } from './schema.ts'
|
|
86
87
|
import { publishSchema } from './schema-store.ts'
|
|
88
|
+
import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
|
|
87
89
|
import { createWebhookService, type WebhookService } from './webhooks.ts'
|
|
88
90
|
|
|
89
91
|
/** Re-exported for tests; the canonical list lives in @crombolo/core. */
|
|
@@ -492,7 +494,22 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
492
494
|
await store.endAllSessionsForUser(userId)
|
|
493
495
|
},
|
|
494
496
|
})
|
|
495
|
-
|
|
497
|
+
// Story 032: the authenticator provider, offered only where secrets can be sealed.
|
|
498
|
+
const twoFactor = new TwoFactorService(options.db, {
|
|
499
|
+
secretKey: options.config.secretKey,
|
|
500
|
+
issuer: options.config.siteName,
|
|
501
|
+
})
|
|
502
|
+
const configuration = userPorts.users.configuration
|
|
503
|
+
const currentConfiguration = userPorts.users.currentConfiguration
|
|
504
|
+
deps.userManagement = {
|
|
505
|
+
...userPorts.users,
|
|
506
|
+
configuration: () => ({ ...configuration(), allowTwoFactor: twoFactor.available }),
|
|
507
|
+
currentConfiguration: () => ({
|
|
508
|
+
...currentConfiguration(),
|
|
509
|
+
allowTwoFactor: twoFactor.available,
|
|
510
|
+
}),
|
|
511
|
+
twoFactor: userTwoFactorPort(twoFactor),
|
|
512
|
+
}
|
|
496
513
|
deps.userGroups = userPorts.groups
|
|
497
514
|
deps.userData = userPorts.userData
|
|
498
515
|
deps.media = createMediaPort(options.db, mediaFiles, {
|
|
@@ -621,3 +638,40 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
621
638
|
}
|
|
622
639
|
return deps
|
|
623
640
|
}
|
|
641
|
+
|
|
642
|
+
/** The user operations' view of two-factor sign-in: the one authenticator provider. */
|
|
643
|
+
function userTwoFactorPort(service: TwoFactorService): UserTwoFactorPort {
|
|
644
|
+
const provider = USER_TWO_FACTOR_PROVIDER
|
|
645
|
+
return {
|
|
646
|
+
providerName: provider,
|
|
647
|
+
async list(userKey) {
|
|
648
|
+
return [
|
|
649
|
+
{
|
|
650
|
+
providerName: provider,
|
|
651
|
+
isEnabledOnUser: (await service.providers(userKey)).includes(provider),
|
|
652
|
+
},
|
|
653
|
+
]
|
|
654
|
+
},
|
|
655
|
+
setup(account) {
|
|
656
|
+
const setup = service.setup(account)
|
|
657
|
+
return setup.ok
|
|
658
|
+
? {
|
|
659
|
+
ok: true,
|
|
660
|
+
secret: setup.value.secret,
|
|
661
|
+
qrCodeSetupImageUrl: setup.value.qrCodeSetupImageUrl,
|
|
662
|
+
}
|
|
663
|
+
: { ok: false, message: setup.message }
|
|
664
|
+
},
|
|
665
|
+
async enable(userKey, code, secret) {
|
|
666
|
+
const enabled = await service.enable(userKey, provider, secret, code)
|
|
667
|
+
return enabled.ok
|
|
668
|
+
? { ok: true, recoveryCodes: enabled.value.recoveryCodes }
|
|
669
|
+
: { ok: false, message: enabled.message }
|
|
670
|
+
},
|
|
671
|
+
async disable(userKey, code) {
|
|
672
|
+
const disabled = await service.disable(userKey, provider, code)
|
|
673
|
+
return disabled.ok ? { ok: true } : { ok: false, message: disabled.message }
|
|
674
|
+
},
|
|
675
|
+
reset: (userKey) => service.reset(userKey, provider),
|
|
676
|
+
}
|
|
677
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A developer's sign-ins to remotes (story 108): kept in the operating system's
|
|
3
|
+
* keychain (macOS Keychain, Windows Credential Manager, libsecret) through
|
|
4
|
+
* `Bun.secrets`, keyed by the remote's origin, never in the site's folder.
|
|
5
|
+
* Shared by the CLI's commands and a lazily-fetching local media store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const KEYCHAIN_SERVICE = 'crombolo-remote'
|
|
9
|
+
|
|
10
|
+
export interface Keychain {
|
|
11
|
+
get(name: string): Promise<string | null>
|
|
12
|
+
set(name: string, value: string): Promise<void>
|
|
13
|
+
delete(name: string): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The operating system's keychain. */
|
|
17
|
+
export function osKeychain(): Keychain {
|
|
18
|
+
return {
|
|
19
|
+
get: (name) => Bun.secrets.get({ service: KEYCHAIN_SERVICE, name }),
|
|
20
|
+
set: (name, value) => Bun.secrets.set({ service: KEYCHAIN_SERVICE, name, value }),
|
|
21
|
+
delete: async (name) => {
|
|
22
|
+
await Bun.secrets.delete({ service: KEYCHAIN_SERVICE, name })
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A keychain in memory, for tests and machines without one. */
|
|
28
|
+
export function memoryKeychain(): Keychain {
|
|
29
|
+
const values = new Map<string, string>()
|
|
30
|
+
return {
|
|
31
|
+
get: async (name) => values.get(name) ?? null,
|
|
32
|
+
set: async (name, value) => {
|
|
33
|
+
values.set(name, value)
|
|
34
|
+
},
|
|
35
|
+
delete: async (name) => {
|
|
36
|
+
values.delete(name)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoteSignIn {
|
|
42
|
+
accessToken: string
|
|
43
|
+
refreshToken: string
|
|
44
|
+
/** Milliseconds since the epoch. */
|
|
45
|
+
expiresAt: number
|
|
46
|
+
tokenUrl: string
|
|
47
|
+
clientId: string
|
|
48
|
+
/** Who approved it, for `whoami`. */
|
|
49
|
+
user?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keyOf = (origin: string) => new URL(origin).origin
|
|
53
|
+
|
|
54
|
+
export async function readSignIn(
|
|
55
|
+
keychain: Keychain,
|
|
56
|
+
origin: string,
|
|
57
|
+
): Promise<RemoteSignIn | undefined> {
|
|
58
|
+
const stored = await keychain.get(keyOf(origin))
|
|
59
|
+
if (!stored) return undefined
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(stored) as RemoteSignIn
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const saveSignIn = (keychain: Keychain, origin: string, signIn: RemoteSignIn) =>
|
|
68
|
+
keychain.set(keyOf(origin), JSON.stringify(signIn))
|
|
69
|
+
|
|
70
|
+
export const forgetSignIn = (keychain: Keychain, origin: string) => keychain.delete(keyOf(origin))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A usable access token: the stored one, or a refreshed one when it is within a
|
|
74
|
+
* minute of expiring. Undefined when there is no sign-in, or the remote refused
|
|
75
|
+
* the refresh — the person signs in again.
|
|
76
|
+
*/
|
|
77
|
+
export async function freshAccessToken(
|
|
78
|
+
keychain: Keychain,
|
|
79
|
+
origin: string,
|
|
80
|
+
http: typeof fetch = fetch,
|
|
81
|
+
force = false,
|
|
82
|
+
): Promise<string | undefined> {
|
|
83
|
+
const signIn = await readSignIn(keychain, origin)
|
|
84
|
+
if (!signIn) return undefined
|
|
85
|
+
if (!force && signIn.expiresAt - Date.now() > 60_000) return signIn.accessToken
|
|
86
|
+
const response = await http(signIn.tokenUrl, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
89
|
+
body: new URLSearchParams({
|
|
90
|
+
grant_type: 'refresh_token',
|
|
91
|
+
refresh_token: signIn.refreshToken,
|
|
92
|
+
client_id: signIn.clientId,
|
|
93
|
+
}).toString(),
|
|
94
|
+
})
|
|
95
|
+
if (!response.ok) return undefined
|
|
96
|
+
const tokens = (await response.json()) as {
|
|
97
|
+
access_token: string
|
|
98
|
+
refresh_token: string
|
|
99
|
+
expires_in: number
|
|
100
|
+
}
|
|
101
|
+
await saveSignIn(keychain, origin, {
|
|
102
|
+
...signIn,
|
|
103
|
+
accessToken: tokens.access_token,
|
|
104
|
+
refreshToken: tokens.refresh_token,
|
|
105
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
106
|
+
})
|
|
107
|
+
return tokens.access_token
|
|
108
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media fetched from a remote on first request (story 108, `pull
|
|
3
|
+
* --media-info-only --lazy-media`): a local site pulled without its media
|
|
4
|
+
* files reads each one through from the remote it came from the first time it
|
|
5
|
+
* is asked for, under the developer's own sign-in, and keeps it.
|
|
6
|
+
*
|
|
7
|
+
* Only the store the site serves from is wrapped. An export reads the plain
|
|
8
|
+
* store, so pushing a lazily-pulled site never downloads what it never used.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { logger } from './logging.ts'
|
|
13
|
+
import type { MediaStore, StoredFile } from './media-store.ts'
|
|
14
|
+
import { freshAccessToken, type Keychain, osKeychain } from './remote-credentials.ts'
|
|
15
|
+
|
|
16
|
+
export const REMOTE_MEDIA_FILE = join('.crombolo', 'remote-media.json')
|
|
17
|
+
|
|
18
|
+
export interface RemoteMediaSource {
|
|
19
|
+
/** The remote's API base, which is also what its sign-in is kept under. */
|
|
20
|
+
api: string
|
|
21
|
+
environment: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readRemoteMedia(siteDir: string): RemoteMediaSource | undefined {
|
|
25
|
+
const file = join(siteDir, REMOTE_MEDIA_FILE)
|
|
26
|
+
if (!existsSync(file)) return undefined
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<RemoteMediaSource>
|
|
29
|
+
return typeof parsed.api === 'string' && typeof parsed.environment === 'string'
|
|
30
|
+
? { api: parsed.api, environment: parsed.environment }
|
|
31
|
+
: undefined
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeRemoteMedia(siteDir: string, source: RemoteMediaSource): void {
|
|
38
|
+
const file = join(siteDir, REMOTE_MEDIA_FILE)
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
40
|
+
writeFileSync(file, `${JSON.stringify(source, null, 2)}\n`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The site's store, reading through to its remote when the site was pulled lazily. */
|
|
44
|
+
export function withRemoteMedia(
|
|
45
|
+
store: MediaStore,
|
|
46
|
+
siteDir: string,
|
|
47
|
+
options: { keychain?: Keychain; http?: typeof fetch } = {},
|
|
48
|
+
): MediaStore {
|
|
49
|
+
const source = readRemoteMedia(siteDir)
|
|
50
|
+
if (!source) return store
|
|
51
|
+
const keychain = options.keychain ?? osKeychain()
|
|
52
|
+
const http = options.http ?? fetch
|
|
53
|
+
const log = logger('media')
|
|
54
|
+
let warned = false
|
|
55
|
+
|
|
56
|
+
const fetchThrough = async (key: string): Promise<StoredFile | undefined> => {
|
|
57
|
+
const token = await freshAccessToken(keychain, source.api, http)
|
|
58
|
+
if (!token) {
|
|
59
|
+
if (!warned)
|
|
60
|
+
log.warning(
|
|
61
|
+
'A media file is not here yet, and fetching it needs `crombolo login` to {api}',
|
|
62
|
+
{
|
|
63
|
+
api: source.api,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
warned = true
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
const path = key.split('/').map(encodeURIComponent).join('/')
|
|
70
|
+
const response = await http(`${source.api}/environments/${source.environment}/media/${path}`, {
|
|
71
|
+
headers: { authorization: `Bearer ${token}` },
|
|
72
|
+
}).catch(() => undefined)
|
|
73
|
+
if (!response?.ok) return undefined
|
|
74
|
+
await store.put(key, await response.blob())
|
|
75
|
+
return store.get(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
...store,
|
|
80
|
+
description: `${store.description} (fetching from ${new URL(source.api).origin})`,
|
|
81
|
+
async get(key) {
|
|
82
|
+
const local = await store.get(key)
|
|
83
|
+
if (local || key.startsWith('.')) return local
|
|
84
|
+
return fetchThrough(key)
|
|
85
|
+
},
|
|
86
|
+
put: (key, body, putOptions) => store.put(key, body, putOptions),
|
|
87
|
+
delete: (key) => store.delete(key),
|
|
88
|
+
list: (prefix) => store.list(prefix),
|
|
89
|
+
...(store.publicUrl ? { publicUrl: (key: string) => store.publicUrl?.(key) } : {}),
|
|
90
|
+
}
|
|
91
|
+
}
|