Files
Unraid-AppOps-Gateway-Public/backend/tests/test_oidc_login.py
T

183 lines
10 KiB
Python

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()