Refactor app instance to Keycloak SSO

This commit is contained in:
2026-06-15 15:54:39 +08:00
parent fc9fd93c36
commit 461d1300ad
246 changed files with 1350 additions and 52721 deletions

View File

@ -0,0 +1,110 @@
from __future__ import annotations
import time
import jwt
import pytest
from fastapi import HTTPException
from beaver.interfaces.web.keycloak_auth import (
KeycloakAuthConfig,
KeycloakIdentity,
KeycloakTokenVerifier,
extract_bearer_token,
)
def _verifier() -> KeycloakTokenVerifier:
return KeycloakTokenVerifier(
config=KeycloakAuthConfig(
issuer="https://keycloak.bwgdi.com/realms/beaver",
client_id="beaver-agnet",
token_url="https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/token",
jwks_url="https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/certs",
)
)
def _claims(**overrides):
now = int(time.time())
payload = {
"sub": "user-123",
"preferred_username": "alice",
"email": "alice@example.com",
"name": "Alice Example",
"iss": "https://keycloak.bwgdi.com/realms/beaver",
"aud": "beaver-agnet",
"azp": "beaver-agnet",
"iat": now,
"exp": now + 300,
"nonce": "nonce-1",
"realm_access": {"roles": ["user", "admin"]},
"resource_access": {"beaver-agnet": {"roles": ["agent-user"]}},
}
payload.update(overrides)
return payload
def test_extract_bearer_token_accepts_case_insensitive_prefix() -> None:
assert extract_bearer_token("Bearer abc.def") == "abc.def"
assert extract_bearer_token("bearer xyz") == "xyz"
def test_extract_bearer_token_rejects_missing_or_invalid_header() -> None:
with pytest.raises(HTTPException) as missing:
extract_bearer_token(None)
with pytest.raises(HTTPException) as invalid:
extract_bearer_token("Basic abc")
assert missing.value.status_code == 401
assert invalid.value.status_code == 401
def test_validate_claims_accepts_audience_and_extracts_roles() -> None:
identity = _verifier().validate_claims(_claims(), expected_nonce="nonce-1")
assert identity == KeycloakIdentity(
user_id="user-123",
username="alice",
email="alice@example.com",
name="Alice Example",
realm_roles=("user", "admin"),
client_roles=("agent-user",),
)
def test_validate_claims_accepts_azp_when_audience_differs() -> None:
identity = _verifier().validate_claims(_claims(aud="account", azp="beaver-agnet"))
assert identity.user_id == "user-123"
def test_validate_claims_rejects_wrong_nonce() -> None:
with pytest.raises(HTTPException) as exc:
_verifier().validate_claims(_claims(), expected_nonce="different")
assert exc.value.status_code == 401
assert "nonce" in exc.value.detail.lower()
def test_validate_claims_rejects_wrong_audience_and_azp() -> None:
with pytest.raises(HTTPException) as exc:
_verifier().validate_claims(_claims(aud="account", azp="other-client"))
assert exc.value.status_code == 401
assert "audience" in exc.value.detail.lower()
def test_verify_raises_http_exception_for_bad_jwt(monkeypatch) -> None:
verifier = _verifier()
def fake_decode(*args, **kwargs):
raise jwt.InvalidTokenError("bad token")
monkeypatch.setattr(jwt, "decode", fake_decode)
with pytest.raises(HTTPException) as exc:
verifier.verify("bad-token")
assert exc.value.status_code == 401
assert "invalid token" in exc.value.detail.lower()

View File

@ -5,6 +5,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.keycloak_auth import KeycloakIdentity
from beaver.services.agent_service import AgentService
from beaver.services.user_file_resolver import UserFileStorageResolver
from beaver.services.user_files import LocalUserFileStorage, UserFileService
@ -12,10 +13,24 @@ from beaver.services.user_files import LocalUserFileStorage, UserFileService
def _auth_headers(app, username: str = "alice") -> dict[str, str]:
token = f"test-token-{username}"
app.state.auth_tokens[token] = username
app.state.keycloak_token_verifier = _FakeKeycloakVerifier(username=username)
return {"Authorization": f"Bearer {token}"}
class _FakeKeycloakVerifier:
def __init__(self, *, username: str) -> None:
self.username = username
def verify(self, token: str, *, expected_nonce: str | None = None) -> KeycloakIdentity:
return KeycloakIdentity(
user_id=self.username,
username=self.username,
email=f"{self.username}@example.com",
realm_roles=("user",),
client_roles=("agent-user",),
)
def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)