341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""Integration tests for OIDC authentication flows.
|
|
|
|
Exercises the real ``/auth/*`` routes mounted on a FastAPI app with
|
|
OIDC auth enabled. The external IdP (token endpoint, userinfo) is
|
|
mocked via ``unittest.mock`` so no network calls leave the process.
|
|
|
|
Covers: login redirect, CLI login/poll, callback token exchange,
|
|
logout, and expired-ticket eviction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import httpx
|
|
import jwt
|
|
import pytest
|
|
|
|
from omnigent.server.admin_list import AdminList
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
from omnigent.server.oidc import OIDCConfig
|
|
from omnigent.server.routes.auth import (
|
|
_CLI_TICKET_TTL_SECONDS,
|
|
_CliTicket,
|
|
_evict_expired_tickets,
|
|
create_auth_router,
|
|
)
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_TEST_SECRET = b"a" * 32
|
|
_GITHUB_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"
|
|
|
|
|
|
def _make_oidc_config() -> OIDCConfig:
|
|
"""Build a minimal GitHub-flavoured OIDCConfig for testing."""
|
|
return OIDCConfig(
|
|
issuer="https://github.com",
|
|
client_id="test-client-id",
|
|
client_secret="test-client-secret",
|
|
redirect_uri="http://localhost:8000/auth/callback",
|
|
cookie_secret=_TEST_SECRET,
|
|
scopes="read:user user:email",
|
|
session_ttl_hours=8,
|
|
logout_redirect_uri=None,
|
|
allowed_domains=None,
|
|
provider_type="github",
|
|
authorization_endpoint="https://github.com/login/oauth/authorize",
|
|
token_endpoint=_GITHUB_TOKEN_ENDPOINT,
|
|
jwks_uri=None,
|
|
userinfo_endpoint="https://api.github.com/user",
|
|
allow_invites=False,
|
|
)
|
|
|
|
|
|
def _build_oidc_app() -> httpx.ASGITransport:
|
|
"""Build a minimal FastAPI app with only the OIDC auth router."""
|
|
from fastapi import FastAPI
|
|
|
|
config = _make_oidc_config()
|
|
auth_provider = UnifiedAuthProvider(source="oidc", oidc_config=config)
|
|
admin_list = AdminList(Path("/tmp/nonexistent-admin-list.txt"))
|
|
|
|
router = create_auth_router(
|
|
auth_provider=auth_provider,
|
|
permission_store=None,
|
|
admin_list=admin_list,
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router, prefix="/auth")
|
|
return httpx.ASGITransport(app=app)
|
|
|
|
|
|
def _mint_state_cookie(
|
|
state: str,
|
|
code_verifier: str = "test-verifier",
|
|
return_to: str = "/",
|
|
ticket: str | None = None,
|
|
) -> str:
|
|
"""Mint a signed auth-state cookie matching what /auth/login produces."""
|
|
config = _make_oidc_config()
|
|
payload: dict = {
|
|
"state": state,
|
|
"code_verifier": code_verifier,
|
|
"return_to": return_to,
|
|
"exp": int(time.time()) + 300,
|
|
}
|
|
if ticket:
|
|
payload["ticket"] = ticket
|
|
return jwt.encode(payload, config.cookie_secret, algorithm="HS256")
|
|
|
|
|
|
def _mock_httpx_client_for_github(
|
|
token_status: int = 200,
|
|
token_json: dict | None = None,
|
|
emails_json: list | None = None,
|
|
) -> AsyncMock:
|
|
"""Build a mock httpx.AsyncClient context manager for GitHub endpoints.
|
|
|
|
Returns an AsyncMock suitable for use as an async context manager
|
|
(``async with httpx.AsyncClient() as client``).
|
|
"""
|
|
if token_json is None:
|
|
token_json = {"access_token": "gho_test_token", "token_type": "bearer"}
|
|
if emails_json is None:
|
|
emails_json = [{"email": "alice@example.com", "primary": True, "verified": True}]
|
|
|
|
token_resp = MagicMock()
|
|
token_resp.status_code = token_status
|
|
token_resp.json.return_value = token_json
|
|
token_resp.text = str(token_json)
|
|
|
|
emails_resp = MagicMock()
|
|
emails_resp.status_code = 200
|
|
emails_resp.json.return_value = emails_json
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=token_resp)
|
|
mock_client.get = AsyncMock(return_value=emails_resp)
|
|
|
|
# Make it work as an async context manager.
|
|
mock_cm = AsyncMock()
|
|
mock_cm.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
|
return mock_cm
|
|
|
|
|
|
# ── 1. Login redirect ──────────────────────────────────────────────
|
|
|
|
|
|
async def test_login_redirects_to_idp_with_pkce_params() -> None:
|
|
"""GET /auth/login returns a 302 to the IdP with PKCE parameters.
|
|
|
|
The redirect URL must contain client_id, redirect_uri, state,
|
|
code_challenge, and code_challenge_method=S256.
|
|
"""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://test", follow_redirects=False
|
|
) as client:
|
|
resp = await client.get("/auth/login")
|
|
|
|
assert resp.status_code == 302
|
|
location = resp.headers["location"]
|
|
parsed = urlparse(location)
|
|
params = parse_qs(parsed.query)
|
|
|
|
assert parsed.scheme == "https"
|
|
assert parsed.hostname == "github.com"
|
|
assert parsed.path == "/login/oauth/authorize"
|
|
assert params["client_id"] == ["test-client-id"]
|
|
assert params["redirect_uri"] == ["http://localhost:8000/auth/callback"]
|
|
assert params["code_challenge_method"] == ["S256"]
|
|
assert "state" in params
|
|
assert "code_challenge" in params
|
|
# Auth state cookie must be set.
|
|
assert "ap_auth_state" in resp.cookies
|
|
|
|
|
|
# ── 2. CLI login ───────────────────────────────────────────────────
|
|
|
|
|
|
async def test_cli_login_creates_ticket() -> None:
|
|
"""POST /auth/cli-login returns a ticket_id and login URL."""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post("/auth/cli-login")
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert "ticket" in body
|
|
assert "login_url" in body
|
|
assert body["login_url"].startswith("/auth/login?ticket=")
|
|
|
|
|
|
# ── 3. CLI poll (pending) ─────────────────────────────────────────
|
|
|
|
|
|
async def test_cli_poll_returns_pending_before_callback() -> None:
|
|
"""GET /auth/cli-poll returns 202 while the ticket is unfulfilled."""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
# Create a ticket first.
|
|
create_resp = await client.post("/auth/cli-login")
|
|
ticket_id = create_resp.json()["ticket"]
|
|
|
|
# Poll -- should be pending.
|
|
poll_resp = await client.get(f"/auth/cli-poll?ticket={ticket_id}")
|
|
|
|
assert poll_resp.status_code == 202
|
|
assert poll_resp.json()["status"] == "pending"
|
|
|
|
|
|
async def test_cli_poll_returns_410_for_unknown_ticket() -> None:
|
|
"""GET /auth/cli-poll returns 410 for a non-existent ticket."""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/auth/cli-poll?ticket=nonexistent")
|
|
|
|
assert resp.status_code == 410
|
|
|
|
|
|
# ── 4. Callback with mocked IdP ───────────────────────────────────
|
|
|
|
|
|
async def test_callback_exchanges_code_and_sets_session_cookie() -> None:
|
|
"""GET /auth/callback mocks the IdP token exchange, sets a session cookie.
|
|
|
|
Mocks the GitHub token endpoint and emails endpoint. Verifies
|
|
the session cookie is set and the auth state cookie is cleared.
|
|
"""
|
|
mock_cm = _mock_httpx_client_for_github()
|
|
transport = _build_oidc_app()
|
|
state = "test-state-value"
|
|
state_cookie = _mint_state_cookie(state)
|
|
|
|
# Build the test client BEFORE patching httpx.AsyncClient so the
|
|
# test transport is real; only the route's outbound IdP call is mocked.
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://test", follow_redirects=False
|
|
) as client:
|
|
with patch("omnigent.server.routes.auth.httpx.AsyncClient", return_value=mock_cm):
|
|
resp = await client.get(
|
|
"/auth/callback",
|
|
params={"code": "auth-code-123", "state": state},
|
|
cookies={"ap_auth_state": state_cookie},
|
|
)
|
|
|
|
assert resp.status_code == 302
|
|
assert resp.headers["location"] == "/"
|
|
# Session cookie should be set.
|
|
assert "ap_session" in resp.cookies
|
|
# Validate the session JWT.
|
|
session_jwt = resp.cookies["ap_session"]
|
|
payload = jwt.decode(session_jwt, _TEST_SECRET, algorithms=["HS256"])
|
|
assert payload["sub"] == "alice@example.com"
|
|
|
|
|
|
async def test_callback_returns_400_on_missing_code() -> None:
|
|
"""GET /auth/callback without code param returns 400."""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/auth/callback", params={"state": "x"})
|
|
|
|
assert resp.status_code == 400
|
|
assert "Missing code or state" in resp.json()["error"]
|
|
|
|
|
|
async def test_callback_returns_400_on_state_mismatch() -> None:
|
|
"""GET /auth/callback with mismatched state returns 400."""
|
|
transport = _build_oidc_app()
|
|
state_cookie = _mint_state_cookie("correct-state")
|
|
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get(
|
|
"/auth/callback",
|
|
params={"code": "auth-code", "state": "wrong-state"},
|
|
cookies={"ap_auth_state": state_cookie},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert "State mismatch" in resp.json()["error"]
|
|
|
|
|
|
async def test_callback_returns_400_on_token_exchange_failure() -> None:
|
|
"""GET /auth/callback returns 400 when the IdP token exchange fails."""
|
|
mock_cm = _mock_httpx_client_for_github(
|
|
token_status=401,
|
|
token_json={"error": "bad_verification_code"},
|
|
)
|
|
transport = _build_oidc_app()
|
|
state = "test-state"
|
|
state_cookie = _mint_state_cookie(state)
|
|
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
with patch("omnigent.server.routes.auth.httpx.AsyncClient", return_value=mock_cm):
|
|
resp = await client.get(
|
|
"/auth/callback",
|
|
params={"code": "bad-code", "state": state},
|
|
cookies={"ap_auth_state": state_cookie},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert "Token exchange failed" in resp.json()["error"]
|
|
|
|
|
|
# ── 5. Logout ──────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_logout_clears_cookie_and_redirects() -> None:
|
|
"""GET /auth/logout clears the session cookie and redirects to /."""
|
|
transport = _build_oidc_app()
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://test", follow_redirects=False
|
|
) as client:
|
|
resp = await client.get("/auth/logout")
|
|
|
|
assert resp.status_code == 302
|
|
assert resp.headers["location"] == "/"
|
|
# The session cookie should be cleared with Max-Age=0 deletion semantics.
|
|
set_cookie_headers = resp.headers.get_list("set-cookie")
|
|
session_cookies = [h for h in set_cookie_headers if "ap_session" in h]
|
|
assert session_cookies, "expected a Set-Cookie header for ap_session"
|
|
assert any("Max-Age=0" in h for h in session_cookies), (
|
|
f"expected Max-Age=0 deletion cookie; got {session_cookies}"
|
|
)
|
|
|
|
|
|
# ── 6. Expired ticket eviction ─────────────────────────────────────
|
|
|
|
|
|
async def test_evict_expired_tickets_removes_old_entries() -> None:
|
|
"""_evict_expired_tickets removes tickets older than the TTL."""
|
|
tickets: dict[str, _CliTicket] = {
|
|
"fresh": _CliTicket(created_at=time.time()),
|
|
"expired": _CliTicket(created_at=time.time() - _CLI_TICKET_TTL_SECONDS - 1),
|
|
"also_expired": _CliTicket(created_at=time.time() - _CLI_TICKET_TTL_SECONDS - 100),
|
|
}
|
|
|
|
_evict_expired_tickets(tickets)
|
|
|
|
assert "fresh" in tickets
|
|
assert "expired" not in tickets
|
|
assert "also_expired" not in tickets
|
|
|
|
|
|
async def test_evict_expired_tickets_noop_when_all_fresh() -> None:
|
|
"""_evict_expired_tickets is a no-op when no tickets are expired."""
|
|
tickets: dict[str, _CliTicket] = {
|
|
"a": _CliTicket(created_at=time.time()),
|
|
"b": _CliTicket(created_at=time.time() - 10),
|
|
}
|
|
|
|
_evict_expired_tickets(tickets)
|
|
|
|
assert len(tickets) == 2
|