mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 14:07:13 +02:00
fix: align MCP OAuth flow with Copilot client ID requirements
- Use vscode.dev/redirect as redirect URI when using the Copilot client ID, with the local callback URL encoded in the state parameter - Use PRM resource scopes (e.g. api://icmmcpapi-prod/mcp.tools) instead of generic OIDC scopes, always include offline_access - Listen on root path instead of /callback to match vscode.dev/redirect behavior - Extract PRM scopes from protected resource metadata discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -58,27 +58,40 @@ export async function performMcpOAuthFlow(options: McpOAuthFlowOptions): Promise
|
|||||||
try {
|
try {
|
||||||
onStatusChange?.('discovering');
|
onStatusChange?.('discovering');
|
||||||
|
|
||||||
const authServerUrl = await discoverAuthorizationServer(serverUrl);
|
const prm = await discoverProtectedResource(serverUrl);
|
||||||
const metadata = await fetchAuthServerMetadata(authServerUrl);
|
const metadata = await fetchAuthServerMetadata(prm.authorizationServer);
|
||||||
|
|
||||||
// Use explicit static config, fall back to the well-known GitHub Copilot client ID,
|
// Use explicit static config, fall back to the well-known GitHub Copilot client ID,
|
||||||
// and only attempt dynamic registration as a last resort.
|
// and only attempt dynamic registration as a last resort.
|
||||||
const COPILOT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
|
const COPILOT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
|
||||||
|
const VSCODE_REDIRECT_URI = 'https://vscode.dev/redirect';
|
||||||
|
|
||||||
const clientId = staticClientConfig?.clientId
|
const clientId = staticClientConfig?.clientId
|
||||||
?? (metadata.registration_endpoint
|
?? (metadata.registration_endpoint
|
||||||
? await dynamicClientRegistration(metadata, serverUrl)
|
? await dynamicClientRegistration(metadata, serverUrl)
|
||||||
: COPILOT_CLIENT_ID);
|
: COPILOT_CLIENT_ID);
|
||||||
|
|
||||||
|
const useCopilotRedirect = clientId === COPILOT_CLIENT_ID;
|
||||||
|
|
||||||
const { verifier, challenge } = generatePkceChallenge();
|
const { verifier, challenge } = generatePkceChallenge();
|
||||||
const { port, redirectUri, waitForCallback, close } = await startCallbackServer();
|
const { redirectUri: localRedirectUri, waitForCallback, close } = await startCallbackServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scopes = metadata.scopes_supported?.join(' ') ?? '';
|
// Prefer PRM resource scopes (e.g. api://icmmcpapi-prod/mcp.tools), add offline_access.
|
||||||
|
// Fall back to auth server scopes_supported, then generic OIDC scopes.
|
||||||
|
const scopes = buildScopes(prm.resourceScopes, metadata.scopes_supported);
|
||||||
|
|
||||||
|
// When using the Copilot client ID, redirect through vscode.dev/redirect which
|
||||||
|
// reads the state parameter to find the local callback URL and forwards the code.
|
||||||
|
const redirectUri = useCopilotRedirect ? VSCODE_REDIRECT_URI : localRedirectUri;
|
||||||
|
const state = useCopilotRedirect ? localRedirectUri : randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const authUrl = buildAuthorizationUrl(metadata.authorization_endpoint, {
|
const authUrl = buildAuthorizationUrl(metadata.authorization_endpoint, {
|
||||||
clientId,
|
clientId,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codeChallenge: challenge,
|
codeChallenge: challenge,
|
||||||
scope: scopes,
|
scope: scopes,
|
||||||
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
onStatusChange?.('awaiting-consent');
|
onStatusChange?.('awaiting-consent');
|
||||||
@@ -105,11 +118,24 @@ export async function performMcpOAuthFlow(options: McpOAuthFlowOptions): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the OAuth scope string.
|
||||||
|
* Priority: PRM resource scopes > auth server scopes > empty.
|
||||||
|
* Always appends offline_access for refresh token support.
|
||||||
|
*/
|
||||||
|
function buildScopes(resourceScopes?: string[], authServerScopes?: string[]): string {
|
||||||
|
const scopes = resourceScopes ?? authServerScopes ?? [];
|
||||||
|
const set = new Set(scopes);
|
||||||
|
set.add('offline_access');
|
||||||
|
return [...set].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Discovery ───────────────────────────────────────────────── */
|
/* ── Discovery ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ProtectedResourceMetadata {
|
interface ProtectedResourceMetadata {
|
||||||
resource: string;
|
resource: string;
|
||||||
authorization_servers?: string[];
|
authorization_servers?: string[];
|
||||||
|
scopes_supported?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthServerMetadata {
|
interface AuthServerMetadata {
|
||||||
@@ -149,7 +175,12 @@ async function fetchWellKnownMetadata(baseUrl: string, suffix: string): Promise<
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverAuthorizationServer(serverUrl: string): Promise<string> {
|
interface PrmDiscoveryResult {
|
||||||
|
authorizationServer: string;
|
||||||
|
resourceScopes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverProtectedResource(serverUrl: string): Promise<PrmDiscoveryResult> {
|
||||||
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
|
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
throw new Error('Protected Resource Metadata discovery failed: no well-known endpoint found');
|
throw new Error('Protected Resource Metadata discovery failed: no well-known endpoint found');
|
||||||
@@ -161,7 +192,7 @@ async function discoverAuthorizationServer(serverUrl: string): Promise<string> {
|
|||||||
throw new Error('No authorization server found in Protected Resource Metadata');
|
throw new Error('No authorization server found in Protected Resource Metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
return authServer;
|
return { authorizationServer: authServer, resourceScopes: prm.scopes_supported };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuthServerMetadata(authServerUrl: string): Promise<AuthServerMetadata> {
|
async function fetchAuthServerMetadata(authServerUrl: string): Promise<AuthServerMetadata> {
|
||||||
@@ -233,6 +264,7 @@ function buildAuthorizationUrl(
|
|||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
codeChallenge: string;
|
codeChallenge: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
state: string;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const url = new URL(authorizationEndpoint);
|
const url = new URL(authorizationEndpoint);
|
||||||
@@ -244,7 +276,7 @@ function buildAuthorizationUrl(
|
|||||||
if (params.scope) {
|
if (params.scope) {
|
||||||
url.searchParams.set('scope', params.scope);
|
url.searchParams.set('scope', params.scope);
|
||||||
}
|
}
|
||||||
url.searchParams.set('state', randomBytes(16).toString('hex'));
|
url.searchParams.set('state', params.state);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,17 +301,18 @@ function startCallbackServer(): Promise<CallbackServerHandle> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
if (!req.url?.startsWith('/callback')) {
|
const url = new URL(req.url ?? '/', `http://127.0.0.1`);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
const errorDescription = url.searchParams.get('error_description');
|
||||||
|
|
||||||
|
// Ignore requests without code or error (e.g. favicon)
|
||||||
|
if (!code && !error) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url, `http://127.0.0.1`);
|
|
||||||
const code = url.searchParams.get('code');
|
|
||||||
const error = url.searchParams.get('error');
|
|
||||||
const errorDescription = url.searchParams.get('error_description');
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
if (code) {
|
if (code) {
|
||||||
res.end('<html><body><h2>Authentication successful</h2><p>You can close this tab.</p></body></html>');
|
res.end('<html><body><h2>Authentication successful</h2><p>You can close this tab.</p></body></html>');
|
||||||
@@ -308,7 +341,7 @@ function startCallbackServer(): Promise<CallbackServerHandle> {
|
|||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
port: addr.port,
|
port: addr.port,
|
||||||
redirectUri: `http://127.0.0.1:${addr.port}/callback`,
|
redirectUri: `http://127.0.0.1:${addr.port}/`,
|
||||||
waitForCallback: () => callbackPromise,
|
waitForCallback: () => callbackPromise,
|
||||||
close: () => server.close(),
|
close: () => server.close(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user