Skip to content

Oauth Proxy - allowed_client_redirect_uris parameter allowing all uris during DCR #3037

Description

@actuallygmenzies

Description

Problem:

The allowed_client_redirect_uris configuration parameter is not being enforced during OAuth authorization flows. Clients can bypass this restriction by registering arbitrary redirect URIs through Dynamic Client Registration (DCR), allowing unauthorized redirect URIs to be used in authorization requests.

If I understand this correctly this bypasses the intended design of allowed_client_redirect_uris where you can limit what redirect loopback uri mcp clients can utilise.

Setting the list to empty [] or even including specific uris doesn't appear to work, below is an example segment for what I'm trying to test this where I'm block everything but the "https://claude.ai/api/mcp/auth_callback" URI.

auth_provider = AzureProvider(
    client_id=os.getenv("AZURE_OAUTH_CLIENT_ID"),
    client_secret=os.getenv("AZURE_OAUTH_CLIENT_SECRET"),
    tenant_id=os.getenv("AZURE_TENANT_ID"),
    base_url=os.getenv("MCP_SERVER_BASE_URL"),
    required_scopes=["read"],
    allowed_client_redirect_uris=[
         "https://claude.ai/api/mcp/auth_callback",
    ],
)

From the troubleshooting I've done myself I believe the main culprit is that at src/fastmcp/server/auth/oauth_proxy/models.py

When a redirect URI doesn't match allowed_patterns, it falls back to super().validate_redirect_uri() (from OAuthClientInformationFull), which only checks if the URI was registered during DCR. This creates a bypass: any URI registered via DCR is automatically validated, regardless of the allowed_client_redirect_uris restriction.

Below is the section in models.py Lines 160 - 179

    def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
        """Validate redirect URI against allowed patterns.

        Since we're acting as a proxy and clients register dynamically,
        we validate their redirect URIs against configurable patterns.
        This is essential for cached token scenarios where the client may
        reconnect with a different port.
        """
        if redirect_uri is not None:
            # Validate against allowed patterns
            if validate_redirect_uri(
                redirect_uri=redirect_uri,
                allowed_patterns=self.allowed_redirect_uri_patterns,
            ):
                return redirect_uri
            # Fall back to normal validation if not in allowed patterns
            return super().validate_redirect_uri(redirect_uri)
        # If no redirect_uri provided, use default behavior
        return super().validate_redirect_uri(redirect_uri)

DCR Client registration section in src/fastmcp/server/auth/oauth_proxy/proxy.py

Lines 572 - 620

    @override
    async def register_client(self, client_info: OAuthClientInformationFull) -> None:
        """Register a client locally

        When a client registers, we create a ProxyDCRClient that is more
        forgiving about validating redirect URIs, since the DCR client's
        redirect URI will likely be localhost or unknown to the proxied IDP. The
        proxied IDP only knows about this server's fixed redirect URI.
        """

        # Create a ProxyDCRClient with configured redirect URI validation
        if client_info.client_id is None:
            raise ValueError("client_id is required for client registration")
        # We use token_endpoint_auth_method="none" because the proxy handles
        # all upstream authentication. The client_secret must also be None
        # because the SDK requires secrets to be provided if they're set,
        # regardless of auth method.
        proxy_client: ProxyDCRClient = ProxyDCRClient(
            client_id=client_info.client_id,
            client_secret=None,
            redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
            grant_types=client_info.grant_types
            or ["authorization_code", "refresh_token"],
            scope=client_info.scope or self._default_scope_str,
            token_endpoint_auth_method="none",
            allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
            client_name=getattr(client_info, "client_name", None),
        )

I haven't managed to get as far as approving the consent screen as I've been testing completing a registration just using DCR to add a disallowed redirect uri but theoretically a user could click to consent to the malicious redirect uri as per below which I got with my test example server code below.

INFO:     127.0.0.1:57536 - "POST /register HTTP/1.1" 201 Created
INFO:     127.0.0.1:57536 - "GET /authorize?client_id=c3438fa4-a0f3-4f3d-b8fc-9d2feb722f83&redirect_uri=https://evil.com/steal-authorization-codes&response_type=code&scope=read&state=test456&code_challenge=Lt3mTWF8q8c1lK8l26cNYD9LT6RiaS5yVvhpd6Arm60&code_challenge_method=S256 HTTP/1.1" 302 Found

Expectation:

My expectation is that the MCP server should deny the client attempting to register with the disallowed client redirect uri.

===

I'm not a seasoned python developer by any means so please tell me where I'm wrong with this and I hope this makes sense :), I'm currently trying to fully understand how to best allow only specific clients to use an MCP server for my organisation and this seems like a piece of that puzzle.

I believe this is the case for 2. versions and the 3. version (The one I tested with)

P.S. Your talk on "Your MCP server is bad (and you should feel bad)" was great :)

Example Code

import os

from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider

# Load environment variables from .env file
load_dotenv()

# The AzureProvider handles Azure's token format and validation
auth_provider = AzureProvider(
    client_id=os.getenv("AZURE_OAUTH_CLIENT_ID"),
    client_secret=os.getenv("AZURE_OAUTH_CLIENT_SECRET"),
    tenant_id=os.getenv("AZURE_TENANT_ID"),
    base_url=os.getenv("MCP_SERVER_BASE_URL"),
    required_scopes=["read"],
    allowed_client_redirect_uris=[
         "https://claude.ai/api/mcp/auth_callback",
    ]
)

# Create the MCP server instance
mcp = FastMCP(os.getenv("MCP_SERVER_NAME", "MCP Server"), 
              auth=auth_provider)

Version Information

FastMCP version:                                                                  3.0.0b1
MCP version:                                                                       1.26.0
Python version:                                                                    3.12.3
Platform:                    Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39
FastMCP root path: /home/REDACTED/REDACTED/.venv/lib/python3.12/site-packages

Metadata

Metadata

Assignees

No one assigned

    Labels

    authRelated to authentication (Bearer, JWT, OAuth, WorkOS) for client or server.bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions