Authenticate and authorize every route: org layer + Supabase JWKS
Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.
Authentication. Access tokens are verified against the Supabase
project's published JWKS via `createSupabaseJwtVerifier` from
`@continuum/ts-core`: RS256, public key only, no secret in this
process. It replaces the HS256 `@elysiajs/jwt` path, where
`SUPABASE_JWT_SECRET` was simultaneously the verification key and a
key that could mint tokens, present in the environment of every
instance. `src/auth/verifier.ts` builds it at module load so a
misconfigured deployment fails at boot rather than on first login.
The edge-gateway token stays symmetric and that is not an
inconsistency: this service is both its issuer and its only verifier,
so there is no second party to keep a secret from. It now carries an
`exp`, which it did not, despite the response saying
`expiresInSeconds: 3600`.
Tenancy. An org layer above farms - `organisations`,
`org_members(org_id, user_id, role)`, `farms.org_id` - so a farm can
have more than one operator without sharing an account. `farms.owner_id`
is gone. Authorization is one rule applied everywhere: resolve the
thing being acted on back to an organisation and check the caller's
role there, user -> org -> farm -> printer, with nothing in between
carrying permissions of its own. The ladder is
`viewer` < `operator` < `owner`; printer commands and job submission
need `operator`, because they move physical machines, and that is the
one boundary here that is not about data.
A non-member gets 404, not 403: a 403 would confirm that a given farm
or printer id exists and belongs to someone, which is an enumeration
oracle over the whole system. 403 is kept for a member who may see the
record but not do this to it.
List endpoints scope in the `where` clause, not after the query - a
filter applied to the result is one `return` away from being skipped.
They are also bounded now (`limit`/`offset`, default 50, max 200
refused rather than clamped).
`ownerId` and `submittedBy` no longer come from request bodies. Nor do
the printer telemetry columns: `POST /printers` is narrowed to the
columns a client owns, because a client that could POST
`status: "idle"` could make the scheduler send work to a printer that
is mid-print.
Also fills the CRUD gaps that made the API unusable - PATCH and DELETE
for farms, printers, jobs and orgs, and membership management - plus
one error shape for the whole API via `toErrorResponse`, so an
authorization failure is a throw that leaves the handler rather than a
status somebody has to remember to set.
Guards on the two changes that cannot be undone through the API:
removing or demoting an organisation's last owner, and deleting an
organisation while farms are still attached (it would cascade to every
printer, print job and stored frame).
Migration 0001 is hand-edited after generation. drizzle-kit emitted
`ADD COLUMN org_id uuid NOT NULL`, which cannot apply to a populated
table, and dropped `owner_id` without moving what it encodes anywhere.
It now adds the column nullable, creates one organisation per distinct
legacy owner with that user as its `owner` member, points each farm at
it, and only then tightens the column and drops `owner_id`. Verified
both ways: from empty via `drizzle-kit migrate`, and against a seeded
database where two farms shared an owner and a third did not.
Testing. `scripts/dev-auth.ts` is a local RS256 issuer publishing a
JWKS at the path the backend fetches, so the real verification path is
testable without a Supabase project. The alternative was a test-only
bypass in the backend - an env var making it trust an unsigned token -
which would be a permanent hole one misconfiguration from production,
and would mean the code under test is not the code that ships.
- `tests/authz.test.ts` (19 tests): the role ladder written out in full
rather than derived the same way the implementation derives it, and
the real JWKS path - a token signed by a key outside the JWKS, an
expired token and a token with `aud: "anon"` (Supabase's own anon key
is a valid project JWT) are all rejected, with the same message, so
probing cannot tell which part of a forged token to fix next.
- `scripts/e2e-authz.sh` (68 assertions, real Postgres, real routes):
four users across two organisations; anonymous 401s, cross-org 404s,
viewer-cannot-command, operator-cannot-rotate, a spoofed
`submittedBy` in the body ignored, no credential in any read, and the
rotated token still minting an edge JWT.
- `scripts/e2e-edge.sh` updated for the org layer and now authenticates
as a seeded `operator` - the weakest role that should work, so the
run would fail if the command boundary were not where it claims to
be. Re-verified against both live printers: telemetry from `h2c` and
`p1p` persisted, and an authenticated `set_fan_speed` went
HTTP -> WebSocket -> MQTT -> P1P and back `ok:true`, fan returned
to 0.
Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user