import json import logging from time import time from urllib.parse import parse_qs, urlsplit import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException from fastapi.testclient import TestClient from sqlmodel import Session, SQLModel, create_engine @pytest.fixture def oidc(tmp_path, monkeypatch): from backend.app.core.config import settings from backend.app.db import session as database from backend.app.main import app from backend.app.services import oidc_login engine = create_engine(f"sqlite:///{tmp_path / 'oidc.db'}", connect_args={"check_same_thread": False}) monkeypatch.setattr(database, "engine", engine) SQLModel.metadata.create_all(engine) monkeypatch.setattr(settings, "auth_public_url", "https://appops.example") monkeypatch.setattr(settings, "frontend_origin", "https://appops.example") monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example/application/o/appops/") monkeypatch.setattr(settings, "authentik_client_id", "appops-test") monkeypatch.setattr(settings, "authentik_client_secret", "not-a-real-client-secret") monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"]) monkeypatch.setattr(settings, "auth_admin_emails", []) monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False) monkeypatch.setattr(settings, "entra_tenant_id", "00000000-0000-0000-0000-000000000001") monkeypatch.setattr(settings, "entra_client_id", "entra-test") monkeypatch.setattr(settings, "entra_client_secret", "not-a-real-entra-secret") key = rsa.generate_private_key(public_exponent=65537, key_size=2048) jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key())) jwk.update({"kid": "test-key", "alg": "RS256", "use": "sig"}) state = {"nonce": "", "claim_overrides": {}, "token_calls": 0} def transport(request): if request.url.path.endswith("openid-configuration"): return httpx.Response(200, json={ "issuer": settings.authentik_issuer, "authorization_endpoint": "https://auth.example/authorize", "token_endpoint": "https://auth.example/token", "jwks_uri": "https://auth.example/keys", }) if request.url.path == "/token": state["token_calls"] += 1 form = parse_qs(request.content.decode()) assert form["grant_type"] == ["authorization_code"] assert len(form["code_verifier"][0]) >= 43 assert form["redirect_uri"] == ["https://appops.example/api/auth/callback/authentik"] claims = {"iss": settings.authentik_issuer, "sub": "owner", "aud": "appops-test", "iat": int(time()), "exp": int(time()) + 300, "nonce": state["nonce"], "email": "jens@example.com", **state["claim_overrides"]} token = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"}) return httpx.Response(200, json={"id_token": token, "access_token": "discard-this-token"}) if request.url.path == "/keys": return httpx.Response(200, json={"keys": [jwk]}) return httpx.Response(404) original_client = httpx.Client monkeypatch.setattr(oidc_login.httpx, "Client", lambda **kwargs: original_client(transport=httpx.MockTransport(transport), **kwargs)) client = TestClient(app, base_url="https://appops.example") yield client, state, key, {"keys": [jwk]}, engine engine.dispose() def begin(client, state): response = client.get("/api/auth/login/authentik", follow_redirects=False) assert response.status_code == 302 params = parse_qs(urlsplit(response.headers["location"]).query) state["nonce"] = params["nonce"][0] assert params["code_challenge_method"] == ["S256"] assert "HttpOnly" in response.headers["set-cookie"] assert "Secure" in response.headers["set-cookie"] return params["state"][0] def test_oidc_real_code_flow_issues_secure_admin_session_and_rejects_replay(oidc): client, state, _, _, _ = oidc login_state = begin(client, state) response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/" assert "HttpOnly" in response.headers["set-cookie"] and "Secure" in response.headers["set-cookie"] assert "discard-this-token" not in response.text + response.headers["set-cookie"] status = client.get("/api/auth/session").json() assert status["authenticated"] is True and status["user"]["role"] == "admin" replay = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False) assert replay.status_code == 303 and replay.headers["location"] == "/?signin_error=expired" assert state["token_calls"] == 1 assert client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1", "Origin": "https://evil.example"}).status_code == 403 assert client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1", "Origin": "https://appops.example"}).status_code == 200 assert client.get("/api/auth/session").json()["authenticated"] is False def test_state_is_bound_to_original_browser_before_token_exchange(oidc): client, state, _, _, _ = oidc login_state = begin(client, state) client.cookies.clear() response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False) assert response.status_code == 303 and response.headers["location"] == "/?signin_error=expired" assert state["token_calls"] == 0 assert client.get("/api/auth/session").json()["authenticated"] is False def test_local_address_login_redirects_before_creating_browser_flow(oidc): from sqlmodel import select from backend.app.db.models import LoginFlow client, _, _, _, engine = oidc response = client.get("http://198.18.10.150:1216/api/auth/login/authentik", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "https://appops.example/api/auth/login/authentik" assert "set-cookie" not in response.headers with Session(engine) as db: assert db.exec(select(LoginFlow)).all() == [] def test_provider_cancellation_returns_to_login_without_echoing_provider_input(oidc): client, state, _, _, _ = oidc response = client.get("/api/auth/callback/authentik?error=access_denied&error_description=sensitive-provider-input", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/?signin_error=cancelled" assert "sensitive-provider-input" not in response.text + str(response.headers) assert state["token_calls"] == 0 def test_entra_federation_keeps_authentik_callback_and_identity_validation(oidc, monkeypatch): from backend.app.core.config import settings client, state, _, _, _ = oidc monkeypatch.setattr(settings, "entra_tenant_id", "") monkeypatch.setattr(settings, "authentik_entra_source", "entra-id") providers = client.get("/api/auth/providers").json() assert providers[1] == {"id": "entra", "label": "Microsoft Entra via Authentik", "configured": True} response = client.get("/api/auth/login/entra", follow_redirects=False) params = parse_qs(urlsplit(response.headers["location"]).query) assert params["redirect_uri"] == ["https://appops.example/api/auth/callback/authentik"] assert params["code_challenge_method"] == ["S256"] state["nonce"] = params["nonce"][0] response = client.get(f"/api/auth/callback/authentik?state={params['state'][0]}&code=test-code", follow_redirects=False) assert response.status_code == 303 assert client.get("/api/auth/session").json()["user"]["provider"] == "authentik" @pytest.mark.parametrize("overrides", [{"nonce": "wrong"}, {"iss": "https://evil.example"}, {"aud": "other-app"}, {"exp": 1}, {"azp": "other-app"}]) def test_oidc_rejects_invalid_signed_identity_claims(oidc, overrides): client, state, _, _, _ = oidc login_state = begin(client, state) state["claim_overrides"] = overrides response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False) assert response.status_code == 303 and response.headers["location"] == "/?signin_error=failed" assert client.get("/api/auth/session").json()["authenticated"] is False def test_entra_tenant_is_pinned_and_uses_fixed_object_id(oidc, monkeypatch): from backend.app.core.config import settings from backend.app.services.oidc_login import provider_config, verify_id_token from backend.app.services.user_access import provision_user _, _, key, jwks, engine = oidc config = provider_config("entra") claims = {"iss": config["issuer"], "sub": "app-specific-sub", "oid": "fixed-object-id", "tid": settings.entra_tenant_id, "aud": config["client_id"], "iat": int(time()), "exp": int(time()) + 300, "nonce": "expected", "preferred_username": "jens@example.com"} encoded = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"}) verified = verify_id_token(encoded, jwks, config, "expected", "entra") monkeypatch.setattr(settings, "auth_admin_subjects", ["entra:fixed-object-id"]) with Session(engine) as db: user = provision_user(db, "entra", config["issuer"], verified) assert user.subject == "fixed-object-id" and user.role == "admin" claims["tid"] = "other-tenant" with pytest.raises(HTTPException): verify_id_token(jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"}), jwks, config, "expected", "entra") monkeypatch.setattr(settings, "entra_tenant_id", "common") with pytest.raises(HTTPException): provider_config("entra") def test_login_codes_are_removed_from_access_logs(): from backend.app.main import LoginAccessLogFilter record = logging.LogRecord("uvicorn.access", logging.INFO, "", 0, "%s %s %s %s %s", ("client", "GET", "/api/auth/callback/entra?code=secret&state=secret", "1.1", 303), None) assert LoginAccessLogFilter().filter(record) assert "secret" not in record.getMessage()