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

+84 -1
View File
@@ -42,7 +42,7 @@ vi.mock("next/headers", () => ({
// --- Imports use the mocked modules above. ---
import { requireAuth, HttpError } from "../api-handler";
import { requireAuth, HttpError, apiHandler } from "../api-handler";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
@@ -270,6 +270,89 @@ describe("requireAuth — Bearer token path", () => {
});
});
describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
// Drive the FULL apiHandler path so the request method is in event context —
// the H1 check reads it to allow safe reads but block writes.
const route = apiHandler(async (_req: Request) => {
await requireAuth();
return Response.json({ ok: true });
});
function call(method: string): Promise<Response> {
setBearerHeader(`Bearer ${RAW_TOKEN}`);
return route(
new Request("https://health.example/api/measurements/batch", {
method,
}) as never,
);
}
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
});
function mockHealthReadToken(): void {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "mcp-token",
userId: "user-1",
permissions: ["health:read"],
revoked: false,
expiresAt: null,
} as never);
}
it("accepts a health:read token on a GET read route", async () => {
mockHealthReadToken();
const res = await call("GET");
expect(res.status).toBe(200);
});
it("rejects a health:read token on POST with 403", async () => {
mockHealthReadToken();
const res = await call("POST");
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "mcp_audience_write_blocked",
}),
}),
);
});
it("rejects a health:read token on DELETE with 403", async () => {
mockHealthReadToken();
const res = await call("DELETE");
expect(res.status).toBe(403);
});
it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "wild",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("POST");
expect(res.status).toBe(200);
});
it("does NOT restrict a health:read+write token on POST", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "rw",
userId: "user-1",
permissions: ["health:read", "health:write"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("POST");
expect(res.status).toBe(200);
});
});
describe("requireAuth — cookie path remains intact", () => {
it("returns the session payload without consulting the Bearer header", async () => {
const cookieSession = {
+45 -1
View File
@@ -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,
+9
View File
@@ -29,6 +29,12 @@ export interface IssueTokenOptions {
* carries continuity). Ignored when `expiresInDays` alone is supplied.
*/
expiresInMinutes?: number;
/**
* Links this access token to an MCP OAuth connection (H2). Set only by the
* `/api/mcp/oauth` bridge so a connection revoke can kill every access row it
* issued and the connector token list can exclude the transient access rows.
*/
mcpConnectionId?: string;
}
/**
@@ -53,6 +59,9 @@ export async function issueApiToken(
tokenHash: tokenHashValue,
permissions: opts.permissions ?? ["*"],
expiresAt,
...(opts.mcpConnectionId
? { mcpConnectionId: opts.mcpConnectionId }
: {}),
},
select: { id: true },
});
@@ -0,0 +1,68 @@
/**
* Bounded body reader (M3) — reject oversized bodies WITHOUT buffering them.
*/
import { describe, it, expect } from "vitest";
import { readBodyCapped } from "../read-capped";
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
});
}
const enc = (s: string) => new TextEncoder().encode(s);
describe("readBodyCapped", () => {
it("returns the decoded text under the cap", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: streamOf([enc("hello "), enc("world")]) },
1024,
);
expect(res).toEqual({ ok: true, text: "hello world" });
});
it("rejects up front on an oversized Content-Length without consuming the body", async () => {
const body = streamOf([enc("x")]);
let getReaderCalled = false;
const original = body.getReader.bind(body);
body.getReader = ((...args: unknown[]) => {
getReaderCalled = true;
// @ts-expect-error — passthrough spy
return original(...args);
}) as typeof body.getReader;
const res = await readBodyCapped(
{ headers: new Headers({ "content-length": "999999" }), body },
16,
);
expect(res).toEqual({ ok: false, reason: "too_large" });
expect(getReaderCalled).toBe(false);
});
it("aborts mid-stream the moment the running total exceeds the cap", async () => {
let chunksPulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(c) {
chunksPulled++;
// 10 bytes per chunk; cap is 16, so the 2nd chunk overflows.
c.enqueue(enc("0123456789"));
},
});
const res = await readBodyCapped({ headers: new Headers(), body }, 16);
expect(res).toEqual({ ok: false, reason: "too_large" });
// It must stop pulling once it overflows, not drain the (infinite) stream.
expect(chunksPulled).toBeLessThanOrEqual(2);
});
it("treats a null body as empty", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: null },
16,
);
expect(res).toEqual({ ok: true, text: "" });
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Bounded body reader (M3).
*
* `await res.text()` / `await request.text()` buffer the WHOLE body before any
* size check runs, so a hostile peer can stream (or decompress) gigabytes into
* memory for the duration of the request timeout before a post-hoc
* `raw.length > MAX` rejection fires. This helper enforces the cap WHILE
* reading: it rejects up front on an oversized `Content-Length`, then streams
* the body and aborts the moment the running byte count exceeds the ceiling.
*
* Returns `{ ok: false }` on overflow (the caller maps it to its own error);
* returns the decoded UTF-8 text on success.
*/
export type CappedReadResult =
| { ok: true; text: string }
| { ok: false; reason: "too_large" };
export async function readBodyCapped(
source: { headers: Headers; body: ReadableStream<Uint8Array> | null },
maxBytes: number,
): Promise<CappedReadResult> {
// Up-front reject on a declared oversized length — cheap and stops the read
// before a single byte is buffered.
const declared = source.headers.get("content-length");
if (declared) {
const n = Number(declared);
if (Number.isFinite(n) && n > maxBytes) {
return { ok: false, reason: "too_large" };
}
}
const body = source.body;
if (!body) return { ok: true, text: "" };
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maxBytes) {
// Stop pulling immediately — do not buffer past the ceiling.
await reader.cancel().catch(() => {});
return { ok: false, reason: "too_large" };
}
chunks.push(value);
}
} finally {
reader.releaseLock?.();
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return { ok: true, text: new TextDecoder("utf-8").decode(merged) };
}
@@ -0,0 +1,58 @@
/**
* MCP Phase 3 (M2) — mcp-token-cleanup queue registration guard.
*
* Source-text-grep guard like the other queue-wiring checks: assert the queue
* is in `allQueues`, scheduled, and wired to a `boss.work` handler. An
* unregistered queue silently never drains (the recurring v1.4.37 dead-queue
* bug), which would leave expired MCP connector access tokens growing unbounded.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REGISTRAR_PATH = join(
__dirname,
"..",
"reminder",
"register-maintenance.ts",
);
const workerSource =
readFileSync(REGISTRAR_PATH, "utf8") +
readFileSync(
join(__dirname, "..", "reminder", "cleanup-handlers.ts"),
"utf8",
);
describe("reminder-worker — mcp-token-cleanup wiring", () => {
it("imports the cleanup handler from the cleanup-handlers module", () => {
expect(workerSource).toMatch(/\bhandleMcpTokenCleanup\b/);
expect(workerSource).toMatch(/\bMcpTokenCleanupPayload\b/);
});
it("registers the mcp-token-cleanup queue in the allQueues loop", () => {
const allQueuesMatch = workerSource.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
expect(allQueuesMatch).not.toBeNull();
expect(allQueuesMatch![1]).toMatch(/\bMCP_TOKEN_CLEANUP_QUEUE\b/);
});
it("schedules the mcp-token-cleanup cron", () => {
expect(workerSource).toMatch(
/\[MCP_TOKEN_CLEANUP_QUEUE,\s*MCP_TOKEN_CLEANUP_CRON\]/,
);
});
it("registers a boss.work handler for the queue", () => {
expect(workerSource).toMatch(
/boss\.work[\s\S]{0,200}MCP_TOKEN_CLEANUP_QUEUE[\s\S]{0,200}handleMcpTokenCleanup/,
);
});
it("prunes MCP tokens inside the handler", () => {
expect(workerSource).toMatch(
/handleMcpTokenCleanup[\s\S]{0,400}cleanupExpiredMcpTokens/,
);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Daily cleanup for the MCP OAuth surface (M2).
*
* Every `authorization_code` exchange and every hourly refresh mints a fresh
* 60-minute `hlk_` access-token row through the bridge. Nothing deleted the
* expired/revoked rows, so a single connection refreshing hourly left ~720 dead
* rows per month, forever — unbounded table growth, and (before the connection
* model) a flooded connector list.
*
* This sweep hard-deletes MCP-connector access tokens that are past a short
* grace window after expiry, or revoked. It is scoped to `health:read`-only
* connector rows so it never touches native-client or wildcard tokens. Long-
* revoked connection anchors are pruned too once no access token references
* them, keeping the `mcp_oauth_connections` table bounded.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
/** Grace window kept after expiry before a dead access row is deleted. */
const ACCESS_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
/** Revoked connections older than this are pruned (kept briefly for audit). */
const CONNECTION_PRUNE_MS = 30 * 24 * 60 * 60 * 1000;
export interface McpTokenCleanupResult {
accessTokensDeleted: number;
connectionsDeleted: number;
}
export async function cleanupExpiredMcpTokens(
prisma: PrismaClient,
): Promise<McpTokenCleanupResult> {
const now = Date.now();
const expiryCutoff = new Date(now - ACCESS_TOKEN_GRACE_MS);
const connectionCutoff = new Date(now - CONNECTION_PRUNE_MS);
// Connector access tokens only: permissions is exactly `["health:read"]`.
const access = await prisma.apiToken.deleteMany({
where: {
permissions: { equals: [SCOPE_HEALTH_READ] },
OR: [{ revoked: true }, { expiresAt: { lt: expiryCutoff } }],
},
});
// Prune long-revoked connection anchors. SetNull on the access-token FK means
// any lingering linked rows simply lose the pointer; the predicate keeps the
// window generous so an in-flight rotation never races a delete.
const connections = await prisma.mcpOAuthConnection.deleteMany({
where: { revokedAt: { lt: connectionCutoff } },
});
return {
accessTokensDeleted: access.count,
connectionsDeleted: connections.count,
};
}
+27
View File
@@ -11,6 +11,7 @@ import {
cleanupExpiredWhoopOAuthStates,
} from "@/lib/jobs/whoop-oauth-state-cleanup";
import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup";
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup";
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
@@ -221,6 +222,32 @@ export async function handleCoachMessageCleanup(
});
}
export interface McpTokenCleanupPayload {
triggeredAt: string;
}
export async function handleMcpTokenCleanup(
jobs: Job<McpTokenCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent("job.mcp_token_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
const result = await cleanupExpiredMcpTokens(p);
evt.addMeta(
"mcp_token_cleanup_access_deleted",
result.accessTokensDeleted,
);
evt.addMeta(
"mcp_token_cleanup_connections_deleted",
result.connectionsDeleted,
);
} catch (err) {
evt.addWarning(`mcp-token-cleanup failed: ${err}`);
}
});
}
export async function handleWithingsOAuthStateCleanup(
jobs: Job<WithingsOAuthStateCleanupPayload>[],
) {
@@ -79,6 +79,8 @@ import {
handleAuditLogCleanup,
CoachMessageCleanupPayload,
handleCoachMessageCleanup,
McpTokenCleanupPayload,
handleMcpTokenCleanup,
} from "./cleanup-handlers";
import {
HostMetricSamplePayload,
@@ -168,6 +170,14 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup";
const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *";
// MCP Phase 3 (M2) — daily prune of expired/revoked MCP connector access
// tokens (every code exchange + hourly refresh mints a 60-minute row) and
// long-revoked OAuth connection anchors. Slots at 03:55 Europe/Berlin after
// the coach-message cleanup (03:50), inside the existing 03:xx window.
const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup";
const MCP_TOKEN_CLEANUP_CRON = "55 3 * * *";
const allQueues = [
DATA_BACKUP_QUEUE,
@@ -209,6 +219,10 @@ const allQueues = [
// the daily schedule silently no-ops and the encrypted coach_messages
// table grows unbounded.
COACH_MESSAGE_CLEANUP_QUEUE,
// MCP Phase 3 (M2) — expired/revoked MCP access-token + connection prune.
// Without this entry the daily schedule silently no-ops and the api_tokens
// table grows unbounded with dead 60-minute connector rows.
MCP_TOKEN_CLEANUP_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -260,6 +274,8 @@ const schedules: ScheduleEntry[] = [
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
// v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history.
[COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON],
// MCP Phase 3 (M2) — daily 03:55 Europe/Berlin prune for dead MCP tokens.
[MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON],
];
/**
@@ -349,6 +365,13 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleCoachMessageCleanup,
);
// MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens +
// long-revoked connection anchors. Single-flight like every other cleanup.
await boss.work<McpTokenCleanupPayload>(
MCP_TOKEN_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleMcpTokenCleanup,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
+5
View File
@@ -164,6 +164,11 @@ export class WideEventBuilder {
return this.highestLevel;
}
/** The HTTP method of the current request, if one was attached. */
getHttpMethod(): string | undefined {
return this.event.http?.method;
}
getRequestId(): string {
return this.event.request_id!;
}
@@ -0,0 +1,195 @@
/**
* MCP OAuth connection anchor — H2 revocation + reuse-detection.
*
* These pin the security contract the stateless refresh artifact alone cannot
* provide: a revoked connection stops every future refresh, a replayed
* (already-rotated) jti revokes the whole connection, and a settings revoke
* terminates the chain and kills the connection's access tokens.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
mcpOAuthConnection: {
create: vi.fn(),
findUnique: vi.fn(),
updateMany: vi.fn(),
findMany: vi.fn(),
},
apiToken: {
updateMany: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import {
createConnection,
rotateConnection,
revokeConnectionForUser,
} from "../connections";
const CONN = {
id: "conn-1",
userId: "user-1",
clientId: "https://app.example/client.json",
currentJti: "jti-current",
revokedAt: null as Date | null,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.mcpOAuthConnection.updateMany).mockResolvedValue({
count: 1,
} as never);
});
describe("createConnection", () => {
it("seeds currentJti with the first refresh jti", async () => {
vi.mocked(prisma.mcpOAuthConnection.create).mockResolvedValue({
id: "conn-1",
} as never);
const id = await createConnection({
userId: "user-1",
clientId: CONN.clientId,
clientName: "Claude",
scope: "health:read offline_access",
resource: "https://health.example/mcp",
jti: "jti-1",
});
expect(id).toBe("conn-1");
expect(prisma.mcpOAuthConnection.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ currentJti: "jti-1" }),
}),
);
});
});
describe("rotateConnection — happy path", () => {
it("advances currentJti and revokes prior access tokens (L4)", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
...CONN,
} as never);
const out = await rotateConnection({
connectionId: "conn-1",
presentedJti: "jti-current",
newJti: "jti-next",
clientId: CONN.clientId,
userId: "user-1",
});
expect(out.ok).toBe(true);
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ currentJti: "jti-current" }),
data: expect.objectContaining({ currentJti: "jti-next" }),
}),
);
// L4 — prior access tokens revoked on rotation.
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { mcpConnectionId: "conn-1", revoked: false },
data: { revoked: true },
}),
);
});
});
describe("rotateConnection — revoked connection stops refresh", () => {
it("rejects when the connection is revoked", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
...CONN,
revokedAt: new Date(),
} as never);
const out = await rotateConnection({
connectionId: "conn-1",
presentedJti: "jti-current",
newJti: "jti-next",
clientId: CONN.clientId,
userId: "user-1",
});
expect(out).toEqual({ ok: false, reason: "revoked" });
expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled();
});
});
describe("rotateConnection — reuse detection revokes the family", () => {
it("revokes the connection when a stale jti is presented", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
...CONN,
currentJti: "jti-rotated-already",
} as never);
const out = await rotateConnection({
connectionId: "conn-1",
presentedJti: "jti-old", // not the current jti → replay
newJti: "jti-next",
clientId: CONN.clientId,
userId: "user-1",
});
expect(out).toEqual({ ok: false, reason: "reuse_detected" });
// The whole connection is revoked + its access tokens killed.
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "conn-1", revokedAt: null },
data: expect.objectContaining({ revokedAt: expect.any(Date) }),
}),
);
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { mcpConnectionId: "conn-1", revoked: false },
}),
);
});
it("rejects a connection owned by another user", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
...CONN,
userId: "someone-else",
} as never);
const out = await rotateConnection({
connectionId: "conn-1",
presentedJti: "jti-current",
newJti: "jti-next",
clientId: CONN.clientId,
userId: "user-1",
});
expect(out).toEqual({ ok: false, reason: "not_found" });
});
});
describe("revokeConnectionForUser — settings revoke kills the chain", () => {
it("revokes a connection owned by the user and its access tokens", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
id: "conn-1",
userId: "user-1",
revokedAt: null,
} as never);
const ok = await revokeConnectionForUser("user-1", "conn-1");
expect(ok).toBe(true);
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ revokedAt: expect.any(Date) }),
}),
);
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { mcpConnectionId: "conn-1", revoked: false },
data: { revoked: true },
}),
);
});
it("refuses to revoke another user's connection", async () => {
vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({
id: "conn-1",
userId: "someone-else",
revokedAt: null,
} as never);
const ok = await revokeConnectionForUser("user-1", "conn-1");
expect(ok).toBe(false);
expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled();
});
});
@@ -149,6 +149,35 @@ describe("clients — CIMD via safeFetch (SSRF-safe)", () => {
const res = await resolveClient("https://169.254.169.254/meta.json");
expect(res).toEqual({ ok: false, reason: "ssrf_blocked" });
});
it("rejects a CIMD doc with a non-https / non-loopback redirect (L2)", async () => {
const clientId = "https://app.example/mcp-client.json";
vi.mocked(safeFetch).mockResolvedValue(
new Response(
JSON.stringify({
client_id: clientId,
client_name: "Sketchy",
redirect_uris: ["http://evil.example/cb"], // non-loopback http
}),
{ status: 200 },
),
);
const res = await resolveClient(clientId);
expect(res).toEqual({ ok: false, reason: "invalid_metadata" });
});
it("rejects an oversized CIMD body via the streaming cap (M3)", async () => {
const clientId = "https://app.example/mcp-client.json";
// Declared Content-Length over the 16 KB cap → rejected before reading.
vi.mocked(safeFetch).mockResolvedValue(
new Response("x".repeat(64), {
status: 200,
headers: { "content-length": String(64 * 1024) },
}),
);
const res = await resolveClient(clientId);
expect(res).toEqual({ ok: false, reason: "invalid_metadata" });
});
});
describe("clients — redirect URI matching", () => {
+35 -3
View File
@@ -23,6 +23,7 @@
* ordinary registered entry.
*/
import { safeFetch } from "@/lib/safe-fetch";
import { readBodyCapped } from "@/lib/http/read-capped";
import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts";
export interface ResolvedClient {
@@ -55,6 +56,30 @@ function asStringArray(value: unknown): string[] | null {
return out;
}
const REDIRECT_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"];
/**
* A redirect URI must be an absolute HTTPS URL or an `http` loopback URL. This
* is the single source of truth shared by DCR (the `register` route) and CIMD
* (L2): both registration paths enforce the identical https/loopback floor.
*/
export function isAllowableRedirectUri(value: string): boolean {
let url: URL;
try {
url = new URL(value);
} catch {
return false;
}
if (url.protocol === "https:") return true;
if (
url.protocol === "http:" &&
REDIRECT_LOOPBACK_HOSTS.includes(url.hostname)
) {
return true;
}
return false;
}
/** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */
export function isCimdClientId(clientId: string): boolean {
try {
@@ -131,13 +156,16 @@ async function resolveCimdClient(clientId: string): Promise<ClientResolution> {
}
if (!res.ok) return { ok: false, reason: "unknown_client" };
const raw = await res.text();
if (raw.length > CIMD_MAX_BYTES) {
// M3 — enforce the byte cap WHILE reading: reject up front on an oversized
// Content-Length and abort the stream the moment it overflows, so a hostile
// CIMD host cannot buffer gigabytes into memory before a post-hoc check.
const read = await readBodyCapped(res, CIMD_MAX_BYTES);
if (!read.ok) {
return { ok: false, reason: "invalid_metadata" };
}
let doc: Record<string, unknown>;
try {
doc = JSON.parse(raw);
doc = JSON.parse(read.text);
} catch {
return { ok: false, reason: "invalid_metadata" };
}
@@ -148,6 +176,10 @@ async function resolveCimdClient(clientId: string): Promise<ClientResolution> {
}
const redirectUris = asStringArray(doc.redirect_uris);
if (!redirectUris) return { ok: false, reason: "invalid_metadata" };
// L2 — CIMD redirect URIs get the same https/loopback floor DCR enforces.
if (!redirectUris.every(isAllowableRedirectUri)) {
return { ok: false, reason: "invalid_metadata" };
}
return {
ok: true,
+52 -1
View File
@@ -15,9 +15,49 @@
* `resource` value matches the user-entered URL (a Claude.ai requirement).
*/
/**
* Thrown when the MCP/OAuth surface is asked to resolve its canonical origin but
* neither `APP_URL` nor `NEXT_PUBLIC_APP_URL` is configured. The surface fails
* closed in that case (M1): without an operator-pinned origin the issuer,
* endpoint URLs, and RFC 8707 audience would be derived from the attacker-
* influenceable `Host` header, so we refuse to serve rather than trust it.
*/
export class McpOriginNotConfiguredError extends Error {
constructor() {
super(
"APP_URL (or NEXT_PUBLIC_APP_URL) must be set to serve the MCP/OAuth surface",
);
this.name = "McpOriginNotConfiguredError";
}
}
/** The operator-configured origin, env-only — `Host` is never trusted. */
function configuredOrigin(): string | null {
for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
const trimmed = value?.trim();
if (!trimmed) continue;
try {
return new URL(trimmed).origin;
} catch {
// try the next candidate
}
}
return null;
}
/** Whether the operator has pinned a canonical origin (M1 fail-closed gate). */
export function isMcpOriginConfigured(): boolean {
return configuredOrigin() !== null;
}
/**
* Resolve the public origin of this deployment, e.g. `https://health.example`.
* Operator config wins; the request URL is the last resort.
*
* Operator config (`APP_URL` → `NEXT_PUBLIC_APP_URL`) is authoritative. The
* request URL is a last resort retained ONLY for non-security-bearing callers;
* every MCP/OAuth entry point first asserts `isMcpOriginConfigured()` and fails
* closed (M1), so on those paths the env value is always what is returned and
* the `Host`-derived fallback is never reached.
*/
export function resolveBaseOrigin(requestUrl?: string): string {
const candidates = [
@@ -38,6 +78,17 @@ export function resolveBaseOrigin(requestUrl?: string): string {
return "http://localhost:3000";
}
/**
* Like `resolveBaseOrigin` but fails closed: throws `McpOriginNotConfiguredError`
* when no operator origin is set. Every MCP/OAuth surface calls this so a
* missing `APP_URL` refuses service instead of trusting the `Host` header (M1).
*/
export function requireBaseOrigin(): string {
const origin = configuredOrigin();
if (!origin) throw new McpOriginNotConfiguredError();
return origin;
}
/**
* The canonical MCP resource identifier (RFC 8707 audience). This is the value
* the AS binds every issued token to and the value a client MUST send as
+190
View File
@@ -0,0 +1,190 @@
/**
* MCP OAuth connections — persistent, revocable, theft-detectable refresh
* state (Phase 3 security fix, H2).
*
* The bridge AS mints stateless `hlrt_` refresh artifacts (see `artifacts.ts`).
* On their own they reference no server row, so they could not be revoked and a
* replayed (already-rotated) artifact was not treated as theft. This module is
* the anchor the artifact lacked: one `McpOAuthConnection` row per connector
* connection that requested `offline_access`.
*
* - `createConnection` is called on the `authorization_code` exchange. It
* records the granted scope/audience and seeds `currentJti` with the jti of
* the first refresh artifact.
* - `rotateConnection` is called on every `refresh_token` exchange. It is the
* reuse-detection gate: a presented jti that is not the connection's
* `currentJti` is a replay of an already-rotated token → the connection is
* revoked (family revocation, mirroring `refresh-token.ts`) and the refresh
* fails. A revoked connection always fails. The happy path advances
* `currentJti` atomically (a `where currentJti = presented` guard so two
* concurrent refreshes cannot both win) and revokes the prior access tokens
* (L4).
* - `revokeConnection` is the kill switch the settings card flips: it stamps
* `revokedAt` and revokes every access token the connection ever issued, so
* a settings "revoke" terminates the whole refresh chain.
*
* The access tokens themselves stay ordinary `hlk_` `ApiToken` rows, linked
* back through `ApiToken.mcpConnectionId`.
*/
import { prisma } from "@/lib/db";
export interface ConnectionRotation {
/** The connection id (`cid`) embedded in the refresh artifact. */
connectionId: string;
/** The jti presented in the refresh artifact being exchanged. */
presentedJti: string;
/** The jti to advance the connection to (the new refresh artifact's jti). */
newJti: string;
/** The client the refresh artifact claims — must match the stored client. */
clientId: string;
/** The user the refresh artifact claims — must match the stored owner. */
userId: string;
}
export type RotationOutcome =
| { ok: true }
| {
ok: false;
reason: "not_found" | "revoked" | "reuse_detected" | "client_mismatch";
};
/** Create the connection anchor for a freshly-granted `offline_access` flow. */
export async function createConnection(args: {
userId: string;
clientId: string;
clientName: string;
scope: string;
resource: string;
jti: string;
}): Promise<string> {
const row = await prisma.mcpOAuthConnection.create({
data: {
userId: args.userId,
clientId: args.clientId,
clientName: args.clientName,
scope: args.scope,
resource: args.resource,
currentJti: args.jti,
},
select: { id: true },
});
return row.id;
}
/**
* Rotate a connection's refresh chain with reuse-detection.
*
* Returns `{ ok: true }` only when the presented jti is the connection's live
* `currentJti` and the connection is not revoked; on success `currentJti` is
* advanced to `newJti` and the connection's prior access tokens are revoked.
* A presented jti that does not match `currentJti` is a replay and revokes the
* connection before returning `reuse_detected`.
*/
export async function rotateConnection(
args: ConnectionRotation,
): Promise<RotationOutcome> {
const connection = await prisma.mcpOAuthConnection.findUnique({
where: { id: args.connectionId },
select: {
id: true,
userId: true,
clientId: true,
currentJti: true,
revokedAt: true,
},
});
if (!connection || connection.userId !== args.userId) {
return { ok: false, reason: "not_found" };
}
if (connection.clientId !== args.clientId) {
return { ok: false, reason: "client_mismatch" };
}
if (connection.revokedAt) {
return { ok: false, reason: "revoked" };
}
if (connection.currentJti !== args.presentedJti) {
// Replay of an already-rotated refresh token — treat as theft and revoke
// the whole connection (reuse-detection family revocation).
await revokeConnectionRow(connection.id);
return { ok: false, reason: "reuse_detected" };
}
// Advance the chain atomically: only the caller presenting the current jti
// wins, so two concurrent refreshes cannot both rotate the same row.
const advanced = await prisma.mcpOAuthConnection.updateMany({
where: {
id: connection.id,
currentJti: args.presentedJti,
revokedAt: null,
},
data: { currentJti: args.newJti, lastUsedAt: new Date() },
});
if (advanced.count === 0) {
return { ok: false, reason: "reuse_detected" };
}
// L4 — revoke the access tokens minted before this rotation so a leaked
// 60-minute access token cannot outlive the refresh that spawned it.
await prisma.apiToken.updateMany({
where: { mcpConnectionId: connection.id, revoked: false },
data: { revoked: true },
});
return { ok: true };
}
/** Internal — stamp `revokedAt` and revoke every linked access token. */
async function revokeConnectionRow(connectionId: string): Promise<void> {
await prisma.mcpOAuthConnection.updateMany({
where: { id: connectionId, revokedAt: null },
data: { revokedAt: new Date() },
});
await prisma.apiToken.updateMany({
where: { mcpConnectionId: connectionId, revoked: false },
data: { revoked: true },
});
}
/**
* Revoke a connection on behalf of its owner (settings "revoke"). Ownership is
* enforced so a connection id alone cannot revoke another user's connection.
* Returns true when a live connection owned by the user was revoked.
*/
export async function revokeConnectionForUser(
userId: string,
connectionId: string,
): Promise<boolean> {
const connection = await prisma.mcpOAuthConnection.findUnique({
where: { id: connectionId },
select: { id: true, userId: true, revokedAt: true },
});
if (!connection || connection.userId !== userId) return false;
await revokeConnectionRow(connection.id);
return true;
}
export interface ConnectionSummary {
id: string;
clientName: string;
scope: string;
createdAt: Date;
lastUsedAt: Date | null;
}
/** The user's live (non-revoked) connector connections for the settings list. */
export async function listConnectionsForUser(
userId: string,
): Promise<ConnectionSummary[]> {
return prisma.mcpOAuthConnection.findMany({
where: { userId, revokedAt: null },
select: {
id: true,
clientName: true,
scope: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
}
+4
View File
@@ -9,6 +9,10 @@ export const integrationKeys = {
// for the remote MCP endpoint). Distinct key from `tokens()` so the MCP
// settings card's mint / revoke mutations invalidate only this list.
mcpTokens: () => ["mcp-tokens"] as const,
// MCP Phase 3 — OAuth connector connections (the revocable unit for a remote
// client authorized through the OAuth bridge). Distinct from `mcpTokens()` so
// a connection revoke invalidates only the connection list.
mcpConnections: () => ["mcp-connections"] as const,
telegram: () => ["telegram"] as const,
telegramSettings: () => ["telegram", "settings"] as const,
withings: () => ["withings"] as const,