Skip to content

Commit 622ae1f

Browse files
committed
Add server-side OAuth discovery proxy and improve auth UI
Introduces a server-side endpoint for OAuth metadata discovery to avoid CORS issues and support RFC 9728 probing. Updates the client service to use this proxy, adds ProtectedResourceMetadata type, and improves ServerCard UI to better indicate authentication requirements.
1 parent 9ce1b90 commit 622ae1f

File tree

4 files changed

+157
-36
lines changed

4 files changed

+157
-36
lines changed

src/lib/components/mcp/ServerCard.svelte

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@
4444
const hasToken = tokens.has(server.id);
4545
const hasConfig = configs.has(server.id);
4646
47-
if (!server.oauthEnabled && !hasToken && !hasConfig) return "none";
47+
if (!server.oauthEnabled && !hasToken && !hasConfig) {
48+
// Health check returned 401 but OAuth wasn't set up - prompt for authentication
49+
if (server.authRequired) return "missing";
50+
return "none";
51+
}
4852
4953
const token = tokens.get(server.id);
5054
if (!token) return "missing";
@@ -269,14 +273,18 @@
269273
<div class="flex items-center gap-2 rounded bg-amber-50 px-2 py-1.5 dark:bg-amber-900/20">
270274
<IconLocked class="size-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
271275
<span class="flex-1 text-xs text-amber-800 dark:text-amber-200">
272-
Authentication expired
276+
{tokenStatus === "expired" ? "Authentication expired" : "Authentication required"}
273277
</span>
274278
<button
275279
onclick={handleReauthenticate}
276280
disabled={isReauthenticating}
277281
class="rounded bg-amber-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-amber-700 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-600"
278282
>
279-
{isReauthenticating ? "..." : "Re-authenticate"}
283+
{isReauthenticating
284+
? "..."
285+
: tokenStatus === "expired"
286+
? "Re-authenticate"
287+
: "Authenticate"}
280288
</button>
281289
</div>
282290
</div>

src/lib/services/mcpOAuthService.ts

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,48 +28,25 @@ const FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
2828

2929
/**
3030
* Discover OAuth server metadata from MCP server URL
31-
* Checks /.well-known/oauth-authorization-server per RFC 8414
31+
* Uses server-side proxy to avoid CORS issues with RFC 9728 discovery
3232
*/
3333
export async function discoverOAuthMetadata(
3434
serverUrl: string
3535
): Promise<OAuthServerMetadata | null> {
3636
try {
37-
const url = new URL(serverUrl);
38-
// Per MCP spec: discovery at authorization base URL (server URL with path removed)
39-
const wellKnownUrl = `${url.origin}/.well-known/oauth-authorization-server`;
40-
41-
const response = await fetch(wellKnownUrl, {
42-
headers: { Accept: "application/json" },
43-
// Short timeout for discovery
44-
signal: AbortSignal.timeout(10000),
37+
const response = await fetch(`${base}/api/mcp/oauth/discover`, {
38+
method: "POST",
39+
headers: { "Content-Type": "application/json" },
40+
body: JSON.stringify({ url: serverUrl }),
41+
signal: AbortSignal.timeout(20000),
4542
});
4643

47-
if (!response.ok) {
48-
// Server doesn't support OAuth discovery - not an error
49-
return null;
50-
}
51-
52-
const metadata = (await response.json()) as OAuthServerMetadata;
53-
54-
// Validate required fields per OAuth 2.0 Authorization Server Metadata
55-
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
56-
console.warn("OAuth metadata missing required endpoints");
57-
return null;
58-
}
59-
60-
// Validate PKCE support (required by MCP OAuth 2.1)
61-
if (
62-
metadata.code_challenge_methods_supported &&
63-
!metadata.code_challenge_methods_supported.includes("S256")
64-
) {
65-
console.warn("OAuth server does not support required S256 PKCE method");
66-
return null;
67-
}
44+
if (!response.ok) return null;
6845

69-
return metadata;
46+
const data = await response.json();
47+
return data.metadata ?? null;
7048
} catch (error) {
71-
// Discovery failure is not an error - server may not require OAuth
72-
console.debug("OAuth discovery failed (server may not require OAuth):", error);
49+
console.debug("OAuth discovery failed:", error);
7350
return null;
7451
}
7552
}

src/lib/types/McpOAuth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,15 @@ export interface ClientRegistrationResponse {
9292
client_id_issued_at?: number;
9393
client_secret_expires_at?: number;
9494
}
95+
96+
/**
97+
* Protected Resource Metadata (RFC 9728)
98+
*/
99+
export interface ProtectedResourceMetadata {
100+
resource: string;
101+
authorization_servers?: string[];
102+
scopes_supported?: string[];
103+
bearer_methods_supported?: string[];
104+
resource_name?: string;
105+
resource_documentation?: string;
106+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { RequestHandler } from "./$types";
2+
import { isValidUrl } from "$lib/server/urlSafety";
3+
4+
interface DiscoveryRequest {
5+
url: string;
6+
}
7+
8+
interface OAuthServerMetadata {
9+
issuer: string;
10+
authorization_endpoint: string;
11+
token_endpoint: string;
12+
registration_endpoint?: string;
13+
scopes_supported?: string[];
14+
response_types_supported?: string[];
15+
code_challenge_methods_supported?: string[];
16+
token_endpoint_auth_methods_supported?: string[];
17+
grant_types_supported?: string[];
18+
}
19+
20+
interface ProtectedResourceMetadata {
21+
resource: string;
22+
authorization_servers?: string[];
23+
scopes_supported?: string[];
24+
}
25+
26+
function parseResourceMetadataUrl(wwwAuthenticate: string): string | null {
27+
const match = wwwAuthenticate.match(/resource_metadata="([^"]+)"/);
28+
return match?.[1] ?? null;
29+
}
30+
31+
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T | null> {
32+
try {
33+
const response = await fetch(url, {
34+
headers: { Accept: "application/json" },
35+
signal,
36+
});
37+
if (!response.ok) return null;
38+
return (await response.json()) as T;
39+
} catch {
40+
return null;
41+
}
42+
}
43+
44+
async function fetchAuthServerMetadata(
45+
issuerUrl: string,
46+
signal: AbortSignal
47+
): Promise<OAuthServerMetadata | null> {
48+
const url = new URL(issuerUrl);
49+
const wellKnownUrl = `${url.origin}/.well-known/oauth-authorization-server`;
50+
const metadata = await fetchJson<OAuthServerMetadata>(wellKnownUrl, signal);
51+
52+
if (!metadata?.authorization_endpoint || !metadata?.token_endpoint) return null;
53+
if (
54+
metadata.code_challenge_methods_supported &&
55+
!metadata.code_challenge_methods_supported.includes("S256")
56+
) {
57+
return null;
58+
}
59+
return metadata;
60+
}
61+
62+
export const POST: RequestHandler = async ({ request }) => {
63+
const controller = new AbortController();
64+
const timeoutId = setTimeout(() => controller.abort(), 15000);
65+
66+
try {
67+
const body: DiscoveryRequest = await request.json();
68+
const { url } = body;
69+
70+
if (!url || !isValidUrl(url)) {
71+
return new Response(JSON.stringify({ error: "Invalid URL" }), {
72+
status: 400,
73+
headers: { "Content-Type": "application/json" },
74+
});
75+
}
76+
77+
// RFC 9728: Probe the server to get resource_metadata from WWW-Authenticate
78+
const probeResponse = await fetch(url, {
79+
method: "GET",
80+
signal: controller.signal,
81+
});
82+
83+
if (probeResponse.status === 401) {
84+
const wwwAuth = probeResponse.headers.get("www-authenticate") ?? "";
85+
const prmUrl = parseResourceMetadataUrl(wwwAuth);
86+
87+
if (prmUrl) {
88+
const prm = await fetchJson<ProtectedResourceMetadata>(prmUrl, controller.signal);
89+
if (prm?.authorization_servers?.[0]) {
90+
const metadata = await fetchAuthServerMetadata(
91+
prm.authorization_servers[0],
92+
controller.signal
93+
);
94+
if (metadata) {
95+
clearTimeout(timeoutId);
96+
return new Response(JSON.stringify({ metadata }), {
97+
headers: { "Content-Type": "application/json" },
98+
});
99+
}
100+
}
101+
}
102+
}
103+
104+
// Fallback: same-origin well-known
105+
const metadata = await fetchAuthServerMetadata(url, controller.signal);
106+
clearTimeout(timeoutId);
107+
108+
if (metadata) {
109+
return new Response(JSON.stringify({ metadata }), {
110+
headers: { "Content-Type": "application/json" },
111+
});
112+
}
113+
114+
return new Response(JSON.stringify({ metadata: null }), {
115+
headers: { "Content-Type": "application/json" },
116+
});
117+
} catch (error) {
118+
clearTimeout(timeoutId);
119+
return new Response(
120+
JSON.stringify({ error: error instanceof Error ? error.message : "Discovery failed" }),
121+
{ status: 500, headers: { "Content-Type": "application/json" } }
122+
);
123+
}
124+
};

0 commit comments

Comments
 (0)