mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
99bf81c64f
commit
f21e51794c
22 changed files with 330 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue