@schlessera/brain-ui-server 0.34.1 → 0.35.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/README.md +28 -0
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +18 -8
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/push-sender.d.ts +7 -3
- package/dist/activity/push-sender.d.ts.map +1 -1
- package/dist/activity/push-sender.js +45 -11
- package/dist/activity/push-sender.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +8 -0
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +11 -1
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +52 -2
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +2 -5
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/sql.d.ts +3 -3
- package/dist/activity/sql.d.ts.map +1 -1
- package/dist/activity/sql.js +7 -5
- package/dist/activity/sql.js.map +1 -1
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +15 -3
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +3 -1
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +32 -17
- package/dist/activity/stream.js.map +1 -1
- package/dist/app-env.d.ts +8 -0
- package/dist/app-env.d.ts.map +1 -0
- package/dist/app-env.js +2 -0
- package/dist/app-env.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +28 -3
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +4 -2
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +60 -20
- package/dist/brain/client.js.map +1 -1
- package/dist/brain/types.d.ts +9 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/cron/run-job.d.ts.map +1 -1
- package/dist/cron/run-job.js +60 -10
- package/dist/cron/run-job.js.map +1 -1
- package/dist/db/principals.d.ts +72 -0
- package/dist/db/principals.d.ts.map +1 -0
- package/dist/db/principals.js +294 -0
- package/dist/db/principals.js.map +1 -0
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -1
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +37 -9
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +148 -74
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +9 -7
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +25 -7
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/principals.d.ts +18 -0
- package/dist/middleware/principals.d.ts.map +1 -0
- package/dist/middleware/principals.js +138 -0
- package/dist/middleware/principals.js.map +1 -0
- package/dist/middleware/require-owner.d.ts +5 -0
- package/dist/middleware/require-owner.d.ts.map +1 -0
- package/dist/middleware/require-owner.js +10 -0
- package/dist/middleware/require-owner.js.map +1 -0
- package/dist/observability/loggers.js +1 -1
- package/dist/observability/loggers.js.map +1 -1
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +3 -0
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +102 -63
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/push.d.ts +61 -2
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +8 -3
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +30 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +11 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +9 -2
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +44 -6
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +7 -4
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +83 -12
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +12 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +46 -6
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/history.d.ts.map +1 -1
- package/dist/ws/history.js +7 -5
- package/dist/ws/history.js.map +1 -1
- package/dist/ws/host.d.ts +22 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +39 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts +7 -7
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +208 -52
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/shrink.d.ts +3 -1
- package/dist/ws/shrink.d.ts.map +1 -1
- package/dist/ws/shrink.js +46 -16
- package/dist/ws/shrink.js.map +1 -1
- package/dist/ws/turns.d.ts +58 -3
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +101 -2
- package/dist/ws/turns.js.map +1 -1
- package/migrations/011_principals.sql +13 -0
- package/migrations/012_activity_principals.sql +12 -0
- package/migrations/013_push_subscription_principals.sql +7 -0
- package/migrations/014_invalidate_legacy_sessions.sql +14 -0
- package/package.json +3 -3
- package/src/activity/notify.ts +16 -7
- package/src/activity/push-sender.ts +79 -16
- package/src/activity/query.ts +8 -0
- package/src/activity/recorder.ts +68 -3
- package/src/activity/runtime.ts +2 -5
- package/src/activity/sql.ts +7 -5
- package/src/activity/store.ts +32 -1
- package/src/activity/stream.ts +37 -17
- package/src/app-env.ts +8 -0
- package/src/app.ts +33 -4
- package/src/brain/client.ts +61 -20
- package/src/brain/types.ts +10 -0
- package/src/cron/run-job.ts +41 -17
- package/src/db/principals.ts +452 -0
- package/src/files/walker.ts +5 -1
- package/src/index.ts +6 -1
- package/src/middleware/auth.ts +241 -81
- package/src/middleware/passkeys.ts +35 -14
- package/src/middleware/principals.ts +189 -0
- package/src/middleware/require-owner.ts +13 -0
- package/src/observability/loggers.ts +1 -1
- package/src/routes/activity.ts +3 -0
- package/src/routes/brain.ts +107 -70
- package/src/routes/files.ts +10 -5
- package/src/routes/push.ts +11 -5
- package/src/routes/sessions.ts +45 -20
- package/src/share/staging.ts +11 -4
- package/src/ws/bridge.ts +14 -2
- package/src/ws/clients.ts +52 -7
- package/src/ws/connection.ts +98 -20
- package/src/ws/dispatch.ts +66 -6
- package/src/ws/history.ts +7 -5
- package/src/ws/host.ts +59 -1
- package/src/ws/run-session.ts +247 -74
- package/src/ws/shrink.ts +48 -14
- package/src/ws/turns.ts +136 -5
package/src/middleware/auth.ts
CHANGED
|
@@ -8,8 +8,23 @@ import { isTailscaleAllowed, clientIp } from "./tailscale.js";
|
|
|
8
8
|
import type { AuthConfig } from "../config/env.js";
|
|
9
9
|
import { readJsonBody } from "./body-limit.js";
|
|
10
10
|
import { requireJson } from "./origin.js";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createPrincipal,
|
|
13
|
+
isCookieBearingPrincipal,
|
|
14
|
+
isUsablePrincipal,
|
|
15
|
+
PrincipalCredentialNotFoundError,
|
|
16
|
+
PrincipalLimitError,
|
|
17
|
+
prunePrincipals,
|
|
18
|
+
prunePrincipalsIfDue,
|
|
19
|
+
resolveAmbientPrincipal,
|
|
20
|
+
resolvePrincipal,
|
|
21
|
+
revokePrincipal,
|
|
22
|
+
revokeAllPrincipals,
|
|
23
|
+
touchLastSeen,
|
|
24
|
+
type Principal,
|
|
25
|
+
} from "../db/principals.js";
|
|
26
|
+
import type { AppEnv } from "../app-env.js";
|
|
27
|
+
import type { WsHost } from "../ws/host.js";
|
|
13
28
|
|
|
14
29
|
/**
|
|
15
30
|
* Authentication for a remote surface to an agent with write access to the
|
|
@@ -44,10 +59,13 @@ export interface AuthRuntime extends AuthConfig {
|
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
const COOKIE_NAME = "brain_ui_session";
|
|
47
|
-
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
48
|
-
const
|
|
62
|
+
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
63
|
+
const LOGIN_LABEL_MAX_LENGTH = 64;
|
|
64
|
+
const LOGIN_LABEL_FALLBACK = "Unknown device";
|
|
65
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
49
66
|
const SESSION_REVOKED_CLOSE_CODE = 1008;
|
|
50
67
|
const SESSION_REVOKED_CLOSE_REASON = "Sessions invalidated";
|
|
68
|
+
|
|
51
69
|
export const LOGIN_RATE_LIMIT = 5; // failures per window, per client IP
|
|
52
70
|
const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
|
|
53
71
|
// A global cap in addition to the per-IP one: the per-IP key is derived from
|
|
@@ -172,11 +190,17 @@ export function authGuard(
|
|
|
172
190
|
mode: AuthMode,
|
|
173
191
|
auth: AuthRuntime,
|
|
174
192
|
db: Database
|
|
175
|
-
): MiddlewareHandler {
|
|
193
|
+
): MiddlewareHandler<AppEnv> {
|
|
176
194
|
switch (mode) {
|
|
177
195
|
case "tailscale":
|
|
178
196
|
return async (c, next) => {
|
|
197
|
+
prunePrincipalsIfDue(db, Date.now());
|
|
179
198
|
if (isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops)) {
|
|
199
|
+
const ip = clientIp(c, auth.trustProxy, auth.trustProxyHops);
|
|
200
|
+
c.set(
|
|
201
|
+
"principal",
|
|
202
|
+
resolveAmbientPrincipal(db, "tailscale", ip, sanitizeAmbientLabel(ip))
|
|
203
|
+
);
|
|
180
204
|
await next();
|
|
181
205
|
} else {
|
|
182
206
|
return c.json({ error: "VPN access required" }, 403);
|
|
@@ -184,7 +208,18 @@ export function authGuard(
|
|
|
184
208
|
};
|
|
185
209
|
case "proxy":
|
|
186
210
|
return async (c, next) => {
|
|
187
|
-
|
|
211
|
+
prunePrincipalsIfDue(db, Date.now());
|
|
212
|
+
const proxyIdentity = resolveProxyIdentity(c, auth);
|
|
213
|
+
if (proxyIdentity) {
|
|
214
|
+
c.set(
|
|
215
|
+
"principal",
|
|
216
|
+
resolveAmbientPrincipal(
|
|
217
|
+
db,
|
|
218
|
+
"proxy",
|
|
219
|
+
proxyIdentity.identity,
|
|
220
|
+
proxyIdentity.label
|
|
221
|
+
)
|
|
222
|
+
);
|
|
188
223
|
await next();
|
|
189
224
|
} else {
|
|
190
225
|
return c.json(
|
|
@@ -195,7 +230,10 @@ export function authGuard(
|
|
|
195
230
|
};
|
|
196
231
|
case "password":
|
|
197
232
|
return async (c, next) => {
|
|
198
|
-
|
|
233
|
+
prunePrincipalsIfDue(db, Date.now());
|
|
234
|
+
const principal = await resolveCookiePrincipal(c, auth, db);
|
|
235
|
+
if (principal) {
|
|
236
|
+
c.set("principal", principal);
|
|
199
237
|
await next();
|
|
200
238
|
} else {
|
|
201
239
|
return c.json(
|
|
@@ -205,7 +243,17 @@ export function authGuard(
|
|
|
205
243
|
}
|
|
206
244
|
};
|
|
207
245
|
case "none":
|
|
208
|
-
return async (
|
|
246
|
+
return async (c, next) => {
|
|
247
|
+
prunePrincipalsIfDue(db, Date.now());
|
|
248
|
+
c.set(
|
|
249
|
+
"principal",
|
|
250
|
+
resolveAmbientPrincipal(
|
|
251
|
+
db,
|
|
252
|
+
"none",
|
|
253
|
+
"No authentication",
|
|
254
|
+
"No authentication"
|
|
255
|
+
)
|
|
256
|
+
);
|
|
209
257
|
await next();
|
|
210
258
|
};
|
|
211
259
|
}
|
|
@@ -217,22 +265,47 @@ export function authGuard(
|
|
|
217
265
|
* automatically same-origin) is the credential, and no header-modifying
|
|
218
266
|
* middleware may sit on the WS route (immutable-header errors). Mirrors
|
|
219
267
|
* {@link authGuard} without emitting a response.
|
|
268
|
+
*
|
|
269
|
+
* @returns The resolved principal for an authorized upgrade, otherwise null.
|
|
220
270
|
*/
|
|
221
271
|
export async function isWsAuthorized(
|
|
222
|
-
c: Context
|
|
272
|
+
c: Context<AppEnv>,
|
|
223
273
|
mode: AuthMode,
|
|
224
274
|
auth: AuthRuntime,
|
|
225
275
|
db: Database
|
|
226
|
-
): Promise<
|
|
276
|
+
): Promise<Principal | null> {
|
|
277
|
+
prunePrincipalsIfDue(db, Date.now());
|
|
227
278
|
switch (mode) {
|
|
228
279
|
case "none":
|
|
229
|
-
return
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
280
|
+
return resolveAmbientPrincipal(
|
|
281
|
+
db,
|
|
282
|
+
"none",
|
|
283
|
+
"No authentication",
|
|
284
|
+
"No authentication"
|
|
285
|
+
);
|
|
286
|
+
case "tailscale": {
|
|
287
|
+
if (!isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops)) return null;
|
|
288
|
+
const ip = clientIp(c, auth.trustProxy, auth.trustProxyHops);
|
|
289
|
+
return resolveAmbientPrincipal(
|
|
290
|
+
db,
|
|
291
|
+
"tailscale",
|
|
292
|
+
ip,
|
|
293
|
+
sanitizeAmbientLabel(ip)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
case "proxy": {
|
|
297
|
+
const proxyIdentity = resolveProxyIdentity(c, auth);
|
|
298
|
+
return proxyIdentity
|
|
299
|
+
? resolveAmbientPrincipal(
|
|
300
|
+
db,
|
|
301
|
+
"proxy",
|
|
302
|
+
proxyIdentity.identity,
|
|
303
|
+
proxyIdentity.label
|
|
304
|
+
)
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
234
307
|
case "password":
|
|
235
|
-
return
|
|
308
|
+
return resolveCookiePrincipal(c, auth, db);
|
|
236
309
|
}
|
|
237
310
|
}
|
|
238
311
|
|
|
@@ -246,11 +319,21 @@ export async function isWsAuthorized(
|
|
|
246
319
|
export async function issueSessionCookie(
|
|
247
320
|
c: Context,
|
|
248
321
|
auth: AuthRuntime,
|
|
249
|
-
db: Database
|
|
322
|
+
db: Database,
|
|
323
|
+
principalId: string
|
|
250
324
|
): Promise<void> {
|
|
251
325
|
const secret = auth.cookieSecret ?? "";
|
|
252
|
-
const
|
|
253
|
-
|
|
326
|
+
const principal = resolvePrincipal(db, principalId);
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
if (
|
|
329
|
+
!principal ||
|
|
330
|
+
!isCookieBearingPrincipal(principal) ||
|
|
331
|
+
!isUsablePrincipal(principal, now)
|
|
332
|
+
) {
|
|
333
|
+
throw new Error(`Cannot issue a session cookie for unusable principal ${principalId}`);
|
|
334
|
+
}
|
|
335
|
+
const maxAge = Math.floor((principal.expiresAt - now) / 1_000);
|
|
336
|
+
await setSignedCookie(c, COOKIE_NAME, principal.id, secret, {
|
|
254
337
|
httpOnly: true,
|
|
255
338
|
sameSite: "Strict",
|
|
256
339
|
// Always Secure — every real deployment serves over HTTPS, and we do not
|
|
@@ -258,87 +341,150 @@ export async function issueSessionCookie(
|
|
|
258
341
|
// over plaintext. (Local password testing must use https/localhost.)
|
|
259
342
|
secure: true,
|
|
260
343
|
path: "/",
|
|
261
|
-
maxAge
|
|
344
|
+
maxAge,
|
|
262
345
|
});
|
|
263
346
|
}
|
|
264
347
|
|
|
265
|
-
|
|
348
|
+
/** Turn the client-controlled User-Agent into a short, log-safe display hint. */
|
|
349
|
+
export function sanitizeLoginLabel(userAgent: unknown): string {
|
|
350
|
+
if (typeof userAgent !== "string") return LOGIN_LABEL_FALLBACK;
|
|
351
|
+
const label = userAgent
|
|
352
|
+
.replace(CONTROL_CHARACTERS, "")
|
|
353
|
+
.trim()
|
|
354
|
+
.slice(0, LOGIN_LABEL_MAX_LENGTH)
|
|
355
|
+
.trim();
|
|
356
|
+
// A label exists to be recognised in a device list. A string with nothing
|
|
357
|
+
// alphanumeric in it ("(((") is technically a User-Agent and useless as a
|
|
358
|
+
// hint, so it takes the fallback rather than being shown as-is.
|
|
359
|
+
if (!/[\p{L}\p{N}]/u.test(label)) return LOGIN_LABEL_FALLBACK;
|
|
360
|
+
return label;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type LoginPrincipalLineage =
|
|
364
|
+
| { authMethod: "password" }
|
|
365
|
+
| { authMethod: "passkey"; credentialId: string };
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Create the durable owner principal for a verified login and issue its cookie.
|
|
369
|
+
* Returns a response only when the live-principal cap refuses the login.
|
|
370
|
+
*/
|
|
371
|
+
export async function issueLoginSession(
|
|
266
372
|
c: Context,
|
|
267
373
|
auth: AuthRuntime,
|
|
268
|
-
db: Database
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
374
|
+
db: Database,
|
|
375
|
+
lineage: LoginPrincipalLineage
|
|
376
|
+
): Promise<Response | null> {
|
|
377
|
+
prunePrincipals(db, Date.now());
|
|
378
|
+
|
|
273
379
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
380
|
+
const principal = createPrincipal(db, {
|
|
381
|
+
authMethod: lineage.authMethod,
|
|
382
|
+
label: sanitizeLoginLabel(c.req.header("user-agent")),
|
|
383
|
+
credentialId:
|
|
384
|
+
lineage.authMethod === "passkey" ? lineage.credentialId : undefined,
|
|
385
|
+
ttlSeconds: SESSION_TTL_SECONDS,
|
|
386
|
+
});
|
|
387
|
+
await issueSessionCookie(c, auth, db, principal.id);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
if (err instanceof PrincipalCredentialNotFoundError) {
|
|
390
|
+
return c.json({ error: "Passkey verification failed" }, 401);
|
|
391
|
+
}
|
|
392
|
+
if (!(err instanceof PrincipalLimitError)) throw err;
|
|
393
|
+
// 503: valid credentials reached a server-side capacity limit. This is
|
|
394
|
+
// neither an authentication failure nor a request the client can repair
|
|
395
|
+
// except by waiting for expiry or signing out another device.
|
|
396
|
+
return c.json(
|
|
397
|
+
{ error: "Session capacity reached. Sign out another device and try again." },
|
|
398
|
+
503
|
|
399
|
+
);
|
|
289
400
|
}
|
|
290
|
-
const ageSeconds = (Date.now() - issuedAt) / 1000;
|
|
291
|
-
if (ageSeconds < 0 || ageSeconds >= SESSION_TTL_SECONDS) return false;
|
|
292
401
|
|
|
293
|
-
|
|
294
|
-
// state refuses loudly and must never degrade to epoch zero.
|
|
295
|
-
return cookieEpoch === sessionsEpoch(db);
|
|
402
|
+
return null;
|
|
296
403
|
}
|
|
297
404
|
|
|
298
405
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
406
|
+
* Resolve the principal named by a valid session cookie. Signature and payload
|
|
407
|
+
* shape are checked before the principal store is queried.
|
|
301
408
|
*/
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
409
|
+
export async function resolveCookiePrincipal(
|
|
410
|
+
c: Context,
|
|
411
|
+
auth: AuthRuntime,
|
|
412
|
+
db: Database
|
|
413
|
+
): Promise<Principal | null> {
|
|
414
|
+
const secret = auth.cookieSecret ?? "";
|
|
415
|
+
if (!secret) return null;
|
|
416
|
+
let value: string | false | undefined;
|
|
309
417
|
try {
|
|
310
|
-
value =
|
|
418
|
+
value = await getSignedCookie(c, secret, COOKIE_NAME);
|
|
311
419
|
} catch {
|
|
312
|
-
|
|
420
|
+
return null;
|
|
313
421
|
}
|
|
314
|
-
if (
|
|
315
|
-
|
|
422
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{22}$/.test(value)) {
|
|
423
|
+
return null;
|
|
316
424
|
}
|
|
317
|
-
return value as number;
|
|
318
|
-
}
|
|
319
425
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
426
|
+
// Deliberately after signature and shape verification, and outside the
|
|
427
|
+
// cookie-parser catch: arbitrary ids cannot drive reads, while corrupt
|
|
428
|
+
// authoritative state fails only the principal whose signed cookie names it.
|
|
429
|
+
const principal = resolvePrincipal(db, value);
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
if (
|
|
432
|
+
!principal ||
|
|
433
|
+
!isCookieBearingPrincipal(principal) ||
|
|
434
|
+
!isUsablePrincipal(principal, now)
|
|
435
|
+
) {
|
|
436
|
+
return null;
|
|
325
437
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
438
|
+
touchLastSeen(db, principal.id, now);
|
|
439
|
+
return principal;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Apply durable revocation to every live runtime authorization context. */
|
|
443
|
+
export function applyPrincipalRevocation(
|
|
444
|
+
revoker: Pick<WsHost, "revokePrincipals">,
|
|
445
|
+
principalIds: readonly string[]
|
|
446
|
+
): void {
|
|
447
|
+
revoker.revokePrincipals(
|
|
448
|
+
principalIds,
|
|
449
|
+
SESSION_REVOKED_CLOSE_CODE,
|
|
450
|
+
SESSION_REVOKED_CLOSE_REASON
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Revoke every cookie-bearing principal and disconnect its attached clients.
|
|
456
|
+
*/
|
|
457
|
+
export function revokeAllSessions(
|
|
458
|
+
db: Database,
|
|
459
|
+
revoker: Pick<WsHost, "revokePrincipals">
|
|
460
|
+
): void {
|
|
461
|
+
const revokedIds = revokeAllPrincipals(db, Date.now());
|
|
462
|
+
applyPrincipalRevocation(revoker, revokedIds);
|
|
330
463
|
}
|
|
331
464
|
|
|
332
465
|
// --- proxy mode helpers ---
|
|
333
466
|
|
|
334
|
-
|
|
467
|
+
const AMBIENT_LABEL_MAX_LENGTH = 64;
|
|
468
|
+
|
|
469
|
+
function sanitizeAmbientLabel(identity: string): string {
|
|
470
|
+
return identity
|
|
471
|
+
.replace(CONTROL_CHARACTERS, "")
|
|
472
|
+
.trim()
|
|
473
|
+
.slice(0, AMBIENT_LABEL_MAX_LENGTH);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function resolveProxyIdentity(
|
|
477
|
+
c: Context,
|
|
478
|
+
auth: AuthRuntime
|
|
479
|
+
): { identity: string; label: string } | null {
|
|
335
480
|
// The proxy-auth header is only meaningful when a trusted proxy fronts the app
|
|
336
481
|
// and TRUST_PROXY says so; otherwise a client could set it directly. Gate on
|
|
337
482
|
// trustProxy, consistent with tailscale-mode XFF trust. (assertAuthConfig
|
|
338
483
|
// already refuses to boot proxy mode without it — this is belt-and-braces.)
|
|
339
|
-
if (!auth.trustProxy) return
|
|
484
|
+
if (!auth.trustProxy) return null;
|
|
340
485
|
const user = c.req.header(auth.proxyAuthHeader);
|
|
341
|
-
|
|
486
|
+
if (!user || user.trim().length === 0) return null;
|
|
487
|
+
return { identity: user, label: sanitizeAmbientLabel(user) };
|
|
342
488
|
}
|
|
343
489
|
|
|
344
490
|
// --- login rate limiter (in-memory failure windows + verification reservations) ---
|
|
@@ -506,8 +652,8 @@ export function authRoutes(
|
|
|
506
652
|
deps: {
|
|
507
653
|
/** Authoritative session state. */
|
|
508
654
|
db: Database;
|
|
509
|
-
/**
|
|
510
|
-
|
|
655
|
+
/** Runtime connections and queued work invalidated by principal revocation. */
|
|
656
|
+
revoker: Pick<WsHost, "revokePrincipals">;
|
|
511
657
|
/**
|
|
512
658
|
* When provided and returning true, password login is refused (the app
|
|
513
659
|
* injects passkeys' passwordLoginDisabled so the shared password dies
|
|
@@ -522,8 +668,8 @@ export function authRoutes(
|
|
|
522
668
|
/** Test-only password verifier; production uses Bun.password.verify. */
|
|
523
669
|
verifyPassword?: (password: string, hash: string) => Promise<boolean>;
|
|
524
670
|
}
|
|
525
|
-
): Hono {
|
|
526
|
-
const app = new Hono();
|
|
671
|
+
): Hono<AppEnv> {
|
|
672
|
+
const app = new Hono<AppEnv>();
|
|
527
673
|
const { log, failures } = deps;
|
|
528
674
|
const verifyPassword = deps.verifyPassword ?? Bun.password.verify;
|
|
529
675
|
let warnedUntrustedForwardedFor = false;
|
|
@@ -633,7 +779,10 @@ export function authRoutes(
|
|
|
633
779
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
634
780
|
}
|
|
635
781
|
|
|
636
|
-
await
|
|
782
|
+
const capacityResponse = await issueLoginSession(c, auth, deps.db, {
|
|
783
|
+
authMethod: "password",
|
|
784
|
+
});
|
|
785
|
+
if (capacityResponse) return capacityResponse;
|
|
637
786
|
log?.emit({
|
|
638
787
|
severityText: "INFO",
|
|
639
788
|
body: "login succeeded",
|
|
@@ -643,10 +792,21 @@ export function authRoutes(
|
|
|
643
792
|
});
|
|
644
793
|
|
|
645
794
|
app.post("/auth/logout", async (c) => {
|
|
646
|
-
if (mode === "password"
|
|
647
|
-
|
|
795
|
+
if (mode === "password") {
|
|
796
|
+
const principal = await resolveCookiePrincipal(c, auth, deps.db);
|
|
797
|
+
if (!principal) {
|
|
798
|
+
return c.json({ error: "Authentication required", authRequired: true }, 401);
|
|
799
|
+
}
|
|
800
|
+
c.set("principal", principal);
|
|
801
|
+
if (principal.kind === "owner") {
|
|
802
|
+
revokeAllSessions(deps.db, deps.revoker);
|
|
803
|
+
} else {
|
|
804
|
+
applyPrincipalRevocation(
|
|
805
|
+
deps.revoker,
|
|
806
|
+
revokePrincipal(deps.db, principal.id, Date.now())
|
|
807
|
+
);
|
|
808
|
+
}
|
|
648
809
|
}
|
|
649
|
-
if (mode === "password") bumpSessionsEpoch(deps.db, deps.clients);
|
|
650
810
|
deleteCookie(c, COOKIE_NAME, { path: "/" });
|
|
651
811
|
return c.json({ ok: true });
|
|
652
812
|
});
|
|
@@ -17,26 +17,29 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
type AuthMode,
|
|
19
19
|
type AuthRuntime,
|
|
20
|
-
|
|
20
|
+
issueLoginSession,
|
|
21
21
|
consumeLoginToken,
|
|
22
22
|
isLoginBlocked,
|
|
23
23
|
recordLoginFailure,
|
|
24
24
|
PASSKEY_LOGIN_RATE_LIMIT,
|
|
25
25
|
acquirePasskeyVerification,
|
|
26
26
|
releasePasskeyVerification,
|
|
27
|
-
|
|
27
|
+
applyPrincipalRevocation,
|
|
28
28
|
} from "./auth.js";
|
|
29
29
|
import { clientIp } from "./tailscale.js";
|
|
30
30
|
import type { WebAuthnConfig } from "../config/env.js";
|
|
31
31
|
import { readJsonBody } from "./body-limit.js";
|
|
32
32
|
import { requireJson } from "./origin.js";
|
|
33
|
-
import type {
|
|
33
|
+
import type { AppEnv } from "../app-env.js";
|
|
34
|
+
import { revokeByCredential } from "../db/principals.js";
|
|
35
|
+
import type { WsHost } from "../ws/host.js";
|
|
36
|
+
import { requireOwner } from "./require-owner.js";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* WebAuthn passkeys as an extension of `password` mode: the password bootstraps
|
|
37
40
|
* the first registration (and stays as recovery), passkeys are the day-to-day
|
|
38
41
|
* login. A successful assertion mints the same session cookie as password login
|
|
39
|
-
* (
|
|
42
|
+
* (issueLoginSession), so authGuard / isWsAuthorized / TTL are untouched.
|
|
40
43
|
*
|
|
41
44
|
* Everything environmental is injected as a {@link PasskeyContext} — the
|
|
42
45
|
* resolved WebAuthn identity, the auth runtime (for trust/proxy semantics and
|
|
@@ -50,8 +53,8 @@ import type { ClientSet } from "../ws/clients.js";
|
|
|
50
53
|
/** Injected per-app dependencies for every passkey route and helper. */
|
|
51
54
|
export interface PasskeyContext {
|
|
52
55
|
db: Database;
|
|
53
|
-
/**
|
|
54
|
-
|
|
56
|
+
/** Runtime authorization state invalidated when a credential is revoked. */
|
|
57
|
+
revoker: Pick<WsHost, "revokePrincipals">;
|
|
55
58
|
/** WebAuthn identity plus ceremony-only origin and RP overrides. */
|
|
56
59
|
webauthn: WebAuthnConfig;
|
|
57
60
|
auth: AuthRuntime;
|
|
@@ -400,11 +403,14 @@ export function passkeyPublicRoutes(
|
|
|
400
403
|
});
|
|
401
404
|
return fail();
|
|
402
405
|
}
|
|
406
|
+
const currentRow = credentialById(ctx.db, row.id);
|
|
407
|
+
if (!currentRow || currentRow.rp_id !== rp.rpID) return fail();
|
|
408
|
+
|
|
403
409
|
ctx.db
|
|
404
410
|
.prepare(
|
|
405
411
|
"UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
|
|
406
412
|
)
|
|
407
|
-
.run(result.authenticationInfo.newCounter, now(),
|
|
413
|
+
.run(result.authenticationInfo.newCounter, now(), currentRow.id);
|
|
408
414
|
} finally {
|
|
409
415
|
releasePasskeyVerification(ip);
|
|
410
416
|
}
|
|
@@ -433,7 +439,11 @@ export function passkeyPublicRoutes(
|
|
|
433
439
|
return fail();
|
|
434
440
|
}
|
|
435
441
|
|
|
436
|
-
await
|
|
442
|
+
const capacityResponse = await issueLoginSession(c, ctx.auth, ctx.db, {
|
|
443
|
+
authMethod: "passkey",
|
|
444
|
+
credentialId: row.id,
|
|
445
|
+
});
|
|
446
|
+
if (capacityResponse) return capacityResponse;
|
|
437
447
|
return c.json({ ok: true });
|
|
438
448
|
});
|
|
439
449
|
|
|
@@ -441,17 +451,24 @@ export function passkeyPublicRoutes(
|
|
|
441
451
|
}
|
|
442
452
|
|
|
443
453
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
454
|
+
* Owner-only passkey management routes. Mount AFTER the auth guard: the
|
|
455
|
+
* family middleware below authorizes the resolved principal, it does not
|
|
456
|
+
* authenticate the request itself.
|
|
446
457
|
*/
|
|
447
458
|
export function passkeyManagementRoutes(
|
|
448
459
|
mode: AuthMode,
|
|
449
460
|
ctx: PasskeyContext,
|
|
450
461
|
deps: PasskeyDeps = {}
|
|
451
|
-
): Hono {
|
|
462
|
+
): Hono<AppEnv> {
|
|
452
463
|
const verifyRegistration = deps.verifyRegistrationResponse ?? realVerifyRegistration;
|
|
453
464
|
const now = deps.now ?? Date.now;
|
|
454
|
-
const app = new Hono();
|
|
465
|
+
const app = new Hono<AppEnv>();
|
|
466
|
+
|
|
467
|
+
app.use("/auth/passkey/*", async (c, next) => {
|
|
468
|
+
if (mode !== "password") return notEnabled(c);
|
|
469
|
+
await next();
|
|
470
|
+
});
|
|
471
|
+
app.use("/auth/passkey/*", requireOwner());
|
|
455
472
|
|
|
456
473
|
app.post("/auth/passkey/register-options", async (c) => {
|
|
457
474
|
if (mode !== "password") return notEnabled(c);
|
|
@@ -577,11 +594,15 @@ export function passkeyManagementRoutes(
|
|
|
577
594
|
|
|
578
595
|
app.delete("/auth/passkey/:id", (c) => {
|
|
579
596
|
if (mode !== "password") return notEnabled(c);
|
|
597
|
+
const credentialId = c.req.param("id");
|
|
580
598
|
const result = ctx.db
|
|
581
599
|
.prepare("DELETE FROM passkey_credentials WHERE id = ?")
|
|
582
|
-
.run(
|
|
600
|
+
.run(credentialId);
|
|
583
601
|
if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
|
|
584
|
-
|
|
602
|
+
applyPrincipalRevocation(
|
|
603
|
+
ctx.revoker,
|
|
604
|
+
revokeByCredential(ctx.db, credentialId, Date.now())
|
|
605
|
+
);
|
|
585
606
|
return c.json({ ok: true });
|
|
586
607
|
});
|
|
587
608
|
|