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
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Key, Loader2, Plug, Trash2 } from "lucide-react";
|
||||
import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -54,15 +54,136 @@ interface McpTokenInfo {
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
interface McpConnectionInfo {
|
||||
id: string;
|
||||
clientName: string;
|
||||
scope: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export function McpSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<McpEnableCard />
|
||||
<McpConnectionsCard />
|
||||
<McpTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpConnectionsCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: queryKeys.mcpConnections(),
|
||||
queryFn: async () => apiGet<McpConnectionInfo[]>("/api/mcp/connections"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleRevoke(connectionId: string) {
|
||||
try {
|
||||
const res = await apiFetchRaw(`/api/mcp/connections/${connectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("settings.mcp.connectionRevokeFailed"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() });
|
||||
} catch {
|
||||
toast.error(t("settings.mcp.connectionRevokeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const list = connections ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.mcp.connectionsTitle")}
|
||||
description={t("settings.mcp.connectionsDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{list.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
|
||||
{t("settings.mcp.noConnections")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{list.map((conn) => (
|
||||
<li
|
||||
key={conn.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{conn.clientName}
|
||||
</p>
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.mcp.connectionConnected")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{conn.scope}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.mcp.connectionLastUsed")}:
|
||||
</span>{" "}
|
||||
{conn.lastUsedAt
|
||||
? formatDateTime(conn.lastUsedAt)
|
||||
: t("settings.mcp.connectionNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.mcp.connectionRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(conn.id)}
|
||||
>
|
||||
{t("settings.mcp.connectionRevoke")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function McpEnableCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
Reference in new issue
Block a user