"""The token exchange, which is where this agent's security actually lives.""" from __future__ import annotations import pytest from assistant_agent.identity import TokenExchanger, TokenExchangeError class _Response: def __init__(self, status_code: int, payload: dict | None = None, text: str = "") -> None: self.status_code = status_code self._payload = payload or {} self.text = text def json(self) -> dict: return self._payload class _Client: """Records what was posted, so the assertions are about the request shape.""" def __init__(self, response: _Response) -> None: self.response = response self.calls: list[dict] = [] async def post(self, url: str, data: dict) -> _Response: self.calls.append({"url": url, "data": data}) return self.response def _exchanger(client: _Client) -> TokenExchanger: return TokenExchanger( client, # type: ignore[arg-type] token_endpoint="https://kc/realms/r/protocol/openid-connect/token", client_id="classic-models-agent-catalogue", client_secret="s3cret", ) async def test_audience_is_requested_as_a_scope() -> None: """Keycloak answers 'Requested audience not available' to `audience=...`. The audience has to be asked for as a scope instead. This is the single detail that decides whether the exchange works at all, so it is asserted rather than left to a comment. """ client = _Client(_Response(200, {"access_token": "t", "expires_in": 300})) await _exchanger(client).exchange("user-token", "mcp-catalog-audience") data = client.calls[0]["data"] assert data["scope"] == "openid mcp-catalog-audience" assert "audience" not in data assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" assert data["subject_token"] == "user-token" async def test_tokens_are_cached_per_audience_not_globally() -> None: """Two audiences must produce two exchanges. Reusing one token across audiences would hand the agent a broader credential than any single MCP server needs, which is the thing the per-audience design exists to prevent. """ client = _Client(_Response(200, {"access_token": "t", "expires_in": 300})) exchanger = _exchanger(client) await exchanger.exchange("user-token", "mcp-catalog-audience") await exchanger.exchange("user-token", "mcp-catalog-audience") await exchanger.exchange("user-token", "mcp-sales-audience") scopes = [c["data"]["scope"] for c in client.calls] assert scopes == ["openid mcp-catalog-audience", "openid mcp-sales-audience"] async def test_refusal_is_raised_with_keycloak_s_own_message() -> None: """A failed exchange must not degrade into an unauthenticated call.""" client = _Client(_Response(400, text='{"error":"invalid_client"}')) with pytest.raises(TokenExchangeError, match="invalid_client"): await _exchanger(client).exchange("user-token", "mcp-catalog-audience")