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:
@@ -100,6 +100,40 @@ describe("gateway -> backend frames", () => {
|
||||
// The old protocol's handshake, which no gateway ever sent.
|
||||
expect(Value.Check(EdgeClientMessageSchema, { type: "auth", token: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The hello carries no credential any more - the edge JWT goes in an
|
||||
* `Authorization: Bearer` header on the upgrade, verified before the
|
||||
* socket exists (see tests/edge_upgrade.test.ts). This asserts the schema
|
||||
* does not require the `token` field it used to.
|
||||
*
|
||||
* Note the committed captures predate that change and still contain
|
||||
* `token: "dev-token"`, which validates because this schema is a liberal
|
||||
* reader. That is deliberate and harmless: the field is now ignored, and
|
||||
* an old gateway sending it is refused at the upgrade for having no
|
||||
* header, so there is no path by which a token in a frame authenticates
|
||||
* anything.
|
||||
*/
|
||||
it("accepts a hello with no token at all", () => {
|
||||
check(EdgeClientMessageSchema, {
|
||||
type: "hello",
|
||||
gateway_id: "dev-gw",
|
||||
farm_id: "dev-farm",
|
||||
});
|
||||
check(EdgeClientMessageSchema, {
|
||||
type: "hello",
|
||||
gateway_id: "dev-gw",
|
||||
farm_id: "dev-farm",
|
||||
stream_base_url: "http://gateway.local:1984",
|
||||
});
|
||||
});
|
||||
|
||||
it("still requires the fields a hello cannot work without", () => {
|
||||
// `farm_id` is checked against the token's own claim rather than
|
||||
// trusted, which is only possible if it is actually sent.
|
||||
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", gateway_id: "g" })).toBe(false);
|
||||
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", farm_id: "f" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backend -> gateway frames", () => {
|
||||
|
||||
Reference in New Issue
Block a user