Files
RAGcore-Public/tests/security/oidc/test_auth_web.py
T

180 lines
6.3 KiB
Python

"""/auth/login, /auth/callback, /auth/logout: cookies, rotation and CSRF."""
from __future__ import annotations
from uuid import uuid4
from fastapi.testclient import TestClient
from ragcore.application.auth import SessionService, SignedTokenCodec, decode_session
from ragcore.application.auth.ports import AuthorizationChallenge
from ragcore.domain.auth import OidcClaims
from ragcore.domain.control import Role
from ragcore.main import create_app
from ragcore.web.auth.dependencies import (
PENDING_LOGIN_COOKIE_NAME,
SESSION_COOKIE_NAME,
get_session_codec,
get_session_service,
)
SECRET = b"0" * 32
class FakeOidcProvider:
def __init__(self, claims: OidcClaims) -> None:
self.claims = claims
async def build_authorization_challenge(self, *, redirect_uri: str) -> AuthorizationChallenge:
return AuthorizationChallenge(
redirect_url="https://idp.example.test/authorize?mock=1",
state="fixed-state-for-tests",
nonce="fixed-nonce-for-tests",
code_verifier="fixed-verifier-for-tests",
)
async def complete_login(
self, *, code: str, code_verifier: str, nonce: str, redirect_uri: str
) -> OidcClaims:
return self.claims
def _fixture(*, https: bool = False) -> tuple[TestClient, SignedTokenCodec]:
provider = FakeOidcProvider(
OidcClaims(subject="alice", email="alice@example.test", groups=("ragcore-editors",))
)
service = SessionService(
provider=provider, group_roles={"ragcore-editors": Role.EDITOR}, workspace_id=uuid4()
)
codec = SignedTokenCodec(secret=SECRET)
api = create_app()
api.dependency_overrides[get_session_service] = lambda: service
api.dependency_overrides[get_session_codec] = lambda: codec
base_url = "https://testserver" if https else "http://testserver"
return TestClient(api, base_url=base_url, follow_redirects=False), codec
def test_login_redirects_and_sets_a_pending_login_cookie() -> None:
client, _ = _fixture()
response = client.get("/auth/login")
assert response.status_code == 302
assert response.headers["location"].startswith("https://idp.example.test/")
set_cookie = response.headers.get("set-cookie", "")
assert PENDING_LOGIN_COOKIE_NAME in set_cookie
assert "HttpOnly" in set_cookie
assert "samesite=lax" in set_cookie.lower()
def test_pending_login_cookie_is_marked_secure_over_https() -> None:
client, _ = _fixture(https=True)
response = client.get("/auth/login")
assert "Secure" in response.headers.get("set-cookie", "")
def test_pending_login_cookie_is_not_marked_secure_over_plain_http() -> None:
client, _ = _fixture(https=False)
response = client.get("/auth/login")
assert "Secure" not in response.headers.get("set-cookie", "")
def test_full_login_then_callback_sets_a_rotated_session_cookie() -> None:
client, _ = _fixture()
login_response = client.get("/auth/login")
callback_response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "fixed-state-for-tests"},
)
assert callback_response.status_code == 302
assert callback_response.headers["location"] == "/admin/dashboard"
set_cookie = callback_response.headers.get("set-cookie", "")
assert SESSION_COOKIE_NAME in set_cookie
# The pending-login cookie is cleared once login completes.
assert f"{PENDING_LOGIN_COOKIE_NAME}=" in set_cookie or any(
PENDING_LOGIN_COOKIE_NAME in header
for header in callback_response.headers.get_list("set-cookie")
)
session_cookie_value = client.cookies.get(SESSION_COOKIE_NAME)
pending_cookie_value = client.cookies.get(PENDING_LOGIN_COOKIE_NAME)
assert session_cookie_value
assert not pending_cookie_value
assert login_response.headers["set-cookie"] != callback_response.headers["set-cookie"]
def test_callback_with_mismatched_state_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "an-attacker-supplied-state"},
)
assert response.status_code == 400
assert response.json()["code"] == "INVALID_STATE"
def test_callback_without_a_pending_login_cookie_is_rejected() -> None:
client, _ = _fixture()
response = client.get(
"/auth/callback",
params={"code": "auth-code", "state": "fixed-state-for-tests"},
)
assert response.status_code == 400
assert response.json()["code"] == "LOGIN_EXPIRED"
def test_logout_without_csrf_token_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
response = client.post("/auth/logout")
assert response.status_code == 403
assert response.json()["code"] == "CSRF_TOKEN_INVALID"
def test_logout_with_a_forged_csrf_token_is_rejected() -> None:
client, _ = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
response = client.post("/auth/logout", headers={"x-csrf-token": "not-the-real-token"})
assert response.status_code == 403
assert response.json()["code"] == "CSRF_TOKEN_INVALID"
def test_logout_with_the_correct_csrf_token_succeeds_and_clears_the_session() -> None:
client, codec = _fixture()
client.get("/auth/login")
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
session = decode_session(client.cookies[SESSION_COOKIE_NAME], codec=codec)
response = client.post("/auth/logout", headers={"x-csrf-token": session.csrf_token})
assert response.status_code == 302
assert response.headers["location"] == "/login"
assert not client.cookies.get(SESSION_COOKIE_NAME)
def test_logout_requires_authentication_before_csrf_is_even_checked() -> None:
client, _ = _fixture()
response = client.post("/auth/logout", headers={"x-csrf-token": "anything"})
assert response.status_code == 401
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
def test_auth_routes_are_503_when_oidc_is_not_configured() -> None:
api = create_app()
client = TestClient(api, follow_redirects=False)
response = client.get("/auth/login")
assert response.status_code == 503
assert response.json()["code"] == "AUTH_UNAVAILABLE"