Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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()
|
||||
Reference in new issue
Block a user