Use issuer_url for OAuth issuer identity (#4652)

* Use issuer_url for OAuth issuer identity, not base_url

* Apply ruff format to issuer identity tests

* Align ID-JAG audience docstring with issuer_url

* Make InMemoryOAuthProvider keyword-only like its parent

* Keep ID-JAG audience on base_url, out of scope for issuer identity

* Remove stray scratch script

* Make AuthorizationHandler keyword-only

* Bind ID-JAG audience to the issuer identifier

* Fix double slash in issuer_url well-known log hint
This commit is contained in:
Jeremiah Lowin 2026-07-27 10:43:12 -04:00 committed by GitHub
commit a42faab783
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 384 additions and 30 deletions

View file

@ -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

View file

@ -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):**
@ -718,7 +720,7 @@ For each ID-JAG presented at the token endpoint, the proxy checks that:
- the JOSE header `typ` is `oauth-id-jag+jwt`;
- the `iss` claim is one of the configured `trusted_issuers`;
- the signature verifies against the issuer's published keys;
- the `aud` claim identifies this authorization server;
- the `aud` claim identifies this authorization server — configure your identity provider to mint assertions whose `aud` is the `issuer` value published at `/.well-known/oauth-authorization-server`, which is your `issuer_url` when you set one and your `base_url` otherwise;
- the signed `client_id` claim matches the client presenting the assertion — an assertion the IdP minted for one client cannot be redeemed by another;
- the signed `resource` claim names this server — an assertion minted for a different MCP server behind the same IdP is rejected;
- `exp` (and `iat`/`nbf`, when present) place the assertion within a short lifetime and its validity window; and

View file

@ -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,
@ -837,7 +839,8 @@ class OAuthProvider(
):
logger.info(
f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. "
f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). "
f"Ensure well-known routes are accessible at root "
f"({str(self.issuer_url).rstrip('/')}/.well-known/). "
f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers"
)
@ -892,10 +895,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 +917,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

View file

@ -177,8 +177,10 @@ class AuthorizationHandler(SDKAuthorizationHandler):
def __init__(
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 +189,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

View file

@ -99,10 +99,12 @@ class IdentityAssertion(BaseModel):
audience: str | None = Field(
default=None,
description=(
"Expected `aud` value on the ID-JAG. When omitted, the audience is the "
"authorization server's own issuer URL (its base URL), which is where the "
"ID-JAG's `aud` must point per SEP-990. Override only when the IdP mints "
"assertions bound to a different audience identifier."
"Expected `aud` value on the ID-JAG. When omitted, the audience is this "
"server's issuer identifier — the `issuer` published in its authorization "
"server metadata, which is `issuer_url` when set and `base_url` otherwise "
"— and that is where the ID-JAG's `aud` must point per SEP-990. Override "
"only when the IdP mints assertions bound to a different audience "
"identifier."
),
)
required_scopes: list[str] | None = Field(

View file

@ -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)

View file

@ -688,14 +688,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
)
# 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).
# Identity assertion (SEP-990 ID-JAG): per RFC 7523 §3 the `aud` must
# identify this authorization server, and an authorization server is
# identified by its issuer — the same value published as `issuer` in
# the authorization server metadata, which is `issuer_url` (defaulting
# to `base_url`).
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 +761,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 +2385,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 +2474,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 +2599,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 +2767,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}")

View file

@ -36,8 +36,10 @@ class InMemoryOAuthProvider(OAuthProvider):
def __init__(
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 +48,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,

View file

@ -0,0 +1,297 @@
"""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.
RFC 7523 §3 requires the `aud` to identify the authorization server,
and an authorization server is identified by its issuer the value
published as `issuer` in the authorization server metadata.
"""
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

View file

@ -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

View file

@ -103,7 +103,7 @@ class TestStaticTokenVerifier:
"""Test that server raises error when both OAuth and TokenVerifier provided."""
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
oauth_provider = InMemoryOAuthProvider("http://test.com")
oauth_provider = InMemoryOAuthProvider(base_url="http://test.com")
token_verifier = StaticTokenVerifier({"token": {"client_id": "test"}})
# This should work - OAuth provider