diff --git a/pb_hooks/whoami.pb.js b/pb_hooks/whoami.pb.js new file mode 100644 index 0000000..8499685 --- /dev/null +++ b/pb_hooks/whoami.pb.js @@ -0,0 +1,36 @@ +/// + +// Token introspection for amber-api (the facts service). +// +// GET /api/amber/whoami (auth: users) → { id, nsfwEnabled } +// +// WHY THIS EXISTS: amber-api has to know whether an inbound request carries a +// valid Amber user token before it will spend an ffprobe on a caller-supplied +// URL. PocketBase auth tokens are stateless JWTs signed with a per-user secret +// that only PocketBase holds, so **offline verification is impossible by +// design** — a third-party service cannot check one itself. The only correct +// check is to present the token here and see whether PocketBase accepts it, +// which `$apis.requireAuth("users")` does before this handler ever runs. +// +// So the handler body is deliberately trivial: reaching it *is* the answer. It +// returns the two facts amber-api needs and nothing else — no email, no +// username, no profile list. A token-introspection endpoint is a tempting place +// to leak account data, and amber-api has no business knowing any of it. +// +// `nsfwEnabled` is included because it's the same admin-only flag that gates +// adult release channels, and a future amber-api surface may need to scope +// results by it. It is read-only here. +// +// amber-api caches positives for ~10 minutes and negatives for ~30 seconds, so +// this route sees roughly one request per device per 10 minutes — cheap enough +// to sit in front of every probe lookup. +// +// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so +// anything a handler needs must be declared INSIDE it — file-scope helpers throw +// ReferenceError at request time. Nothing is hoisted here; keep it that way. +routerAdd("GET", "/api/amber/whoami", (e) => { + return e.json(200, { + id: e.auth.id, + nsfwEnabled: e.auth.getBool("nsfwEnabled"), + }) +}, $apis.requireAuth("users")) diff --git a/scripts/verify.py b/scripts/verify.py index fee8dfa..8836c2e 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -244,5 +244,31 @@ st, r = req("POST", "/api/collections/watch_state/records", alice, "updatedAt": CLIENT_TS}) check("alice CANNOT attach a row to bob's profile", st, st in (400, 403, 404)) +# --- /api/amber/whoami: token introspection for amber-api --- +# amber-api cannot verify a PocketBase JWT itself (stateless, signed with a +# per-user secret PocketBase alone holds), so it asks this route. These checks +# guard two things: that it fails closed for anyone unauthenticated, and that it +# leaks nothing beyond the two fields amber-api needs. +st, r = req("GET", "/api/amber/whoami") +check("whoami: anonymous → 401 (fails closed)", st, st == 401) + +st, r = req("GET", "/api/amber/whoami", "Bearer totally-not-a-token") +check("whoami: bogus token → 401", st, st == 401) + +st, r = req("GET", "/api/amber/whoami", alice) +check("whoami: valid token → 200", st, st == 200) +check("whoami returns the caller's own id", st, r.get("id") == ua["id"]) +check("whoami returns nsfwEnabled", st, r.get("nsfwEnabled") is True) +# A token-introspection endpoint is a tempting place to leak account data; +# amber-api has no business knowing the email, username or profile list. +check("whoami leaks nothing else", st, + set(r.keys()) == {"id", "nsfwEnabled"}) + +# Per-caller, not "some authenticated user": a shared/cached answer here would +# let amber-api attribute one device's requests to another account. +st, r = req("GET", "/api/amber/whoami", bob) +check("whoami is per-caller (bob's token returns bob's id)", st, r.get("id") == ub["id"]) +check("whoami ids are distinct per caller", st, ua["id"] != ub["id"]) + print("\n" + ("ALL CHECKS PASSED" if allpass else "SOME CHECKS FAILED")) sys.exit(0 if allpass else 1)