diff --git a/AGENTS.md b/AGENTS.md index 9e97c7890..42c31a06a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,9 @@ public identity headers such as `X-User-Id`, `X-Organization-Id`, `X-Group-Id`, `X-Group-Ids`, `X-User-Role`, or `X-Dev-Auth-Token`; tests/mocks must exercise the signed-session path. +- JWT/session verification must reject unsupported critical headers (`crit`) + before trusting payload claims; do not rely only on library defaults for this + boundary. - Private backend `/api/*` routers must be registered with the default `get_auth_context` signed-session dependency; only explicitly documented public endpoints such as `/` may omit it. Keep runtime feature/configuration diff --git a/backend/api/auth.py b/backend/api/auth.py index 5cfc5d719..f919f941e 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -127,6 +127,7 @@ def _cached_oidc_signing_key_from_jwt(token: str) -> Any: header = jwt.get_unverified_header(token) except Exception: raise _authentication_error() from None + _reject_unsupported_critical_headers(header) key_id = header.get("kid") if not isinstance(key_id, str) or not key_id.strip(): raise _authentication_error() @@ -136,6 +137,11 @@ def _cached_oidc_signing_key_from_jwt(token: str) -> Any: raise _authentication_error() +def _reject_unsupported_critical_headers(header: dict[str, Any]) -> None: + if "crit" in header: + raise _authentication_error() + + def _session_secret_bytes() -> bytes: configured = settings.AUTH_SESSION_HMAC_SECRET if configured is None: @@ -216,6 +222,7 @@ def _verify_signed_session_payload(authorization: str | None) -> dict[str, Any]: header = _json_object_from_base64url_segment(header_segment) if header.get("alg") != SESSION_SIGNING_ALGORITHM: raise _authentication_error() + _reject_unsupported_critical_headers(header) secret = _session_secret_bytes() signing_input = f"{header_segment}.{payload_segment}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 221c8c7a6..9e2cab304 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,4 +25,4 @@ opentelemetry-instrumentation-fastapi==0.46b0 opentelemetry-exporter-otlp==1.25.0 setuptools==78.1.1 websockets==14.1 -PyJWT==2.8.0 +PyJWT==2.13.0 diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 3a1316ac7..98cc60023 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -337,6 +337,25 @@ async def test_signed_bearer_session_rejects_rs256_algorithm(): assert exc.value.status_code == 401 +@pytest.mark.asyncio +async def test_signed_bearer_session_rejects_unknown_critical_header(): + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + token = _signed_session_token( + _valid_session_payload(), + header={ + "alg": "HS256", + "typ": "JWT", + "crit": ["x-custom-policy"], + "x-custom-policy": "require-mfa", + }, + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 401 + + @pytest.mark.asyncio async def test_signed_bearer_session_rejects_wrong_secret(): settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) @@ -718,6 +737,55 @@ def mock_jwt_decode(*args, **kwargs): assert context.organization_id == "org-acme" +@pytest.mark.asyncio +async def test_oidc_rejects_unknown_critical_header_before_decode(monkeypatch): + import jwt + + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + class MockKey: + key_id = "test-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + + decode_called = False + + def mock_jwt_decode(*args, **kwargs): + nonlocal decode_called + decode_called = True + return {} + + monkeypatch.setattr(jwt, "decode", mock_jwt_decode) + token = _signed_session_token( + _valid_session_payload(), + header={ + "alg": "RS256", + "typ": "JWT", + "kid": "test-key", + "crit": ["x-custom-policy"], + "x-custom-policy": "require-mfa", + }, + ) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert exc.value.status_code == 401 + assert decode_called is False + + @pytest.mark.asyncio async def test_oidc_validation_failure_does_not_fallback_to_signed_session(monkeypatch): import jwt