mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A bridge that pushes workouts from a watch vendor had no credential that reached POST /api/workouts/batch: the route called requireAuth() with no scope, so under the fail-closed default only a cookie session or a wildcard token got through, and a measurements:write token was refused 403. The route's header still described the pre-fail-closed behaviour. Add a separate workouts:write scope rather than widening measurements:write, so a token already handed to a scale does not start accepting workouts. - POST /api/tokens/workouts mints it, mirroring the measurement and document mints: cookie session plus a fresh proof, a literal permission array, 10 mints a minute, at most 10 live tokens (409 tokens.workouts.ceiling_reached), a one-year default lifetime, audited. - POST /api/workouts/batch names the scope. Sessions and wildcard tokens behave as before. A scoped caller's workouts are stored as EXTERNAL, resolved from the credential, and a batch in which any entry names a source is refused 422 (workout.batch.source_not_permitted), the same rule the measurement batch applies. EXTERNAL is on no source ladder, so when the phone recorded the same session the phone's copy is shown. - Settings > API offers a workout token card next to the measurement one, and the endpoint list shows the batch route. - Guards: the mint site, the declared scope vocabulary, the recent-proof routes, and a new check that only the batch declares the scope. - OpenAPI documents the mint, the batch's narrow-token behaviour and both error codes; docs/api/README.md lists the workout and document mints. Refs #1054
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
/**
|
|
* Structural guard on the recent-proof gate.
|
|
*
|
|
* Exporting the whole record, making a clinician share link, minting a token,
|
|
* and the admin backup, restore, wipe and reset actions ask for a fresh proof,
|
|
* not only a live session (`requireRecentProof` / `assertRecentCookieProof` in
|
|
* `src/lib/api-handler.ts`). This file pins which routes those are, so a route
|
|
* cannot drop the gate, and a new one cannot quietly join the list of routes
|
|
* whose Bearer arm still takes the token alone.
|
|
*
|
|
* Matching is on the IMPORT and then on calls of the local name, the way
|
|
* `step-up-elevation-guard.test.ts` does it, so an aliased import is followed.
|
|
* Every sweep asserts it matched something: a matcher that finds nothing agrees
|
|
* with every list.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { walkSourceFiles } from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
function read(rel: string): string {
|
|
return readFileSync(join(SRC, rel), "utf8");
|
|
}
|
|
|
|
function sourceFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 })
|
|
.filter((p) => !p.startsWith("generated/"))
|
|
.filter((p) => !p.includes("__tests__"))
|
|
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
|
.sort();
|
|
}
|
|
|
|
function importedAs(rel: string, exportName: string): string[] {
|
|
const names: string[] = [];
|
|
for (const m of read(rel).matchAll(
|
|
/import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g,
|
|
)) {
|
|
if (!/api-handler$/.test(m[2])) continue;
|
|
for (const part of m[1].split(",")) {
|
|
const piece = part.trim().replace(/^type\s+/, "");
|
|
const aliased = piece.match(
|
|
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/,
|
|
);
|
|
if (aliased && aliased[1] === exportName) names.push(aliased[2]);
|
|
else if (piece === exportName) names.push(piece);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** Every call of `exportName` in `rel`, as the source text of its argument. */
|
|
function callArgs(rel: string, exportName: string): string[] {
|
|
const src = read(rel);
|
|
const args: string[] = [];
|
|
for (const local of importedAs(rel, exportName)) {
|
|
for (const m of src.matchAll(
|
|
new RegExp(`\\b${local}\\s*\\(([^)]*)\\)`, "g"),
|
|
)) {
|
|
args.push(m[1].replace(/\s+/g, " ").trim());
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function callers(exportName: string): string[] {
|
|
return sourceFiles().filter(
|
|
(rel) => rel !== "lib/api-handler.ts" && callArgs(rel, exportName).length,
|
|
);
|
|
}
|
|
|
|
/** Routes that resolve their own caller and take the recent-proof gate. */
|
|
const RECENT_PROOF_ROUTES: Record<string, string> = {
|
|
// Offering access to the record. No shipped client calls it on a token.
|
|
"app/api/account/grants/route.ts": "elevation",
|
|
// `type=all` only; the single-type exports stay on `requireAuth`.
|
|
"app/api/export/route.ts": "elevation",
|
|
"app/api/export/full-backup/route.ts": "elevation",
|
|
"app/api/export/encrypted/route.ts": "elevation-if-enrolled",
|
|
// The shipped iOS app calls these two on its token without an elevation and
|
|
// reads a 401 outside its known paths as a dead session. Tightening them is
|
|
// an app release first; see the iOS note for v1.39.3.
|
|
"app/api/share-links/route.ts": "token",
|
|
"app/api/mcp/tokens/route.ts": "token",
|
|
};
|
|
|
|
/**
|
|
* Routes that resolve a cookie OR a token through their own resolver
|
|
* (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm
|
|
* only. Their Bearer arm is the token, as with the `token` rule above: the
|
|
* shipped app calls both with its token and no elevation.
|
|
*/
|
|
const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
|
|
"app/api/export/health-record/route.ts",
|
|
"app/api/fhir/Patient/$everything/route.ts",
|
|
].sort();
|
|
|
|
/**
|
|
* The connection consent. It resolves the session through `getSession`
|
|
* (OAuth answers in its own error format, not the envelope) and asks for the
|
|
* proof on the consent page and again on "allow".
|
|
*/
|
|
const CONSENT_PROOF_ROUTES = ["app/api/mcp/oauth/authorize/route.ts"];
|
|
|
|
/** Cookie-only routes that add the gate after their own resolver. */
|
|
const COOKIE_PROOF_ROUTES = [
|
|
"app/api/admin/backups/[id]/download/route.ts",
|
|
"app/api/admin/backups/[id]/restore/route.ts",
|
|
"app/api/admin/backups/upload/route.ts",
|
|
"app/api/admin/data/route.ts",
|
|
"app/api/admin/users/[id]/reset-password/route.ts",
|
|
"app/api/auth/reproof/route.ts",
|
|
"app/api/tokens/documents/route.ts",
|
|
"app/api/tokens/measurements/route.ts",
|
|
"app/api/tokens/workouts/route.ts",
|
|
].sort();
|
|
|
|
describe("the recent-proof gate", () => {
|
|
it("reads the tree it claims to sweep", () => {
|
|
expect(sourceFiles().length).toBeGreaterThan(1500);
|
|
});
|
|
|
|
it("is taken by exactly the known routes, each with its Bearer rule", () => {
|
|
const found: Record<string, string> = {};
|
|
for (const rel of callers("requireRecentProof")) {
|
|
const args = callArgs(rel, "requireRecentProof");
|
|
expect(args).toHaveLength(1);
|
|
const rule = args[0].match(/bearer:\s*"([^"]+)"/)?.[1];
|
|
expect(rule, rel).toBeDefined();
|
|
found[rel] = rule!;
|
|
}
|
|
expect(Object.keys(found).length).toBeGreaterThan(0);
|
|
expect(found).toEqual(RECENT_PROOF_ROUTES);
|
|
});
|
|
|
|
it("the cookie arm is added by exactly the known routes", () => {
|
|
const found = callers("assertRecentCookieProof");
|
|
expect(found.length).toBeGreaterThan(0);
|
|
expect(found).toEqual(
|
|
[
|
|
...COOKIE_PROOF_ROUTES,
|
|
...TOKEN_ARM_COOKIE_PROOF_ROUTES,
|
|
...CONSENT_PROOF_ROUTES,
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it("a route that resolves either transport asks only on the cookie", () => {
|
|
for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
|
|
expect(read(rel), rel).toMatch(
|
|
/if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("each cookie-only route resolves a cookie before it asks", () => {
|
|
for (const rel of COOKIE_PROOF_ROUTES) {
|
|
const resolvers = [
|
|
...importedAs(rel, "requireAdmin"),
|
|
...importedAs(rel, "requireCookieAuth"),
|
|
];
|
|
expect(resolvers, rel).toHaveLength(1);
|
|
expect(importedAs(rel, "requireAuth"), rel).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("every route whose Bearer arm takes an elevation spends it", () => {
|
|
const withElevation = Object.entries(RECENT_PROOF_ROUTES).filter(
|
|
([, rule]) => rule !== "token",
|
|
);
|
|
expect(withElevation.length).toBeGreaterThan(0);
|
|
for (const [rel] of withElevation) {
|
|
expect(read(rel), rel).toMatch(/\.commitElevation\(\)/);
|
|
}
|
|
});
|
|
|
|
it("key rotation keeps its stricter second-factor gate", () => {
|
|
const rel = "app/api/admin/encryption/rotate/route.ts";
|
|
expect(callArgs(rel, "requireFreshMfa").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
|
|
const src = read("lib/api-handler.ts");
|
|
const start = src.indexOf("export async function assertRecentCookieProof(");
|
|
expect(start).toBeGreaterThan(-1);
|
|
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
|
|
// The enrolled arm names mfaVerifiedAt alone: a password re-proof
|
|
// (`reproofAt`) or a bare sign-in (`createdAt`) must not stand in for it.
|
|
expect(body).toMatch(/enrolled\s*\?\s*fresh\(row\?\.mfaVerifiedAt\)\s*:/);
|
|
});
|
|
});
|