From 98ac0402dfb4bacb908dad8c1f476f3ee2db6aa2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:48:01 -0400 Subject: [PATCH] Keep ID-JAG audience on base_url, out of scope for issuer identity --- .../fastmcp/server/auth/identity_assertion.py | 7 +++--- .../fastmcp/server/auth/oauth_proxy/proxy.py | 10 +++++---- risk.py | 22 +++++++++++++++++++ tests/server/auth/test_issuer_url_identity.py | 11 +++++++--- 4 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 risk.py diff --git a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py index 1dbd60824..b86eceb34 100644 --- a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py +++ b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py @@ -100,10 +100,9 @@ class IdentityAssertion(BaseModel): default=None, description=( "Expected `aud` value on the ID-JAG. When omitted, the audience is the " - "authorization server's own issuer identifier — its `issuer_url`, which " - "defaults to `base_url` — 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." + "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." ), ) required_scopes: list[str] | None = Field( diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index ea486c8d1..98c4c17b1 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -689,15 +689,17 @@ 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 identifier, which - # is `issuer_url` (defaulting to `base_url`) — the same value advertised - # as `issuer` in the authorization server metadata. + # bound to is this authorization server's own issuer URL (base_url). + # Deliberately left on base_url while the metadata issuer moves to + # issuer_url: changing it would reject assertions an IdP is already + # minting, and recovering needs IdP-side reconfiguration rather than a + # re-authorization. Tracked separately. 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.issuer_url), + audience=str(self.base_url), ) # ID-JAG access tokens are self-contained (no upstream token or JTI # mapping to delete), so revocation tracks their jtis here until the diff --git a/risk.py b/risk.py new file mode 100644 index 000000000..b32eda907 --- /dev/null +++ b/risk.py @@ -0,0 +1,22 @@ +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.jwt import StaticTokenVerifier + +def mk(**kw): + p = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="cid", upstream_client_secret="sec", + token_verifier=StaticTokenVerifier(tokens={"t": {"client_id": "c"}}), + jwt_signing_key="x"*64, + **kw, + ) + p.get_routes("/mcp") + return p + +# Case A: issuer_url NOT set -> must be identical before/after +a = mk(base_url="https://example.com") +print("A no issuer_url -> jwt iss:", a.jwt_issuer.issuer) + +# Case B: issuer_url set differently -> this is what changes +b = mk(base_url="https://example.com/api", issuer_url="https://example.com") +print("B issuer_url set -> jwt iss:", b.jwt_issuer.issuer) diff --git a/tests/server/auth/test_issuer_url_identity.py b/tests/server/auth/test_issuer_url_identity.py index f66524378..22c6b6d93 100644 --- a/tests/server/auth/test_issuer_url_identity.py +++ b/tests/server/auth/test_issuer_url_identity.py @@ -200,12 +200,17 @@ class TestOAuthProxyIssuerIdentity: @pytest.mark.parametrize( "issuer_url, expected", - [(ISSUER_URL, ISSUER), (None, BASE_URL_ISSUER)], + [(ISSUER_URL, BASE_URL_ISSUER), (None, BASE_URL_ISSUER)], ) - def test_identity_assertion_audience_is_issuer_identifier( + def test_identity_assertion_audience_stays_on_base_url( self, issuer_url: str | None, expected: str ): - """SEP-990: an ID-JAG is bound to the server's advertised issuer.""" + """The ID-JAG audience is intentionally not moved to issuer_url. + + Changing it would reject assertions an IdP is already minting, and the + operator could only recover by reconfiguring the IdP. Tracked as + separate work from the issuer identity fix. + """ proxy = OAuthProxy( upstream_authorization_endpoint="https://upstream.example.com/authorize", upstream_token_endpoint="https://upstream.example.com/token",