mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user