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", () => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Elysia } from "elysia";
|
||||
import jwt from "@elysiajs/jwt";
|
||||
import { UnauthorizedError } from "@continuum/ts-core";
|
||||
import { farmFromEdgeUpgrade } from "../src/auth/edge_upgrade";
|
||||
|
||||
/**
|
||||
* The gateway protocol's authentication boundary.
|
||||
*
|
||||
* The edge JWT travels in `Authorization: Bearer` on the upgrade request and
|
||||
* is verified before any socket exists. It used to be a `token` field on the
|
||||
* `hello` message, which meant an unauthenticated peer got a live socket and
|
||||
* a parsed frame before proving anything. These tests are what stops that
|
||||
* coming back.
|
||||
*/
|
||||
describe("farmFromEdgeUpgrade", () => {
|
||||
const verifyOk = async (token: string) =>
|
||||
token === "good" ? { scope: "edge", farmId: "farm-1" } : false;
|
||||
|
||||
it("returns the farm from a valid edge token", async () => {
|
||||
expect(await farmFromEdgeUpgrade("Bearer good", verifyOk)).toBe("farm-1");
|
||||
});
|
||||
|
||||
it("refuses a missing or malformed Authorization header", async () => {
|
||||
for (const header of [undefined, null, "", "good", "Basic good", "Bearer"]) {
|
||||
await expect(farmFromEdgeUpgrade(header, verifyOk)).rejects.toThrow(UnauthorizedError);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a token the verifier rejects", async () => {
|
||||
await expect(farmFromEdgeUpgrade("Bearer forged", verifyOk)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
/**
|
||||
* A signature alone is not authorisation. The same secret could be made to
|
||||
* sign something else one day, and such a token must not open a farm's
|
||||
* gateway socket.
|
||||
*/
|
||||
it("refuses a correctly signed token minted for a different scope", async () => {
|
||||
const verify = async () => ({ scope: "user", farmId: "farm-1" });
|
||||
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
/**
|
||||
* Without this, the connection's `farmId` would be `undefined`, which to
|
||||
* anything doing a lookup reads as "every farm" rather than as an error.
|
||||
*/
|
||||
it("refuses a token with no usable farm claim", async () => {
|
||||
for (const farmId of [undefined, null, "", 42, {}]) {
|
||||
const verify = async () => ({ scope: "edge", farmId }) as Record<string, unknown>;
|
||||
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The messages must not say which part failed - that tells the holder of a
|
||||
* forged token what to fix next. Asserted rather than assumed, because the
|
||||
* natural way to write this code is one specific message per branch.
|
||||
*/
|
||||
it("says the same thing however the token fails", async () => {
|
||||
const messages = new Set<string>();
|
||||
for (const verify of [
|
||||
async () => false as const,
|
||||
async () => ({ scope: "user", farmId: "farm-1" }),
|
||||
async () => ({ scope: "edge" }) as Record<string, unknown>,
|
||||
]) {
|
||||
await farmFromEdgeUpgrade("Bearer x", verify).catch((err: Error) =>
|
||||
messages.add(err.message),
|
||||
);
|
||||
}
|
||||
expect(messages.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* That the check above actually gates the upgrade, which is the property the
|
||||
* whole design rests on and is a framework behaviour rather than ours: a
|
||||
* `resolve` that throws must fail the HTTP upgrade so `open` never runs.
|
||||
*
|
||||
* This builds its own Elysia app rather than importing `edgeWsRoute`, which
|
||||
* pulls in `../../db` and so a live Postgres. What it proves is that this
|
||||
* composition refuses a socket and that a resolved value reaches `ws.data`;
|
||||
* what it cannot prove is that `edge.ts` is wired this way. `scripts/
|
||||
* e2e-edge.sh` runs the real route against a real database and a real
|
||||
* gateway, and is the test for that.
|
||||
*/
|
||||
describe("a resolve that throws gates the websocket upgrade", () => {
|
||||
const SECRET = "edge-upgrade-test-secret";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(jwt({ name: "edgeJwt", secret: SECRET }))
|
||||
.get("/mint", ({ edgeJwt, query }) =>
|
||||
edgeJwt.sign({ scope: query.scope ?? "edge", farmId: "farm-1" }),
|
||||
)
|
||||
.resolve({ as: "scoped" }, async ({ headers, edgeJwt }): Promise<{ edgeFarmId: string }> => ({
|
||||
edgeFarmId: await farmFromEdgeUpgrade(headers.authorization, (t) => edgeJwt.verify(t)),
|
||||
}))
|
||||
.ws("/ws/edge/v1", {
|
||||
open(ws) {
|
||||
ws.send(JSON.stringify({ farm: (ws.data as { edgeFarmId: string }).edgeFarmId }));
|
||||
},
|
||||
})
|
||||
.listen(0);
|
||||
|
||||
const base = `localhost:${app.server!.port}`;
|
||||
|
||||
function attempt(headers: Record<string, string>): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(`ws://${base}/ws/edge/v1`, { headers } as never);
|
||||
const done = (r: string) => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Already closed by the server; nothing to do.
|
||||
}
|
||||
resolve(r);
|
||||
};
|
||||
ws.onmessage = (event) => done(`opened:${event.data}`);
|
||||
ws.onerror = () => done("refused");
|
||||
ws.onclose = () => done("refused");
|
||||
setTimeout(() => done("timed out"), 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
it("opens for a valid edge token and exposes its farm", async () => {
|
||||
const token = await (await fetch(`http://${base}/mint`)).text();
|
||||
expect(await attempt({ Authorization: `Bearer ${token}` })).toBe('opened:{"farm":"farm-1"}');
|
||||
});
|
||||
|
||||
it("never opens a socket without a valid edge token", async () => {
|
||||
const wrongScope = await (await fetch(`http://${base}/mint?scope=user`)).text();
|
||||
expect(await attempt({})).toBe("refused");
|
||||
expect(await attempt({ Authorization: "Bearer not-a-jwt" })).toBe("refused");
|
||||
expect(await attempt({ Authorization: `Bearer ${wrongScope}` })).toBe("refused");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user