"""/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"