@kelpie/server 0.11.0 → 0.12.0
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/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.d.ts +18 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +17 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +21 -4
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/repository.d.ts +2 -1
- package/dist/modules/auth/repository.d.ts.map +1 -1
- package/dist/modules/auth/repository.js +1 -0
- package/dist/modules/auth/repository.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +2 -6
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +18 -1
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +12 -1
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts +18 -0
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +95 -6
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/auth/session.d.ts +12 -0
- package/dist/modules/auth/session.d.ts.map +1 -1
- package/dist/modules/auth/session.js +12 -0
- package/dist/modules/auth/session.js.map +1 -1
- package/dist/runtime/module.d.ts +63 -1
- package/dist/runtime/module.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/registry.js +40 -2
- package/dist/runtime/registry.js.map +1 -1
- package/migrations/0043_external_sign_in.sql +2 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -2
- package/src/index.ts +13 -1
- package/src/lib/errors.ts +30 -0
- package/src/modules/auth/index.ts +25 -4
- package/src/modules/auth/repository.ts +2 -1
- package/src/modules/auth/routes.ts +2 -8
- package/src/modules/auth/schema.ts +12 -1
- package/src/modules/auth/service.ts +143 -6
- package/src/modules/auth/session.ts +14 -0
- package/src/runtime/module.ts +70 -1
- package/src/runtime/registry.ts +54 -0
|
@@ -5,6 +5,7 @@ import type { KelpieModule } from '../../runtime/module.ts'
|
|
|
5
5
|
import { mountAuthRoutes } from './routes.ts'
|
|
6
6
|
import * as schema from './schema.ts'
|
|
7
7
|
import { createAuthService } from './service.ts'
|
|
8
|
+
import { describeClient, writeSessionCookie } from './session.ts'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Accounts, sessions, and password recovery.
|
|
@@ -38,6 +39,11 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
|
|
|
38
39
|
appBaseUrl,
|
|
39
40
|
})
|
|
40
41
|
|
|
42
|
+
// `Secure` everywhere except development. A test host reaches the API
|
|
43
|
+
// over http through a test client, which stores the cookie regardless of
|
|
44
|
+
// the flag, so test is not excluded.
|
|
45
|
+
const cookie = { secure: NODE_ENV !== 'development' }
|
|
46
|
+
|
|
41
47
|
context.schema(schema, migrationsDirectory)
|
|
42
48
|
|
|
43
49
|
context.routes((router) => {
|
|
@@ -45,11 +51,26 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
|
|
|
45
51
|
db: context.db,
|
|
46
52
|
now: context.now,
|
|
47
53
|
service,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
cookie,
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// How a module signs a browser in. It verifies an identity its own way
|
|
59
|
+
// and hands it here; everything from the cookie onwards is what a
|
|
60
|
+
// password sign-in does, through the same helper with the same flags.
|
|
61
|
+
context.provideExternalSignIn(async (honoContext, identity) => {
|
|
62
|
+
const issued = await service.completeExternalSignIn({
|
|
63
|
+
...identity,
|
|
64
|
+
...describeClient(honoContext),
|
|
52
65
|
})
|
|
66
|
+
|
|
67
|
+
writeSessionCookie(honoContext, issued.sessionToken, cookie)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
account: issued.account,
|
|
71
|
+
created: issued.created,
|
|
72
|
+
activeWorkspaceId: issued.activeWorkspaceId,
|
|
73
|
+
}
|
|
53
74
|
})
|
|
54
75
|
|
|
55
76
|
return Promise.resolve()
|
|
@@ -48,10 +48,11 @@ export async function insertUser(db: Queryable, values: typeof users.$inferInser
|
|
|
48
48
|
return created
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** `null` clears the password, leaving the account reachable only through a module or a reset. */
|
|
51
52
|
export async function updateUserPassword(
|
|
52
53
|
db: Queryable,
|
|
53
54
|
userId: string,
|
|
54
|
-
passwordHash: string,
|
|
55
|
+
passwordHash: string | null,
|
|
55
56
|
now: Date,
|
|
56
57
|
): Promise<void> {
|
|
57
58
|
await db
|
|
@@ -11,7 +11,7 @@ import { resolveActorFrom } from './credentials.ts'
|
|
|
11
11
|
import type { CredentialDependencies } from './credentials.ts'
|
|
12
12
|
import type { PreferenceValues } from './preferences.ts'
|
|
13
13
|
import type { AccountView, AuthService, IssuedSession, SessionView } from './service.ts'
|
|
14
|
-
import { clearSessionCookie, writeSessionCookie } from './session.ts'
|
|
14
|
+
import { clearSessionCookie, describeClient, writeSessionCookie } from './session.ts'
|
|
15
15
|
import type { SessionCookieOptions } from './session.ts'
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -108,13 +108,6 @@ async function readBody<T>(context: Context, schema: z.ZodType<T>): Promise<T> {
|
|
|
108
108
|
return parsed.data
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** The device and location shown on the Security page come from the request itself. */
|
|
112
|
-
function describeClient(context: Context): { device?: string; location?: string } {
|
|
113
|
-
const userAgent = context.req.header('User-Agent')
|
|
114
|
-
|
|
115
|
-
return userAgent === undefined ? {} : { device: userAgent }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
111
|
function accountResponse(issued: IssuedSession): Record<string, unknown> {
|
|
119
112
|
return {
|
|
120
113
|
account: accountBody(issued.account),
|
|
@@ -149,6 +142,7 @@ function sessionResponse(session: SessionView): Record<string, unknown> {
|
|
|
149
142
|
location: session.location,
|
|
150
143
|
last_active_at: session.lastActiveAt.toISOString(),
|
|
151
144
|
current: session.current,
|
|
145
|
+
signed_in_via: session.signedInVia,
|
|
152
146
|
}
|
|
153
147
|
}
|
|
154
148
|
|
|
@@ -18,7 +18,12 @@ export const users = pgTable('users', {
|
|
|
18
18
|
id: primaryId(),
|
|
19
19
|
email: citext('email').notNull().unique(),
|
|
20
20
|
name: text('name').notNull(),
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Null for an account that has only ever signed in through an identity
|
|
23
|
+
* module. Password sign-in is refused while it is null; a password is set
|
|
24
|
+
* through the reset flow, whose emailed link proves control of the address.
|
|
25
|
+
*/
|
|
26
|
+
passwordHash: text('password_hash'),
|
|
22
27
|
/** Null until the address is verified, either by confirming a token or by accepting a workspace invite. */
|
|
23
28
|
emailVerifiedAt: moment('email_verified_at'),
|
|
24
29
|
createdAt: createdAt(),
|
|
@@ -34,6 +39,12 @@ export const sessions = pgTable('sessions', {
|
|
|
34
39
|
tokenHash: text('token_hash').notNull().unique(),
|
|
35
40
|
device: text('device'),
|
|
36
41
|
location: text('location'),
|
|
42
|
+
/**
|
|
43
|
+
* Which module completed this sign-in (`sign-in:google`), null for a password
|
|
44
|
+
* one. The Security page lists sessions, and a person should be able to tell
|
|
45
|
+
* the two apart.
|
|
46
|
+
*/
|
|
47
|
+
signedInVia: text('signed_in_via'),
|
|
37
48
|
lastActiveAt: moment('last_active_at').notNull().defaultNow(),
|
|
38
49
|
expiresAt: moment('expires_at').notNull(),
|
|
39
50
|
createdAt: createdAt(),
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
|
|
2
2
|
import type { EmailSender } from '../../lib/email.ts'
|
|
3
3
|
import { renderEmail } from '../../lib/emailContent.ts'
|
|
4
|
-
import { AppError } from '../../lib/errors.ts'
|
|
4
|
+
import { AppError, ExternalSignInError } from '../../lib/errors.ts'
|
|
5
5
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
6
6
|
import { UNIQUE_VIOLATION, postgresErrorCode } from '../../lib/database.ts'
|
|
7
7
|
import type { Database } from '../../lib/database.ts'
|
|
8
8
|
import { MINIMUM_PASSWORD_LENGTH, hashPassword, verifyPassword } from '../../lib/passwords.ts'
|
|
9
9
|
import { generateToken, hashToken } from '../../lib/tokens.ts'
|
|
10
|
+
import type { VerifiedIdentity } from '../../runtime/module.ts'
|
|
10
11
|
import type { TransactionScope } from '../../runtime/transaction.ts'
|
|
11
12
|
import type { SessionActor } from './actor.ts'
|
|
12
13
|
import { DEFAULT_PREFERENCES, applyPreferenceChanges } from './preferences.ts'
|
|
@@ -54,12 +55,19 @@ export interface IssuedSession {
|
|
|
54
55
|
readonly activeWorkspaceId: string | null
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** What `completeExternalSignIn` returns: an issued session plus whether it made the account. */
|
|
59
|
+
export interface ExternalIssuedSession extends IssuedSession {
|
|
60
|
+
readonly created: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
export interface SessionView {
|
|
58
64
|
readonly id: string
|
|
59
65
|
readonly device: string | null
|
|
60
66
|
readonly location: string | null
|
|
61
67
|
readonly lastActiveAt: Date
|
|
62
68
|
readonly current: boolean
|
|
69
|
+
/** The module that signed this session in, or null for a password sign-in. */
|
|
70
|
+
readonly signedInVia: string | null
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export interface SignUpInput {
|
|
@@ -85,6 +93,12 @@ export interface LogInInput {
|
|
|
85
93
|
readonly location?: string
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/** A `VerifiedIdentity` plus what the request itself says about the client. */
|
|
97
|
+
export interface ExternalSignInInput extends VerifiedIdentity {
|
|
98
|
+
readonly device?: string
|
|
99
|
+
readonly location?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
function toAccountView(user: repository.UserRecord): AccountView {
|
|
89
103
|
return { id: user.id, email: user.email, name: user.name, emailVerified: user.emailVerifiedAt !== null }
|
|
90
104
|
}
|
|
@@ -105,6 +119,16 @@ function normaliseEmail(email: string): string {
|
|
|
105
119
|
return email.trim().toLowerCase()
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The name to use when a provider sends none. `users.name` is not nullable and
|
|
124
|
+
* an address is the only other thing an identity is guaranteed to carry.
|
|
125
|
+
*/
|
|
126
|
+
function localPartOf(email: string): string {
|
|
127
|
+
const [local] = email.split('@')
|
|
128
|
+
|
|
129
|
+
return local === undefined || local.length === 0 ? email : local
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
/**
|
|
109
133
|
* Rejects a field that is only whitespace.
|
|
110
134
|
*
|
|
@@ -128,6 +152,12 @@ export interface AuthService {
|
|
|
128
152
|
/** Creates the account only. The first workspace comes from onboarding. */
|
|
129
153
|
signUp(input: SignUpInput): Promise<IssuedSession>
|
|
130
154
|
logIn(input: LogInInput): Promise<IssuedSession>
|
|
155
|
+
/**
|
|
156
|
+
* Signs in an identity a module verified elsewhere, provisioning the account
|
|
157
|
+
* when the module asked for it. Never sends a verification email: the
|
|
158
|
+
* provider already proved control of the address.
|
|
159
|
+
*/
|
|
160
|
+
completeExternalSignIn(input: ExternalSignInInput): Promise<ExternalIssuedSession>
|
|
131
161
|
logOut(actor: SessionActor): Promise<void>
|
|
132
162
|
getAccount(actor: SessionActor): Promise<AccountView>
|
|
133
163
|
updateAccount(actor: SessionActor, changes: UpdateAccountChanges): Promise<AccountView>
|
|
@@ -167,6 +197,8 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
167
197
|
user: repository.UserRecord,
|
|
168
198
|
device: string | undefined,
|
|
169
199
|
location: string | undefined,
|
|
200
|
+
/** The module that verified the identity, or null for a password sign-in. */
|
|
201
|
+
signedInVia: string | null = null,
|
|
170
202
|
): Promise<IssuedSession> {
|
|
171
203
|
const now = dependencies.now()
|
|
172
204
|
const token = newToken()
|
|
@@ -180,6 +212,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
180
212
|
tokenHash: hashToken(token),
|
|
181
213
|
device: device ?? null,
|
|
182
214
|
location: location ?? null,
|
|
215
|
+
signedInVia,
|
|
183
216
|
lastActiveAt: now,
|
|
184
217
|
expiresAt,
|
|
185
218
|
})
|
|
@@ -269,17 +302,113 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
269
302
|
async logIn(input: LogInInput): Promise<IssuedSession> {
|
|
270
303
|
const user = await repository.findUserByEmail(dependencies.db, normaliseEmail(input.email))
|
|
271
304
|
|
|
272
|
-
// Hash a throwaway password when the
|
|
273
|
-
//
|
|
305
|
+
// Hash a throwaway password when the account is unknown or has no
|
|
306
|
+
// password, so all three cases take the same time to answer.
|
|
274
307
|
const storedHash = user?.passwordHash ?? (await hashPassword('not-a-real-password-placeholder'))
|
|
275
|
-
|
|
276
|
-
|
|
308
|
+
// Always run the verify, even when the hash is the placeholder: skipping
|
|
309
|
+
// it would make a passwordless account answer faster than a wrong one.
|
|
310
|
+
const matches = await verifyPassword(storedHash, input.password)
|
|
311
|
+
|
|
312
|
+
// The null check is the guard, not the placeholder hash. Without it,
|
|
313
|
+
// whoever guessed the placeholder string would sign in to every account
|
|
314
|
+
// that has no password.
|
|
315
|
+
if (user === undefined || user.passwordHash === null || !matches) {
|
|
277
316
|
throw AppError.unauthorized('Email or password is incorrect')
|
|
278
317
|
}
|
|
279
318
|
|
|
280
319
|
return dependencies.transaction(({ tx }) => issueSession(tx, user, input.device, input.location))
|
|
281
320
|
},
|
|
282
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Signs in an identity a module already verified.
|
|
324
|
+
*
|
|
325
|
+
* Core does not redo the verification and never learns the protocol. What
|
|
326
|
+
* it owns is everything downstream: finding or provisioning the account,
|
|
327
|
+
* issuing the session, and recording which module vouched for it.
|
|
328
|
+
*/
|
|
329
|
+
async completeExternalSignIn(input: ExternalSignInInput): Promise<ExternalIssuedSession> {
|
|
330
|
+
// An unverified address is not an identity. A provider that does not say
|
|
331
|
+
// it checked is treated as not having checked.
|
|
332
|
+
if (!input.emailVerified) {
|
|
333
|
+
throw new ExternalSignInError('email_unverified')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const email = requireText(normaliseEmail(input.email), 'email')
|
|
337
|
+
const name = requireText(input.name ?? localPartOf(email), 'name')
|
|
338
|
+
const now = dependencies.now()
|
|
339
|
+
|
|
340
|
+
const link = async (
|
|
341
|
+
tx: repository.Queryable,
|
|
342
|
+
user: repository.UserRecord,
|
|
343
|
+
): Promise<ExternalIssuedSession> => {
|
|
344
|
+
// Signing in through a provider on an account whose address was never
|
|
345
|
+
// confirmed is the pre-registration takeover case: somebody registered
|
|
346
|
+
// this address with a password and never proved they own it. The
|
|
347
|
+
// provider just proved the opposite. Drop that password and every
|
|
348
|
+
// session it opened; the real owner sets a new one through a reset.
|
|
349
|
+
if (user.emailVerifiedAt === null) {
|
|
350
|
+
await repository.updateUserPassword(tx, user.id, null, now)
|
|
351
|
+
await repository.deleteAllSessionsForUser(tx, user.id)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The provider proved control of the address, which is the same thing
|
|
355
|
+
// accepting an invite proves.
|
|
356
|
+
await repository.markEmailVerified(tx, user.id, now)
|
|
357
|
+
|
|
358
|
+
const issued = await issueSession(
|
|
359
|
+
tx,
|
|
360
|
+
{ ...user, emailVerifiedAt: user.emailVerifiedAt ?? now },
|
|
361
|
+
input.device,
|
|
362
|
+
input.location,
|
|
363
|
+
input.verifiedBy,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return { ...issued, created: false }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const existing = await repository.findUserByEmail(dependencies.db, email)
|
|
370
|
+
|
|
371
|
+
if (existing !== undefined) {
|
|
372
|
+
return dependencies.transaction(({ tx }) => link(tx, existing))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (input.provision === 'refuse') {
|
|
376
|
+
throw new ExternalSignInError('unknown_identity')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return dependencies.transaction(async ({ tx }) => {
|
|
380
|
+
let user: repository.UserRecord
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
user = await repository.insertUser(tx, {
|
|
384
|
+
id: dependencies.createId('user'),
|
|
385
|
+
email,
|
|
386
|
+
name,
|
|
387
|
+
passwordHash: null,
|
|
388
|
+
emailVerifiedAt: now,
|
|
389
|
+
})
|
|
390
|
+
} catch (error: unknown) {
|
|
391
|
+
if (postgresErrorCode(error) !== UNIQUE_VIOLATION) {
|
|
392
|
+
throw error
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Two first sign-ins for the same address at once. The other one won;
|
|
396
|
+
// this is now an ordinary link.
|
|
397
|
+
const raced = await repository.findUserByEmail(tx, email)
|
|
398
|
+
|
|
399
|
+
if (raced === undefined) {
|
|
400
|
+
throw error
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return link(tx, raced)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const issued = await issueSession(tx, user, input.device, input.location, input.verifiedBy)
|
|
407
|
+
|
|
408
|
+
return { ...issued, created: true }
|
|
409
|
+
})
|
|
410
|
+
},
|
|
411
|
+
|
|
283
412
|
async logOut(actor: SessionActor): Promise<void> {
|
|
284
413
|
await repository.deleteSession(dependencies.db, actor.userId, actor.sessionId)
|
|
285
414
|
},
|
|
@@ -308,9 +437,12 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
308
437
|
: { email: requireText(normaliseEmail(changes.email), 'email') }),
|
|
309
438
|
}
|
|
310
439
|
|
|
440
|
+
// A passwordless account cannot prove itself this way. It sets a password
|
|
441
|
+
// through the reset flow first, which is the same bar this check is.
|
|
311
442
|
if (
|
|
312
443
|
changes.email !== undefined &&
|
|
313
444
|
(changes.currentPassword === undefined ||
|
|
445
|
+
user.passwordHash === null ||
|
|
314
446
|
!(await verifyPassword(user.passwordHash, changes.currentPassword)))
|
|
315
447
|
) {
|
|
316
448
|
throw AppError.unauthorized('Current password is incorrect')
|
|
@@ -405,6 +537,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
405
537
|
location: record.location,
|
|
406
538
|
lastActiveAt: record.lastActiveAt,
|
|
407
539
|
current: record.id === actor.sessionId,
|
|
540
|
+
signedInVia: record.signedInVia,
|
|
408
541
|
}))
|
|
409
542
|
},
|
|
410
543
|
|
|
@@ -494,7 +627,11 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
494
627
|
|
|
495
628
|
const user = await repository.findUserById(dependencies.db, actor.userId)
|
|
496
629
|
|
|
497
|
-
if (
|
|
630
|
+
if (
|
|
631
|
+
user === undefined ||
|
|
632
|
+
user.passwordHash === null ||
|
|
633
|
+
!(await verifyPassword(user.passwordHash, currentPassword))
|
|
634
|
+
) {
|
|
498
635
|
throw AppError.unauthorized('Current password is incorrect')
|
|
499
636
|
}
|
|
500
637
|
|
|
@@ -11,6 +11,20 @@ import type { Context } from 'hono'
|
|
|
11
11
|
|
|
12
12
|
export const SESSION_COOKIE = 'kelpie_session'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The device and location shown on the Security page come from the request
|
|
16
|
+
* itself.
|
|
17
|
+
*
|
|
18
|
+
* Here rather than in `routes.ts` because the external sign-in handler needs
|
|
19
|
+
* the same description, and a session created by a module should look the same
|
|
20
|
+
* on that page as one created by the login form.
|
|
21
|
+
*/
|
|
22
|
+
export function describeClient(context: Context): { device?: string; location?: string } {
|
|
23
|
+
const userAgent = context.req.header('User-Agent')
|
|
24
|
+
|
|
25
|
+
return userAgent === undefined ? {} : { device: userAgent }
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/** Thirty days, matching the session row's expiry. */
|
|
15
29
|
const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
|
16
30
|
|
package/src/runtime/module.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Handler, Hono, MiddlewareHandler } from 'hono'
|
|
1
|
+
import type { Context, Handler, Hono, MiddlewareHandler } from 'hono'
|
|
2
2
|
import type { ZodType } from 'zod'
|
|
3
3
|
|
|
4
4
|
import type { Actor } from '../lib/actor.ts'
|
|
@@ -58,6 +58,53 @@ export interface McpToolRegistry {
|
|
|
58
58
|
tool<Input>(definition: McpToolDefinition<Input>): void
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* An identity a module verified somewhere else, handed back for core to sign in.
|
|
63
|
+
*
|
|
64
|
+
* Core never learns what OIDC or SAML is. Verification is the module's job and
|
|
65
|
+
* core does not redo it; core's job is that everything downstream of the cookie
|
|
66
|
+
* is identical to a password sign-in.
|
|
67
|
+
*/
|
|
68
|
+
export interface VerifiedIdentity {
|
|
69
|
+
readonly email: string
|
|
70
|
+
/** Core refuses the identity when false, rather than assuming the module checked. */
|
|
71
|
+
readonly emailVerified: boolean
|
|
72
|
+
/** The provider's display name, when it has one. The local part of the address is used otherwise. */
|
|
73
|
+
readonly name: string | null
|
|
74
|
+
/** Who verified it, recorded on the session: the module id, optionally suffixed (`sign-in:google`). */
|
|
75
|
+
readonly verifiedBy: string
|
|
76
|
+
/**
|
|
77
|
+
* What to do with an address core has never seen. The policy is the module's,
|
|
78
|
+
* because only it knows whether its provider vouches for strangers; executing
|
|
79
|
+
* it is core's.
|
|
80
|
+
*/
|
|
81
|
+
readonly provision: 'create' | 'refuse'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The account a completed sign-in belongs to. */
|
|
85
|
+
export interface CompletedSignInAccount {
|
|
86
|
+
readonly id: string
|
|
87
|
+
readonly email: string
|
|
88
|
+
readonly name: string
|
|
89
|
+
readonly emailVerified: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CompletedSignIn {
|
|
93
|
+
readonly account: CompletedSignInAccount
|
|
94
|
+
/** True when this sign-in provisioned the account rather than finding it. */
|
|
95
|
+
readonly created: boolean
|
|
96
|
+
readonly activeWorkspaceId: string | null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Completes a sign-in. Takes the Hono context because writing the session
|
|
101
|
+
* cookie on it is the point.
|
|
102
|
+
*/
|
|
103
|
+
export type ExternalSignInHandler = (
|
|
104
|
+
context: Context,
|
|
105
|
+
identity: VerifiedIdentity,
|
|
106
|
+
) => Promise<CompletedSignIn>
|
|
107
|
+
|
|
61
108
|
/**
|
|
62
109
|
* What a module gets to build with, beyond its own contributions.
|
|
63
110
|
*
|
|
@@ -119,6 +166,28 @@ export interface ModuleContext extends ModuleServices {
|
|
|
119
166
|
* delegates to the provider `email.provider` picked.
|
|
120
167
|
*/
|
|
121
168
|
readonly email: EmailSender
|
|
169
|
+
/**
|
|
170
|
+
* Installs the one implementation of external sign-in. Core's `auth` module
|
|
171
|
+
* calls this; a second caller fails boot.
|
|
172
|
+
*
|
|
173
|
+
* The same shape as `provideEmailSender`: a capability only one module owns,
|
|
174
|
+
* reached by every module through a proxy, so registration order does not
|
|
175
|
+
* matter to a consumer.
|
|
176
|
+
*/
|
|
177
|
+
provideExternalSignIn(handler: ExternalSignInHandler): void
|
|
178
|
+
/**
|
|
179
|
+
* Completes a sign-in for an identity this module already verified: finds or
|
|
180
|
+
* provisions the account, issues a session, and writes the session cookie on
|
|
181
|
+
* `context`.
|
|
182
|
+
*
|
|
183
|
+
* The alternative is a module writing core's `sessions` table itself, which
|
|
184
|
+
* ties it to core's token hashing, cookie flags and expiry with nothing
|
|
185
|
+
* keeping the two in step (`modules.md`).
|
|
186
|
+
*
|
|
187
|
+
* @throws ExternalSignInError when the identity is unverified, or unknown and
|
|
188
|
+
* the module asked for `provision: 'refuse'`.
|
|
189
|
+
*/
|
|
190
|
+
completeExternalSignIn(context: Context, identity: VerifiedIdentity): Promise<CompletedSignIn>
|
|
122
191
|
/**
|
|
123
192
|
* Registers routes that mount under `/v1/public`, take no credentials, and
|
|
124
193
|
* answer cross-origin requests from any site.
|
package/src/runtime/registry.ts
CHANGED
|
@@ -13,12 +13,15 @@ import type { EntitlementRegistry } from './entitlements.ts'
|
|
|
13
13
|
import { createEventBus } from './events.ts'
|
|
14
14
|
import type { EventBus } from './events.ts'
|
|
15
15
|
import type {
|
|
16
|
+
CompletedSignIn,
|
|
17
|
+
ExternalSignInHandler,
|
|
16
18
|
KelpieModule,
|
|
17
19
|
McpTool,
|
|
18
20
|
ModuleCatalogEntry,
|
|
19
21
|
ModuleContext,
|
|
20
22
|
ModuleServices,
|
|
21
23
|
SchemaContribution,
|
|
24
|
+
VerifiedIdentity,
|
|
22
25
|
} from './module.ts'
|
|
23
26
|
import { createModuleConfigProvider, moduleCapabilityName, validateModuleConfig } from './moduleConfig.ts'
|
|
24
27
|
import { ModuleBootError, orderModules } from './order.ts'
|
|
@@ -180,6 +183,46 @@ class EmailSenderProxy implements EmailSender {
|
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Completes a sign-in for an identity a module verified elsewhere.
|
|
188
|
+
*
|
|
189
|
+
* Same shape as `EmailSenderProxy`, and for the same reason: one module owns
|
|
190
|
+
* the implementation, every module reaches it through this object, and a
|
|
191
|
+
* consumer captures the proxy at register time but only calls it at request
|
|
192
|
+
* time, so registration order does not matter.
|
|
193
|
+
*
|
|
194
|
+
* Exactly one installer, unlike email's named registry: there is one `users`
|
|
195
|
+
* table and one session cookie, so "which implementation" is never a question
|
|
196
|
+
* an assembly should have to answer.
|
|
197
|
+
*/
|
|
198
|
+
class ExternalSignInProxy {
|
|
199
|
+
private target: ExternalSignInHandler | undefined = undefined
|
|
200
|
+
private installedBy: string | undefined = undefined
|
|
201
|
+
|
|
202
|
+
install(moduleId: string, handler: ExternalSignInHandler): void {
|
|
203
|
+
if (this.installedBy !== undefined) {
|
|
204
|
+
throw new ModuleBootError([
|
|
205
|
+
`module "${moduleId}" provides external sign-in, but module "${this.installedBy}" already did`,
|
|
206
|
+
])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.target = handler
|
|
210
|
+
this.installedBy = moduleId
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
complete(context: Context, identity: VerifiedIdentity): Promise<CompletedSignIn> {
|
|
214
|
+
if (this.target === undefined) {
|
|
215
|
+
return Promise.reject(
|
|
216
|
+
new Error(
|
|
217
|
+
'external sign-in used before a module installed it. Is the auth module in the assembly?',
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return this.target(context, identity)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
183
226
|
/**
|
|
184
227
|
* The name the runtime uses for its built-in log sender. Reserved: a module
|
|
185
228
|
* that tries to register under this name fails boot.
|
|
@@ -204,11 +247,20 @@ function createModuleContext(
|
|
|
204
247
|
moduleCatalog: readonly ModuleCatalogEntry[],
|
|
205
248
|
emailProxy: EmailSenderProxy,
|
|
206
249
|
providers: Map<string, RegisteredProvider>,
|
|
250
|
+
externalSignIn: ExternalSignInProxy,
|
|
207
251
|
): ModuleContext {
|
|
208
252
|
return {
|
|
209
253
|
...options.services,
|
|
210
254
|
email: emailProxy,
|
|
211
255
|
|
|
256
|
+
provideExternalSignIn(handler) {
|
|
257
|
+
externalSignIn.install(module.id, handler)
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
completeExternalSignIn(context, identity) {
|
|
261
|
+
return externalSignIn.complete(context, identity)
|
|
262
|
+
},
|
|
263
|
+
|
|
212
264
|
provideEmailSender(name, build) {
|
|
213
265
|
if (name === LOG_PROVIDER_NAME) {
|
|
214
266
|
throw new ModuleBootError([
|
|
@@ -348,6 +400,7 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
|
|
|
348
400
|
const entitlements = options.entitlements ?? createEntitlementRegistry()
|
|
349
401
|
const emailProxy = new EmailSenderProxy()
|
|
350
402
|
const emailProviders = new Map<string, RegisteredProvider>()
|
|
403
|
+
const externalSignIn = new ExternalSignInProxy()
|
|
351
404
|
|
|
352
405
|
// The built-in log provider is always available, no module required. Named
|
|
353
406
|
// 'log' in kelpie.config.ts's email.provider picks this one. Built eagerly
|
|
@@ -429,6 +482,7 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
|
|
|
429
482
|
moduleCatalog,
|
|
430
483
|
emailProxy,
|
|
431
484
|
emailProviders,
|
|
485
|
+
externalSignIn,
|
|
432
486
|
)
|
|
433
487
|
|
|
434
488
|
try {
|