test: cover the per-user OAuth flow, threat cases, store and startup

This commit is contained in:
Latte committed 2026-10-14 16:41:18 +02:00
1 parent 566d0b65dd
commit 7286440d64
9 files changed
+2505 -7

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
"""Tests for the OAuth identity resolver: no mix-up between identities, upstream refresh."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from mcp.server.auth.provider import AccessToken
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.oauth.resolver import (
OAUTH_ONLY,
SIGN_IN_AGAIN,
OAuthGiteaExecutor,
OAuthIdentityResolver,
SessionIdentity,
)
from aegis_gitea_mcp.oauth.store import OAuthStore, SessionRecord, TokenRecord
from aegis_gitea_mcp.oauth.upstream import UpstreamError, UpstreamInvalidGrant, UpstreamTokens
from tests.test_oauth_helpers import (
ENCRYPTION_KEY,
GITEA_TOKEN,
USER_REFRESH,
USER_TOKEN,
FakeClock,
oauth_settings,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
class FakeUpstream:
"""Stands in for GiteaUpstream.refresh."""
def __init__(self, *outcomes: UpstreamTokens | Exception, delay: float = 0.0) -> None:
self.outcomes = list(outcomes)
self.calls: list[str] = []
self.delay = delay
async def refresh(self, refresh_token: str) -> UpstreamTokens:
self.calls.append(refresh_token)
if self.delay:
await asyncio.sleep(self.delay)
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class FakeAdmin:
def __init__(self) -> None:
self.seen: list[AccessToken | None] = []
async def resolve(self, access: AccessToken | None) -> Identity:
self.seen.append(access)
return Identity(login="root", mode="admin-token", token=GITEA_TOKEN)
def make_store(tmp_path: Path, clock: FakeClock, **session: Any) -> OAuthStore:
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock)
values: dict[str, Any] = {
"session_id": "s1",
"client_id": "c1",
"login": "alice",
"is_admin": False,
"gitea_access_token": USER_TOKEN,
"gitea_refresh_token": USER_REFRESH,
"gitea_expires_at": clock.now + 3600,
"scopes": ["gitea"],
"resource": "https://mcp.example.com/mcp",
"created_at": clock.now,
}
values.update(session)
store.sessions["s1"] = SessionRecord(**values)
store.tokens["h"] = TokenRecord("access", "s1", "c1", ["gitea"], clock.now + 3600)
return store
def oauth_access(session_id: Any = "s1", subject: str = "alice", **claims: Any) -> AccessToken:
return AccessToken(
token="sha256:x",
client_id="c1",
scopes=["gitea"],
subject=subject,
claims={"auth": "oauth", "session_id": session_id, **claims},
)
STATIC = AccessToken(token="static", client_id=STATIC_CLIENT_ID, scopes=["gitea"])
def resolver(
store: OAuthStore, upstream: Any = None, admin: Any = None, clock: Any = None
) -> OAuthIdentityResolver:
return OAuthIdentityResolver(
store, upstream or FakeUpstream(), admin, clock=clock or FakeClock()
)
async def test_oauth_token_resolves_to_the_session_user(tmp_path: Path) -> None:
clock = FakeClock()
identity = await resolver(make_store(tmp_path, clock), clock=clock).resolve(oauth_access())
assert isinstance(identity, SessionIdentity)
assert (identity.login, identity.mode, identity.token) == ("alice", "oauth", USER_TOKEN)
assert identity.redact == (USER_REFRESH,)
assert USER_TOKEN not in repr(identity) and USER_REFRESH not in repr(identity)
assert identity.metadata() == {"login": "alice", "mode": "oauth", "sudo": None}
async def test_static_bearer_and_no_token_go_to_the_admin_resolver(tmp_path: Path) -> None:
clock = FakeClock()
admin = FakeAdmin()
subject = resolver(make_store(tmp_path, clock), admin=admin, clock=clock)
assert (await subject.resolve(STATIC)).mode == "admin-token"
assert (await subject.resolve(None)).mode == "admin-token"
assert admin.seen == [STATIC, None]
async def test_oauth_only_deployment_fails_closed_for_admin_mode(tmp_path: Path) -> None:
clock = FakeClock()
subject = resolver(make_store(tmp_path, clock), admin=None, clock=clock)
for access in (STATIC, None):
with pytest.raises(ToolError) as caught:
await subject.resolve(access)
assert caught.value.detail == OAUTH_ONLY and caught.value.status_code == 401
@pytest.mark.parametrize(
"access",
[
AccessToken(token="t", client_id="some-client", scopes=["gitea"]),
AccessToken(token="t", client_id="some-client", scopes=["gitea"], claims={"auth": "x"}),
oauth_access(session_id=None),
oauth_access(session_id=5),
],
)
async def test_unknown_token_kinds_never_reach_the_admin(
tmp_path: Path, access: AccessToken
) -> None:
clock = FakeClock()
admin = FakeAdmin()
with pytest.raises(ToolError, match="unknown identity"):
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(access)
assert admin.seen == []
async def test_oauth_marker_on_a_static_client_id_still_needs_a_session(tmp_path: Path) -> None:
"""A token claiming OAuth is never treated as the static bearer."""
clock = FakeClock()
admin = FakeAdmin()
forged = AccessToken(
token="t",
client_id=STATIC_CLIENT_ID,
scopes=["gitea"],
subject="alice",
claims={"auth": "oauth", "session_id": "missing"},
)
with pytest.raises(ToolError, match="sign in again"):
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(forged)
assert admin.seen == []
@pytest.mark.parametrize(("session_id", "subject"), [("gone", "alice"), ("s1", "mallory")])
async def test_ended_session_or_subject_mismatch_means_sign_in_again(
tmp_path: Path, session_id: str, subject: str
) -> None:
clock = FakeClock()
admin = FakeAdmin()
with pytest.raises(ToolError) as caught:
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(
oauth_access(session_id, subject)
)
assert caught.value.detail == SIGN_IN_AGAIN and caught.value.status_code == 401
assert admin.seen == []
async def test_token_without_expiry_is_never_refreshed(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream()
store = make_store(tmp_path, clock, gitea_expires_at=None)
await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert upstream.calls == []
async def test_refresh_keeps_the_old_refresh_token_when_gitea_sends_none(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamTokens("new-access", None, clock.now + 3600))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
identity = await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert identity.token == "new-access"
assert store.sessions["s1"].gitea_refresh_token == USER_REFRESH
reloaded = OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock)
assert reloaded.sessions["s1"].gitea_access_token == "new-access"
async def test_concurrent_calls_refresh_once(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(
UpstreamTokens("new-access", "new-refresh", clock.now + 3600), delay=0.01
)
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
subject = resolver(store, upstream, clock=clock)
identities = await asyncio.gather(*(subject.resolve(oauth_access()) for _ in range(5)))
assert upstream.calls == [USER_REFRESH]
assert {identity.token for identity in identities} == {"new-access"}
async def test_transient_failure_keeps_a_still_valid_token(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamError("token request failed: HTTP 502"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
identity = await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert identity.token == USER_TOKEN
assert "s1" in store.sessions
async def test_transient_failure_with_an_expired_token_is_retryable(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamError("token request failed: HTTP 502"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now - 10)
with pytest.raises(ToolError) as caught:
await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert caught.value.status_code == 503
assert "s1" in store.sessions
async def test_invalid_grant_ends_the_session_and_its_tokens(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamInvalidGrant("Gitea rejected the grant"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
admin = FakeAdmin()
with pytest.raises(ToolError, match="sign in again"):
await resolver(store, upstream, admin=admin, clock=clock).resolve(oauth_access())
assert store.sessions == {} and store.tokens == {}
assert admin.seen == []
assert OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock).sessions == {}
@pytest.mark.parametrize(("offset", "survives"), [(30.0, True), (-30.0, False)])
async def test_no_refresh_token(tmp_path: Path, offset: float, survives: bool) -> None:
clock = FakeClock()
store = make_store(
tmp_path, clock, gitea_refresh_token=None, gitea_expires_at=clock.now + offset
)
subject = resolver(store, FakeUpstream(), clock=clock)
if survives:
assert (await subject.resolve(oauth_access())).token == USER_TOKEN
else:
with pytest.raises(ToolError, match="sign in again"):
await subject.resolve(oauth_access())
assert ("s1" in store.sessions) is survives
async def test_session_ended_while_waiting_for_the_refresh_lock(tmp_path: Path) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
subject = resolver(store, FakeUpstream(), clock=clock)
lock = subject._locks.setdefault("s1", asyncio.Lock())
async with lock:
task = asyncio.ensure_future(subject.resolve(oauth_access()))
await asyncio.sleep(0)
store.end_session("s1")
with pytest.raises(ToolError, match="sign in again"):
await task
async def test_save_failures_are_logged_not_raised(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
async def broken_save() -> None:
raise OSError("disk full")
monkeypatch.setattr(store, "save", broken_save)
upstream = FakeUpstream(
UpstreamTokens("new-access", None, clock.now + 3600), UpstreamInvalidGrant("x")
)
subject = resolver(store, upstream, clock=clock)
assert (await subject.resolve(oauth_access())).token == "new-access"
store.sessions["s1"].gitea_expires_at = clock.now + 10
with pytest.raises(ToolError):
await subject.resolve(oauth_access())
assert "could not save the refreshed Gitea token" in caplog.text
assert "could not save the OAuth store" in caplog.text
async def test_audit_failure_does_not_block_ending_a_session(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
class BrokenAudit:
def log_security_event(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("audit down")
subject = OAuthIdentityResolver(
store,
FakeUpstream(UpstreamInvalidGrant("x")), # type: ignore[arg-type]
None,
clock=clock,
audit=BrokenAudit(), # type: ignore[arg-type]
)
with pytest.raises(ToolError):
await subject.resolve(oauth_access())
assert store.sessions == {}
assert "audit log write failed: RuntimeError" in caplog.text
async def test_executor_redacts_session_secrets(httpx_mock: Any) -> None:
executor = OAuthGiteaExecutor(oauth_settings(), extra_secrets=[ENCRYPTION_KEY])
httpx_mock.add_response(
url="https://gitea.example.com/api/v1/version",
json={"a": USER_REFRESH, "b": ENCRYPTION_KEY, "c": USER_TOKEN},
)
identity = SessionIdentity(
login="alice", mode="oauth", token=USER_TOKEN, redact=(USER_REFRESH,)
)
outcome = await executor.request("GET", "/version", identity)
await executor.aclose()
assert outcome.payload["data"] == {"a": "[REDACTED]", "b": "[REDACTED]", "c": "[REDACTED]"}