Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ def _build_oidc_jwks_client() -> PyJWKClient | None:
SESSION_ISSUER = "naruon-control-plane"
SESSION_AUDIENCE = "naruon-api"
JWT_DECODE_REQUIRED_CLAIMS = ("exp", "iss", "aud")
# An OIDC ID token carries aud == client_id, so verifying signature/issuer/aud
# alone lets a frontend ID token be replayed as an API access token (RFC 8725
# §3.11). naruon's API credential is the OIDC access token (the frontend sends
# token_response.access_token). Accept only material the IdP marks as an access
# token: Keycloak sets the body claim typ="Bearer"; RFC 9068 sets the header
# typ "at+jwt". ID tokens (Keycloak typ="ID") and unmarked material are rejected.
# ponytail: extend these sets if a non-Keycloak/non-RFC9068 IdP is onboarded.
# Bandit B105 is inapplicable: this is RFC 9068's public typ identifier.
OIDC_ACCESS_TOKEN_HEADER_TYPE = "at+jwt" # nosec B105
OIDC_ACCESS_TOKEN_BODY_TYPES = frozenset({"bearer"})
MIN_SESSION_SECRET_BYTES = 32
MAX_SIGNED_SESSION_EXPIRATION_SECONDS = 12 * 60 * 60
MAX_SIGNED_SESSION_CLOCK_SKEW_SECONDS = 60
Expand Down Expand Up @@ -263,10 +273,37 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]:
raise _authentication_error()
if not isinstance(payload, dict):
raise _authentication_error()
_require_oidc_access_token(header, payload)
return payload
raise _authentication_error()


def _require_oidc_access_token(
header: dict[str, Any], payload: dict[str, Any]
) -> None:
"""Reject OIDC ID tokens replayed as API access tokens (RFC 8725 §3.11).

The frontend transmits the OIDC access token as the API bearer, so only
material the IdP marks as an access token may build a session: an RFC 9068
header typ of "at+jwt", or a Keycloak body typ of "Bearer". ID tokens
(Keycloak typ="ID") and tokens with no access-token marker are rejected even
when signature, issuer, and audience verify.
"""
header_type = header.get("typ")
if (
isinstance(header_type, str)
and header_type.strip().lower() == OIDC_ACCESS_TOKEN_HEADER_TYPE
):
return
body_type = payload.get("typ")
if (
isinstance(body_type, str)
and body_type.strip().lower() in OIDC_ACCESS_TOKEN_BODY_TYPES
):
return
raise _authentication_error()


def _reject_unsupported_critical_headers(header: dict[str, Any]) -> None:
if "crit" in header:
raise _authentication_error()
Expand Down
101 changes: 101 additions & 0 deletions backend/tests/test_auth_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,7 @@ def mock_jwt_decode(*args, **kwargs):
return {
"iss": "https://login.example.test/realms/naruon",
"aud": "naruon-api",
"typ": "Bearer",
"sub": "alice",
"role": "member",
"org": "org-acme",
Expand Down Expand Up @@ -1026,6 +1027,7 @@ def mock_jwt_decode(*args, **kwargs):
return {
"iss": "https://login.example.test/realms/naruon",
"aud": ("naruon-api", "naruon-admin"),
"typ": "Bearer",
"sub": "alice",
"role": "member",
"org": "org-acme",
Expand All @@ -1051,6 +1053,104 @@ def mock_jwt_decode(*args, **kwargs):
assert context.user_id == "alice"


def _oidc_settings_snapshot():
return (
settings.OIDC_ISSUER_URL,
settings.OIDC_CLIENT_ID,
settings.AUTH_SESSION_HMAC_SECRET,
)


def _apply_oidc_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 _install_oidc_decode(monkeypatch, payload):
import jwt

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(),))
monkeypatch.setattr(jwt, "decode", lambda *a, **k: payload)


def _oidc_claims(**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


@pytest.mark.asyncio
@pytest.mark.parametrize(
"header,claims",
[
# Keycloak ID token replayed as an API bearer (the Strix finding).
({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {"typ": "ID"}),
# Forged/opaque material with no access-token marker (the PoC shape).
({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {}),
# An ID-token typ must not sneak through via the header either.
({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {"typ": "id"}),
],
)
async def test_oidc_rejects_id_token_replayed_as_api_bearer(
monkeypatch, header, claims
):
previous = _oidc_settings_snapshot()
_apply_oidc_settings()
_install_oidc_decode(monkeypatch, _oidc_claims(**claims))
token = _signed_session_token(_valid_session_payload(), header=header)

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_rfc9068_access_token_header_typ(monkeypatch):
previous = _oidc_settings_snapshot()
_apply_oidc_settings()
# RFC 9068 access token: marker in the header typ, no body typ claim.
_install_oidc_decode(monkeypatch, _oidc_claims())
token = _signed_session_token(
_valid_session_payload(),
header={"alg": "RS256", "typ": "at+jwt", "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
Expand All @@ -1072,6 +1172,7 @@ class MockKey:
def mock_jwt_decode(*args, **kwargs):
return {
"iss": "https://login.example.test/realms/naruon",
"typ": "Bearer",
"sub": "alice",
"role": "member",
"org": "org-acme",
Expand Down
4 changes: 4 additions & 0 deletions backend/tests/test_repo_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ def test_infra_compose_services_use_read_only_hardening_anchor():
"keycloak",
):
assert f" {service}:\n <<: *service-hardening" in compose
service_block = compose.split(f" {service}:", 1)[1].split("\n\n ", 1)[0]
assert "security_opt:" in service_block
assert "- no-new-privileges:true" in service_block
assert "read_only: true" in service_block

assert "GF_SECURITY_ADMIN_PASSWORD=admin" not in compose
assert (
Expand Down
Loading