From 694061a68bf9413e5ba1a1a97d7815f621c552f5 Mon Sep 17 00:00:00 2001 From: cbizeul Date: Wed, 20 May 2026 12:05:19 +0000 Subject: [PATCH 1/4] fix(oidc): require azp == client_id on multi-audience ID tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier only checked 'client_id in aud' when aud was a JSON array. Per OIDC Core 1.0 §3.1.3.7, a token whose aud lists multiple audiences must also contain an azp claim equal to the RP's client_id. Without that check, a token issued by the same IdP for a sibling client that happens to include this client in its audience list was accepted, letting one tenant's tokens authenticate against another tenant's sessions. Fixes #385 --- openrag/services/auth/oidc_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openrag/services/auth/oidc_client.py b/openrag/services/auth/oidc_client.py index 30db6850b..bf3e6176a 100644 --- a/openrag/services/auth/oidc_client.py +++ b/openrag/services/auth/oidc_client.py @@ -286,6 +286,14 @@ async def _verify_id_token(self, token: str, *, expected_nonce: str | None) -> d if isinstance(aud, list): if self.client_id not in aud: raise ValueError(f"ID token aud {aud!r} does not contain client_id {self.client_id!r}") + # OIDC Core 1.0 §3.1.3.7: when aud lists multiple audiences, azp + # MUST be present and equal to client_id. + if len(aud) > 1: + azp = decoded.get("azp") + if azp != self.client_id: + raise ValueError( + f"ID token has multi-aud {aud!r} but azp {azp!r} != client_id {self.client_id!r}" + ) elif aud != self.client_id: raise ValueError(f"ID token aud {aud!r} != client_id {self.client_id!r}") From ee46880d23bf37d5b52bca49afd54d1ec7220b81 Mon Sep 17 00:00:00 2001 From: cbizeul Date: Wed, 20 May 2026 12:25:45 +0000 Subject: [PATCH 2/4] style: ruff format --- openrag/services/auth/oidc_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openrag/services/auth/oidc_client.py b/openrag/services/auth/oidc_client.py index bf3e6176a..0a7604a1c 100644 --- a/openrag/services/auth/oidc_client.py +++ b/openrag/services/auth/oidc_client.py @@ -291,9 +291,7 @@ async def _verify_id_token(self, token: str, *, expected_nonce: str | None) -> d if len(aud) > 1: azp = decoded.get("azp") if azp != self.client_id: - raise ValueError( - f"ID token has multi-aud {aud!r} but azp {azp!r} != client_id {self.client_id!r}" - ) + raise ValueError(f"ID token has multi-aud {aud!r} but azp {azp!r} != client_id {self.client_id!r}") elif aud != self.client_id: raise ValueError(f"ID token aud {aud!r} != client_id {self.client_id!r}") From f54696d6f3cdf35b31a7c6ec3b0b8bfb9770c1e9 Mon Sep 17 00:00:00 2001 From: cbizeul Date: Wed, 20 May 2026 13:12:43 +0000 Subject: [PATCH 3/4] test(oidc): regression for azp check on multi-audience tokens (#385) --- openrag/components/auth/test_oidc_client.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/openrag/components/auth/test_oidc_client.py b/openrag/components/auth/test_oidc_client.py index 8a2e79198..d796fbbc6 100644 --- a/openrag/components/auth/test_oidc_client.py +++ b/openrag/components/auth/test_oidc_client.py @@ -253,6 +253,54 @@ async def test_nonce_mismatch_raises(self, client): with pytest.raises(ValueError, match="nonce"): await client.exchange_code(code="code", code_verifier="v", expected_nonce="wrong-nonce") + # OIDC Core 1.0 §3.1.3.7: when aud lists multiple audiences, azp MUST + # be present and equal to client_id. Regression for #385. + @pytest.mark.asyncio + async def test_multi_aud_requires_matching_azp(self, client): + _setup_discovery(client._mock_router) + _setup_jwks(client._mock_router) + + nonce = "n-azp" + # Multi-aud token with the wrong (or missing) azp must be rejected + id_token = _sign_jwt( + _id_token_payload(nonce, extra={"aud": [CLIENT_ID, "other-client"], "azp": "other-client"}) + ) + token_response = { + "id_token": id_token, + "access_token": "at", + "expires_in": 300, + "token_type": "Bearer", + } + client._mock_router.post(f"{ISSUER}/protocol/openid-connect/token").mock( + return_value=httpx.Response(200, json=token_response) + ) + + with pytest.raises(ValueError, match="multi-aud"): + await client.exchange_code(code="code", code_verifier="v", expected_nonce=nonce) + + @pytest.mark.asyncio + async def test_multi_aud_with_correct_azp_passes(self, client): + _setup_discovery(client._mock_router) + _setup_jwks(client._mock_router) + + nonce = "n-azp-ok" + id_token = _sign_jwt( + _id_token_payload(nonce, extra={"aud": [CLIENT_ID, "other-client"], "azp": CLIENT_ID}) + ) + token_response = { + "id_token": id_token, + "access_token": "at", + "expires_in": 300, + "token_type": "Bearer", + } + client._mock_router.post(f"{ISSUER}/protocol/openid-connect/token").mock( + return_value=httpx.Response(200, json=token_response) + ) + + bundle = await client.exchange_code(code="code", code_verifier="v", expected_nonce=nonce) + assert bundle.claims["aud"] == [CLIENT_ID, "other-client"] + assert bundle.claims["azp"] == CLIENT_ID + # --------------------------------------------------------------------------- # Token refresh From 8a73176c7494775559e67dc3be12e4bcec5a5824 Mon Sep 17 00:00:00 2001 From: cbizeul Date: Wed, 20 May 2026 13:39:21 +0000 Subject: [PATCH 4/4] style: ruff format/check fixes --- openrag/components/auth/test_oidc_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openrag/components/auth/test_oidc_client.py b/openrag/components/auth/test_oidc_client.py index d796fbbc6..1b45aa2d0 100644 --- a/openrag/components/auth/test_oidc_client.py +++ b/openrag/components/auth/test_oidc_client.py @@ -284,9 +284,7 @@ async def test_multi_aud_with_correct_azp_passes(self, client): _setup_jwks(client._mock_router) nonce = "n-azp-ok" - id_token = _sign_jwt( - _id_token_payload(nonce, extra={"aud": [CLIENT_ID, "other-client"], "azp": CLIENT_ID}) - ) + id_token = _sign_jwt(_id_token_payload(nonce, extra={"aud": [CLIENT_ID, "other-client"], "azp": CLIENT_ID})) token_response = { "id_token": id_token, "access_token": "at",