Skip to content

Commit a42faab

Browse files
authored
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
2 parents b1e0586 + a2bec08 commit a42faab

11 files changed

Lines changed: 384 additions & 30 deletions

File tree

docs/deployment/http.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix
544544
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
545545
```
546546

547-
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
547+
**`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`.
548548

549549
```python
550550
# Usually not needed - just set base_url and it works

docs/servers/auth/oauth-proxy.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ mcp = FastMCP(name="My Server", auth=auth)
135135
<ParamField body="issuer_url" type="AnyHttpUrl | str | None">
136136
Issuer URL for OAuth authorization server metadata (defaults to `base_url`).
137137

138+
`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.
139+
138140
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`.
139141

140142
**Default behavior (recommended for most cases):**
@@ -718,7 +720,7 @@ For each ID-JAG presented at the token endpoint, the proxy checks that:
718720
- the JOSE header `typ` is `oauth-id-jag+jwt`;
719721
- the `iss` claim is one of the configured `trusted_issuers`;
720722
- the signature verifies against the issuer's published keys;
721-
- the `aud` claim identifies this authorization server;
723+
- 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;
722724
- the signed `client_id` claim matches the client presenting the assertion — an assertion the IdP minted for one client cannot be redeemed by another;
723725
- the signed `resource` claim names this server — an assertion minted for a different MCP server behind the same IdP is rejected;
724726
- `exp` (and `iat`/`nbf`, when present) place the assertion within a short lifetime and its validity window; and

fastmcp_slim/fastmcp/server/auth/auth.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any
55
from urllib.parse import urlparse
66

7+
from mcp.server.auth.handlers.metadata import MetadataHandler
78
from mcp.server.auth.handlers.token import TokenErrorResponse
89
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
910
from mcp.server.auth.json_response import PydanticJSONResponse
@@ -30,6 +31,7 @@
3031
TokenVerifier as TokenVerifierProtocol,
3132
)
3233
from mcp.server.auth.routes import (
34+
build_metadata,
3335
cors_middleware,
3436
create_auth_routes,
3537
create_protected_resource_routes,
@@ -837,7 +839,8 @@ def __init__(
837839
):
838840
logger.info(
839841
f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. "
840-
f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). "
842+
f"Ensure well-known routes are accessible at root "
843+
f"({str(self.issuer_url).rstrip('/')}/.well-known/). "
841844
f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers"
842845
)
843846

@@ -892,10 +895,10 @@ def get_routes(
892895
# Configure resource URL before creating routes
893896
self.set_mcp_path(mcp_path)
894897

895-
# Create standard OAuth authorization server routes
896-
# Pass base_url as issuer_url to ensure metadata declares endpoints where
897-
# they're actually accessible (operational routes are mounted at
898-
# base_url)
898+
# Create standard OAuth authorization server routes. Pass base_url so
899+
# the SDK mounts operational routes and declares endpoint URLs where
900+
# they're actually accessible; the metadata route is replaced below so
901+
# that the advertised `issuer` reports issuer_url instead.
899902
assert self.base_url is not None # typing check
900903
assert (
901904
self.issuer_url is not None
@@ -914,6 +917,35 @@ def get_routes(
914917
oauth_routes: list[Route] = []
915918
for route in sdk_routes:
916919
if (
920+
isinstance(route, Route)
921+
and route.path == "/.well-known/oauth-authorization-server"
922+
):
923+
# The SDK bakes the metadata into the handler when it builds the
924+
# route, and derives both `issuer` and every endpoint URL from a
925+
# single argument. Rebuild it here so the endpoints stay on
926+
# base_url (where the routes are mounted) while `issuer`
927+
# reports issuer_url — the identifier clients used for RFC 8414
928+
# discovery, which §3.3 requires the metadata to match.
929+
metadata = build_metadata(
930+
self.base_url,
931+
self.service_documentation_url,
932+
self.client_registration_options or ClientRegistrationOptions(),
933+
self.revocation_options or RevocationOptions(),
934+
)
935+
metadata.issuer = self.issuer_url
936+
metadata_handler = MetadataHandler(metadata)
937+
oauth_routes.append(
938+
Route(
939+
path=route.path,
940+
endpoint=cors_middleware(
941+
metadata_handler.handle, ["GET", "OPTIONS"]
942+
),
943+
methods=route.methods or ["GET", "OPTIONS"],
944+
name=route.name,
945+
include_in_schema=route.include_in_schema,
946+
)
947+
)
948+
elif (
917949
isinstance(route, Route)
918950
and route.path == "/token"
919951
and route.methods is not None

fastmcp_slim/fastmcp/server/auth/handlers/authorize.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,10 @@ class AuthorizationHandler(SDKAuthorizationHandler):
177177

178178
def __init__(
179179
self,
180+
*,
180181
provider: OAuthAuthorizationServerProvider,
181182
base_url: AnyHttpUrl | str,
183+
issuer_url: AnyHttpUrl | str | None = None,
182184
server_name: str | None = None,
183185
server_icon_url: str | None = None,
184186
):
@@ -187,14 +189,17 @@ def __init__(
187189
Args:
188190
provider: OAuth authorization server provider
189191
base_url: Base URL of the server for constructing endpoint URLs
192+
issuer_url: Authorization server issuer identifier. Defaults to
193+
`base_url`, which is correct whenever the server's identity and
194+
its endpoint locations are the same URL.
190195
server_name: Optional server name for branding
191196
server_icon_url: Optional server icon URL for branding
192197
"""
193198
super().__init__(provider)
194199
# Unnormalized on purpose: this must match the discovery document's
195200
# `issuer` field byte-for-byte per RFC 9207, and that field is built
196-
# from the same unmodified base_url (see OAuthProxy.get_routes()).
197-
self._issuer = str(base_url)
201+
# from the same unmodified issuer_url (see OAuthProxy.get_routes()).
202+
self._issuer = str(issuer_url if issuer_url is not None else base_url)
198203
self._base_url = str(base_url).rstrip("/")
199204
self._server_name = server_name
200205
self._server_icon_url = server_icon_url

fastmcp_slim/fastmcp/server/auth/identity_assertion.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,12 @@ class IdentityAssertion(BaseModel):
9999
audience: str | None = Field(
100100
default=None,
101101
description=(
102-
"Expected `aud` value on the ID-JAG. When omitted, the audience is the "
103-
"authorization server's own issuer URL (its base URL), which is where the "
104-
"ID-JAG's `aud` must point per SEP-990. Override only when the IdP mints "
105-
"assertions bound to a different audience identifier."
102+
"Expected `aud` value on the ID-JAG. When omitted, the audience is this "
103+
"server's issuer identifier — the `issuer` published in its authorization "
104+
"server metadata, which is `issuer_url` when set and `base_url` otherwise "
105+
"— and that is where the ID-JAG's `aud` must point per SEP-990. Override "
106+
"only when the IdP mints assertions bound to a different audience "
107+
"identifier."
106108
),
107109
)
108110
required_scopes: list[str] | None = Field(

fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ async def _show_consent_page(
357357
url=build_client_redirect(
358358
txn["client_redirect_uri"],
359359
callback_params,
360-
iss=str(self.base_url),
360+
iss=str(self.issuer_url),
361361
),
362362
status_code=302,
363363
)
@@ -532,7 +532,7 @@ async def _submit_consent(
532532
"state": txn.get("client_state") or "",
533533
}
534534
client_callback_url = build_client_redirect(
535-
txn["client_redirect_uri"], callback_params, iss=str(self.base_url)
535+
txn["client_redirect_uri"], callback_params, iss=str(self.issuer_url)
536536
)
537537
response = RedirectResponse(url=client_callback_url, status_code=302)
538538

fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -688,14 +688,17 @@ def __init__(
688688
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
689689
)
690690

691-
# Identity assertion (SEP-990 ID-JAG): the audience the ID-JAG must be
692-
# bound to is this authorization server's own issuer URL (base_url).
691+
# Identity assertion (SEP-990 ID-JAG): per RFC 7523 §3 the `aud` must
692+
# identify this authorization server, and an authorization server is
693+
# identified by its issuer — the same value published as `issuer` in
694+
# the authorization server metadata, which is `issuer_url` (defaulting
695+
# to `base_url`).
693696
self._identity_assertion: IdentityAssertion | None = identity_assertion
694697
self._identity_assertion_validator: IdentityAssertionValidator | None = None
695698
if identity_assertion is not None:
696699
self._identity_assertion_validator = IdentityAssertionValidator(
697700
config=identity_assertion,
698-
audience=str(self.base_url),
701+
audience=str(self.issuer_url),
699702
)
700703
# ID-JAG access tokens are self-contained (no upstream token or JTI
701704
# mapping to delete), so revocation tracks their jtis here until the
@@ -758,9 +761,11 @@ def set_mcp_path(self, mcp_path: str | None) -> None:
758761
super().set_mcp_path(mcp_path)
759762

760763
# Create JWT issuer with correct audience based on actual MCP path
761-
# This ensures tokens are bound to the specific resource URL
764+
# This ensures tokens are bound to the specific resource URL. The `iss`
765+
# claim is the authorization server's issuer identifier (`issuer_url`),
766+
# which matches the `issuer` advertised in the metadata document.
762767
self._jwt_issuer = JWTIssuer(
763-
issuer=str(self.base_url),
768+
issuer=str(self.issuer_url),
764769
audience=str(self._resource_url),
765770
signing_key=self._jwt_signing_key,
766771
)
@@ -2380,6 +2385,7 @@ def get_routes(
23802385
authorize_handler = AuthorizationHandler(
23812386
provider=self,
23822387
base_url=self.base_url, # ty: ignore[invalid-argument-type]
2388+
issuer_url=self.issuer_url,
23832389
server_name=None, # Could be extended to pass server metadata
23842390
server_icon_url=None,
23852391
)
@@ -2468,6 +2474,14 @@ def get_routes(
24682474
revocation_options,
24692475
supports_identity_assertion=self._identity_assertion is not None,
24702476
)
2477+
# `build_metadata` derives both the `issuer` field and every
2478+
# endpoint URL from a single argument. Endpoints must stay on
2479+
# `base_url` (that is where the routes are actually mounted),
2480+
# while the issuer identity is `issuer_url`. RFC 8414 §3.3
2481+
# requires `issuer` to match the URL the client used for
2482+
# discovery, which is the `issuer_url` advertised in the
2483+
# protected resource metadata.
2484+
metadata.issuer = self.issuer_url # ty: ignore[invalid-assignment]
24712485
# RFC 9207: every authorization response we issue carries an
24722486
# `iss` matching this issuer byte-for-byte, so this route must
24732487
# always be overridden to advertise support — not just when
@@ -2585,7 +2599,7 @@ async def _handle_idp_callback(
25852599
url=build_client_redirect(
25862600
client_redirect_uri,
25872601
error_params,
2588-
iss=str(self.base_url),
2602+
iss=str(self.issuer_url),
25892603
),
25902604
status_code=302,
25912605
)
@@ -2753,7 +2767,7 @@ async def _handle_idp_callback(
27532767
}
27542768

27552769
client_callback_url = build_client_redirect(
2756-
client_redirect_uri, callback_params, iss=str(self.base_url)
2770+
client_redirect_uri, callback_params, iss=str(self.issuer_url)
27572771
)
27582772

27592773
logger.debug(f"Forwarding to client callback for transaction {txn_id}")

fastmcp_slim/fastmcp/server/auth/providers/in_memory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ class InMemoryOAuthProvider(OAuthProvider):
3636

3737
def __init__(
3838
self,
39+
*,
3940
base_url: AnyHttpUrl | str | None = None,
4041
resource_base_url: AnyHttpUrl | str | None = None,
42+
issuer_url: AnyHttpUrl | str | None = None,
4143
service_documentation_url: AnyHttpUrl | str | None = None,
4244
client_registration_options: ClientRegistrationOptions | None = None,
4345
revocation_options: RevocationOptions | None = None,
@@ -46,6 +48,7 @@ def __init__(
4648
super().__init__(
4749
base_url=base_url or "http://fastmcp.example.com",
4850
resource_base_url=resource_base_url,
51+
issuer_url=issuer_url,
4952
service_documentation_url=service_documentation_url,
5053
client_registration_options=client_registration_options,
5154
revocation_options=revocation_options,

0 commit comments

Comments
 (0)