UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

84 lines (82 loc) 4.17 kB
import { OAuthError } from "../types/error.js"; import { isDomainAllowed } from "./domain.js"; import { isLoopbackHost } from "./loopback.js"; import { useEnv } from "@directus/env"; //#region src/services/mcp-oauth/utils/redirect.ts const MAX_REDIRECT_URI_LENGTH = 255; function parseAllowedCustomRedirect(value) { let parsed; try { parsed = new URL(value); } catch { return null; } if (parsed.protocol === "http:" || parsed.protocol === "https:") return null; if (!parsed.hostname || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) return null; if (parsed.pathname && parsed.pathname !== "/") return null; return { protocol: parsed.protocol, hostname: parsed.hostname.toLowerCase() }; } function getAllowedCustomRedirects() { return (useEnv()["MCP_OAUTH_ALLOWED_CUSTOM_REDIRECTS"] ?? []).flatMap((value) => { const redirect = parseAllowedCustomRedirect(value); return redirect ? [redirect] : []; }); } function getAllowedCustomRedirectSchemes() { return [...new Set(getAllowedCustomRedirects().map(({ protocol }) => protocol))]; } function isAllowedCustomRedirectUri(parsed) { if (parsed.port) return false; return getAllowedCustomRedirects().some(({ protocol, hostname }) => parsed.protocol === protocol && parsed.hostname.toLowerCase() === hostname); } /** * Validate a redirect URI per RFC 6749 Section 3.1.2 + OAuth 2.1 policy (HTTPS, no fragment, no userinfo). * RFC 8252 Section 7.3: HTTP is allowed for loopback addresses (localhost, 127.0.0.1, [::1]). * Compatibility: MCP_OAUTH_ALLOWED_CUSTOM_REDIRECTS configures known custom-scheme desktop redirects. * Optional MCP_OAUTH_ALLOWED_REDIRECT_DOMAINS env var enforces a server-wide domain allowlist * (loopback and known desktop redirects bypass the allowlist to keep native OAuth clients working). */ function validateRedirectUri(uri) { if (typeof uri !== "string") throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must be a string"); if (uri.length > MAX_REDIRECT_URI_LENGTH) throw new OAuthError(400, "invalid_redirect_uri", `redirect_uri must not exceed ${MAX_REDIRECT_URI_LENGTH} characters`); let parsed; try { parsed = new URL(uri); } catch { throw new OAuthError(400, "invalid_redirect_uri", `Invalid redirect URI: ${uri}`); } if (parsed.hash) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must not contain a fragment"); if (parsed.username || parsed.password) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must not contain userinfo"); if (isAllowedCustomRedirectUri(parsed)) return; if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must use HTTPS (except for localhost)"); const allowedDomains = useEnv()["MCP_OAUTH_ALLOWED_REDIRECT_DOMAINS"] ?? []; if (allowedDomains.length > 0 && !isLoopbackHost(parsed.hostname) && !isDomainAllowed(parsed.hostname, allowedDomains)) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri domain is not in the allowlist"); } /** * Check if a requested redirect_uri matches any registered URI. * RFC 6749 Section 3.1.2: exact string match for non-loopback. * RFC 8252 Section 7.3: loopback redirect URIs (localhost, 127.0.0.1, [::1]) MUST allow any port * at request time, since native apps bind to ephemeral ports. */ function matchRedirectUri(requested, registered) { let reqUrl; try { reqUrl = new URL(requested); } catch { return false; } if (reqUrl.username || reqUrl.password || reqUrl.hash) return false; return registered.some((reg) => { if (reg === requested) return true; try { const regUrl = new URL(reg); if (isLoopbackHost(regUrl.hostname) && isLoopbackHost(reqUrl.hostname)) return regUrl.protocol === reqUrl.protocol && regUrl.username === reqUrl.username && regUrl.password === reqUrl.password && regUrl.hostname === reqUrl.hostname && regUrl.pathname === reqUrl.pathname && regUrl.search === reqUrl.search && regUrl.hash === reqUrl.hash; } catch {} return false; }); } //#endregion export { getAllowedCustomRedirectSchemes, matchRedirectUri, validateRedirectUri };