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:
Marc-André Bombeck committed 2026-06-27 15:09:11 +02:00
1 parent 06363771ee
commit 22eb9cba0d
39 files changed
+1826 -72

No files matched your search

@@ -0,0 +1,41 @@
-- 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;
+41 -1
View File
@@ -536,6 +536,7 @@ model User {
medications Medication[]
intakeEvents MedicationIntakeEvent[]
apiTokens ApiToken[]
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
@@ -2369,13 +2370,52 @@ model ApiToken {
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revoked Boolean @default(false)
// The MCP OAuth connection this access token belongs to, when it was minted
// through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and
// native-client tokens. Lets a connection revoke kill every access row it
// ever issued (H2), and lets the connector token list exclude the transient
// 60-minute access rows so it does not flood (M2).
mcpConnectionId String? @map("mcp_connection_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([mcpConnectionId])
@@map("api_tokens")
}
// MCP OAuth connection — the revocable, theft-detectable anchor for a remote
// connector's refresh-token chain (Phase 3 security fix, H2).
//
// The stateless `hlrt_` refresh artifact carries this row's `id` (`cid`) plus
// the rotation `jti`; the row is the persistent server state the artifact alone
// lacks. `currentJti` is the only refresh `jti` valid for the connection at any
// moment — every rotation advances it. A presented refresh whose `jti` is not
// `currentJti` is a replay of an already-rotated token (theft signal) and
// revokes the connection (reuse-detection family revocation, mirroring
// `refresh-token.ts`). `revokedAt` is the kill switch the settings card flips,
// so revoking a connection stops every future refresh and (via the
// `ApiToken.mcpConnectionId` back-link) kills its outstanding access tokens.
model McpOAuthConnection {
id String @id @default(cuid())
userId String @map("user_id")
clientId String @map("client_id")
clientName String @map("client_name")
scope String
resource String
currentJti String @map("current_jti")
createdAt DateTime @default(now()) @map("created_at")
lastUsedAt DateTime? @map("last_used_at")
revokedAt DateTime? @map("revoked_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessTokens ApiToken[]
@@index([userId])
@@map("mcp_oauth_connections")
}
// v1.15.20 — registration invite tokens. When the operator has disabled
// open registration (`AppSettings.registrationEnabled = false`), a valid,
// unexpired invite token still admits a signup. The raw `hlv_<hex>` token