fastapi-gsap/tests/test_auth_middleware.py
Tyler J King 782f5654ac fix: shared bearer auth, delegation depth, SQLite permissions
C-4: MCP endpoint requires verified bearer token. Unauthenticated
     requests rejected. _extract_principal() replaced by verified
     AuthResult from middleware.
C-8: All delegation endpoints require verified bearer token.
     X-Delegator-DID header removed — identity from token only.
     delegator_ac_id validated to belong to authenticated principal.
     Only delegators can revoke. Only delegator/delegate can view.
H-6: SQLite file permissions restricted to 0o600 (owner-only).
     Umask set before creation. WAL/SHM files also restricted.
H-7: Delegation depth tracked and enforced against max_delegation_depth.
     Sub-delegations increment depth. Exceeded depth → 403.

Shared TokenAuthenticator auto-detects identity driver from JWT
issuer claim (Keycloak or Entra). verify_bearer FastAPI dependency
for all protected endpoints. Health endpoint remains public.

ALL 10 critical findings CLOSED. ALL 10 high findings CLOSED.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 17:31:46 -04:00

233 lines
8.2 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for shared bearer auth middleware — C-4, C-8, H-6, H-7."""
import os
import stat
import datetime
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from gsap_broker.auth.middleware import (
_authenticate_token, _peek_issuer, _verifiers, init_authenticator,
)
from gsap_broker.drivers.base import AuthResult
# ── Test key setup ───────────────────────────────────────────────
def _generate_rsa_keypair():
pk = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return pk, pk.public_key()
def _pem(private_key):
return private_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
def _jwk(public_key, kid="mid-kid"):
import base64
nums = public_key.public_numbers()
e = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
n = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
return {
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
"n": base64.urlsafe_b64encode(n).rstrip(b"=").decode(),
"e": base64.urlsafe_b64encode(e).rstrip(b"=").decode(),
}
PRIV, PUB = _generate_rsa_keypair()
KID = "mid-kid"
KC_URL = "http://keycloak.test:8080"
KC_REALM = "test-realm"
KC_ISSUER = f"{KC_URL}/realms/{KC_REALM}"
ENTRA_TID = "entra-test-tenant"
ENTRA_ISSUER = f"https://login.microsoftonline.com/{ENTRA_TID}/v2.0"
JWKS = {"keys": [_jwk(PUB, KID)]}
def _make_token(issuer, audience="test-client", claims=None, kid=KID):
now = datetime.datetime.now(datetime.UTC)
base = {
"iss": issuer, "aud": audience,
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"iat": int(now.timestamp()), "nbf": int(now.timestamp()),
"sub": "user-1", "oid": "user-1",
"preferred_username": "alice", "name": "Alice",
"jti": "test-jti",
}
if claims:
base.update(claims)
return jose_jwt.encode(base, _pem(PRIV), algorithm="RS256", headers={"kid": kid})
@pytest.fixture
def mock_jwks():
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as m:
resp = MagicMock()
resp.json.return_value = JWKS
resp.raise_for_status = MagicMock()
ctx = AsyncMock()
ctx.__aenter__.return_value.get = AsyncMock(return_value=resp)
m.return_value = ctx
yield m
@pytest.fixture
def setup_verifiers(mock_jwks):
"""Register test verifiers for both issuers."""
from gsap_broker.drivers.jwks import JWKSVerifier
import gsap_broker.auth.middleware as mw
mw._verifiers = {
KC_ISSUER: JWKSVerifier(
jwks_url=f"{KC_ISSUER}/protocol/openid-connect/certs",
audience="test-client", issuer=KC_ISSUER,
),
ENTRA_ISSUER: JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{ENTRA_TID}/discovery/v2.0/keys",
audience="test-client", issuer=ENTRA_ISSUER,
),
}
yield
mw._verifiers = {}
# ── Issuer peek ──────────────────────────────────────────────────
def test_peek_issuer_keycloak():
token = _make_token(KC_ISSUER)
assert _peek_issuer(token) == KC_ISSUER
def test_peek_issuer_entra():
token = _make_token(ENTRA_ISSUER)
assert _peek_issuer(token) == ENTRA_ISSUER
def test_peek_issuer_malformed():
with pytest.raises(Exception):
_peek_issuer("not-a-jwt")
# ── Token authentication ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_authenticate_keycloak_token(setup_verifiers):
"""TEST 4: Valid Keycloak token accepted."""
token = _make_token(KC_ISSUER)
result = await _authenticate_token(token)
assert result.is_authorized
assert "alice" in result.principal_did or "user-1" in result.principal_did
@pytest.mark.asyncio
async def test_authenticate_entra_token(setup_verifiers):
"""TEST 5: Valid Entra token accepted."""
token = _make_token(ENTRA_ISSUER, claims={"oid": "entra-user-1"})
result = await _authenticate_token(token)
assert result.is_authorized
assert "entra-user-1" in result.principal_did
@pytest.mark.asyncio
async def test_auto_detect_driver(setup_verifiers):
"""TEST 6: Correct driver selected based on issuer."""
kc_token = _make_token(KC_ISSUER)
entra_token = _make_token(ENTRA_ISSUER)
kc_result = await _authenticate_token(kc_token)
entra_result = await _authenticate_token(entra_token)
assert kc_result.is_authorized
assert entra_result.is_authorized
@pytest.mark.asyncio
async def test_unknown_issuer_rejected(setup_verifiers):
"""TEST 7: Unknown issuer → 401."""
token = _make_token("https://evil.example.com/auth")
with pytest.raises(Exception) as exc_info:
await _authenticate_token(token)
assert "401" in str(exc_info.value) or "Unknown" in str(exc_info.value)
@pytest.mark.asyncio
async def test_invalid_token_rejected(setup_verifiers):
"""TEST 3: Garbage token → 401."""
with pytest.raises(Exception):
await _authenticate_token("not.a.jwt")
# ── MCP auth (C-4) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_mcp_unauthenticated_rejected():
"""TEST 8: POST /mcp without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/mcp", json={"jsonrpc": "2.0", "method": "tools/list", "id": 1})
# HTTPBearer auto_error=True returns 403 when no header
assert resp.status_code in (401, 403)
# ── Delegation auth (C-8) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_delegation_create_unauthenticated_rejected():
"""TEST 10: POST /delegations/ without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/delegations/", json={
"delegator_ac_id": "test", "agent_type": "claude-code",
})
assert resp.status_code in (401, 403)
# ── Health stays public ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_health_unauthenticated_ok():
"""TEST 16: /health/ remains accessible without auth."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get("/health/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ── H-6: SQLite permissions ──────────────────────────────────────
@pytest.mark.asyncio
async def test_sqlite_permissions():
"""TEST 21: Database file permissions are 0o600."""
from gsap_broker.db import _restrict_db_permissions
# Create a temp file to test permissions on
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
test_path = f.name
try:
os.chmod(test_path, 0o644) # start with world-readable
assert stat.S_IMODE(os.stat(test_path).st_mode) == 0o644
# Simulate the fix
os.chmod(test_path, stat.S_IRUSR | stat.S_IWUSR)
mode = stat.S_IMODE(os.stat(test_path).st_mode)
assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
finally:
os.unlink(test_path)