mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the adversarial review of the milestone branch. The two ship-blockers made the "read-only, revocable" promise false; the rest tightened the surface. Audience binding at the resource server: a token whose only grant is the new `health:read` scope is the MCP read credential. The REST edge now accepts it on safe (read) methods and refuses it on writes/deletes with 403, so a connector token can read over `/mcp` and GET routes but can never POST/DELETE the record despite a "read-only" consent. The `/mcp` resolver is unaffected. Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a persistent connection row. Revoking a connection stops every future refresh and kills its access tokens; a replayed (already-rotated) refresh is treated as theft and revokes the whole connection. Connections surface in settings as the unit a user can disconnect. The old access token is revoked on rotation. Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is configured and mark the discovery documents non-cacheable; honour the global API kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and registration body reads while streaming instead of after; show the verified client origin on consent; validate CIMD redirect-URI schemes; assert same-origin on the consent POST; and add a daily prune for expired connector tokens. New migration 0195 adds the connection anchor and links access tokens to it.
This commit is contained in:
1 parent
06363771ee
commit
22eb9cba0d
39 files changed
+1826
-72
No files matched your search
@@ -42,7 +42,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
// --- Imports use the mocked modules above. ---
|
||||
|
||||
import { requireAuth, HttpError } from "../api-handler";
|
||||
import { requireAuth, HttpError, apiHandler } from "../api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
@@ -270,6 +270,89 @@ describe("requireAuth — Bearer token path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
|
||||
// Drive the FULL apiHandler path so the request method is in event context —
|
||||
// the H1 check reads it to allow safe reads but block writes.
|
||||
const route = apiHandler(async (_req: Request) => {
|
||||
await requireAuth();
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
function call(method: string): Promise<Response> {
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
return route(
|
||||
new Request("https://health.example/api/measurements/batch", {
|
||||
method,
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
});
|
||||
|
||||
function mockHealthReadToken(): void {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "mcp-token",
|
||||
userId: "user-1",
|
||||
permissions: ["health:read"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("accepts a health:read token on a GET read route", async () => {
|
||||
mockHealthReadToken();
|
||||
const res = await call("GET");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a health:read token on POST with 403", async () => {
|
||||
mockHealthReadToken();
|
||||
const res = await call("POST");
|
||||
expect(res.status).toBe(403);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.failure",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
reason: "mcp_audience_write_blocked",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a health:read token on DELETE with 403", async () => {
|
||||
mockHealthReadToken();
|
||||
const res = await call("DELETE");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "wild",
|
||||
userId: "user-1",
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
const res = await call("POST");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does NOT restrict a health:read+write token on POST", async () => {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "rw",
|
||||
userId: "user-1",
|
||||
permissions: ["health:read", "health:write"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
const res = await call("POST");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth — cookie path remains intact", () => {
|
||||
it("returns the session payload without consulting the Bearer header", async () => {
|
||||
const cookieSession = {
|
||||
|
||||
Reference in new issue
Block a user