From c4c72ac2401e2bc307e9a6c9e63d000229727fc9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:10:15 -0400 Subject: [PATCH] Use issuer_url for OAuth issuer identity, not base_url --- docs/deployment/http.mdx | 2 +- docs/servers/auth/oauth-proxy.mdx | 2 + fastmcp_slim/fastmcp/server/auth/auth.py | 39 ++- .../fastmcp/server/auth/handlers/authorize.py | 8 +- .../server/auth/oauth_proxy/consent.py | 4 +- .../fastmcp/server/auth/oauth_proxy/proxy.py | 25 +- .../server/auth/providers/in_memory.py | 2 + tests/server/auth/test_issuer_url_identity.py | 294 ++++++++++++++++++ tests/server/auth/test_oauth_mounting.py | 13 +- 9 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 tests/server/auth/test_issuer_url_identity.py diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index 3eb7bf10b..9ba21d900 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -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 diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index f3fe8071a..0c2ae6658 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -135,6 +135,8 @@ mcp = FastMCP(name="My Server", auth=auth) 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):** diff --git a/fastmcp_slim/fastmcp/server/auth/auth.py b/fastmcp_slim/fastmcp/server/auth/auth.py index 10864a58a..6ab5a2480 100644 --- a/fastmcp_slim/fastmcp/server/auth/auth.py +++ b/fastmcp_slim/fastmcp/server/auth/auth.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py index b5086ea75..f7b749e68 100644 --- a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py +++ b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py index 753aae198..5eda1d70d 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py @@ -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) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 3faf72baf..ea486c8d1 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -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}") diff --git a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py index 3ae686dac..ed748361c 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py @@ -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, diff --git a/tests/server/auth/test_issuer_url_identity.py b/tests/server/auth/test_issuer_url_identity.py new file mode 100644 index 000000000..a04b00e15 --- /dev/null +++ b/tests/server/auth/test_issuer_url_identity.py @@ -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 diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index 50bf67b1a..db4e9a77f 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -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