mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Use issuer_url for OAuth issuer identity, not base_url
This commit is contained in:
parent
2a93404e8c
commit
c4c72ac240
9 changed files with 367 additions and 22 deletions
|
|
@ -544,7 +544,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix
|
|||
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
|
||||
```
|
||||
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
|
||||
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. It sets the `issuer` advertised in the authorization server metadata and the `iss` on issued tokens, while the endpoints in that metadata continue to point at `base_url`.
|
||||
|
||||
```python
|
||||
# Usually not needed - just set base_url and it works
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ mcp = FastMCP(name="My Server", auth=auth)
|
|||
<ParamField body="issuer_url" type="AnyHttpUrl | str | None">
|
||||
Issuer URL for OAuth authorization server metadata (defaults to `base_url`).
|
||||
|
||||
`issuer_url` is the server's OAuth identity: it is the `issuer` field of the authorization server metadata, the `iss` claim of the tokens the proxy mints, and the RFC 9207 `iss` parameter on authorization responses. `base_url` remains the location of the endpoints, so `authorization_endpoint`, `token_endpoint`, and the rest of the metadata still point at `base_url` where the routes are actually mounted.
|
||||
|
||||
When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`.
|
||||
|
||||
**Default behavior (recommended for most cases):**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import json
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from mcp.server.auth.handlers.metadata import MetadataHandler
|
||||
from mcp.server.auth.handlers.token import TokenErrorResponse
|
||||
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
|
|
@ -30,6 +31,7 @@ from mcp.server.auth.provider import (
|
|||
TokenVerifier as TokenVerifierProtocol,
|
||||
)
|
||||
from mcp.server.auth.routes import (
|
||||
build_metadata,
|
||||
cors_middleware,
|
||||
create_auth_routes,
|
||||
create_protected_resource_routes,
|
||||
|
|
@ -892,10 +894,10 @@ class OAuthProvider(
|
|||
# Configure resource URL before creating routes
|
||||
self.set_mcp_path(mcp_path)
|
||||
|
||||
# Create standard OAuth authorization server routes
|
||||
# Pass base_url as issuer_url to ensure metadata declares endpoints where
|
||||
# they're actually accessible (operational routes are mounted at
|
||||
# base_url)
|
||||
# Create standard OAuth authorization server routes. Pass base_url so
|
||||
# the SDK mounts operational routes and declares endpoint URLs where
|
||||
# they're actually accessible; the metadata route is replaced below so
|
||||
# that the advertised `issuer` reports issuer_url instead.
|
||||
assert self.base_url is not None # typing check
|
||||
assert (
|
||||
self.issuer_url is not None
|
||||
|
|
@ -914,6 +916,35 @@ class OAuthProvider(
|
|||
oauth_routes: list[Route] = []
|
||||
for route in sdk_routes:
|
||||
if (
|
||||
isinstance(route, Route)
|
||||
and route.path == "/.well-known/oauth-authorization-server"
|
||||
):
|
||||
# The SDK bakes the metadata into the handler when it builds the
|
||||
# route, and derives both `issuer` and every endpoint URL from a
|
||||
# single argument. Rebuild it here so the endpoints stay on
|
||||
# base_url (where the routes are mounted) while `issuer`
|
||||
# reports issuer_url — the identifier clients used for RFC 8414
|
||||
# discovery, which §3.3 requires the metadata to match.
|
||||
metadata = build_metadata(
|
||||
self.base_url,
|
||||
self.service_documentation_url,
|
||||
self.client_registration_options or ClientRegistrationOptions(),
|
||||
self.revocation_options or RevocationOptions(),
|
||||
)
|
||||
metadata.issuer = self.issuer_url
|
||||
metadata_handler = MetadataHandler(metadata)
|
||||
oauth_routes.append(
|
||||
Route(
|
||||
path=route.path,
|
||||
endpoint=cors_middleware(
|
||||
metadata_handler.handle, ["GET", "OPTIONS"]
|
||||
),
|
||||
methods=route.methods or ["GET", "OPTIONS"],
|
||||
name=route.name,
|
||||
include_in_schema=route.include_in_schema,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
isinstance(route, Route)
|
||||
and route.path == "/token"
|
||||
and route.methods is not None
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ class AuthorizationHandler(SDKAuthorizationHandler):
|
|||
self,
|
||||
provider: OAuthAuthorizationServerProvider,
|
||||
base_url: AnyHttpUrl | str,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
server_name: str | None = None,
|
||||
server_icon_url: str | None = None,
|
||||
):
|
||||
|
|
@ -187,14 +188,17 @@ class AuthorizationHandler(SDKAuthorizationHandler):
|
|||
Args:
|
||||
provider: OAuth authorization server provider
|
||||
base_url: Base URL of the server for constructing endpoint URLs
|
||||
issuer_url: Authorization server issuer identifier. Defaults to
|
||||
`base_url`, which is correct whenever the server's identity and
|
||||
its endpoint locations are the same URL.
|
||||
server_name: Optional server name for branding
|
||||
server_icon_url: Optional server icon URL for branding
|
||||
"""
|
||||
super().__init__(provider)
|
||||
# Unnormalized on purpose: this must match the discovery document's
|
||||
# `issuer` field byte-for-byte per RFC 9207, and that field is built
|
||||
# from the same unmodified base_url (see OAuthProxy.get_routes()).
|
||||
self._issuer = str(base_url)
|
||||
# from the same unmodified issuer_url (see OAuthProxy.get_routes()).
|
||||
self._issuer = str(issuer_url if issuer_url is not None else base_url)
|
||||
self._base_url = str(base_url).rstrip("/")
|
||||
self._server_name = server_name
|
||||
self._server_icon_url = server_icon_url
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ class ConsentMixin:
|
|||
url=build_client_redirect(
|
||||
txn["client_redirect_uri"],
|
||||
callback_params,
|
||||
iss=str(self.base_url),
|
||||
iss=str(self.issuer_url),
|
||||
),
|
||||
status_code=302,
|
||||
)
|
||||
|
|
@ -532,7 +532,7 @@ class ConsentMixin:
|
|||
"state": txn.get("client_state") or "",
|
||||
}
|
||||
client_callback_url = build_client_redirect(
|
||||
txn["client_redirect_uri"], callback_params, iss=str(self.base_url)
|
||||
txn["client_redirect_uri"], callback_params, iss=str(self.issuer_url)
|
||||
)
|
||||
response = RedirectResponse(url=client_callback_url, status_code=302)
|
||||
|
||||
|
|
|
|||
|
|
@ -689,13 +689,15 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
)
|
||||
|
||||
# Identity assertion (SEP-990 ID-JAG): the audience the ID-JAG must be
|
||||
# bound to is this authorization server's own issuer URL (base_url).
|
||||
# bound to is this authorization server's own issuer identifier, which
|
||||
# is `issuer_url` (defaulting to `base_url`) — the same value advertised
|
||||
# as `issuer` in the authorization server metadata.
|
||||
self._identity_assertion: IdentityAssertion | None = identity_assertion
|
||||
self._identity_assertion_validator: IdentityAssertionValidator | None = None
|
||||
if identity_assertion is not None:
|
||||
self._identity_assertion_validator = IdentityAssertionValidator(
|
||||
config=identity_assertion,
|
||||
audience=str(self.base_url),
|
||||
audience=str(self.issuer_url),
|
||||
)
|
||||
# ID-JAG access tokens are self-contained (no upstream token or JTI
|
||||
# mapping to delete), so revocation tracks their jtis here until the
|
||||
|
|
@ -758,9 +760,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
super().set_mcp_path(mcp_path)
|
||||
|
||||
# Create JWT issuer with correct audience based on actual MCP path
|
||||
# This ensures tokens are bound to the specific resource URL
|
||||
# This ensures tokens are bound to the specific resource URL. The `iss`
|
||||
# claim is the authorization server's issuer identifier (`issuer_url`),
|
||||
# which matches the `issuer` advertised in the metadata document.
|
||||
self._jwt_issuer = JWTIssuer(
|
||||
issuer=str(self.base_url),
|
||||
issuer=str(self.issuer_url),
|
||||
audience=str(self._resource_url),
|
||||
signing_key=self._jwt_signing_key,
|
||||
)
|
||||
|
|
@ -2380,6 +2384,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
authorize_handler = AuthorizationHandler(
|
||||
provider=self,
|
||||
base_url=self.base_url, # ty: ignore[invalid-argument-type]
|
||||
issuer_url=self.issuer_url,
|
||||
server_name=None, # Could be extended to pass server metadata
|
||||
server_icon_url=None,
|
||||
)
|
||||
|
|
@ -2468,6 +2473,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
revocation_options,
|
||||
supports_identity_assertion=self._identity_assertion is not None,
|
||||
)
|
||||
# `build_metadata` derives both the `issuer` field and every
|
||||
# endpoint URL from a single argument. Endpoints must stay on
|
||||
# `base_url` (that is where the routes are actually mounted),
|
||||
# while the issuer identity is `issuer_url`. RFC 8414 §3.3
|
||||
# requires `issuer` to match the URL the client used for
|
||||
# discovery, which is the `issuer_url` advertised in the
|
||||
# protected resource metadata.
|
||||
metadata.issuer = self.issuer_url # ty: ignore[invalid-assignment]
|
||||
# RFC 9207: every authorization response we issue carries an
|
||||
# `iss` matching this issuer byte-for-byte, so this route must
|
||||
# always be overridden to advertise support — not just when
|
||||
|
|
@ -2585,7 +2598,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
url=build_client_redirect(
|
||||
client_redirect_uri,
|
||||
error_params,
|
||||
iss=str(self.base_url),
|
||||
iss=str(self.issuer_url),
|
||||
),
|
||||
status_code=302,
|
||||
)
|
||||
|
|
@ -2753,7 +2766,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
}
|
||||
|
||||
client_callback_url = build_client_redirect(
|
||||
client_redirect_uri, callback_params, iss=str(self.base_url)
|
||||
client_redirect_uri, callback_params, iss=str(self.issuer_url)
|
||||
)
|
||||
|
||||
logger.debug(f"Forwarding to client callback for transaction {txn_id}")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|||
self,
|
||||
base_url: AnyHttpUrl | str | None = None,
|
||||
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,
|
||||
revocation_options: RevocationOptions | None = None,
|
||||
|
|
@ -46,6 +47,7 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|||
super().__init__(
|
||||
base_url=base_url or "http://fastmcp.example.com",
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
service_documentation_url=service_documentation_url,
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
|
|
|
|||
294
tests/server/auth/test_issuer_url_identity.py
Normal file
294
tests/server/auth/test_issuer_url_identity.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""Tests that `issuer_url` is authoritative for authorization server identity.
|
||||
|
||||
Regression tests for #4610. `issuer_url` lets the OAuth issuer identity differ
|
||||
from `base_url`, which is where the OAuth endpoints are actually mounted. The
|
||||
issuer identity — the `issuer` field of the authorization server metadata, the
|
||||
`iss` claim of minted tokens, and the RFC 9207 `iss` authorization response
|
||||
parameter — must come from `issuer_url`, while every endpoint URL must keep
|
||||
coming from `base_url`.
|
||||
|
||||
RFC 8414 §3.3 is the reason this matters: the protected resource metadata points
|
||||
clients at `issuer_url`, the client performs discovery there, and the `issuer`
|
||||
in the returned metadata must match the identifier used for discovery.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.auth import AccessToken, TokenVerifier
|
||||
from fastmcp.server.auth.identity_assertion import IdentityAssertion
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
||||
|
||||
# The server is mounted under /api, so its endpoints live at BASE_URL while its
|
||||
# issuer identity is the root of the same host.
|
||||
BASE_URL = "https://api.example.com/api"
|
||||
ISSUER_URL = "https://api.example.com"
|
||||
|
||||
# Pydantic renders a bare-authority AnyHttpUrl with a trailing slash.
|
||||
ISSUER = "https://api.example.com/"
|
||||
BASE_URL_ISSUER = "https://api.example.com/api"
|
||||
|
||||
|
||||
class _Verifier(TokenVerifier):
|
||||
"""Minimal token verifier."""
|
||||
|
||||
def __init__(self):
|
||||
self.required_scopes = ["read"]
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken:
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="client-id",
|
||||
scopes=self.required_scopes,
|
||||
expires_at=int(time.time() + 3600),
|
||||
)
|
||||
|
||||
|
||||
def build_proxy(issuer_url: str | None) -> OAuthProxy:
|
||||
"""Build an OAuth proxy mounted at BASE_URL, optionally with a distinct issuer."""
|
||||
return OAuthProxy(
|
||||
upstream_authorization_endpoint="https://upstream.example.com/authorize",
|
||||
upstream_token_endpoint="https://upstream.example.com/token",
|
||||
upstream_revocation_endpoint="https://upstream.example.com/revoke",
|
||||
upstream_client_id="client-id",
|
||||
upstream_client_secret="client-secret",
|
||||
token_verifier=_Verifier(),
|
||||
base_url=BASE_URL,
|
||||
issuer_url=issuer_url,
|
||||
client_storage=MemoryStore(),
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
|
||||
def build_provider(issuer_url: str | None) -> InMemoryOAuthProvider:
|
||||
"""Build a plain OAuth provider mounted at BASE_URL, optionally with a distinct issuer."""
|
||||
return InMemoryOAuthProvider(
|
||||
base_url=BASE_URL,
|
||||
issuer_url=issuer_url,
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True),
|
||||
revocation_options=RevocationOptions(enabled=True),
|
||||
)
|
||||
|
||||
|
||||
def build_mounted_app(auth_provider) -> Starlette:
|
||||
"""Mount an authenticated FastMCP server under /api with well-known routes at root."""
|
||||
mcp = FastMCP("test-server", auth=auth_provider)
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
return Starlette(
|
||||
routes=[
|
||||
*auth_provider.get_well_known_routes(mcp_path="/mcp"),
|
||||
Mount("/api", app=mcp_app),
|
||||
],
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_json(auth_provider, path: str) -> dict:
|
||||
"""Fetch a well-known document from a mounted authenticated server."""
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=build_mounted_app(auth_provider)),
|
||||
base_url=ISSUER_URL,
|
||||
) as client:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestOAuthProxyIssuerIdentity:
|
||||
"""`OAuthProxy` (and therefore `OIDCProxy`) identity comes from `issuer_url`."""
|
||||
|
||||
async def test_protected_resource_metadata_points_at_issuer_url(self):
|
||||
metadata = await fetch_json(
|
||||
build_proxy(ISSUER_URL), "/.well-known/oauth-protected-resource/api/mcp"
|
||||
)
|
||||
assert metadata["authorization_servers"] == [ISSUER]
|
||||
|
||||
async def test_authorization_server_metadata_issuer_is_issuer_url(self):
|
||||
metadata = await fetch_json(
|
||||
build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server"
|
||||
)
|
||||
assert metadata["issuer"] == ISSUER
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, expected",
|
||||
[
|
||||
("authorization_endpoint", f"{BASE_URL}/authorize"),
|
||||
("token_endpoint", f"{BASE_URL}/token"),
|
||||
("registration_endpoint", f"{BASE_URL}/register"),
|
||||
("revocation_endpoint", f"{BASE_URL}/revoke"),
|
||||
],
|
||||
)
|
||||
async def test_endpoints_stay_on_base_url(self, field: str, expected: str):
|
||||
metadata = await fetch_json(
|
||||
build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server"
|
||||
)
|
||||
assert metadata[field] == expected
|
||||
|
||||
async def test_minted_token_iss_claim_is_issuer_url(self):
|
||||
proxy = build_proxy(ISSUER_URL)
|
||||
# get_routes() configures the MCP path, which creates the JWT issuer.
|
||||
proxy.get_routes(mcp_path="/mcp")
|
||||
|
||||
token = proxy.jwt_issuer.issue_access_token(
|
||||
client_id="client-id", scopes=["read"], jti="test-jti"
|
||||
)
|
||||
|
||||
assert proxy.jwt_issuer.verify_token(token)["iss"] == ISSUER
|
||||
|
||||
async def test_authorization_response_iss_matches_metadata_issuer(self):
|
||||
"""RFC 9207: the `iss` on a client-facing response matches the metadata."""
|
||||
proxy = build_proxy(ISSUER_URL)
|
||||
redirect = "http://localhost:5100/callback"
|
||||
client = OAuthClientInformationFull(
|
||||
client_id="rfc9207-client",
|
||||
client_secret="s",
|
||||
redirect_uris=[AnyUrl(redirect)],
|
||||
)
|
||||
await proxy.register_client(client)
|
||||
consent_url = await proxy.authorize(
|
||||
client,
|
||||
AuthorizationParams(
|
||||
redirect_uri=AnyUrl(redirect),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
state="client-state",
|
||||
code_challenge="challenge",
|
||||
scopes=["read"],
|
||||
),
|
||||
)
|
||||
txn_id = parse_qs(urlparse(consent_url).query)["txn_id"][0]
|
||||
|
||||
app = Starlette(routes=proxy.get_routes())
|
||||
with TestClient(app) as test_client:
|
||||
metadata = test_client.get(
|
||||
"/.well-known/oauth-authorization-server"
|
||||
).json()
|
||||
|
||||
consent = test_client.get(f"/consent?txn_id={txn_id}")
|
||||
csrf_match = re.search(
|
||||
r"name=\"csrf_token\"\s+value=\"([^\"]+)\"", consent.text
|
||||
)
|
||||
assert csrf_match
|
||||
for name, value in consent.cookies.items():
|
||||
test_client.cookies.set(name, value)
|
||||
|
||||
denial = test_client.post(
|
||||
"/consent",
|
||||
data={
|
||||
"action": "deny",
|
||||
"txn_id": txn_id,
|
||||
"csrf_token": csrf_match.group(1),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert denial.status_code in (302, 303)
|
||||
params = parse_qs(urlparse(denial.headers["location"]).query)
|
||||
assert params["iss"] == [ISSUER]
|
||||
assert params["iss"] == [metadata["issuer"]]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"issuer_url, expected",
|
||||
[(ISSUER_URL, ISSUER), (None, BASE_URL_ISSUER)],
|
||||
)
|
||||
def test_identity_assertion_audience_is_issuer_identifier(
|
||||
self, issuer_url: str | None, expected: str
|
||||
):
|
||||
"""SEP-990: an ID-JAG is bound to the server's advertised issuer."""
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://upstream.example.com/authorize",
|
||||
upstream_token_endpoint="https://upstream.example.com/token",
|
||||
upstream_client_id="client-id",
|
||||
upstream_client_secret="client-secret",
|
||||
token_verifier=_Verifier(),
|
||||
base_url=BASE_URL,
|
||||
issuer_url=issuer_url,
|
||||
client_storage=MemoryStore(),
|
||||
jwt_signing_key="test-secret",
|
||||
identity_assertion=IdentityAssertion(
|
||||
trusted_issuers=["https://login.example.com"]
|
||||
),
|
||||
)
|
||||
|
||||
validator = proxy._identity_assertion_validator
|
||||
assert validator is not None
|
||||
assert validator.audience == [expected.rstrip("/"), f"{expected.rstrip('/')}/"]
|
||||
|
||||
|
||||
class TestOAuthProxyIssuerDefaults:
|
||||
"""With `issuer_url` unset, identity falls back to `base_url` as before."""
|
||||
|
||||
async def test_authorization_server_metadata_issuer_is_base_url(self):
|
||||
# base_url has a path, so RFC 8414 path-aware discovery applies.
|
||||
metadata = await fetch_json(
|
||||
build_proxy(None), "/.well-known/oauth-authorization-server/api"
|
||||
)
|
||||
assert metadata["issuer"] == BASE_URL_ISSUER
|
||||
|
||||
async def test_protected_resource_metadata_points_at_base_url(self):
|
||||
metadata = await fetch_json(
|
||||
build_proxy(None), "/.well-known/oauth-protected-resource/api/mcp"
|
||||
)
|
||||
assert metadata["authorization_servers"] == [BASE_URL_ISSUER]
|
||||
|
||||
async def test_minted_token_iss_claim_is_base_url(self):
|
||||
proxy = build_proxy(None)
|
||||
proxy.get_routes(mcp_path="/mcp")
|
||||
|
||||
token = proxy.jwt_issuer.issue_access_token(
|
||||
client_id="client-id", scopes=["read"], jti="test-jti"
|
||||
)
|
||||
|
||||
assert proxy.jwt_issuer.verify_token(token)["iss"] == BASE_URL_ISSUER
|
||||
|
||||
|
||||
class TestOAuthProviderIssuerIdentity:
|
||||
"""The plain `OAuthProvider` path behaves the same way."""
|
||||
|
||||
async def test_authorization_server_metadata_issuer_is_issuer_url(self):
|
||||
metadata = await fetch_json(
|
||||
build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server"
|
||||
)
|
||||
assert metadata["issuer"] == ISSUER
|
||||
|
||||
async def test_protected_resource_metadata_points_at_issuer_url(self):
|
||||
metadata = await fetch_json(
|
||||
build_provider(ISSUER_URL),
|
||||
"/.well-known/oauth-protected-resource/api/mcp",
|
||||
)
|
||||
assert metadata["authorization_servers"] == [ISSUER]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, expected",
|
||||
[
|
||||
("authorization_endpoint", f"{BASE_URL}/authorize"),
|
||||
("token_endpoint", f"{BASE_URL}/token"),
|
||||
("registration_endpoint", f"{BASE_URL}/register"),
|
||||
("revocation_endpoint", f"{BASE_URL}/revoke"),
|
||||
],
|
||||
)
|
||||
async def test_endpoints_stay_on_base_url(self, field: str, expected: str):
|
||||
metadata = await fetch_json(
|
||||
build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server"
|
||||
)
|
||||
assert metadata[field] == expected
|
||||
|
||||
async def test_issuer_defaults_to_base_url(self):
|
||||
# base_url has a path, so RFC 8414 path-aware discovery applies.
|
||||
metadata = await fetch_json(
|
||||
build_provider(None), "/.well-known/oauth-authorization-server/api"
|
||||
)
|
||||
assert metadata["issuer"] == BASE_URL_ISSUER
|
||||
|
|
@ -209,7 +209,8 @@ class TestOAuthMounting:
|
|||
Scenario: FastMCP server mounted at /api prefix
|
||||
- issuer_url: https://api.example.com (root level)
|
||||
- base_url: https://api.example.com/api (includes mount prefix)
|
||||
- Expected: metadata declares endpoints at base_url
|
||||
- Expected: metadata declares endpoints at base_url and issuer at
|
||||
issuer_url
|
||||
"""
|
||||
# Create OAuth proxy with different base_url and issuer_url
|
||||
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
||||
|
|
@ -261,12 +262,10 @@ class TestOAuthMounting:
|
|||
== "https://api.example.com/api/register"
|
||||
)
|
||||
|
||||
# The issuer field should use base_url (where the server is actually running)
|
||||
# Note: MCP SDK may or may not add a trailing slash
|
||||
assert metadata["issuer"] in [
|
||||
"https://api.example.com/api",
|
||||
"https://api.example.com/api/",
|
||||
]
|
||||
# The issuer field reports issuer_url: it is the identifier the
|
||||
# client used for RFC 8414 discovery, and §3.3 requires the two to
|
||||
# match. Only the endpoint URLs follow base_url.
|
||||
assert metadata["issuer"] == "https://api.example.com/"
|
||||
|
||||
async def test_oauth_authorization_server_metadata_path_aware_discovery(
|
||||
self, test_tokens
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue