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

+41
View File
@@ -0,0 +1,41 @@
/**
* Revoke an MCP OAuth connection (H2).
*
* Revoking a connection terminates its whole refresh chain: it stamps
* `revokedAt` (so every future refresh fails) and revokes every access token
* the connection ever issued. Ownership is enforced inside
* `revokeConnectionForUser`, so a connection id alone cannot revoke another
* user's connection.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { revokeConnectionForUser } from "@/lib/mcp/oauth/connections";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
annotate({ action: { name: "mcp.connections.revoke" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const revoked = await revokeConnectionForUser(user.id, id);
if (!revoked) {
return apiError("Connection not found", 404);
}
await auditLog("mcp.connections.revoke", {
userId: user.id,
details: { connectionId: id },
});
return apiSuccess({ revoked: true });
},
);
+26
View File
@@ -0,0 +1,26 @@
/**
* MCP OAuth connections — list (H2).
*
* A connection is the revocable unit for a remote connector authorized through
* the OAuth bridge (Claude.ai / ChatGPT / a third-party client). Unlike the
* transient 60-minute access-token rows, the connection persists across
* refreshes, so revoking it terminates the whole refresh chain. `userId` is
* always narrowed from the session — never a body field.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { listConnectionsForUser } from "@/lib/mcp/oauth/connections";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "mcp.connections.list" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const connections = await listConnectionsForUser(user.id);
return apiSuccess(connections);
});
@@ -21,9 +21,13 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
import { GET, POST } from "../authorize/route";
import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
@@ -81,6 +85,7 @@ function signedIn() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(null);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("GET /authorize — consent gate", () => {
@@ -157,6 +162,92 @@ describe("GET /authorize — rejected requests", () => {
});
});
describe("GET /authorize — consent provenance (L1)", () => {
it("labels a DCR client as unverified (self-asserted name)", async () => {
signedIn();
const res = await GET(getReq(authorizeUrl()) as never);
const body = await res.text();
expect(body).toMatch(/Unverified application/);
});
});
describe("authorize — surface availability (M1/M4)", () => {
it("GET returns 503 when the API is globally disabled", async () => {
signedIn();
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(503);
});
it("POST returns 503 when the API is globally disabled", async () => {
signedIn();
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(
postReq({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "allow",
}) as never,
);
expect(res.status).toBe(503);
});
});
describe("POST /authorize — cross-origin guard (L3)", () => {
it("rejects a cross-origin Origin with 403", async () => {
signedIn();
const body = new URLSearchParams({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "allow",
});
const req = new Request("https://health.example/api/mcp/oauth/authorize", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
origin: "https://evil.example",
},
body: body.toString(),
});
const res = await POST(req as never);
expect(res.status).toBe(403);
});
it("rejects a cross-site Sec-Fetch-Site with 403", async () => {
signedIn();
const body = new URLSearchParams({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "allow",
});
const req = new Request("https://health.example/api/mcp/oauth/authorize", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"sec-fetch-site": "cross-site",
},
body: body.toString(),
});
const res = await POST(req as never);
expect(res.status).toBe(403);
});
});
describe("POST /authorize — decision", () => {
it("requires a session (401 when unauthenticated)", async () => {
const res = await POST(
@@ -18,8 +18,12 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
import { POST } from "../register/route";
import { isApiGloballyEnabled } from "@/lib/app-settings";
function jsonReq(body: unknown): Request {
return new Request("https://health.example/api/mcp/oauth/register", {
@@ -29,7 +33,10 @@ function jsonReq(body: unknown): Request {
});
}
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("POST /register", () => {
it("registers a public client and returns an hlc_ client_id", async () => {
@@ -69,4 +76,13 @@ describe("POST /register", () => {
);
expect(res.status).toBe(400);
});
it("returns 503 when the API is globally disabled (M4)", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(
jsonReq({ redirect_uris: ["https://chatgpt.com/cb"] }) as never,
);
expect(res.status).toBe(503);
expect((await res.json()).error).toBe("temporarily_unavailable");
});
});
+124 -7
View File
@@ -34,11 +34,23 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/mcp/oauth/connections", () => ({
createConnection: vi.fn(async () => "conn-1"),
rotateConnection: vi.fn(async () => ({ ok: true })),
}));
import { randomUUID } from "node:crypto";
import { POST } from "../token/route";
import { issueApiToken } from "@/lib/auth/issue-token";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
createConnection,
rotateConnection,
} from "@/lib/mcp/oauth/connections";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
@@ -80,8 +92,27 @@ beforeEach(() => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(createConnection).mockResolvedValue("conn-1");
vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
});
function refreshToken(overrides: Record<string, unknown> = {}): string {
return signArtifact(
"refreshToken",
{
jti: randomUUID(),
cid: "conn-1",
sub: "user-1",
client_id: CLIENT_ID,
scope: "health:read offline_access",
resource: RESOURCE,
...overrides,
},
1_000_000,
);
}
describe("authorization_code grant", () => {
it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
const res = await POST(
@@ -104,6 +135,22 @@ describe("authorization_code grant", () => {
);
});
it("creates a revocable connection anchor and links the access token (H2)", async () => {
await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(createConnection).toHaveBeenCalledTimes(1);
expect(issueApiToken).toHaveBeenCalledWith(
expect.objectContaining({ mcpConnectionId: "conn-1" }),
);
});
it("rejects a wrong PKCE verifier with invalid_grant", async () => {
const res = await POST(
tokenReq({
@@ -169,8 +216,61 @@ describe("authorization_code grant", () => {
});
describe("refresh_token grant", () => {
it("rotates: issues a new access + refresh pair", async () => {
const refresh = signArtifact(
it("rotates: issues a new access + refresh pair against a live connection", async () => {
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refreshToken(),
client_id: CLIENT_ID,
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toMatch(/^hlk_/);
expect(body.refresh_token).toMatch(/^hlrt_/);
expect(rotateConnection).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }),
);
});
it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => {
vi.mocked(rotateConnection).mockResolvedValue({
ok: false,
reason: "revoked",
});
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refreshToken(),
client_id: CLIENT_ID,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(issueApiToken).not.toHaveBeenCalled();
});
it("rejects a replayed refresh and reports reuse detection (H2)", async () => {
vi.mocked(rotateConnection).mockResolvedValue({
ok: false,
reason: "reuse_detected",
});
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refreshToken(),
client_id: CLIENT_ID,
}) as never,
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("invalid_grant");
expect(body.error_description).toMatch(/reuse/i);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("rejects a legacy refresh token with no connection binding", async () => {
const legacy = signArtifact(
"refreshToken",
{
jti: randomUUID(),
@@ -184,14 +284,31 @@ describe("refresh_token grant", () => {
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refresh,
refresh_token: legacy,
client_id: CLIENT_ID,
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toMatch(/^hlk_/);
expect(body.refresh_token).toMatch(/^hlrt_/);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(rotateConnection).not.toHaveBeenCalled();
});
});
describe("operator kill-switch + origin config (M1/M4)", () => {
it("refuses to mint when the API is globally disabled", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(503);
expect((await res.json()).error).toBe("temporarily_unavailable");
expect(issueApiToken).not.toHaveBeenCalled();
});
});
+78
View File
@@ -31,9 +31,12 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
audienceMatches,
AUTH_CODE_TTL_SECONDS,
isMcpOriginConfigured,
resolveBaseOrigin,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
@@ -96,6 +99,10 @@ function grantScope(requested: string | undefined): string {
interface ValidatedRequest {
clientId: string;
clientName: string;
/** The VERIFIED client origin shown on the consent screen (L1). */
clientOrigin: string;
/** Registration provenance — drives the trust label on consent (L1). */
clientSource: "cimd" | "dcr";
redirectUri: string;
codeChallenge: string;
scope: string;
@@ -174,11 +181,28 @@ async function validate(
};
}
// L1 — the VERIFIED origin. For CIMD the `client_id` IS the document URL, so
// its host is cryptographically tied to the fetched-and-matched document; for
// DCR there is no verified origin (anyone can self-register), so the consent
// screen labels it unverified rather than trusting the self-asserted name.
const clientOrigin =
resolved.client.source === "cimd"
? (() => {
try {
return new URL(p.client_id).host;
} catch {
return p.client_id;
}
})()
: "";
return {
ok: true,
v: {
clientId: p.client_id,
clientName: resolved.client.clientName,
clientOrigin,
clientSource: resolved.client.source,
redirectUri: p.redirect_uri,
codeChallenge: p.code_challenge as string,
scope: grantScope(p.scope),
@@ -188,6 +212,20 @@ async function validate(
};
}
/**
* L3 — same-origin assertion for the consent POST. A non-browser caller sends
* neither `Origin` nor `Sec-Fetch-Site`, so their absence is allowed; a present
* `Origin` that is not our own origin, or a `Sec-Fetch-Site` that is not
* `same-origin`, is refused. Mirrors the `/mcp` DNS-rebinding posture.
*/
function consentOriginAllowed(request: NextRequest): boolean {
const origin = request.headers.get("origin");
if (origin && origin !== resolveBaseOrigin(request.url)) return false;
const site = request.headers.get("sec-fetch-site");
if (site && site !== "same-origin") return false;
return true;
}
function redirectBack(
redirectUri: string,
query: Record<string, string>,
@@ -197,8 +235,19 @@ function redirectBack(
return Response.redirect(url.toString(), 302);
}
/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */
async function surfaceAvailable(): Promise<boolean> {
return isMcpOriginConfigured() && (await isApiGloballyEnabled());
}
export async function GET(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
if (!(await surfaceAvailable())) {
return Response.json(
{ error: "temporarily_unavailable" },
{ status: 503 },
);
}
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
@@ -244,10 +293,17 @@ export async function GET(request: NextRequest): Promise<Response> {
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
`<input type="hidden" name="${name}" value="${htmlEscape(value)}">`;
// L1 — show the VERIFIED origin (or an explicit "unverified" label for DCR)
// next to the self-asserted name so the user authorizes who, not just what.
const provenance =
v.clientSource === "cimd"
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
return html(
`<main>
<h1>Authorize access</h1>
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
${provenance}
<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>
<form method="POST" action="/api/mcp/oauth/authorize">
${hidden("response_type", "code")}
@@ -268,6 +324,25 @@ export async function GET(request: NextRequest): Promise<Response> {
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
if (!(await surfaceAvailable())) {
return Response.json(
{ error: "temporarily_unavailable" },
{ status: 503 },
);
}
// L3 — defence-in-depth same-origin assertion on the consent decision. The
// session cookie is already SameSite=Lax (a cross-site POST carries none),
// but reject an explicit cross-origin Origin / Sec-Fetch-Site outright so a
// forged "allow" cannot ride a present session.
if (!consentOriginAllowed(request)) {
annotate({ action: { name: "mcp.oauth.authorize.cross_origin" } });
return Response.json(
{ error: "access_denied", error_description: "Cross-origin POST" },
{ status: 403 },
);
}
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
@@ -341,6 +416,9 @@ export async function POST(request: NextRequest): Promise<Response> {
jti: randomUUID(),
sub: session.user.id,
client_id: v.clientId,
// Carried so the token endpoint can label the persistent connection
// (H2) without re-resolving the client.
client_name: v.clientName,
redirect_uri: v.redirectUri,
code_challenge: v.codeChallenge,
scope: v.scope,
+16 -22
View File
@@ -18,7 +18,12 @@ import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { readBodyCapped } from "@/lib/http/read-capped";
import {
isAllowableRedirectUri,
registerDcrClient,
} from "@/lib/mcp/oauth/clients";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -27,24 +32,6 @@ const REGISTER_LIMIT = 20;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
const MAX_BODY_BYTES = 16 * 1024;
/** A redirect URI must be an absolute HTTPS URL or an http loopback URL. */
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:" &&
["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
) {
return true;
}
return false;
}
const registrationSchema = z.object({
redirect_uris: z
.array(z.string().min(1).max(2048))
@@ -96,13 +83,20 @@ export async function POST(request: NextRequest): Promise<Response> {
);
}
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
// M4 — the operator API kill-switch covers the OAuth surface too.
if (!(await isApiGloballyEnabled())) {
return oauthError("temporarily_unavailable", "API is disabled", 503);
}
// M3 — enforce the cap WHILE reading the (unauthenticated) registration
// body so a hostile client cannot buffer an oversized payload first.
const read = await readBodyCapped(request, MAX_BODY_BYTES);
if (!read.ok) {
return oauthError("invalid_client_metadata", "Payload too large", 400);
}
let body: unknown;
try {
body = JSON.parse(raw);
body = JSON.parse(read.text);
} catch {
return oauthError("invalid_client_metadata", "Invalid JSON", 400);
}
+104 -24
View File
@@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
@@ -38,11 +39,16 @@ import {
import {
ACCESS_TOKEN_TTL_MINUTES,
audienceMatches,
isMcpOriginConfigured,
REFRESH_TOKEN_TTL_DAYS,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
import {
createConnection,
rotateConnection,
} from "@/lib/mcp/oauth/connections";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -82,6 +88,7 @@ interface AuthCodeClaims {
jti: string;
sub: string;
client_id: string;
client_name?: string;
redirect_uri: string;
code_challenge: string;
scope: string;
@@ -90,40 +97,56 @@ interface AuthCodeClaims {
interface RefreshClaims {
jti: string;
/** Connection id — the persistent, revocable anchor for the chain (H2). */
cid?: string;
sub: string;
client_id: string;
scope: string;
resource: string;
}
/**
* Mint the access token (+ refresh artifact when `offline_access` is granted).
*
* `connectionId` is the persistent anchor for the refresh chain (H2): the access
* token is linked to it (so a connection revoke kills it) and the refresh
* artifact carries it (`cid`). `refreshJti` is the connection's new
* `currentJti` — for the `authorization_code` grant the caller has just created
* the connection seeded with it; for `refresh_token` the caller has just
* advanced the connection to it.
*/
async function mintTokenPair(args: {
userId: string;
clientId: string;
scope: string;
resource: string;
grant: "authorization_code" | "refresh_token";
connectionId?: string;
refreshJti?: string;
}): Promise<Response> {
const access = await issueApiToken({
userId: args.userId,
name: "MCP connector",
permissions: [SCOPE_HEALTH_READ],
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}),
});
const includeRefresh = args.scope.split(/\s+/).includes("offline_access");
const refresh = includeRefresh
? signArtifact(
"refreshToken",
{
jti: randomUUID(),
sub: args.userId,
client_id: args.clientId,
scope: args.scope,
resource: args.resource,
},
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
)
: undefined;
const refresh =
args.connectionId && args.refreshJti
? signArtifact(
"refreshToken",
{
jti: args.refreshJti,
cid: args.connectionId,
sub: args.userId,
client_id: args.clientId,
scope: args.scope,
resource: args.resource,
},
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
)
: undefined;
await auditLog("mcp.oauth.token.issued", {
userId: args.userId,
@@ -145,6 +168,16 @@ async function mintTokenPair(args: {
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.token", async () => {
// M1 — fail closed without a pinned origin; M4 — honour the operator's
// global API kill-switch. Either off and the bridge mints no tokens.
if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) {
return oauthError(
"temporarily_unavailable",
"The MCP OAuth surface is unavailable",
503,
);
}
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:token",
@@ -242,12 +275,34 @@ export async function POST(request: NextRequest): Promise<Response> {
);
}
// H2 — when offline_access is granted, create the persistent, revocable
// connection anchor seeded with the first refresh jti. Without it the
// refresh chain would be stateless and un-revocable.
const offlineGranted = claims.scope
.split(/\s+/)
.includes("offline_access");
let connectionId: string | undefined;
let refreshJti: string | undefined;
if (offlineGranted) {
refreshJti = randomUUID();
connectionId = await createConnection({
userId: claims.sub,
clientId: claims.client_id,
clientName: claims.client_name ?? "MCP client",
scope: claims.scope,
resource: claims.resource,
jti: refreshJti,
});
}
return mintTokenPair({
userId: claims.sub,
clientId: claims.client_id,
scope: claims.scope,
resource: claims.resource,
grant: "authorization_code",
connectionId,
refreshJti,
});
}
@@ -284,18 +339,41 @@ export async function POST(request: NextRequest): Promise<Response> {
"Refresh token is bound to a different audience",
);
}
// Rotation: claim the presented refresh jti so it cannot be reused.
if (
!(await claimJti(
"refresh",
claims.jti,
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
))
) {
annotate({ action: { name: "mcp.oauth.token.refresh_replayed" } });
// H2 — rotation against the persistent connection anchor. The connection's
// `currentJti` is the single valid refresh jti; presenting any other (an
// already-rotated artifact) is a replay that revokes the whole connection
// (reuse-detection family revocation). A revoked connection always fails,
// so the settings "revoke" terminates the chain.
if (!claims.cid) {
return oauthError(
"invalid_grant",
"Refresh token has already been used",
"Refresh token is not bound to a connection",
);
}
const newJti = randomUUID();
const rotation = await rotateConnection({
connectionId: claims.cid,
presentedJti: claims.jti,
newJti,
clientId: claims.client_id,
userId: claims.sub,
});
if (!rotation.ok) {
annotate({
action: {
name:
rotation.reason === "reuse_detected"
? "mcp.oauth.token.refresh_reuse_detected"
: "mcp.oauth.token.refresh_rejected",
},
meta: { reason: rotation.reason },
});
return oauthError(
"invalid_grant",
rotation.reason === "reuse_detected"
? "Refresh token reuse detected; the connection was revoked"
: "Refresh token is invalid, revoked, or already used",
);
}
@@ -305,6 +383,8 @@ export async function POST(request: NextRequest): Promise<Response> {
scope: claims.scope,
resource: claims.resource,
grant: "refresh_token",
connectionId: claims.cid,
refreshJti: newJti,
});
}
+8 -1
View File
@@ -42,8 +42,15 @@ export const GET = apiHandler(async () => {
return apiError("API is globally disabled", 403);
}
// M2 — list only manually-minted connector tokens. OAuth access rows
// (`mcpConnectionId != null`) are transient 60-minute credentials that would
// flood this list; they are surfaced (and revoked) as connections instead.
const tokens = await prisma.apiToken.findMany({
where: { userId: user.id, permissions: { has: SCOPE_HEALTH_READ } },
where: {
userId: user.id,
permissions: { has: SCOPE_HEALTH_READ },
mcpConnectionId: null,
},
select: {
id: true,
name: true,