mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
42 lines
2.1 KiB
SQL
42 lines
2.1 KiB
SQL
-- MCP Phase 3 security fix (H2) — revocable, theft-detectable refresh chains.
|
|
--
|
|
-- The remote MCP OAuth bridge minted stateless `hlrt_` refresh tokens that
|
|
-- referenced no server row: they could not be revoked (the settings "revoke"
|
|
-- only killed the 60-minute access token) and a replayed, already-rotated
|
|
-- refresh token was not treated as theft. This migration adds the persistent
|
|
-- anchor the refresh artifact lacked.
|
|
--
|
|
-- `mcp_oauth_connections` is the revocable unit: one row per connector
|
|
-- connection that requested `offline_access`. `current_jti` is the only refresh
|
|
-- `jti` valid at any moment — every rotation advances it, so a presented `jti`
|
|
-- that is not `current_jti` is a replay (theft signal) that revokes the row.
|
|
-- `revoked_at` is the kill switch the settings card flips; revoking the row
|
|
-- stops every future refresh.
|
|
CREATE TABLE "mcp_oauth_connections" (
|
|
"id" TEXT NOT NULL,
|
|
"user_id" TEXT NOT NULL,
|
|
"client_id" TEXT NOT NULL,
|
|
"client_name" TEXT NOT NULL,
|
|
"scope" TEXT NOT NULL,
|
|
"resource" TEXT NOT NULL,
|
|
"current_jti" TEXT NOT NULL,
|
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"last_used_at" TIMESTAMP(3),
|
|
"revoked_at" TIMESTAMP(3),
|
|
|
|
CONSTRAINT "mcp_oauth_connections_pkey" PRIMARY KEY ("id")
|
|
);
|
|
|
|
CREATE INDEX "mcp_oauth_connections_user_id_idx" ON "mcp_oauth_connections"("user_id");
|
|
|
|
ALTER TABLE "mcp_oauth_connections" ADD CONSTRAINT "mcp_oauth_connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
|
|
-- Link each OAuth-minted access token back to its connection so a connection
|
|
-- revoke kills every access row it issued, and so the connector token list can
|
|
-- exclude the transient access rows (they would otherwise flood it — M2).
|
|
ALTER TABLE "api_tokens" ADD COLUMN "mcp_connection_id" TEXT;
|
|
|
|
CREATE INDEX "api_tokens_mcp_connection_id_idx" ON "api_tokens"("mcp_connection_id");
|
|
|
|
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_mcp_connection_id_fkey" FOREIGN KEY ("mcp_connection_id") REFERENCES "mcp_oauth_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|