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.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user