From ae275ee6077c7f80b3015cc5851708a68dd0cb0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 8 Aug 2026 10:40:04 +0000 Subject: [PATCH] resolve api keys at the websocket doors too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit there were four doors, not two. the ws upgrade in server.tsx and the vault notifications socket each verified the jwt themselves, so a key that worked against /api would have 401'd on cliamp — signed in and can play audio would have been two different questions for the music app. both now call resolveAuthToken. verified: owner key upgrades cliamp (101), bogus key 401, member key 403 on terminal exactly as their jwt is. reset-password and verify-token deliberately keep verify() — they read a purpose-scoped reset token and a key must not be spendable as one. Co-Authored-By: Claude Opus 5 --- src/server.tsx | 8 ++++++-- src/servers/api/auth/verify-token.ts | 9 ++++++--- src/servers/api/vault/websocket.ts | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/server.tsx b/src/server.tsx index 51de4844..b853b98a 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -3,7 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; -import { verify } from './servers/jwt'; +import { resolveAuthToken } from './servers/auth-token'; import { isWsProviderAllowed } from './servers/capabilities/authorize'; import { isTokenBlacklisted } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; @@ -158,7 +158,11 @@ async function upgradeWs( if (!token) return new Response('Unauthorized', { status: 401 }); try { - const user = await verify(token); + // Same resolver as the two HTTP doors, so a key that works against /api works here too — a music app + // holding one needs cliamp and cliamp-audio, and a socket that only understood JWTs would have made + // "signed in" and "can play audio" two different questions. `jti` is absent on a key, so the + // blacklist below simply does not apply to one; its revocation is a column, checked in the lookup. + const user = await resolveAuthToken(token); if (!user) return new Response('Unauthorized', { status: 401 }); if (user.jti) { diff --git a/src/servers/api/auth/verify-token.ts b/src/servers/api/auth/verify-token.ts index 94388e3e..a9f99267 100644 --- a/src/servers/api/auth/verify-token.ts +++ b/src/servers/api/auth/verify-token.ts @@ -4,9 +4,12 @@ import { getUserById } from 'officerdb'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; -// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the -// account-verification and invitation flows this used to serve no longer exist — the sole account is -// created directly by /auth/bootstrap. +// Validates a password-reset link before the reset form is rendered. The account-verification and +// invitation flows this used to serve no longer exist — the owner is created by /auth/bootstrap and +// every other account was inserted by hand, there being no signup route yet. +// +// Deliberately `verify` and not `resolveAuthToken`: this is not an authentication door. It reads a +// purpose-scoped, short-lived reset token, and an API key must never be spendable as one. export const verifyTokenHandler: Handler = async function (ctx) { const { verificationCode } = ctx.get('body'); if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code'); diff --git a/src/servers/api/vault/websocket.ts b/src/servers/api/vault/websocket.ts index a4fa60af..d16065c6 100644 --- a/src/servers/api/vault/websocket.ts +++ b/src/servers/api/vault/websocket.ts @@ -1,5 +1,5 @@ import type { ServerWebSocket } from 'bun'; -import { verify } from '../../jwt'; +import { resolveAuthToken } from '../../auth-token'; import { isTokenBlacklisted } from 'officerdb'; import { isSuperAdmin } from '../../super-admin'; import { isOriginAllowed } from '../../_middlewares'; @@ -60,7 +60,7 @@ export const vaultWebsocket = { // Deferred session validation (Bun requires the upgrade itself to be synchronous). let userId: number; try { - const payload = await verify(ws.data.platformToken); + const payload = await resolveAuthToken(ws.data.platformToken); if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized'); if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized'); if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden');