Allow auth providers to override protected resource base URLs (#3900)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Adam Azzam 2026-04-13 18:36:55 +01:00 committed by GitHub
commit f21e51794c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 330 additions and 7 deletions

View file

@ -117,6 +117,12 @@ mcp = FastMCP(name="My Server", auth=auth)
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level).
</ParamField>
<ParamField body="resource_base_url" type="AnyHttpUrl | str | None">
Optional public base URL for the protected resource metadata and token audience.
Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
</ParamField>
<ParamField body="redirect_path" type="str" default="/auth/callback">
Path for OAuth callbacks. Must match the redirect URI configured in your OAuth
application

View file

@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth)
Public URL of your FastMCP server (e.g., `https://your-server.com`)
</ParamField>
<ParamField body="resource_base_url" type="AnyHttpUrl | str | None">
Optional public base URL for the protected resource metadata and token audience.
Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
</ParamField>
<ParamField body="strict" type="bool | None">
Strict flag for configuration validation. When True, requires all OIDC
mandatory fields.

View file

@ -115,6 +115,12 @@ mcp = FastMCP(name="My Server", auth=auth)
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level).
</ParamField>
<ParamField body="resource_base_url" type="AnyHttpUrl | str | None">
Optional public base URL for the protected resource metadata and token audience.
Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
</ParamField>
<ParamField body="redirect_path" type="str" default="/auth/callback">
Path for OAuth callbacks. Must match the redirect URI configured in your OAuth
application

View file

@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth)
Public URL of your FastMCP server (e.g., `https://your-server.com`)
</ParamField>
<ParamField body="resource_base_url" type="AnyHttpUrl | str | None">
Optional public base URL for the protected resource metadata and token audience.
Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
</ParamField>
<ParamField body="strict" type="bool | None">
Strict flag for configuration validation. When True, requires all OIDC
mandatory fields.

View file

@ -217,6 +217,7 @@ class AuthProvider(TokenVerifierProtocol):
self,
base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
resource_base_url: AnyHttpUrl | str | None = None,
):
"""
Initialize the auth provider.
@ -224,11 +225,21 @@ class AuthProvider(TokenVerifierProtocol):
Args:
base_url: The base URL of this server (e.g., http://localhost:8000).
This is used for constructing .well-known endpoints and OAuth metadata.
resource_base_url: Optional public base URL for the protected resource.
When provided, the resource URL advertised in protected resource
metadata (RFC 9728) is derived from this URL instead of ``base_url``,
while operational OAuth routes remain rooted at ``base_url``.
Providers that mint their own downstream tokens (e.g. ``OAuthProxy``)
also use this as the minted token audience. Upstream token audience
validation is configured separately on the token verifier.
required_scopes: List of OAuth scopes required for all requests.
"""
if isinstance(base_url, str):
base_url = AnyHttpUrl(base_url)
if isinstance(resource_base_url, str):
resource_base_url = AnyHttpUrl(resource_base_url)
self.base_url = base_url
self.resource_base_url = resource_base_url
self.required_scopes = required_scopes or []
self._mcp_path: str | None = None
self._resource_url: AnyHttpUrl | None = None
@ -332,20 +343,24 @@ class AuthProvider(TokenVerifierProtocol):
def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None:
"""Get the actual resource URL being protected.
Uses ``resource_base_url`` if set; otherwise falls back to
``base_url``.
Args:
path: The path where the resource endpoint is mounted (e.g., "/mcp")
Returns:
The full URL of the protected resource
"""
if self.base_url is None:
resource_base_url = self.resource_base_url or self.base_url
if resource_base_url is None:
return None
if path:
prefix = str(self.base_url).rstrip("/")
prefix = str(resource_base_url).rstrip("/")
suffix = path.lstrip("/")
return AnyHttpUrl(f"{prefix}/{suffix}")
return self.base_url
return resource_base_url
class TokenVerifier(AuthProvider):
@ -359,15 +374,25 @@ class TokenVerifier(AuthProvider):
self,
base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
resource_base_url: AnyHttpUrl | str | None = None,
):
"""
Initialize the token verifier.
Args:
base_url: The base URL of this server
resource_base_url: Optional public base URL for the protected resource.
When provided, the resource URL advertised in protected resource
metadata is derived from this URL instead of ``base_url``. Does not
configure upstream token audience validation set ``audience`` on
your verifier to match.
required_scopes: Scopes that are required for all requests
"""
super().__init__(base_url=base_url, required_scopes=required_scopes)
super().__init__(
base_url=base_url,
resource_base_url=resource_base_url,
required_scopes=required_scopes,
)
@property
def scopes_supported(self) -> list[str]:
@ -406,6 +431,7 @@ class RemoteAuthProvider(AuthProvider):
authorization_servers: list[AnyHttpUrl],
base_url: AnyHttpUrl | str,
scopes_supported: list[str] | None = None,
resource_base_url: AnyHttpUrl | str | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
):
@ -415,6 +441,12 @@ class RemoteAuthProvider(AuthProvider):
token_verifier: TokenVerifier instance for token validation
authorization_servers: List of authorization servers that issue valid tokens
base_url: The base URL of this server
resource_base_url: Optional public base URL for the protected resource.
When provided, the resource URL advertised in protected resource
metadata is derived from this URL instead of ``base_url``. Does not
configure the token verifier's audience — set ``audience`` on the
verifier to match if you want validated tokens bound to the same
resource.
scopes_supported: Scopes to advertise in OAuth metadata. If None,
uses the token verifier's scopes_supported property. Use this
when the scopes clients request differ from the scopes that
@ -424,6 +456,7 @@ class RemoteAuthProvider(AuthProvider):
"""
super().__init__(
base_url=base_url,
resource_base_url=resource_base_url,
required_scopes=token_verifier.required_scopes,
)
self.token_verifier = token_verifier
@ -497,6 +530,7 @@ class MultiAuth(AuthProvider):
server: AuthProvider | None = None,
verifiers: list[TokenVerifier] | TokenVerifier | None = None,
base_url: AnyHttpUrl | str | None = None,
resource_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
):
"""Initialize the multi-auth provider.
@ -507,6 +541,8 @@ class MultiAuth(AuthProvider):
the first verifier tried.
verifiers: One or more token verifiers to try after the server.
base_url: Override the base URL. Defaults to the server's base_url.
resource_base_url: Override the protected resource base URL. Defaults
to the server's resource_base_url when available.
required_scopes: Override required scopes. Defaults to the server's.
"""
if verifiers is None:
@ -518,16 +554,29 @@ class MultiAuth(AuthProvider):
raise ValueError("MultiAuth requires at least a server or one verifier")
effective_base_url = base_url or (server.base_url if server else None)
effective_resource_base_url = resource_base_url or (
server.resource_base_url if server else None
)
effective_scopes = (
required_scopes
if required_scopes is not None
else (server.required_scopes if server else None)
)
super().__init__(base_url=effective_base_url, required_scopes=effective_scopes)
super().__init__(
base_url=effective_base_url,
resource_base_url=effective_resource_base_url,
required_scopes=effective_scopes,
)
self.server = server
self.verifiers = list(verifiers)
# If an explicit resource_base_url override was passed to MultiAuth,
# propagate it to the wrapped server so its routes advertise metadata
# consistent with the outer auth challenge URL.
if resource_base_url is not None and self.server is not None:
self.server.resource_base_url = self.resource_base_url
self._sources: list[AuthProvider] = []
if self.server is not None:
self._sources.append(self.server)
@ -593,6 +642,7 @@ class OAuthProvider(
self,
*,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
@ -604,6 +654,9 @@ class OAuthProvider(
Args:
base_url: The public URL of this FastMCP server
resource_base_url: Optional public base URL for the protected resource.
When provided, the protected resource metadata and token audience are
derived from this URL instead of ``base_url``.
issuer_url: The issuer URL for OAuth metadata (defaults to base_url)
service_documentation_url: The URL of the service documentation.
client_registration_options: The client registration options.
@ -611,7 +664,11 @@ class OAuthProvider(
required_scopes: Scopes that are required for all requests.
"""
super().__init__(base_url=base_url, required_scopes=required_scopes)
super().__init__(
base_url=base_url,
resource_base_url=resource_base_url,
required_scopes=required_scopes,
)
if issuer_url is None:
self.issuer_url = self.base_url

View file

@ -240,6 +240,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_verifier: TokenVerifier,
# FastMCP server configuration
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
@ -281,6 +282,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_verifier: Token verifier for validating access tokens
base_url: Public URL of the server that exposes this FastMCP server; redirect path is
relative to this URL
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
issuer_url: Issuer URL for OAuth metadata (defaults to base_url)
service_documentation_url: Optional service documentation URL
@ -343,6 +346,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
super().__init__(
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,

View file

@ -214,6 +214,7 @@ class OIDCProxy(OAuthProxy):
verify_id_token: bool = False,
# FastMCP server configuration
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
# Client configuration
@ -255,6 +256,8 @@ class OIDCProxy(OAuthProxy):
Useful for providers that issue opaque (non-JWT) access tokens, since the
id_token is always a standard JWT verifiable via the provider's JWKS.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
@ -370,6 +373,7 @@ class OIDCProxy(OAuthProxy):
"upstream_revocation_endpoint": revocation_endpoint,
"token_verifier": token_verifier,
"base_url": base_url,
"resource_base_url": resource_base_url,
"issuer_url": issuer_url or base_url,
"service_documentation_url": self.oidc_config.service_documentation,
"allowed_client_redirect_uris": allowed_client_redirect_uris,

View file

@ -65,6 +65,7 @@ class Auth0Provider(OIDCProxy):
client_secret: str,
audience: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
redirect_path: str | None = None,
@ -83,6 +84,8 @@ class Auth0Provider(OIDCProxy):
client_secret: Auth0 application client secret
audience: Auth0 API audience
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
required_scopes: Required Auth0 scopes (defaults to ["openid"])
@ -113,6 +116,7 @@ class Auth0Provider(OIDCProxy):
client_secret=client_secret,
audience=audience,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=auth0_required_scopes,

View file

@ -125,6 +125,7 @@ class AWSCognitoProvider(OIDCProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
aws_region: str = "eu-central-1",
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str = "/auth/callback",
@ -143,6 +144,8 @@ class AWSCognitoProvider(OIDCProxy):
client_id: Cognito app client ID
client_secret: Cognito app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
@ -184,6 +187,7 @@ class AWSCognitoProvider(OIDCProxy):
algorithm="RS256",
required_scopes=required_scopes_final,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -24,6 +24,7 @@ if TYPE_CHECKING:
from azure.identity.aio import OnBehalfOfCredential
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import AuthProvider
@ -103,6 +104,7 @@ class AzureProvider(OAuthProxy):
tenant_id: str,
required_scopes: list[str],
base_url: str,
resource_base_url: AnyHttpUrl | str | None = None,
identifier_uri: str | None = None,
issuer_url: str | None = None,
redirect_path: str | None = None,
@ -131,6 +133,8 @@ class AzureProvider(OAuthProxy):
Example: identifier_uri="api://my-api" + required_scopes=["read"]
tokens validated for "api://my-api/read"
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
@ -242,6 +246,7 @@ class AzureProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -277,6 +277,7 @@ class ClerkProvider(OAuthProxy):
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@ -301,6 +302,8 @@ class ClerkProvider(OAuthProxy):
client_secret: Clerk OAuth application client secret.
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback")
@ -364,6 +367,7 @@ class ClerkProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url,
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -196,6 +196,7 @@ class DiscordProvider(OAuthProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@ -215,6 +216,8 @@ class DiscordProvider(OAuthProxy):
client_id: Discord OAuth client ID (e.g., "123456789")
client_secret: Discord OAuth client secret (e.g., "S....")
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
@ -266,6 +269,7 @@ class DiscordProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -209,6 +209,7 @@ class GitHubProvider(OAuthProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@ -230,6 +231,8 @@ class GitHubProvider(OAuthProxy):
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
client_secret: GitHub OAuth app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
@ -281,6 +284,7 @@ class GitHubProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -235,6 +235,7 @@ class GoogleProvider(OAuthProxy):
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@ -258,6 +259,8 @@ class GoogleProvider(OAuthProxy):
Optional for PKCE public clients (e.g., native apps). When omitted,
jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
@ -341,6 +344,7 @@ class GoogleProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -37,6 +37,7 @@ class InMemoryOAuthProvider(OAuthProvider):
def __init__(
self,
base_url: AnyHttpUrl | str | None = None,
resource_base_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
revocation_options: RevocationOptions | None = None,
@ -44,6 +45,7 @@ class InMemoryOAuthProvider(OAuthProvider):
):
super().__init__(
base_url=base_url or "http://fastmcp.example.com",
resource_base_url=resource_base_url,
service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,
revocation_options=revocation_options,

View file

@ -123,6 +123,7 @@ class OCIProvider(OIDCProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
audience: str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
@ -141,6 +142,8 @@ class OCIProvider(OIDCProxy):
client_id: OCI IAM Domain Integrated Application client id
client_secret: OCI Integrated Application client secret
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
audience: OCI API audience (optional)
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
required_scopes: Required OCI scopes (defaults to ["openid"])
@ -158,6 +161,7 @@ class OCIProvider(OIDCProxy):
client_secret=client_secret,
audience=audience,
base_url=base_url,
resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=oci_required_scopes,

View file

@ -164,6 +164,7 @@ class WorkOSProvider(OAuthProxy):
client_secret: str,
authkit_domain: str,
base_url: AnyHttpUrl | str,
resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@ -184,6 +185,8 @@ class WorkOSProvider(OAuthProxy):
client_secret: WorkOS client secret
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
resource_base_url: Optional public base URL for the protected resource metadata
and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback")
@ -234,6 +237,7 @@ class WorkOSProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,

View file

@ -410,6 +410,44 @@ class TestResourceURLValidation:
assert proxy.jwt_issuer.audience == "https://proxy.example.com/"
def test_set_mcp_path_uses_resource_base_url_for_audience(self, jwt_verifier):
"""Test that resource_base_url controls the protected resource audience."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://oauth.example.com/authorize",
upstream_token_endpoint="https://oauth.example.com/token",
upstream_client_id="upstream-client",
upstream_client_secret="upstream-secret",
token_verifier=jwt_verifier,
base_url="https://proxy.example.com/oauth",
resource_base_url="https://api.example.com",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
proxy.set_mcp_path("/mcp")
assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth"
assert proxy.jwt_issuer.audience == "https://api.example.com/mcp"
def test_set_mcp_path_none_uses_resource_base_url_for_audience(self, jwt_verifier):
"""Test that resource_base_url is used as audience when mcp_path is None."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://oauth.example.com/authorize",
upstream_token_endpoint="https://oauth.example.com/token",
upstream_client_id="upstream-client",
upstream_client_secret="upstream-secret",
token_verifier=jwt_verifier,
base_url="https://proxy.example.com/oauth",
resource_base_url="https://api.example.com",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
)
proxy.set_mcp_path(None)
assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth"
assert proxy.jwt_issuer.audience == "https://api.example.com/"
def test_jwt_issuer_property_raises_if_not_initialized(self, jwt_verifier):
"""Test that jwt_issuer property raises if set_mcp_path not called."""
proxy = OAuthProxy(

View file

@ -57,6 +57,23 @@ class TestGitHubProvider:
# The required_scopes should be passed to the token verifier
assert provider._token_validator.required_scopes == ["user"]
def test_init_with_resource_base_url(self, memory_storage: MemoryStore):
"""Test that resource_base_url overrides the advertised protected resource."""
provider = GitHubProvider(
client_id="test_client",
client_secret="test_secret",
base_url="https://auth.example.com/proxy",
resource_base_url="https://api.example.com",
jwt_signing_key="test-secret",
client_storage=memory_storage,
)
provider.set_mcp_path("/mcp")
assert str(provider.base_url) == "https://auth.example.com/proxy"
assert str(provider.resource_base_url) == "https://api.example.com/"
assert provider.jwt_issuer.audience == "https://api.example.com/mcp"
class TestGitHubTokenVerifier:
"""Test GitHubTokenVerifier."""

View file

@ -5,13 +5,36 @@ import pytest
from pydantic import AnyHttpUrl
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
class LegacyTokenVerifier(TokenVerifier):
"""Mimics custom verifiers that still call the old positional super().__init__."""
def __init__(
self,
base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
):
super().__init__(base_url, required_scopes)
async def verify_token(self, token: str) -> AccessToken | None:
return None
class TestAuthProviderBase:
"""Test suite for base AuthProvider behaviors that apply to all auth providers."""
def test_token_verifier_preserves_legacy_positional_required_scopes(self):
"""Legacy positional super().__init__(base_url, required_scopes) should keep working."""
verifier = LegacyTokenVerifier("https://my-server.com", ["read"])
assert verifier.base_url == AnyHttpUrl("https://my-server.com/")
assert verifier.required_scopes == ["read"]
assert verifier.resource_base_url is None
@pytest.fixture
def basic_remote_provider(self):
"""Basic RemoteAuthProvider fixture for testing base AuthProvider behaviors."""

View file

@ -66,6 +66,34 @@ class TestMultiAuthInit:
auth = MultiAuth(server=provider, base_url="https://override.example.com")
assert auth.base_url == AnyHttpUrl("https://override.example.com/")
def test_resource_base_url_from_server(self):
verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
provider = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://auth.example.com/proxy",
resource_base_url="https://api.example.com",
)
auth = MultiAuth(server=provider)
assert auth.resource_base_url == AnyHttpUrl("https://api.example.com/")
def test_resource_base_url_override(self):
verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
provider = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://auth.example.com/proxy",
resource_base_url="https://api.example.com",
)
auth = MultiAuth(
server=provider,
resource_base_url="https://override.example.com",
)
assert auth.resource_base_url == AnyHttpUrl("https://override.example.com/")
# Override must propagate to the wrapped server so get_routes()
# serves metadata consistent with the outer auth challenge URL.
assert provider.resource_base_url == AnyHttpUrl("https://override.example.com/")
def test_required_scopes_from_server(self):
verifier = StaticTokenVerifier(
tokens={"t": {"client_id": "c", "scopes": ["read"]}},
@ -328,6 +356,65 @@ class TestMultiAuthIntegration:
data = response.json()
assert data["resource"] == "https://api.example.com/mcp"
async def test_multi_auth_uses_server_resource_base_url_in_auth_challenge(self):
"""Auth challenges should advertise resource metadata from resource_base_url."""
verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
server = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://auth.example.com/proxy",
resource_base_url="https://api.example.com",
)
auth = MultiAuth(server=server)
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://localhost",
) as client:
response = await client.get("/mcp")
assert response.status_code == 401
assert (
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
in response.headers["www-authenticate"]
)
async def test_multi_auth_override_propagates_to_served_metadata(self):
"""Override on MultiAuth must propagate so served metadata matches the challenge."""
verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
server = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://auth.example.com/proxy",
)
auth = MultiAuth(server=server, resource_base_url="https://api.example.com")
mcp = FastMCP("test", auth=auth)
app = mcp.http_app(path="/mcp")
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://localhost",
) as client:
response = await client.get("/mcp")
assert response.status_code == 401
assert (
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
in response.headers["www-authenticate"]
)
metadata_response = await client.get(
"/.well-known/oauth-protected-resource/mcp"
)
assert metadata_response.status_code == 200
assert metadata_response.json()["resource"] == "https://api.example.com/mcp"
old_path_response = await client.get(
"/.well-known/oauth-protected-resource/proxy/mcp"
)
assert old_path_response.status_code == 404
async def test_multi_auth_accepts_valid_verifier_token(self):
"""MultiAuth accepts tokens from verifiers (not just the server).

View file

@ -150,6 +150,36 @@ class TestRemoteAuthProvider:
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
)
def test_get_resource_url_uses_resource_base_url_when_provided(self, test_tokens):
"""Test protected resource URLs are derived from resource_base_url when provided."""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
provider = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://auth.example.com/proxy",
resource_base_url="https://api.example.com",
)
assert provider._get_resource_url("/mcp") == AnyHttpUrl(
"https://api.example.com/mcp"
)
def test_init_preserves_legacy_positional_scopes_supported_slot(self, test_tokens):
"""Legacy positional scopes_supported should not bind to resource_base_url."""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
provider = RemoteAuthProvider(
token_verifier,
[AnyHttpUrl("https://auth.example.com")],
"https://api.example.com",
["read"],
)
assert provider._scopes_supported == ["read"]
assert provider.resource_base_url is None
assert provider._get_resource_url("/mcp") == AnyHttpUrl(
"https://api.example.com/mcp"
)
class TestRemoteAuthProviderIntegration:
"""Integration tests for RemoteAuthProvider with FastMCP server."""