resolve api keys at the websocket doors too
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 <noreply@anthropic.com>
This commit is contained in:
+6
-2
@@ -3,7 +3,7 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||||
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
||||||
import { verify } from './servers/jwt';
|
import { resolveAuthToken } from './servers/auth-token';
|
||||||
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
||||||
import { isTokenBlacklisted } from 'officerdb';
|
import { isTokenBlacklisted } from 'officerdb';
|
||||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||||
@@ -158,7 +158,11 @@ async function upgradeWs(
|
|||||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
try {
|
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) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
if (user.jti) {
|
if (user.jti) {
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { getUserById } from 'officerdb';
|
|||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
|
||||||
// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the
|
// Validates a password-reset link before the reset form is rendered. The account-verification and
|
||||||
// account-verification and invitation flows this used to serve no longer exist — the sole account is
|
// invitation flows this used to serve no longer exist — the owner is created by /auth/bootstrap and
|
||||||
// created directly by /auth/bootstrap.
|
// 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) {
|
export const verifyTokenHandler: Handler = async function (ctx) {
|
||||||
const { verificationCode } = ctx.get('body');
|
const { verificationCode } = ctx.get('body');
|
||||||
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { verify } from '../../jwt';
|
import { resolveAuthToken } from '../../auth-token';
|
||||||
import { isTokenBlacklisted } from 'officerdb';
|
import { isTokenBlacklisted } from 'officerdb';
|
||||||
import { isSuperAdmin } from '../../super-admin';
|
import { isSuperAdmin } from '../../super-admin';
|
||||||
import { isOriginAllowed } from '../../_middlewares';
|
import { isOriginAllowed } from '../../_middlewares';
|
||||||
@@ -60,7 +60,7 @@ export const vaultWebsocket = {
|
|||||||
// Deferred session validation (Bun requires the upgrade itself to be synchronous).
|
// Deferred session validation (Bun requires the upgrade itself to be synchronous).
|
||||||
let userId: number;
|
let userId: number;
|
||||||
try {
|
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?.id) return closeClient(ws, state, 4001, 'Unauthorized');
|
||||||
if (payload.jti && (await isTokenBlacklisted(payload.jti))) 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');
|
if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden');
|
||||||
|
|||||||
Reference in New Issue
Block a user