mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do the job the settings copy advertised: /api/ingest/medication gates on the per-medication medication:<id>:ingest grant as well, which this endpoint never issued. It was refused at the ingest surface and admitted everywhere else. With Bearer scope now fail-closed the token is inert rather than dangerous, which is a worse thing to hand a user than the bug it replaces. Remove the mint. The working credential comes from the per-medication API-endpoint toggle, which issues both grants scoped to one medication. List and revoke stay so existing tokens remain visible and revocable, and the settings card now points at the per-medication toggle instead of offering a name field. Six locales updated.
This commit is contained in:
1 parent
df611227f9
commit
8012aa095b
10 files changed
+232
-314
No files matched your search
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Structural guards on the Bearer-scope enforcement boundary.
|
||||
*
|
||||
* The correctness argument for the fail-closed default is structural, not
|
||||
* empirical: there is exactly ONE place a raw Bearer token becomes a user, and
|
||||
* exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover
|
||||
* 300-odd routes; what they can do is rest on that invariant. These guards are
|
||||
* what keep the invariant true.
|
||||
*
|
||||
* They are tripwires, not proofs. They cannot show an allowlist is correct —
|
||||
* only that it has not changed without someone editing this file. A reviewer
|
||||
* who waves through a bad addition defeats all four, and no test substitutes
|
||||
* for that review.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma
|
||||
* client (9 MB; never read it) and test files themselves.
|
||||
*/
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
/**
|
||||
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
|
||||
* This is the set that can circumvent the enforcement point, so it is the
|
||||
* only set that matters for the fail-closed guarantee.
|
||||
*
|
||||
* Two of these authenticate a request; the rest are the resolver itself and
|
||||
* the two edges that wrap it.
|
||||
*/
|
||||
const RESOLUTION_ALLOWLIST = [
|
||||
// The resolver. Owns the one authorisation arm.
|
||||
"lib/auth/bearer.ts",
|
||||
// The REST edge — maps `requireAuth`'s optional scope onto the union.
|
||||
"lib/api-handler.ts",
|
||||
// The MCP edge — the one deliberate `any-valid-token` posture.
|
||||
"lib/mcp/auth.ts",
|
||||
// The `/mcp` transport, via `resolveMcpAuthContext`.
|
||||
"app/mcp/route.ts",
|
||||
// Hand-rolled Bearer authentication: the external medication-ingest
|
||||
// surface, which gates on BOTH `medication:ingest` and the per-medication
|
||||
// `medication:<id>:ingest` grant and never touches `requireAuth`.
|
||||
"app/api/ingest/medication/route.ts",
|
||||
].sort();
|
||||
|
||||
it("no file outside the allowlist resolves a Bearer token to a user", () => {
|
||||
// `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the
|
||||
// hand-rolled equivalent. Either one turns a bearer credential into an
|
||||
// identity, so both have to stay inside the allowlist.
|
||||
const resolvers = filesMatching(
|
||||
/resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/,
|
||||
);
|
||||
|
||||
expect(resolvers).toEqual(RESOLUTION_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("the resolver exposes exactly one authorisation arm", () => {
|
||||
const src = read("lib/auth/bearer.ts");
|
||||
// One wildcard escape hatch, guarding one deny block. If a second
|
||||
// `permissions.includes("*")` short-circuit appears, the arm has forked.
|
||||
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
|
||||
expect(wildcardChecks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => {
|
||||
it("appears at exactly one call site, and that call site is the MCP edge", () => {
|
||||
// `bearer.ts` necessarily names the variant to declare the union; what
|
||||
// must stay unique is a caller PASSING it.
|
||||
const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter(
|
||||
(rel) => rel !== "lib/auth/bearer.ts",
|
||||
);
|
||||
expect(optedOut).toEqual(["lib/mcp/auth.ts"]);
|
||||
});
|
||||
|
||||
it("no route file opts out of the fail-closed default", () => {
|
||||
const routes = sourceFiles().filter((p) => p.endsWith("/route.ts"));
|
||||
const optedOut = routes.filter((rel) =>
|
||||
/kind:\s*"any-valid-token"/.test(read(rel)),
|
||||
);
|
||||
expect(optedOut).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T3 — the mint sites are frozen", () => {
|
||||
/**
|
||||
* Every place an `ApiToken` row is created, with the scope set it may mint.
|
||||
* A new mint site, or a new scope on an existing one, has to be named here —
|
||||
* which is the point: a user-facing mint that hands out a broad scope is the
|
||||
* one failure mode the enforcement change cannot catch by itself.
|
||||
*/
|
||||
const MINT_SITES: Record<string, string> = {
|
||||
// Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login.
|
||||
"lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]',
|
||||
"lib/auth/login-response.ts": '["*"]',
|
||||
"lib/auth/refresh-token.ts": '["*"]',
|
||||
"app/api/auth/passkey/login-verify/route.ts": '["*"]',
|
||||
// The working medication-ingest pair — family marker plus the per-
|
||||
// medication grant that `/api/ingest/medication` actually gates on.
|
||||
"app/api/medications/[id]/api-endpoint/route.ts":
|
||||
'["medication:ingest", scope]',
|
||||
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
|
||||
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
"app/api/mcp/oauth/token/route.ts":
|
||||
"SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
};
|
||||
|
||||
it("only the known files create ApiToken rows", () => {
|
||||
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
|
||||
// `issue-token.ts` defines the helper; the others call it.
|
||||
(rel) => rel !== "lib/auth/issue-token.ts" || true,
|
||||
);
|
||||
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
|
||||
});
|
||||
|
||||
it("no user-facing mint hands out a wildcard scope", () => {
|
||||
// The four `["*"]` mints are all reached only by a completed
|
||||
// password / passkey / refresh exchange. Every other mint is narrow.
|
||||
const userFacing = [
|
||||
"app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"app/api/mcp/tokens/route.ts",
|
||||
"app/api/mcp/oauth/token/route.ts",
|
||||
];
|
||||
for (const rel of userFacing) {
|
||||
expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/);
|
||||
}
|
||||
});
|
||||
|
||||
it("the retired generic token mint is gone", () => {
|
||||
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
|
||||
// not perform its advertised job (it lacked the per-medication grant) and
|
||||
// could reach every other authenticated route. List and revoke stay.
|
||||
const tokensRoute = read("app/api/tokens/route.ts");
|
||||
expect(tokensRoute).not.toMatch(/export const POST/);
|
||||
expect(tokensRoute).toMatch(/export const GET/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T4 — declared scopes are exported constants, never string literals", () => {
|
||||
it("every requireAuth argument is an identifier", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sourceFiles()) {
|
||||
const src = read(rel);
|
||||
// Call sites only — skip the declaration in `api-handler.ts`, whose
|
||||
// parameter list is not an argument.
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
|
||||
)) {
|
||||
const arg = m[1].trim();
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
|
||||
offenders.push(`${rel}: requireAuth(${arg})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the declared scope vocabulary is closed", () => {
|
||||
// Scopes a route may name. Adding one means adding a route that accepts a
|
||||
// narrow token, which is a widening — it belongs in a reviewed diff.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
for (const m of read(rel).matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user