`?? ""` on the access key and secret meant a deployment with no object storage configured started happily, and `POST /jobs` handed clients presigned URLs signed with an empty key. The first sign of trouble was a 403 from R2 - either in the client's uploader or, worse, inside a gateway that had downloaded R2's XML error document in place of a print. Nothing in that chain said "R2 is not configured". Reads both credentials through a `required()` that throws, naming the variable, mirroring auth/verifier.ts and auth/edge_secret.ts. The endpoint is read with `||` rather than `??`, because the committed template ships `R2_ENDPOINT=` empty and `??` only catches null - so an empty value was taken as a real endpoint and the account-id derivation never ran. That is the trap auth/edge_secret.ts documents, and it caught this line too, in the first draft of it. A test covers the derivation. Each boot-failure test fills every other variable so the one under test is the only thing missing; otherwise whichever variable is read first wins and the assertion passes for the wrong reason. e2e-edge.sh gains dummy values, since it boots the backend. It presigns nothing, so they only have to exist.
252 lines
11 KiB
TypeScript
252 lines
11 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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Object storage is a credential like any other, and used to be the one that
|
|
* failed quietly.
|
|
*
|
|
* `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY` defaulted to `""`, so a
|
|
* deployment that copied `.env.example` and filled in everything else
|
|
* started happily. `POST /jobs` then handed a client a presigned URL signed
|
|
* with an empty key, and the first symptom was a 403 from R2 - in the
|
|
* client's uploader, or inside a gateway that had downloaded R2's XML error
|
|
* document instead of a print. Nothing in that chain said "R2 is not
|
|
* configured".
|
|
*/
|
|
describe("object storage configuration", () => {
|
|
const importS3 = (env: Record<string, string>) =>
|
|
Bun.spawn(["bun", "-e", "await import('./src/s3/index.ts')"], {
|
|
cwd: import.meta.dir + "/..",
|
|
env: { ...process.env, ...env },
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
|
|
// Every other variable is filled in, so the one under test is the only
|
|
// thing missing and the error has to name it. Without this the first
|
|
// missing variable in read order wins and the assertion passes for the
|
|
// wrong reason.
|
|
const complete = {
|
|
R2_ACCESS_KEY_ID: "key",
|
|
R2_SECRET_ACCESS_KEY: "secret",
|
|
R2_ACCOUNT_ID: "acct",
|
|
};
|
|
|
|
test("fails at boot when a credential is empty, naming the variable", async () => {
|
|
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
|
|
const proc = importS3({ ...complete, [name]: "" });
|
|
expect(await proc.exited).not.toBe(0);
|
|
expect(await new Response(proc.stderr).text()).toContain(name);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* An empty string, not just an absent variable - which is the case that
|
|
* actually happens, because the committed template ships these keys with
|
|
* no value. `??` would have missed every one of them.
|
|
*/
|
|
test("treats an empty credential as missing, not as a value", async () => {
|
|
const proc = importS3({ ...complete, R2_ACCESS_KEY_ID: "" });
|
|
expect(await proc.exited).not.toBe(0);
|
|
expect(await new Response(proc.stderr).text()).toContain("R2_ACCESS_KEY_ID");
|
|
});
|
|
|
|
test("refuses to start with no endpoint and no account id to derive one from", async () => {
|
|
const proc = importS3({ ...complete, R2_ENDPOINT: "", R2_ACCOUNT_ID: "" });
|
|
expect(await proc.exited).not.toBe(0);
|
|
expect(await new Response(proc.stderr).text()).toContain("R2_ENDPOINT");
|
|
});
|
|
|
|
test("starts with a complete configuration", async () => {
|
|
// R2_ENDPOINT empty on purpose: it must be derived from the account id,
|
|
// which is the `||`-not-`??` case that caught this module out.
|
|
const proc = importS3({ ...complete, R2_ENDPOINT: "" });
|
|
expect(await proc.exited).toBe(0);
|
|
});
|
|
|
|
test("reads each credential in exactly one place", async () => {
|
|
// So there is only ever one line to get right, the same rule
|
|
// `EDGE_JWT_SECRET` is held to above.
|
|
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
|
|
const proc = Bun.spawn(["grep", "-rn", `process.env.${name}`, "src"], {
|
|
cwd: import.meta.dir + "/..",
|
|
stdout: "pipe",
|
|
});
|
|
const hits = (await new Response(proc.stdout).text()).trim();
|
|
// Read through the `required()` helper, so the literal name appears
|
|
// only where it is passed in - not as a `process.env.X` at all.
|
|
expect(hits).toBe("");
|
|
}
|
|
});
|
|
});
|