Files
continuum-backend/src/auth/edge_upgrade.ts
T
octoturge 57af710b1a Verify the edge token at the upgrade, not in the first frame
`/ws/edge/v1` took its credential from a `token` field on the `hello`
message. It now takes it from `Authorization: Bearer` on the upgrade
request, verified before the socket exists, so an unauthenticated peer
gets an HTTP 401 and no WebSocket: no entry in `connections`, no frame
ever parsed, no state of any kind allocated for it.

The old arrangement worked, and every message type refused to act
without a verified farm. But "it cannot do anything" is a weaker
guarantee than "it does not exist" - an anonymous peer still got a live
socket and had its JSON run through `EdgeClientMessageSchema` and the
message switch before proving anything, and it took a timeout to stop
anyone holding sockets open for free. A `?token=` query parameter was
never on the table: a bearer credential in a URL lands in access logs,
proxy logs and error breadcrumbs, which is the thing the proxy's
hand-written `Debug` for `PrinterCommand` already exists to prevent.

The decision lives in `src/auth/edge_upgrade.ts`, a leaf module with no
imports but the error types - the same reason as `dispatch/job_status.ts`
and `stream.ts`. This is the authentication boundary for the entire
gateway protocol, so it should be the last thing only reachable through
a running database. It checks `scope` and not just the signature (the
same secret could be made to sign something else one day), rejects a
token whose farm claim is absent or not a string (that would otherwise
produce a connection whose `farmId` is `undefined`, which reads as
"every farm" to a lookup), and answers every failure with one message -
which part of a forged token to fix next is not something to tell its
holder.

`conn.farmId` is therefore set from the verified token in `open`, and
the `hello` handler no longer verifies anything. It still checks
`message.farm_id` against the token's farm, so a gateway naming a farm
its token was not minted for is refused rather than silently working
against the wrong one. The handshake timer stays but changed meaning: it
is no longer an auth timeout, just a bound on an authenticated socket
that never identifies its gateway.

Verified, not assumed. That a throwing `resolve` fails the upgrade is a
framework behaviour the whole design rests on, so `tests/
edge_upgrade.test.ts` stands up a real Elysia app with a real JWT plugin
and asserts a socket never opens without a valid edge token, and that a
resolved value reaches `ws.data`. The real `continuum-proxy` was also run
against this composition: valid token gives HTTP 101 with the farm taken
from the token, forged token gives 401 at the handshake.

The committed captures predate this and still contain `token:
"dev-token"`. That validates - the schema is a liberal reader - and is
harmless, because the field is now ignored and such a gateway is refused
at the upgrade for having no header. A test says so.

95 tests (was 85), `bunx tsc --noEmit` clean.
2026-10-17 13:59:56 +00:00

62 lines
2.4 KiB
TypeScript

import { UnauthorizedError, extractBearerToken } from "@continuum/ts-core";
/**
* Edge-token checking for the `/ws/edge/v1` upgrade, with no imports beyond
* the error types - the same reason `dispatch/job_status.ts` and `stream.ts`
* are leaf modules: `../db` opens a connection at import time, and this is
* the authentication boundary for the whole gateway protocol, so it is the
* last thing that should only be reachable through a running database.
*/
/**
* Whatever verifies an edge JWT. Narrowed to what this needs rather than
* taking the `@elysiajs/jwt` decorator's type, so a test can pass a stub and
* exercise the real decision logic.
*/
export type EdgeTokenVerifier = (
token: string,
) => Promise<Record<string, unknown> | false | null | undefined>;
/**
* The farm a verified edge token belongs to, or an `UnauthorizedError`.
*
* Every failure gets one message. Which part of a forged token to fix next -
* signature, scope, expiry - is not something to tell whoever is holding
* one; the reason is logged instead. Same reasoning as `requireUser`.
*
* The farm is returned rather than the claims: it is the only thing the
* socket needs, and handing back the whole payload invites a later caller to
* start trusting some other claim without deciding to.
*/
export async function farmFromEdgeUpgrade(
authorization: string | null | undefined,
verify: EdgeTokenVerifier,
): Promise<string> {
let token: string;
try {
token = extractBearerToken(authorization);
} catch {
throw new UnauthorizedError("Authorization header must be 'Bearer <edge token>'");
}
const payload = await verify(token);
// `scope` is checked, not just the signature: the same secret could be
// made to sign something else one day, and a token minted for another
// purpose must not open a farm's gateway socket.
if (!payload || payload.scope !== "edge") {
console.warn("[edge] upgrade rejected: token missing or not scoped to edge");
throw new UnauthorizedError("Invalid or expired edge token");
}
// A token whose farm claim is absent or not a string would otherwise
// produce a connection whose `farmId` is `undefined`, which reads as
// "every farm" to anything doing a lookup.
if (typeof payload.farmId !== "string" || payload.farmId.length === 0) {
console.warn("[edge] upgrade rejected: edge token carries no farm");
throw new UnauthorizedError("Invalid or expired edge token");
}
return payload.farmId;
}