Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-key

# Server-only HMAC key for Remote MCP OAuth endpoint rate-limit buckets.
# Use at least 32 random bytes and never expose it through NEXT_PUBLIC_*.
MCP_OAUTH_RATE_LIMIT_SECRET=replace-with-at-least-32-random-bytes

# Independent server-only HMAC key for deidentifying OAuth security events.
# Use at least 32 random bytes. Never reuse or expose this through NEXT_PUBLIC_*.
MCP_OAUTH_AUDIT_HMAC_SECRET=replace-with-a-different-32-byte-random-value

# Conversational agent rollout. The editor and agent server flags must both be
# enabled, and changing NEXT_PUBLIC_* values requires rebuilding the editor.
NEXT_PUBLIC_SLIDEX_AGENT_ENABLED=false
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,38 @@ Example MCP client configuration:
Remote MCP endpoint:

```text
https://slidexdeck.com/mcp/
https://slidexdeck.com/mcp
```

Remote MCP OAuth requires separate server-only `MCP_OAUTH_RATE_LIMIT_SECRET` and
`MCP_OAUTH_AUDIT_HMAC_SECRET` values with at least 32 random bytes each. Apply
pending Supabase migrations before deploying code that uses atomic token
families, one-time consent requests, and deidentified security events. OAuth
registration, consent, and token endpoints are rate limited; the normal `/mcp`
transport is not. Successful authenticated MCP responses expose only aggregate
`auth`, `store`, `handler`, and `total` durations through `Server-Timing`.

## Validation

```bash
npm run lint
npm run build
```

After the OAuth migration is applied to the target environment, run the real
Next HTTP bearer chain with:

```bash
REMOTE_MCP_SMOKE_BASE_URL=https://slidexdeck.com npm run mcp:test:remote
```

Without `REMOTE_MCP_SMOKE_BASE_URL`, the smoke test keeps its store-level and
in-memory MCP coverage and reports the HTTP bearer chain as skipped.

The live token-endpoint burst test is opt-in because it deliberately exhausts
the shared rate-limit bucket. Run it only in an isolated validation window:

```bash
REMOTE_MCP_SMOKE_BASE_URL=https://slidexdeck.com \
REMOTE_MCP_SMOKE_VERIFY_RATE_LIMIT=1 npm run mcp:test:remote
```
167 changes: 152 additions & 15 deletions app/api/mcp/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,116 @@ import { createSupabaseServerClient } from "@/common/lib/supabase/serverClient";
import { resolveRequestOrigin } from "@/common/lib/siteUrl";
import {
appendOAuthRedirectParams,
isExactMcpRedirectUri,
mcpAuthorizationRequestSchema,
normalizeMcpScopes
} from "@/mcp/oauth";
import { mcpResourceUrl } from "@/mcp/oauthMetadata";
import {
applyOAuthSecurityHeaders,
hashMcpAuthorizationRequest,
isSameOriginMcpConsentPost,
mcpOAuthRateLimitFailure,
recordMcpOAuthSecurityEvent
} from "@/mcp/oauthSecurity";
import { consumeMcpOAuthRateLimit } from "@/mcp/oauthRateLimit";
import { SupabaseMcpOAuthStore } from "@/mcp/supabaseOAuthStore";

export async function POST(request: NextRequest) {
const form = await request.formData();
const form = await request.formData().catch(() => null);
if (!form) {
logInvalidAuthorizationRequest(request, "form_unreadable");
return oauthJsonError("invalid_request", 400);
}
const input = Object.fromEntries(
[...form.entries()].filter((entry): entry is [string, string] => typeof entry[1] === "string")
);
const parsed = mcpAuthorizationRequestSchema.safeParse(input);
if (!parsed.success) return NextResponse.json({ error: "invalid_request" }, { status: 400 });
if (!parsed.success) {
logInvalidAuthorizationRequest(request, "schema_invalid", {
invalidFields: [...new Set(parsed.error.issues.map((issue) => String(issue.path[0] ?? "form")))]
});
return oauthJsonError("invalid_request", 400);
}

const origin = resolveRequestOrigin(request);
if (!isSameOriginMcpConsentPost(request.headers.get("origin"), origin)) {
logInvalidAuthorizationRequest(request, "origin_mismatch", {
actualOrigin: describePublicOrigin(request.headers.get("origin")),
expectedOrigin: describePublicOrigin(origin),
originPresent: request.headers.has("origin")
});
return oauthJsonError("invalid_request", 400);
}

const store = new SupabaseMcpOAuthStore(createSupabaseAdminClient());
const client = await store.getClient(parsed.data.client_id);
const rateLimit = await consumeMcpOAuthRateLimit({
headers: request.headers,
identity: "ip",
kind: "authorize",
store
}).catch(() => null);
if (!rateLimit) return oauthJsonError("temporarily_unavailable", 503);
if (!rateLimit.allowed) {
await recordMcpOAuthSecurityEvent({
errorCode: "authorize_ip",
eventType: "rate_limit_triggered",
headers: request.headers,
route: "/api/mcp/oauth/authorize",
severity: "medium",
store
});
return oauthRateLimitResponse(rateLimit.retryAfterSeconds);
}

const clientResult = await store.getClient(parsed.data.client_id)
.then((client) => ({ client }))
.catch(() => null);
if (!clientResult) return oauthJsonError("temporarily_unavailable", 503);
const { client } = clientResult;
if (
!client ||
!client.grant_types.includes("authorization_code") ||
!client.response_types.includes("code") ||
!client.redirect_uris.includes(parsed.data.redirect_uri)
!client.response_types.includes("code")
) {
return NextResponse.json({ error: "invalid_client" }, { status: 400 });
return oauthJsonError("invalid_client", 400);
}
if (!isExactMcpRedirectUri(parsed.data.redirect_uri, client.redirect_uris)) {
await recordMcpOAuthSecurityEvent({
clientId: client.client_id,
errorCode: "invalid_client",
eventType: "redirect_mismatch",
headers: request.headers,
route: "/api/mcp/oauth/authorize",
severity: "medium",
store
});
return oauthJsonError("invalid_client", 400);
}

if (parsed.data.resource !== mcpResourceUrl(resolveRequestOrigin(request))) {
return oauthRedirect(parsed.data.redirect_uri, "invalid_target", parsed.data.state);
const clientRateLimit = await consumeMcpOAuthRateLimit({
clientId: client.client_id,
headers: request.headers,
identity: "client_ip",
kind: "authorize",
store
}).catch(() => null);
if (!clientRateLimit) return oauthJsonError("temporarily_unavailable", 503);
if (!clientRateLimit.allowed) {
await recordMcpOAuthSecurityEvent({
clientId: client.client_id,
errorCode: "authorize_client",
eventType: "rate_limit_triggered",
headers: request.headers,
route: "/api/mcp/oauth/authorize",
severity: "medium",
store
});
return oauthRateLimitResponse(clientRateLimit.retryAfterSeconds);
}

if (input.decision !== "allow") {
return oauthRedirect(parsed.data.redirect_uri, "access_denied", parsed.data.state);
if (parsed.data.resource !== mcpResourceUrl(origin)) {
return oauthRedirect(parsed.data.redirect_uri, "invalid_target", parsed.data.state);
}

const supabase = await createSupabaseServerClient();
Expand All @@ -51,27 +130,85 @@ export async function POST(request: NextRequest) {
return oauthRedirect(parsed.data.redirect_uri, "invalid_scope", parsed.data.state);
}

const consentNonce = input.consent_nonce;
const consentResult = consentNonce
? await store.consumeConsentRequest({
clientId: parsed.data.client_id,
nonce: consentNonce,
requestHash: hashMcpAuthorizationRequest(parsed.data),
userId: data.user.id
}).then((consumed) => ({ consumed, storeError: false }))
.catch(() => ({ consumed: false, storeError: true }))
: { consumed: false, storeError: false };
if (!consentResult.consumed) {
logInvalidAuthorizationRequest(request, "consent_rejected", {
noncePresent: Boolean(consentNonce),
storeError: consentResult.storeError
});
return oauthJsonError("invalid_request", 400);
}

if (input.decision !== "allow") {
return oauthRedirect(parsed.data.redirect_uri, "access_denied", parsed.data.state);
}

const code = await store.issueAuthorizationCode({
client,
codeChallenge: parsed.data.code_challenge,
redirectUri: parsed.data.redirect_uri,
resource: parsed.data.resource,
scopes,
userId: data.user.id
});
}).catch(() => null);
if (!code) return oauthJsonError("temporarily_unavailable", 503);

return NextResponse.redirect(
return applyOAuthSecurityHeaders(NextResponse.redirect(
appendOAuthRedirectParams(parsed.data.redirect_uri, {
code,
state: parsed.data.state
}),
303
);
));
}

function logInvalidAuthorizationRequest(
request: NextRequest,
stage: "consent_rejected" | "form_unreadable" | "origin_mismatch" | "schema_invalid",
details: Record<string, boolean | string | string[]> = {}
) {
console.warn("[mcp-oauth] authorization request rejected", {
...details,
requestId: request.headers.get("x-request-id") ?? undefined,
stage
});
}

function describePublicOrigin(value: string | null) {
if (!value) return "missing";
if (value.trim() === "null") return "opaque";
try {
const origins = value.split(",").map((origin) => new URL(origin.trim()).origin);
return [...new Set(origins)].join(",");
} catch {
return "invalid";
}
}

function oauthRedirect(redirectUri: string, error: string, state?: string) {
return NextResponse.redirect(
return applyOAuthSecurityHeaders(NextResponse.redirect(
appendOAuthRedirectParams(redirectUri, { error, state }),
303
);
));
}

function oauthJsonError(error: string, status: number) {
return applyOAuthSecurityHeaders(NextResponse.json({ error }, { status }));
}

function oauthRateLimitResponse(retryAfterSeconds: number) {
const failure = mcpOAuthRateLimitFailure(retryAfterSeconds);
return applyOAuthSecurityHeaders(NextResponse.json(failure.body, {
headers: failure.headers,
status: failure.status
}));
}
43 changes: 38 additions & 5 deletions app/api/mcp/oauth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,35 @@ import { NextResponse } from "next/server";

import { createSupabaseAdminClient } from "@/common/lib/supabase/adminClient";
import { mcpClientRegistrationSchema, validateMcpRedirectUri } from "@/mcp/oauth";
import {
applyOAuthSecurityHeaders,
mcpOAuthRateLimitFailure,
recordMcpOAuthSecurityEvent
} from "@/mcp/oauthSecurity";
import { consumeMcpOAuthRateLimit } from "@/mcp/oauthRateLimit";
import { SupabaseMcpOAuthStore } from "@/mcp/supabaseOAuthStore";

export async function POST(request: Request) {
const store = new SupabaseMcpOAuthStore(createSupabaseAdminClient());
const rateLimit = await consumeMcpOAuthRateLimit({
headers: request.headers,
identity: "ip",
kind: "register",
store
}).catch(() => null);
if (!rateLimit) return oauthRegistrationError("temporarily_unavailable", 503);
if (!rateLimit.allowed) {
await recordMcpOAuthSecurityEvent({
errorCode: "register_ip",
eventType: "rate_limit_triggered",
headers: request.headers,
route: "/api/mcp/oauth/register",
severity: "medium",
store
});
return oauthRateLimitResponse(rateLimit.retryAfterSeconds);
}

const body: unknown = await request.json().catch(() => null);
const parsed = mcpClientRegistrationSchema.safeParse(body);

Expand All @@ -19,15 +45,15 @@ export async function POST(request: Request) {
return oauthRegistrationError("invalid_redirect_uri", 400);
}

const store = new SupabaseMcpOAuthStore(createSupabaseAdminClient());
const client = await store.registerClient({
clientName: parsed.data.client_name,
grantTypes: parsed.data.grant_types,
redirectUris,
responseTypes: parsed.data.response_types
});
}).catch(() => null);
if (!client) return oauthRegistrationError("temporarily_unavailable", 503);

return NextResponse.json(
return applyOAuthSecurityHeaders(NextResponse.json(
{
client_id: client.client_id,
client_id_issued_at: Math.floor(new Date(client.created_at).getTime() / 1000),
Expand All @@ -38,10 +64,17 @@ export async function POST(request: Request) {
token_endpoint_auth_method: client.token_endpoint_auth_method
},
{ status: 201 }
);
));
}

function oauthRegistrationError(error: string, status: number) {
return NextResponse.json({ error }, { status });
return applyOAuthSecurityHeaders(NextResponse.json({ error }, { status }));
}

function oauthRateLimitResponse(retryAfterSeconds: number) {
const failure = mcpOAuthRateLimitFailure(retryAfterSeconds);
return applyOAuthSecurityHeaders(NextResponse.json(failure.body, {
headers: failure.headers,
status: failure.status
}));
}
Loading
Loading