feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors

Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.

- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
  Server Metadata at `/.well-known/oauth-protected-resource` and
  `/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
  a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
  mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
  access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
  preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
  resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
  self-describing, and domain-separated; the central log redactors scrub
  them plus the PKCE verifier.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 14:27:09 +02:00
1 parent 03d41bc5c0
commit 7ec3bf0dad
18 files changed
+2186 -3

No files matched your search

@@ -0,0 +1,218 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { GET, POST } from "../authorize/route";
import { getSession } from "@/lib/auth/session";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
const CHALLENGE = s256Challenge(VERIFIER);
const RESOURCE = "https://health.example/mcp";
const CLIENT = registerDcrClient({
clientName: "Claude",
redirectUris: [REDIRECT],
});
function authorizeUrl(
overrides: Record<string, string | undefined> = {},
): string {
const params: Record<string, string | undefined> = {
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
state: "xyz",
resource: RESOURCE,
...overrides,
};
const u = new URL("https://health.example/api/mcp/oauth/authorize");
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) u.searchParams.set(k, v);
}
return u.toString();
}
function getReq(url: string): Request {
return new Request(url, { method: "GET" });
}
function postReq(fields: Record<string, string>): Request {
const body = new URLSearchParams(fields);
return new Request("https://health.example/api/mcp/oauth/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
}
function signedIn() {
vi.mocked(getSession).mockResolvedValue({
session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) },
user: { id: "user-1" } as never,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(null);
});
describe("GET /authorize — consent gate", () => {
it("renders a consent screen for an authenticated user (no code issued)", async () => {
signedIn();
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/Authorize access/);
expect(body).toMatch(/Claude/);
expect(body).toMatch(/decision/); // the Allow/Deny form
// A GET never mints a code.
expect(body).not.toMatch(/hlac_/);
});
it("prompts sign-in (with a return link) when there is no session", async () => {
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/\/auth\/login/);
expect(body).not.toMatch(/hlac_/);
});
});
describe("GET /authorize — rejected requests", () => {
it("rejects an audience mismatch with 400 invalid_target", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ resource: "https://evil.example/mcp" })) as never,
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("invalid_target");
});
it("rejects a missing PKCE challenge with 400", async () => {
signedIn();
const res = await GET(
getReq(
authorizeUrl({
code_challenge: undefined,
code_challenge_method: undefined,
}),
) as never,
);
expect(res.status).toBe(400);
});
it("rejects a non-S256 PKCE method with 400", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ code_challenge_method: "plain" })) as never,
);
expect(res.status).toBe(400);
});
it("rejects an unknown client with 400 invalid_client", async () => {
signedIn();
const res = await GET(
getReq(authorizeUrl({ client_id: "bogus" })) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_client");
});
it("rejects a redirect_uri not registered to the client", async () => {
signedIn();
const res = await GET(
getReq(
authorizeUrl({ redirect_uri: "https://evil.example/cb" }),
) as never,
);
expect(res.status).toBe(400);
});
});
describe("POST /authorize — decision", () => {
it("requires a session (401 when unauthenticated)", async () => {
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(401);
});
it("mints a code and 302s back on allow", async () => {
signedIn();
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",
state: "xyz",
resource: RESOURCE,
decision: "allow",
}) as never,
);
expect(res.status).toBe(302);
const loc = res.headers.get("location") ?? "";
expect(loc.startsWith(REDIRECT)).toBe(true);
const code = new URL(loc).searchParams.get("code");
expect(code).toMatch(/^hlac_/);
expect(new URL(loc).searchParams.get("state")).toBe("xyz");
});
it("302s with access_denied on deny", async () => {
signedIn();
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: "deny",
}) as never,
);
expect(res.status).toBe(302);
const loc = res.headers.get("location") ?? "";
expect(new URL(loc).searchParams.get("error")).toBe("access_denied");
});
});
@@ -0,0 +1,72 @@
/**
* DCR registration (RFC 7591) — stateless `hlc_` client-id issuance.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 19,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { POST } from "../register/route";
function jsonReq(body: unknown): Request {
return new Request("https://health.example/api/mcp/oauth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => vi.clearAllMocks());
describe("POST /register", () => {
it("registers a public client and returns an hlc_ client_id", async () => {
const res = await POST(
jsonReq({
client_name: "ChatGPT",
redirect_uris: [
"https://chatgpt.com/connector_platform_oauth_redirect",
],
}) as never,
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.client_id).toMatch(/^hlc_/);
expect(body.token_endpoint_auth_method).toBe("none");
expect(body.redirect_uris).toEqual([
"https://chatgpt.com/connector_platform_oauth_redirect",
]);
});
it("accepts a loopback redirect (Claude Code)", async () => {
const res = await POST(
jsonReq({ redirect_uris: ["http://127.0.0.1:8976/callback"] }) as never,
);
expect(res.status).toBe(201);
});
it("rejects a missing redirect_uris with invalid_client_metadata", async () => {
const res = await POST(jsonReq({ client_name: "x" }) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_client_metadata");
});
it("rejects a non-https, non-loopback redirect_uri", async () => {
const res = await POST(
jsonReq({ redirect_uris: ["http://evil.example/cb"] }) as never,
);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,204 @@
/**
* Token endpoint — PKCE verification, audience binding, single-use codes,
* refresh-token rotation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(async () => ({
token: "hlk_" + "a".repeat(64),
expiresAt: new Date(Date.now() + 3_600_000),
tokenId: "tok-1",
name: "MCP connector",
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 100,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 0,
resetAt: Date.now() + 60_000,
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { randomUUID } from "node:crypto";
import { POST } from "../token/route";
import { issueApiToken } from "@/lib/auth/issue-token";
import { checkRateLimit } from "@/lib/rate-limit";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
const CLIENT_ID = "https://app.example/client.json";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
const CHALLENGE = s256Challenge(VERIFIER);
const RESOURCE = "https://health.example/mcp";
function authCode(overrides: Record<string, unknown> = {}): string {
return signArtifact(
"authCode",
{
jti: randomUUID(),
sub: "user-1",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
scope: "health:read offline_access",
resource: RESOURCE,
...overrides,
},
120_000,
);
}
function tokenReq(fields: Record<string, string>): Request {
return new Request("https://health.example/api/mcp/oauth/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields).toString(),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 0,
resetAt: Date.now() + 60_000,
});
});
describe("authorization_code grant", () => {
it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
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(200);
const body = await res.json();
expect(body.token_type).toBe("Bearer");
expect(body.access_token).toMatch(/^hlk_/);
expect(body.refresh_token).toMatch(/^hlrt_/);
expect(body.scope).toContain("health:read");
expect(issueApiToken).toHaveBeenCalledWith(
expect.objectContaining({ permissions: ["health:read"] }),
);
});
it("rejects a wrong PKCE verifier with invalid_grant", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: "b".repeat(64),
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(issueApiToken).not.toHaveBeenCalled();
});
it("rejects a code bound to a different audience", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode({ resource: "https://evil.example/mcp" }),
code_verifier: VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_target");
});
it("rejects a code presented by a different client", async () => {
const res = await POST(
tokenReq({
grant_type: "authorization_code",
code: authCode(),
code_verifier: VERIFIER,
client_id: "https://other.example/client.json",
redirect_uri: REDIRECT,
}) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});
it("rejects a replayed code (single-use jti already claimed)", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
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(400);
expect((await res.json()).error).toBe("invalid_grant");
expect(issueApiToken).not.toHaveBeenCalled();
});
});
describe("refresh_token grant", () => {
it("rotates: issues a new access + refresh pair", async () => {
const refresh = signArtifact(
"refreshToken",
{
jti: randomUUID(),
sub: "user-1",
client_id: CLIENT_ID,
scope: "health:read offline_access",
resource: RESOURCE,
},
1_000_000,
);
const res = await POST(
tokenReq({
grant_type: "refresh_token",
refresh_token: refresh,
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_/);
});
});
describe("grant validation", () => {
it("rejects an unsupported grant_type", async () => {
const res = await POST(tokenReq({ grant_type: "password" }) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("unsupported_grant_type");
});
});
+363
View File
@@ -0,0 +1,363 @@
/**
* Authorization endpoint (OAuth 2.1 authorization-code flow + PKCE S256).
*
* This is the consent gate of the bridge AS. It is backed by HealthLog's
* existing Postgres session (passkey / password login) — there is NO parallel
* user store (ADR-006). The flow:
*
* GET — validate the request; if the caller has no session, prompt them to
* sign in (returning here); if they do, render a per-client CONSENT
* screen naming the client + the exact scope. NO code is ever issued on
* a GET (confused-deputy mitigation: a third party cannot silently
* obtain a grant — an authenticated human must click "Allow").
* POST — the consent decision. Requires a session, re-validates every
* parameter, and on "Allow" mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 302s back.
*
* Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is
* rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI —
* RFC 8707), and an exact redirect-URI match against the resolved client (with
* port-agnostic loopback tolerance). Errors are returned directly (never
* redirected to an unvalidated URI) so the endpoint is not an open redirector.
*/
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
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 {
audienceMatches,
AUTH_CODE_TTL_SECONDS,
SCOPE_HEALTH_READ,
} from "@/lib/mcp/oauth/config";
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const AUTHORIZE_LIMIT = 60;
const AUTHORIZE_WINDOW_MS = 15 * 60 * 1000;
const baseParams = {
response_type: z.string(),
client_id: z.string().min(1).max(4096),
redirect_uri: z.string().min(1).max(2048),
code_challenge: z.string().min(1).max(256).optional(),
code_challenge_method: z.string().optional(),
scope: z.string().max(256).optional(),
state: z.string().max(2048).optional(),
resource: z.string().max(2048).optional(),
};
function htmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function badRequest(error: string, description: string): Response {
return Response.json(
{ error, error_description: description },
{ status: 400 },
);
}
function html(body: string, status = 200): Response {
return new Response(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
{
status,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
}
/** The single scope this read surface grants, plus optional refresh continuity. */
function grantScope(requested: string | undefined): string {
const wants = new Set(
(requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean),
);
const granted = [SCOPE_HEALTH_READ];
if (wants.has("offline_access")) granted.push("offline_access");
return granted.join(" ");
}
interface ValidatedRequest {
clientId: string;
clientName: string;
redirectUri: string;
codeChallenge: string;
scope: string;
state?: string;
resource: string;
}
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
async function validate(
params: Record<string, string | undefined>,
requestUrl: string,
): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
const parsed = z.object(baseParams).safeParse(params);
if (!parsed.success) {
return {
ok: false,
res: badRequest("invalid_request", "Missing or malformed parameters"),
};
}
const p = parsed.data;
// 1. Client must resolve (CIMD URL or stateless DCR id). Unknown → cannot
// safely redirect anywhere, so error directly.
const resolved = await resolveClient(p.client_id);
if (!resolved.ok) {
return {
ok: false,
res: badRequest("invalid_client", "Unknown or unresolvable client"),
};
}
// 2. Redirect URI MUST match the registration before we trust it.
if (!redirectUriAllowed(p.redirect_uri, resolved.client.redirectUris)) {
return {
ok: false,
res: badRequest(
"invalid_request",
"redirect_uri does not match the registered client",
),
};
}
// 3. response_type — code only.
if (p.response_type !== "code") {
return {
ok: false,
res: badRequest(
"unsupported_response_type",
"Only response_type=code is supported",
),
};
}
// 4. PKCE S256 is MANDATORY.
if (
p.code_challenge_method !== "S256" ||
!isValidChallenge(p.code_challenge)
) {
return {
ok: false,
res: badRequest(
"invalid_request",
"PKCE with code_challenge_method=S256 is required",
),
};
}
// 5. Audience binding (RFC 8707) — resource MUST equal the canonical /mcp URI.
if (!audienceMatches(p.resource, requestUrl)) {
return {
ok: false,
res: badRequest(
"invalid_target",
"resource must equal this server's MCP endpoint",
),
};
}
return {
ok: true,
v: {
clientId: p.client_id,
clientName: resolved.client.clientName,
redirectUri: p.redirect_uri,
codeChallenge: p.code_challenge as string,
scope: grantScope(p.scope),
state: p.state,
resource: p.resource as string,
},
};
}
function redirectBack(
redirectUri: string,
query: Record<string, string>,
): Response {
const url = new URL(redirectUri);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
return Response.redirect(url.toString(), 302);
}
export async function GET(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
AUTHORIZE_LIMIT,
AUTHORIZE_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
return result.res;
}
const { v } = result;
const session = await getSession();
if (!session) {
// No identity yet — send the human to sign in, returning to this exact
// request. NO grant is issued without an authenticated decision.
const next = url.pathname + url.search;
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
annotate({ action: { name: "mcp.oauth.authorize.login_required" } });
return html(
`<main><h1>Sign in to HealthLog</h1><p>To connect <strong>${htmlEscape(v.clientName)}</strong> you must sign in first.</p><p><a href="${htmlEscape(loginHref)}">Sign in and continue</a></p></main>`,
);
}
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
// 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)}">`;
return html(
`<main>
<h1>Authorize access</h1>
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
<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")}
${hidden("client_id", v.clientId)}
${hidden("redirect_uri", v.redirectUri)}
${hidden("code_challenge", v.codeChallenge)}
${hidden("code_challenge_method", "S256")}
${hidden("scope", v.scope)}
${hidden("resource", v.resource)}
${v.state !== undefined ? hidden("state", v.state) : ""}
<button type="submit" name="decision" value="allow">Allow</button>
<button type="submit" name="decision" value="deny">Deny</button>
</form>
</main>`,
);
});
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:authorize",
AUTHORIZE_LIMIT,
AUTHORIZE_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
// The consent decision MUST come from an authenticated human.
const session = await getSession();
if (!session) {
annotate({
action: { name: "mcp.oauth.authorize.decision_unauthenticated" },
});
return Response.json(
{ error: "access_denied", error_description: "Sign in required" },
{ status: 401 },
);
}
const form = await request.formData();
const params: Record<string, string | undefined> = {};
for (const key of [
"response_type",
"client_id",
"redirect_uri",
"code_challenge",
"code_challenge_method",
"scope",
"state",
"resource",
"decision",
]) {
const value = form.get(key);
params[key] = typeof value === "string" ? value : undefined;
}
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
return result.res;
}
const { v } = result;
if (params.decision !== "allow") {
annotate({ action: { name: "mcp.oauth.authorize.denied" } });
return redirectBack(v.redirectUri, {
error: "access_denied",
...(v.state !== undefined ? { state: v.state } : {}),
});
}
const code = signArtifact(
"authCode",
{
// `jti` makes the code single-use: the token endpoint claims it once
// through the atomic rate-limit upsert, so a replay within the TTL is
// rejected.
jti: randomUUID(),
sub: session.user.id,
client_id: v.clientId,
redirect_uri: v.redirectUri,
code_challenge: v.codeChallenge,
scope: v.scope,
resource: v.resource,
},
AUTH_CODE_TTL_SECONDS * 1000,
);
await auditLog("mcp.oauth.authorize.granted", {
userId: session.user.id,
details: { client_id: v.clientId, scope: v.scope },
});
annotate({ action: { name: "mcp.oauth.authorize.granted" } });
return redirectBack(v.redirectUri, {
code,
...(v.state !== undefined ? { state: v.state } : {}),
});
});
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Dynamic Client Registration (RFC 7591) — the DCR fallback for clients that
* do not present a CIMD `client_id` URL.
*
* Registration is STATELESS: the issued `client_id` is a signed, self-describing
* `hlc_` artifact carrying the registered `redirect_uris` + name (see
* `clients.ts` / `artifacts.ts`), so no client row is written. The endpoint only
* validates the metadata, enforces a per-IP rate limit (it is unauthenticated),
* and returns the RFC 7591 client-information response for a PUBLIC client
* (`token_endpoint_auth_method: "none"` — PKCE is the proof, there is no secret).
*
* Redirect URIs are shape-validated here; the AUTHORITATIVE redirect-URI match
* happens at `/authorize` against the decoded registration.
*/
import { NextRequest } from "next/server";
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";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
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))
.min(1)
.max(12)
.refine((uris) => uris.every(isAllowableRedirectUri), {
message: "redirect_uris must be HTTPS or http loopback URLs",
}),
client_name: z.string().min(1).max(200).optional(),
// Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them).
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
token_endpoint_auth_method: z.string().optional(),
scope: z.string().optional(),
});
function oauthError(
error: string,
description: string,
status: number,
extraHeaders: Record<string, string> = {},
): Response {
return Response.json(
{ error, error_description: description },
{ status, headers: extraHeaders },
);
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.register", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:register",
REGISTER_LIMIT,
REGISTER_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "mcp.oauth.register.rate_limited" } });
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
return oauthError("invalid_client_metadata", "Payload too large", 400);
}
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
return oauthError("invalid_client_metadata", "Invalid JSON", 400);
}
const parsed = registrationSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "mcp.oauth.register.invalid" } });
return oauthError(
"invalid_client_metadata",
"redirect_uris is required and must contain HTTPS or loopback URLs",
400,
);
}
const client = registerDcrClient({
clientName: parsed.data.client_name ?? "MCP client",
redirectUris: parsed.data.redirect_uris,
});
annotate({
action: { name: "mcp.oauth.register.issued" },
meta: { source: "dcr", redirect_uri_count: client.redirectUris.length },
});
// RFC 7591 client-information response for a public client.
return Response.json(
{
client_id: client.clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: client.redirectUris,
client_name: client.clientName,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
},
{ status: 201 },
);
});
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants.
*
* Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_`
* `ApiToken` (the access token) minted through `issue-token.ts` with the narrow
* `health:read` scope (ADR-006). The access token is therefore an ordinary
* Bearer the `/mcp` resolver already understands — bound to `<userId>:<tokenId>`
* (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the
* MCP layer never forwards a client-presented upstream credential.
*
* Gates:
* - PKCE — the presented `code_verifier` MUST hash (S256) to the
* `code_challenge` bound into the authorization code.
* - Single use — the code's `jti` is claimed once through the atomic
* rate-limit upsert; a replay within the TTL is rejected.
* - Audience — the code's bound `resource` MUST equal the canonical `/mcp`
* URI (RFC 8707); a `resource` parameter, when sent, must match too.
* - Client binding — the `client_id` presented at `/token` MUST equal the one
* the code was issued to.
*
* Refresh tokens rotate on every use (OAuth 2.1 public-client rotation): a fresh
* access + refresh pair is returned and the presented refresh `jti` is claimed
* so it cannot be reused.
*/
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
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 {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
ACCESS_TOKEN_TTL_MINUTES,
audienceMatches,
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";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TOKEN_LIMIT = 120;
const TOKEN_WINDOW_MS = 15 * 60 * 1000;
function oauthError(
error: string,
description: string,
status = 400,
): Response {
return Response.json(
{ error, error_description: description },
{ status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } },
);
}
function tokenSuccess(body: Record<string, unknown>): Response {
return Response.json(body, {
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
});
}
/** Claim a one-time-use jti through the atomic limiter (limit 1 ⇒ single use). */
async function claimJti(
kind: string,
jti: string,
ttlMs: number,
): Promise<boolean> {
if (typeof jti !== "string" || jti.length === 0) return false;
const rl = await checkRateLimit(`oauth:jti:${kind}:${jti}`, 1, ttlMs);
return rl.allowed;
}
interface AuthCodeClaims {
jti: string;
sub: string;
client_id: string;
redirect_uri: string;
code_challenge: string;
scope: string;
resource: string;
}
interface RefreshClaims {
jti: string;
sub: string;
client_id: string;
scope: string;
resource: string;
}
async function mintTokenPair(args: {
userId: string;
clientId: string;
scope: string;
resource: string;
grant: "authorization_code" | "refresh_token";
}): Promise<Response> {
const access = await issueApiToken({
userId: args.userId,
name: "MCP connector",
permissions: [SCOPE_HEALTH_READ],
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
});
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;
await auditLog("mcp.oauth.token.issued", {
userId: args.userId,
details: { client_id: args.clientId, grant: args.grant, scope: args.scope },
});
annotate({
action: { name: "mcp.oauth.token.issued" },
meta: { grant: args.grant },
});
return tokenSuccess({
access_token: access.token,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL_MINUTES * 60,
scope: args.scope,
...(refresh ? { refresh_token: refresh } : {}),
});
}
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.token", async () => {
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:token",
TOKEN_LIMIT,
TOKEN_WINDOW_MS,
);
if (!rl.allowed) {
return Response.json(
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
},
);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return oauthError(
"invalid_request",
"Expected application/x-www-form-urlencoded body",
);
}
const get = (k: string): string | undefined => {
const v = form.get(k);
return typeof v === "string" ? v : undefined;
};
const grantType = get("grant_type");
if (grantType === "authorization_code") {
const code = get("code");
const verifier = get("code_verifier");
const clientId = get("client_id");
const redirectUri = get("redirect_uri");
if (!code || !verifier || !clientId || !redirectUri) {
return oauthError(
"invalid_request",
"Missing code, code_verifier, client_id, or redirect_uri",
);
}
const verified = verifyArtifact<AuthCodeClaims>("authCode", code);
if (!verified.ok) {
annotate({ action: { name: "mcp.oauth.token.code_rejected" } });
return oauthError(
"invalid_grant",
"Authorization code is invalid or expired",
);
}
const claims = verified.claims;
// Client + redirect binding.
if (
claims.client_id !== clientId ||
claims.redirect_uri !== redirectUri
) {
return oauthError(
"invalid_grant",
"Authorization code was issued to a different client/redirect",
);
}
// Audience: a resource parameter, if present, must also match.
const resourceParam = get("resource");
if (resourceParam && !audienceMatches(resourceParam, request.url)) {
return oauthError(
"invalid_target",
"resource does not match the bound audience",
);
}
if (!audienceMatches(claims.resource, request.url)) {
return oauthError(
"invalid_target",
"Authorization code is bound to a different audience",
);
}
// PKCE.
if (!verifyPkceS256(verifier, claims.code_challenge)) {
annotate({ action: { name: "mcp.oauth.token.pkce_failed" } });
return oauthError("invalid_grant", "PKCE verification failed");
}
// Single use — claim the code's jti.
if (!(await claimJti("code", claims.jti, 5 * 60 * 1000))) {
annotate({ action: { name: "mcp.oauth.token.code_replayed" } });
return oauthError(
"invalid_grant",
"Authorization code has already been used",
);
}
return mintTokenPair({
userId: claims.sub,
clientId: claims.client_id,
scope: claims.scope,
resource: claims.resource,
grant: "authorization_code",
});
}
if (grantType === "refresh_token") {
const refreshToken = get("refresh_token");
const clientId = get("client_id");
if (!refreshToken || !clientId) {
return oauthError(
"invalid_request",
"Missing refresh_token or client_id",
);
}
const verified = verifyArtifact<RefreshClaims>(
"refreshToken",
refreshToken,
);
if (!verified.ok) {
return oauthError(
"invalid_grant",
"Refresh token is invalid or expired",
);
}
const claims = verified.claims;
if (claims.client_id !== clientId) {
return oauthError(
"invalid_grant",
"Refresh token was issued to a different client",
);
}
if (!audienceMatches(claims.resource, request.url)) {
return oauthError(
"invalid_target",
"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" } });
return oauthError(
"invalid_grant",
"Refresh token has already been used",
);
}
return mintTokenPair({
userId: claims.sub,
clientId: claims.client_id,
scope: claims.scope,
resource: claims.resource,
grant: "refresh_token",
});
}
return oauthError(
"unsupported_grant_type",
"Only authorization_code and refresh_token are supported",
);
});
}