diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 0c2ae6658..2979bffab 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -720,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 diff --git a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py index b86eceb34..07c3b0e58 100644 --- a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py +++ b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py @@ -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( diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 98c4c17b1..539cce937 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -688,18 +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). - # 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. + # 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 diff --git a/tests/server/auth/test_issuer_url_identity.py b/tests/server/auth/test_issuer_url_identity.py index 22c6b6d93..8a27374f8 100644 --- a/tests/server/auth/test_issuer_url_identity.py +++ b/tests/server/auth/test_issuer_url_identity.py @@ -200,16 +200,16 @@ class TestOAuthProxyIssuerIdentity: @pytest.mark.parametrize( "issuer_url, expected", - [(ISSUER_URL, BASE_URL_ISSUER), (None, BASE_URL_ISSUER)], + [(ISSUER_URL, ISSUER), (None, BASE_URL_ISSUER)], ) - def test_identity_assertion_audience_stays_on_base_url( + def test_identity_assertion_audience_is_issuer_identifier( self, issuer_url: str | None, expected: str ): - """The ID-JAG audience is intentionally not moved to issuer_url. + """SEP-990: an ID-JAG is bound to the server's advertised issuer. - 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. + 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",