`EDGE_JWT_SECRET` was read as `process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me"` in two places. Anything holding that string can mint an edge JWT for any `farm_id` it likes and open `/ws/edge/v1` as that farm's gateway: enough to feed a farm false telemetry and to receive the commands meant for its printers. It is in a git repository, so it is not a secret. The `??` made it worse rather than better. It only catches null and undefined, and the committed `.env.example` ships `EDGE_JWT_SECRET=` *empty* - so a deployment that copied the template and filled in everything else signed its edge JWTs with the empty string and never reached the fallback at all. That is exactly the case the fallback existed to cover. Both reads now come from `src/auth/edge_secret.ts`, which throws at module load if the variable is unset or empty. Same shape as `verifier.ts` and for the same reason: a backend that boots and then fails every gateway handshake looks like a network problem somewhere else. There is no development default on purpose - a secret that works out of the box is a secret that reaches production, and `openssl rand -hex 32` is a one-time cost. Tests: the empty-string case, since that is the one the template produced, and a grep for both shapes that reintroduce the hole - a literal passed to `jwt()`, and a defaulted read of the variable - plus an assertion that the variable is read in exactly one place. 60 pass.
173 lines
7.4 KiB
TypeScript
173 lines
7.4 KiB
TypeScript
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
import { Elysia } from "elysia";
|
|
import { toErrorResponse } from "@continuum/ts-core";
|
|
import { roleSatisfies, ORG_ROLES } from "../src/auth/roles";
|
|
import { startDevAuthIssuer } from "../scripts/dev-auth";
|
|
|
|
const USER = "11111111-1111-1111-1111-111111111111";
|
|
|
|
describe("the role ladder", () => {
|
|
test("is ordered viewer < operator < owner", () => {
|
|
expect(ORG_ROLES).toEqual(["viewer", "operator", "owner"]);
|
|
});
|
|
|
|
// Written out in full rather than derived from the ladder: a table that
|
|
// computes the expected answer the same way the implementation does agrees
|
|
// with a wrong implementation.
|
|
const cases: Array<[string, string, boolean]> = [
|
|
["viewer", "viewer", true],
|
|
["viewer", "operator", false],
|
|
["viewer", "owner", false],
|
|
["operator", "viewer", true],
|
|
["operator", "operator", true],
|
|
["operator", "owner", false],
|
|
["owner", "viewer", true],
|
|
["owner", "operator", true],
|
|
["owner", "owner", true],
|
|
];
|
|
|
|
for (const [actual, minimum, expected] of cases) {
|
|
test(`'${actual}' ${expected ? "satisfies" : "does not satisfy"} '${minimum}'`, () => {
|
|
expect(roleSatisfies(actual as never, minimum as never)).toBe(expected);
|
|
});
|
|
}
|
|
|
|
test("a viewer cannot send printer commands", () => {
|
|
// The boundary that moves physical machines, called out on its own
|
|
// because it is the one that is expensive to get wrong.
|
|
expect(roleSatisfies("viewer", "operator")).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Exercises the real verification path - `createRemoteJWKSet` against a real
|
|
* JWKS endpoint over HTTP - rather than a stubbed verifier. The things that
|
|
* actually go wrong here (an unsigned token accepted, a token signed by
|
|
* another key accepted, an expired token accepted) are all invisible to a
|
|
* test that mocks the verifier out.
|
|
*/
|
|
describe("requireUser", () => {
|
|
let issuer: Awaited<ReturnType<typeof startDevAuthIssuer>>;
|
|
let app: Elysia;
|
|
|
|
beforeAll(async () => {
|
|
issuer = await startDevAuthIssuer();
|
|
process.env.SUPABASE_URL = issuer.url;
|
|
// Imported after SUPABASE_URL is set: the verifier is built at module
|
|
// load so a misconfigured deployment fails at boot, not on first request.
|
|
const { requireUser } = await import("../src/auth/plugin");
|
|
app = new Elysia().onError(toErrorResponse).use(requireUser).get("/whoami", ({ user }) => user);
|
|
});
|
|
|
|
afterAll(() => issuer.stop());
|
|
|
|
const get = (headers: Record<string, string> = {}) =>
|
|
app.handle(new Request("http://localhost/whoami", { headers }));
|
|
|
|
test("accepts a properly signed token and exposes the subject", async () => {
|
|
const token = await issuer.mint({ sub: USER, email: "farm@example.test" });
|
|
const response = await get({ authorization: `Bearer ${token}` });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ id: USER, email: "farm@example.test" });
|
|
});
|
|
|
|
test("rejects a request with no Authorization header", async () => {
|
|
const response = await get();
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).error.code).toBe("UNAUTHORIZED");
|
|
});
|
|
|
|
test("rejects a non-Bearer Authorization header", async () => {
|
|
const response = await get({ authorization: "Basic dXNlcjpwYXNz" });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
test("rejects a token signed by a key that is not in the JWKS", async () => {
|
|
const token = await issuer.mintWithForeignKey({ sub: USER });
|
|
const response = await get({ authorization: `Bearer ${token}` });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
test("rejects an expired token", async () => {
|
|
const token = await issuer.mint({ sub: USER, expiresIn: -60 });
|
|
const response = await get({ authorization: `Bearer ${token}` });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
test("rejects a token issued for another audience", async () => {
|
|
// Supabase issues `aud: "authenticated"` for user sessions. Its anon and
|
|
// service-role keys are also valid project JWTs with other audiences, so
|
|
// without this check the public anon key would authenticate as a user.
|
|
const token = await issuer.mint({ sub: USER, audience: "anon" });
|
|
const response = await get({ authorization: `Bearer ${token}` });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
test("does not say why a token was rejected", async () => {
|
|
// Distinguishing "expired" from "bad signature" tells whoever is probing
|
|
// which part of a forged token to fix next.
|
|
const expired = await get({
|
|
authorization: `Bearer ${await issuer.mint({ sub: USER, expiresIn: -60 })}`,
|
|
});
|
|
const foreign = await get({
|
|
authorization: `Bearer ${await issuer.mintWithForeignKey({ sub: USER })}`,
|
|
});
|
|
expect((await expired.json()).error.message).toBe((await foreign.json()).error.message);
|
|
});
|
|
});
|
|
|
|
describe("configuration", () => {
|
|
test("the process refuses to start without SUPABASE_URL", async () => {
|
|
// Fail at boot, not on the first authenticated request: a backend that
|
|
// starts and then 500s on every login looks like an outage somewhere
|
|
// else. Run in a subprocess because the module caches its verifier.
|
|
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/verifier.ts')"], {
|
|
cwd: import.meta.dir + "/..",
|
|
env: { ...process.env, SUPABASE_URL: "" },
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
expect(await proc.exited).not.toBe(0);
|
|
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
|
|
});
|
|
|
|
// An empty value, not an unset one, because that is what the committed
|
|
// `.env.example` ships: the old `process.env.EDGE_JWT_SECRET ?? "dev-..."`
|
|
// only caught null and undefined, so copying the template and filling in
|
|
// everything else left the edge JWTs signed with "". Anything that knew
|
|
// that - or the committed fallback literal - could mint a token for any
|
|
// farm_id and open /ws/edge/v1 as that farm's gateway.
|
|
test("the process refuses to start with an empty EDGE_JWT_SECRET", async () => {
|
|
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/edge_secret.ts')"], {
|
|
cwd: import.meta.dir + "/..",
|
|
env: { ...process.env, EDGE_JWT_SECRET: "" },
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
expect(await proc.exited).not.toBe(0);
|
|
expect(await new Response(proc.stderr).text()).toContain("EDGE_JWT_SECRET");
|
|
});
|
|
|
|
test("no route holds a hardcoded signing secret", async () => {
|
|
// The old fallback was duplicated across two routes, and a third copy
|
|
// would be just as exploitable as the first two, so this greps the tree
|
|
// rather than the files that happened to have it. Two shapes, because
|
|
// either one reintroduces the hole: a literal passed to `jwt()`, and a
|
|
// defaulted read of the variable (`?? "..."` / `|| "..."`), which is
|
|
// what let an empty `EDGE_JWT_SECRET` through.
|
|
const grep = async (pattern: string) => {
|
|
const proc = Bun.spawn(["grep", "-rnE", pattern, "src"], {
|
|
cwd: import.meta.dir + "/..",
|
|
stdout: "pipe",
|
|
});
|
|
return new Response(proc.stdout).text();
|
|
};
|
|
expect(await grep('secret:\\s*["\'`]')).toBe("");
|
|
expect(await grep('EDGE_JWT_SECRET\\s*(\\?\\?|\\|\\|)')).toBe("");
|
|
// And the variable is read in exactly one place, so there is only ever
|
|
// one line to get right.
|
|
expect(await grep("process\\.env\\.EDGE_JWT_SECRET")).toContain("src/auth/edge_secret.ts");
|
|
expect((await grep("process\\.env\\.EDGE_JWT_SECRET")).trim().split("\n")).toHaveLength(1);
|
|
});
|
|
});
|