Bind ID-JAG audience to the issuer identifier

This commit is contained in:
Jeremiah Lowin 2026-07-27 09:37:38 -04:00
commit 62afdca775
No known key found for this signature in database
4 changed files with 19 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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