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

This commit is contained in:
Jeremiah Lowin 2026-07-26 17:48:01 -04:00
commit 98ac0402df
No known key found for this signature in database
4 changed files with 39 additions and 11 deletions

View file

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

View file

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

22
risk.py Normal file
View file

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

View file

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