mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+45
-1
@@ -10,6 +10,27 @@ import { auditLog } from "./auth/audit";
|
||||
import { resolveBearerToken, BearerAuthError } from "./auth/bearer";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ } from "./mcp/oauth/config";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
* with any other method (POST / PUT / PATCH / DELETE) is a write.
|
||||
*/
|
||||
const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the
|
||||
* connector settings card mint tokens whose ONLY grant is `health:read`. Such a
|
||||
* token is bound to the read surface: the `/mcp` resolver accepts it, and so do
|
||||
* safe (read) REST methods, but it must never reach a REST write/delete. A token
|
||||
* carrying any broader or legacy grant (`*`, `medication:ingest`, an explicit
|
||||
* `health:write`, …) is NOT MCP-audience-bound and keeps its existing reach.
|
||||
*/
|
||||
function isMcpAudienceToken(permissions: readonly string[]): boolean {
|
||||
return (
|
||||
permissions.length > 0 && permissions.every((p) => p === SCOPE_HEALTH_READ)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for HTTP errors with status codes.
|
||||
@@ -331,7 +352,30 @@ async function authenticateBearer(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { user, tokenId, expiresAt } = resolution;
|
||||
const { user, tokenId, expiresAt, permissions } = resolution;
|
||||
|
||||
// H1 — audience binding at the resource server. A health:read-only token is
|
||||
// the MCP read credential; it may reach `/mcp` (a separate resolver that
|
||||
// never runs this edge) and safe REST reads, but a write/delete over REST is
|
||||
// outside its audience and is refused. Fail closed when the method is unknown
|
||||
// (no event context) since every real REST request runs inside apiHandler,
|
||||
// which always sets the method — an unknown method means we cannot prove a
|
||||
// read, so we deny. This is RFC 8707 audience binding on the credential the
|
||||
// client actually holds, not only during the OAuth exchange.
|
||||
if (isMcpAudienceToken(permissions)) {
|
||||
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
|
||||
if (!READ_HTTP_METHODS.has(method)) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
reason: "mcp_audience_write_blocked",
|
||||
tokenId,
|
||||
method: method || "unknown",
|
||||
},
|
||||
}).catch(() => {});
|
||||
throw new HttpError(403, "Insufficient permissions");
|
||||
}
|
||||
}
|
||||
|
||||
auditLog("auth.bearer.success", {
|
||||
userId: user.id,
|
||||
|
||||
Reference in new issue
Block a user