mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the adversarial review of the milestone branch. The two ship-blockers made the "read-only, revocable" promise false; the rest tightened the surface. Audience binding at the resource server: a token whose only grant is the new `health:read` scope is the MCP read credential. The REST edge now accepts it on safe (read) methods and refuses it on writes/deletes with 403, so a connector token can read over `/mcp` and GET routes but can never POST/DELETE the record despite a "read-only" consent. The `/mcp` resolver is unaffected. Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a persistent connection row. Revoking a connection stops every future refresh and kills its access tokens; a replayed (already-rotated) refresh is treated as theft and revokes the whole connection. Connections surface in settings as the unit a user can disconnect. The old access token is revoked on rotation. Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is configured and mark the discovery documents non-cacheable; honour the global API kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and registration body reads while streaming instead of after; show the verified client origin on consent; validate CIMD redirect-URI schemes; assert same-origin on the consent POST; and add a daily prune for expired connector tokens. New migration 0195 adds the connection anchor and links access tokens to it.
This commit is contained in:
1 parent
06363771ee
commit
22eb9cba0d
39 files changed
+1826
-72
No files matched your search
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user