diff --git a/src/api/routes/mcp.js b/src/api/routes/mcp.js index 30064e6..6500b26 100644 --- a/src/api/routes/mcp.js +++ b/src/api/routes/mcp.js @@ -20,6 +20,56 @@ function getBaseUrl(req) { return `${proto}://${host}`; } +/** + * Checks if a candidate redirect_uri matches any registered redirect URI. + * Per RFC 8252 Section 7.3 (OAuth 2.0 for Native Apps): + * "The authorization server MUST allow any port for loopback IP redirect URIs." + * @param {string} candidateUri + * @param {Array|string} registeredUris + * @returns {boolean} + */ +function isRedirectUriAllowed(candidateUri, registeredUris) { + if (!candidateUri || !registeredUris) return false; + const uriList = Array.isArray(registeredUris) ? registeredUris : [registeredUris]; + if (uriList.includes(candidateUri)) return true; + + try { + const candidate = new URL(candidateUri); + const isCandidateLoopback = candidate.hostname === '127.0.0.1' || candidate.hostname === 'localhost'; + + for (const reg of uriList) { + try { + const registered = new URL(reg); + const normCandidatePath = candidate.pathname.replace(/\/$/, ''); + const normRegisteredPath = registered.pathname.replace(/\/$/, ''); + + if ( + candidate.origin === registered.origin && + normCandidatePath === normRegisteredPath + ) { + return true; + } + + const isRegisteredLoopback = registered.hostname === '127.0.0.1' || registered.hostname === 'localhost'; + if ( + isCandidateLoopback && + isRegisteredLoopback && + candidate.protocol === registered.protocol && + normCandidatePath === normRegisteredPath + ) { + return true; + } + } catch { + // Ignore malformed registered URI + } + } + } catch { + return false; + } + + return false; +} + const tokenVerifier = { verifyAccessToken: async (token) => { let decoded; @@ -325,7 +375,7 @@ export function register(app, _basePath = '') { registeredUris = [client.redirectUris]; } - if (!redirect_uri || !registeredUris.includes(redirect_uri)) { + if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) { return res.status(400).send('Invalid or unregistered redirect_uri'); } @@ -543,7 +593,7 @@ export function register(app, _basePath = '') { }); } - if (authCode.redirectUri !== redirect_uri) { + if (!isRedirectUriAllowed(redirect_uri, [authCode.redirectUri])) { return res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI does not match authorization code',