Skip to content

Commit 41c79fe

Browse files
oauth poc
1 parent f09477f commit 41c79fe

File tree

2 files changed

+146
-21
lines changed

2 files changed

+146
-21
lines changed

docs/oauth-architecture-plan.md

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ This document outlines the OAuth 2.1 implementation for the Context7 MCP server,
2424
| **Authorization Server** | context7app (Next.js) | Already has Clerk auth and Supabase |
2525
| **Resource Server** | context7 MCP | Validates API keys, serves MCP tools |
2626
| **Token Type** | API Key (not JWT) | Zero changes to existing API validation |
27-
| **Client Registration** | Dynamic (RFC 7591) | Required by MCP spec |
27+
| **Client Registration** | Dynamic (RFC 7591) or Client ID Metadata Documents | MCP spec supports both |
2828
| **User Authentication** | Clerk | Existing auth system |
2929
| **Storage** | Supabase | Existing database |
30+
| **Resource Parameter** | Required (RFC 8707) | Token audience binding per MCP spec |
3031

3132
### Why API Keys Instead of JWT?
3233

@@ -360,6 +361,27 @@ The server creates a record in `mcp_oauth_clients` and responds:
360361

361362
The client saves this `client_id` for future use.
362363

364+
**Alternative: Client ID Metadata Documents**
365+
366+
Instead of Dynamic Client Registration, clients can use **Client ID Metadata Documents** (recommended by MCP spec for clients without prior relationship):
367+
368+
1. Client hosts a metadata JSON at an HTTPS URL (e.g., `https://cursor.com/oauth/client.json`)
369+
2. Client uses this URL as its `client_id` in the authorization request
370+
3. Server fetches the metadata document to validate redirect URIs
371+
372+
Example metadata document hosted by the client:
373+
```json
374+
{
375+
"client_id": "https://cursor.com/oauth/client.json",
376+
"client_name": "Cursor",
377+
"redirect_uris": ["http://127.0.0.1:54321/callback"],
378+
"grant_types": ["authorization_code"],
379+
"token_endpoint_auth_method": "none"
380+
}
381+
```
382+
383+
This approach eliminates the need for the `/register` endpoint when the client supports it.
384+
363385
---
364386

365387
#### Phase 3: Authorization (PKCE Flow)
@@ -377,6 +399,7 @@ https://context7.com/api/mcp-auth/authorize
377399
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
378400
&code_challenge_method=S256
379401
&state=xyz123
402+
&resource=https://mcp.context7.com ← RFC 8707 resource indicator (REQUIRED)
380403
```
381404

382405
**Step 10-11 — Context7app checks for Clerk session**
@@ -410,6 +433,7 @@ A random authorization code is generated and stored in `mcp_auth_codes` along wi
410433
- The `client_id`
411434
- The `user_id`
412435
- The `code_challenge` (for PKCE verification later)
436+
- The `resource` (for token endpoint validation)
413437
- Expiration time (10 minutes)
414438

415439
**Step 19 — Context7app redirects to client with code**
@@ -441,16 +465,19 @@ grant_type=authorization_code
441465
&code=SplxlOBeZQQYbYS6WxSbIA
442466
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
443467
&redirect_uri=http://127.0.0.1:54321/callback
468+
&resource=https://mcp.context7.com ← Must match the authorization request
444469
```
445470

446-
**Step 21 — Context7app validates code and PKCE**
471+
**Step 21 — Context7app validates code, PKCE, and resource**
447472

448473
The server:
449474
1. Looks up the code in `mcp_auth_codes`
450475
2. Checks it hasn't expired or been used
451-
3. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge`
476+
3. Verifies `redirect_uri` matches the stored value
477+
4. Verifies `resource` matches the stored value (RFC 8707)
478+
5. Computes `SHA256(code_verifier)` and verifies it matches the stored `code_challenge`
452479

453-
This proves the same client that started the flow is completing it (prevents code interception attacks).
480+
This proves the same client that started the flow is completing it (prevents code interception attacks) and that the token is bound to the intended resource.
454481

455482
**Step 22 — Context7app creates/regenerates API key**
456483

@@ -563,8 +590,9 @@ export async function GET() {
563590
scopes_supported: ["mcp:read", "mcp:write"],
564591
response_types_supported: ["code"],
565592
grant_types_supported: ["authorization_code"],
566-
code_challenge_methods_supported: ["S256"],
593+
code_challenge_methods_supported: ["S256"], // REQUIRED by MCP spec
567594
token_endpoint_auth_methods_supported: ["none"],
595+
client_id_metadata_document_supported: true, // Support Client ID Metadata Documents
568596
});
569597
}
570598
```
@@ -662,6 +690,7 @@ export async function GET(request: Request) {
662690
const code_challenge = url.searchParams.get("code_challenge");
663691
const code_challenge_method = url.searchParams.get("code_challenge_method");
664692
const scope = url.searchParams.get("scope") || "mcp:read";
693+
const resource = url.searchParams.get("resource"); // RFC 8707 resource indicator
665694

666695
// Validate required parameters
667696
if (!client_id) {
@@ -686,22 +715,57 @@ export async function GET(request: Request) {
686715
);
687716
}
688717

689-
// Validate client exists and redirect_uri is registered
718+
// Validate client - supports both Dynamic Registration and Client ID Metadata Documents
690719
const supabase = createClient();
691-
const { data: client } = await supabase
692-
.from("mcp_oauth_clients")
693-
.select("*")
694-
.eq("client_id", client_id)
695-
.single();
720+
let clientRedirectUris: string[];
696721

697-
if (!client) {
698-
return NextResponse.json(
699-
{ error: "invalid_client", error_description: "Unknown client_id" },
700-
{ status: 400 }
701-
);
722+
// Check if client_id is a URL (Client ID Metadata Document)
723+
if (client_id.startsWith("https://")) {
724+
// Fetch client metadata from the URL
725+
try {
726+
const metadataResponse = await fetch(client_id);
727+
if (!metadataResponse.ok) {
728+
return NextResponse.json(
729+
{ error: "invalid_client", error_description: "Failed to fetch client metadata" },
730+
{ status: 400 }
731+
);
732+
}
733+
const metadata = await metadataResponse.json();
734+
735+
// Validate client_id in metadata matches the URL
736+
if (metadata.client_id !== client_id) {
737+
return NextResponse.json(
738+
{ error: "invalid_client", error_description: "client_id mismatch in metadata" },
739+
{ status: 400 }
740+
);
741+
}
742+
743+
clientRedirectUris = metadata.redirect_uris || [];
744+
} catch {
745+
return NextResponse.json(
746+
{ error: "invalid_client", error_description: "Failed to fetch client metadata" },
747+
{ status: 400 }
748+
);
749+
}
750+
} else {
751+
// Look up in registered clients (Dynamic Registration)
752+
const { data: client } = await supabase
753+
.from("mcp_oauth_clients")
754+
.select("*")
755+
.eq("client_id", client_id)
756+
.single();
757+
758+
if (!client) {
759+
return NextResponse.json(
760+
{ error: "invalid_client", error_description: "Unknown client_id" },
761+
{ status: 400 }
762+
);
763+
}
764+
765+
clientRedirectUris = client.redirect_uris;
702766
}
703767

704-
if (!client.redirect_uris.includes(redirect_uri)) {
768+
if (!clientRedirectUris.includes(redirect_uri)) {
705769
return NextResponse.json(
706770
{ error: "invalid_request", error_description: "redirect_uri not registered" },
707771
{ status: 400 }
@@ -721,14 +785,15 @@ export async function GET(request: Request) {
721785
const authCode = crypto.randomBytes(32).toString("base64url");
722786
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
723787

724-
// Store auth code with PKCE challenge
788+
// Store auth code with PKCE challenge and resource
725789
const { error: insertError } = await supabase.from("mcp_auth_codes").insert({
726790
code: authCode,
727791
client_id: client_id,
728792
user_id: userId,
729793
redirect_uri: redirect_uri,
730794
code_challenge: code_challenge,
731795
scope: scope,
796+
resource: resource, // Store resource for token endpoint validation
732797
expires_at: expiresAt.toISOString(),
733798
});
734799

@@ -780,7 +845,7 @@ export async function POST(request: Request) {
780845
);
781846
}
782847

783-
const { grant_type, code, code_verifier, redirect_uri } = params;
848+
const { grant_type, code, code_verifier, redirect_uri, resource } = params;
784849

785850
// Only support authorization_code grant
786851
if (grant_type !== "authorization_code") {
@@ -830,6 +895,14 @@ export async function POST(request: Request) {
830895
);
831896
}
832897

898+
// Verify resource matches (RFC 8707)
899+
if (authCode.resource && authCode.resource !== resource) {
900+
return NextResponse.json(
901+
{ error: "invalid_grant", error_description: "resource mismatch" },
902+
{ status: 400 }
903+
);
904+
}
905+
833906
// Verify PKCE: SHA256(code_verifier) must equal stored code_challenge
834907
const computedChallenge = crypto
835908
.createHash("sha256")
@@ -1002,6 +1075,7 @@ CREATE TABLE mcp_auth_codes (
10021075
redirect_uri TEXT NOT NULL,
10031076
code_challenge TEXT NOT NULL, -- PKCE challenge
10041077
scope TEXT DEFAULT 'mcp:read',
1078+
resource TEXT, -- RFC 8707 resource indicator (MCP server URI)
10051079
expires_at TIMESTAMPTZ NOT NULL,
10061080
consumed BOOLEAN DEFAULT FALSE,
10071081
created_at TIMESTAMPTZ DEFAULT NOW()

packages/mcp/src/index.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,34 @@ async function main() {
317317
);
318318
};
319319

320-
app.all("/mcp", async (req: express.Request, res: express.Response) => {
320+
// Shared MCP request handler
321+
const handleMcpRequest = async (
322+
req: express.Request,
323+
res: express.Response,
324+
requireAuth: boolean
325+
) => {
321326
try {
322327
const clientIp = getClientIp(req);
323328
const apiKey = extractApiKey(req);
329+
const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`;
330+
331+
// Always add WWW-Authenticate header with OAuth discovery info
332+
res.set(
333+
"WWW-Authenticate",
334+
`Bearer resource_metadata="${resourceUrl}/.well-known/oauth-protected-resource"`
335+
);
336+
337+
// If auth required and no API key, return 401 to trigger OAuth flow
338+
if (requireAuth && !apiKey) {
339+
return res.status(401).json({
340+
jsonrpc: "2.0",
341+
error: {
342+
code: -32001,
343+
message: "Authentication required. Please authenticate to use this MCP server.",
344+
},
345+
id: null,
346+
});
347+
}
324348

325349
const transport = new StreamableHTTPServerTransport({
326350
sessionIdGenerator: undefined,
@@ -345,12 +369,39 @@ async function main() {
345369
});
346370
}
347371
}
348-
});
372+
};
373+
374+
// Anonymous access endpoint - no authentication required
375+
app.all("/mcp", (req, res) => handleMcpRequest(req, res, false));
376+
377+
// OAuth-protected endpoint - requires authentication (returns 401 if no API key)
378+
app.all("/mcp/oauth", (req, res) => handleMcpRequest(req, res, true));
349379

350380
app.get("/ping", (_req: express.Request, res: express.Response) => {
351381
res.json({ status: "ok", message: "pong" });
352382
});
353383

384+
// OAuth 2.0 Protected Resource Metadata (RFC 9728)
385+
// This enables MCP clients to discover the authorization server
386+
app.get(
387+
"/.well-known/oauth-protected-resource",
388+
(_req: express.Request, res: express.Response) => {
389+
// Use environment variables or defaults
390+
// For local testing: AUTH_SERVER_URL=http://localhost:3000
391+
// For production: AUTH_SERVER_URL=https://context7.com
392+
console.log("WELL KNOWN OAUTH PROTECTED RESOURCE", _req.body);
393+
const authServerUrl = process.env.AUTH_SERVER_URL || "http://localhost:3000";
394+
const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`;
395+
396+
res.json({
397+
resource: resourceUrl,
398+
authorization_servers: [authServerUrl],
399+
scopes_supported: ["mcp:read", "mcp:write"],
400+
bearer_methods_supported: ["header"],
401+
});
402+
}
403+
);
404+
354405
// Catch-all 404 handler - must be after all other routes
355406
app.use((_req: express.Request, res: express.Response) => {
356407
res.status(404).json({

0 commit comments

Comments
 (0)