mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user