From 50d7ab55f5bbb86ff4bacfe3b675bb04511e474c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 20:28:13 +0900 Subject: [PATCH 1/2] fix(auth): reject non-ID-token OIDC material in API bearer sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strix (post gpt-5.6-luna migration) flags a HIGH finding in backend/api/auth.py: _decode_cached_oidc_session_payload accepted any same-issuer RS256 JWT whose aud contains OIDC_CLIENT_ID as an API bearer session, with no token-type or authorized-party validation. Access tokens and tokens minted for other clients can share the issuer and carry this API's client_id in aud, so they were replayable as API sessions. The enterprise IdP mints naruon session claims directly into the ID token, so the ID token remains the intended bearer credential; the fix pins the accepted material to exactly that shape instead of changing the architecture: - JOSE header typ, when present, must be JWT (case-insensitive); RFC 9068 access tokens declare at+jwt and are rejected before decode. - token_use, when present, must be "id" (Cognito/Azure-style access-token discriminator). - Payloads carrying scope/scp claims are rejected — ADFS and Azure access tokens keep header typ JWT but always carry these claims; ID tokens do not. - azp, when present, must equal OIDC_CLIENT_ID (OIDC Core 3.1.3.7), so a token minted for another authorized party cannot ride a shared aud. All checks fail closed with the existing 401 and preserve the deliberate tuple-audience contract (multi-audience tokens without azp are still accepted, matching test_oidc_session_accepts_tuple_audience). Tests: 7 new cases in tests/test_auth_real.py (at+jwt and non-string typ headers, token_use=access, scope, scp, azp mismatch, plus a positive ID-token case with matching azp/token_use and no typ header). Full tests/test_auth_real.py passes (97) and ruff check/format are clean. Co-Authored-By: Claude Fable 5 --- backend/api/auth.py | 28 +++++++ backend/tests/test_auth_real.py | 128 ++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..78f9b2661 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -232,6 +232,13 @@ def _oidc_unverified_header(token: str) -> dict[str, Any]: _reject_unsupported_critical_headers(header) if header.get("alg") != "RS256": raise _authentication_error() + # RFC 9068 access tokens declare "at+jwt"; only ID-token material ("JWT", + # or an absent typ from IdPs that omit it) may become an API session. + token_type = header.get("typ") + if token_type is not None and ( + not isinstance(token_type, str) or token_type.strip().upper() != "JWT" + ): + raise _authentication_error() key_id = header.get("kid") if not isinstance(key_id, str) or not key_id.strip(): raise _authentication_error() @@ -263,10 +270,31 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]: raise _authentication_error() if not isinstance(payload, dict): raise _authentication_error() + _reject_non_id_token_payload(payload) return payload raise _authentication_error() +def _reject_non_id_token_payload(payload: dict[str, Any]) -> None: + """Reject same-issuer OIDC material that is not an ID token for this client. + + The enterprise IdP mints naruon session claims directly into the ID token, + so the ID token is the intended API bearer credential. Access tokens and + tokens minted for other clients can share this issuer and carry this API's + client_id in aud; their claim shapes distinguish them: access tokens carry + token_use/scope/scp claims, and tokens for other clients name that client + in azp (OIDC Core 3.1.3.7). + """ + token_use = payload.get("token_use") + if token_use is not None and token_use != "id": + raise _authentication_error() + if "scope" in payload or "scp" in payload: + raise _authentication_error() + authorized_party = payload.get("azp") + if authorized_party is not None and authorized_party != settings.OIDC_CLIENT_ID: + raise _authentication_error() + + def _reject_unsupported_critical_headers(header: dict[str, Any]) -> None: if "crit" in header: raise _authentication_error() diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 11450683e..d9c2c2f62 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -1051,6 +1051,134 @@ def mock_jwt_decode(*args, **kwargs): assert context.user_id == "alice" +def _oidc_test_settings(): + settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + +def _restore_oidc_settings(previous): + ( + settings.OIDC_ISSUER_URL, + settings.OIDC_CLIENT_ID, + settings.AUTH_SESSION_HMAC_SECRET, + ) = previous + + +def _snapshot_oidc_settings(): + return ( + settings.OIDC_ISSUER_URL, + settings.OIDC_CLIENT_ID, + settings.AUTH_SESSION_HMAC_SECRET, + ) + + +def _oidc_id_token_payload(**overrides): + payload = { + "iss": "https://login.example.test/realms/naruon", + "aud": "naruon-api", + "sub": "alice", + "role": "member", + "org": "org-acme", + "groups": ["group-1", "group-2"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + payload.update(overrides) + return payload + + +class _OidcMockKey: + key_id = "test-key" + key = "public_key" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("token_type", ["at+jwt", 123]) +async def test_oidc_rejects_access_token_typ_header(monkeypatch, token_type): + import jwt + + previous = _snapshot_oidc_settings() + _oidc_test_settings() + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (_OidcMockKey(),)) + monkeypatch.setattr(jwt, "decode", lambda *a, **k: _oidc_id_token_payload()) + + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": token_type, "kid": "test-key"}, + ) + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + _restore_oidc_settings(previous) + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload_overrides", + [ + {"token_use": "access"}, + {"scope": "openid api.read"}, + {"scp": ["api.read"]}, + {"azp": "other-client"}, + ], +) +async def test_oidc_rejects_non_id_token_claim_shapes(monkeypatch, payload_overrides): + import jwt + + previous = _snapshot_oidc_settings() + _oidc_test_settings() + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (_OidcMockKey(),)) + monkeypatch.setattr( + jwt, "decode", lambda *a, **k: _oidc_id_token_payload(**payload_overrides) + ) + + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "test-key"}, + ) + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + _restore_oidc_settings(previous) + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_oidc_accepts_id_token_with_matching_azp_and_token_use(monkeypatch): + import jwt + + previous = _snapshot_oidc_settings() + _oidc_test_settings() + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (_OidcMockKey(),)) + monkeypatch.setattr( + jwt, + "decode", + lambda *a, **k: _oidc_id_token_payload(token_use="id", azp="naruon-api"), + ) + + # No typ header: IdPs that omit typ still mint valid ID tokens. + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "kid": "test-key"}, + ) + try: + context = await get_auth_context(authorization=f"Bearer {token}") + finally: + _restore_oidc_settings(previous) + + assert context.session_verifier == "oidc" + assert context.user_id == "alice" + + @pytest.mark.asyncio async def test_oidc_session_rejects_missing_client_id_after_decode(monkeypatch): import jwt From 0abe19748fc637fe49aca45d5a741775a76fc09e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 20:36:38 +0900 Subject: [PATCH 2/2] fix(auth): rename token_use local to avoid bandit B105 false positive bandit flags `token_use != id` as a possible hardcoded password because the variable name contains token (B105, code-scanning alert #269). The comparison is an OIDC claim-value check, not credential material; rename the local to usage_claim instead of suppressing the rule. Co-Authored-By: Claude Fable 5 --- backend/api/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 78f9b2661..a98104a5d 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -285,8 +285,8 @@ def _reject_non_id_token_payload(payload: dict[str, Any]) -> None: token_use/scope/scp claims, and tokens for other clients name that client in azp (OIDC Core 3.1.3.7). """ - token_use = payload.get("token_use") - if token_use is not None and token_use != "id": + usage_claim = payload.get("token_use") + if usage_claim is not None and usage_claim != "id": raise _authentication_error() if "scope" in payload or "scp" in payload: raise _authentication_error()