fix(opax-mcp): authenticate internal agent calls with Cloud Run identity token

This commit is contained in:
Chris Christiansen 2026-09-20 15:34:35 +00:00
parent 8e8952ce20
commit 395aad5d70
2 changed files with 206 additions and 7 deletions

View File

@ -545,18 +545,42 @@ async def _verify_auth(request: Request) -> None:
# Backend Agent helpers (for kall VIDERE til osvauco-agent)
# ---------------------------------------------------------------------------
def _agent_headers() -> dict:
"""Headers for maskin-til-maskin kall videre til osvauco-agent via X-Internal-Key."""
return {
"X-Internal-Key": INTERNAL_API_KEY, # Bruker den nye delte nøkkelen
"Content-Type": "application/json"
def _get_identity_token(audience: str) -> str:
"""Fetches a Google-signed identity token for the given audience."""
# OPAX_LOCAL_DEV_MODE bypass is for local testing where metadata server is not available.
if os.environ.get("OPAX_LOCAL_DEV_MODE") == "true":
logger.warning("OPAX_LOCAL_DEV_MODE is enabled, skipping identity token fetch.")
return ""
try:
auth_req = google.auth.transport.requests.Request()
token = google.oauth2.id_token.fetch_id_token(auth_req, audience)
return token
except Exception as e:
logger.error(f"Failed to fetch identity token for audience {audience}: {e}", exc_info=True)
raise RuntimeError(f"Could not obtain identity token for service-to-service authentication.") from e
async def _agent_headers() -> dict:
"""Headers for machine-to-machine calls to osvauco-agent."""
token = await asyncio.to_thread(_get_identity_token, OSVAUCO_AGENT_URL)
headers = {
"X-Internal-Key": INTERNAL_API_KEY,
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
async def _agent_get(path: str) -> Any:
"""GET-kall til osvauco-agent."""
url = f"{OSVAUCO_AGENT_URL}{path}"
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(url, headers=_agent_headers())
# Headers are now generated asynchronously for each request.
request_headers = await _agent_headers()
r = await c.get(url, headers=request_headers)
r.raise_for_status()
return r.json()
@ -564,7 +588,9 @@ async def _agent_post(path: str, body: dict) -> Any:
"""POST-kall til osvauco-agent."""
url = f"{OSVAUCO_AGENT_URL}{path}"
async with httpx.AsyncClient(timeout=45) as c:
r = await c.post(url, json=body, headers=_agent_headers())
# Headers are now generated asynchronously for each request.
request_headers = await _agent_headers()
r = await c.post(url, json=body, headers=request_headers)
r.raise_for_status()
return r.json()

173
opax-mcp/test_auth.py Normal file
View File

@ -0,0 +1,173 @@
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "opax-mcp"))
import server
class TestIdentityToken(unittest.TestCase):
def test_get_identity_token_returns_fetched_token(self):
with (
patch.dict(
os.environ,
{"OPAX_LOCAL_DEV_MODE": ""},
clear=False,
),
patch(
"server.google.auth.transport.requests.Request"
) as mock_request,
patch(
"server.google.oauth2.id_token.fetch_id_token",
return_value="mock-token",
) as mock_fetch,
):
token = server._get_identity_token(
"https://mock-agent.run.app"
)
self.assertEqual(token, "mock-token")
mock_fetch.assert_called_once_with(
mock_request.return_value,
"https://mock-agent.run.app",
)
def test_get_identity_token_raises_runtime_error_on_failure(self):
with (
patch.dict(
os.environ,
{"OPAX_LOCAL_DEV_MODE": ""},
clear=False,
),
patch(
"server.google.auth.transport.requests.Request"
),
patch(
"server.google.oauth2.id_token.fetch_id_token",
side_effect=Exception("token fetch failed"),
),
):
with self.assertRaises(RuntimeError):
server._get_identity_token(
"https://mock-agent.run.app"
)
def test_local_dev_mode_returns_empty_token_without_fetching(self):
with (
patch.dict(
os.environ,
{"OPAX_LOCAL_DEV_MODE": "true"},
clear=False,
),
patch(
"server.google.oauth2.id_token.fetch_id_token"
) as mock_fetch,
):
token = server._get_identity_token(
"https://mock-agent.run.app"
)
self.assertEqual(token, "")
mock_fetch.assert_not_called()
class TestAgentHeaders(unittest.IsolatedAsyncioTestCase):
async def test_agent_headers_include_bearer_and_internal_key(self):
with (
patch.object(
server,
"OSVAUCO_AGENT_URL",
"https://mock-agent.run.app",
),
patch.object(
server,
"INTERNAL_API_KEY",
"mock-internal-key",
),
patch(
"server.asyncio.to_thread",
new_callable=AsyncMock,
return_value="mock-token",
) as mock_to_thread,
):
headers = await server._agent_headers()
self.assertEqual(
headers["Authorization"],
"Bearer mock-token",
)
self.assertEqual(
headers["X-Internal-Key"],
"mock-internal-key",
)
self.assertEqual(
headers["Content-Type"],
"application/json",
)
mock_to_thread.assert_awaited_once_with(
server._get_identity_token,
"https://mock-agent.run.app",
)
async def test_local_dev_headers_have_no_authorization_header(self):
with (
patch.object(
server,
"OSVAUCO_AGENT_URL",
"https://mock-agent.run.app",
),
patch.object(
server,
"INTERNAL_API_KEY",
"mock-internal-key",
),
patch(
"server.asyncio.to_thread",
new_callable=AsyncMock,
return_value="",
),
):
headers = await server._agent_headers()
self.assertNotIn("Authorization", headers)
self.assertEqual(
headers["X-Internal-Key"],
"mock-internal-key",
)
self.assertEqual(
headers["Content-Type"],
"application/json",
)
async def test_agent_headers_fail_closed_when_token_fetch_fails(self):
with (
patch.object(
server,
"OSVAUCO_AGENT_URL",
"https://mock-agent.run.app",
),
patch.object(
server,
"INTERNAL_API_KEY",
"mock-internal-key",
),
patch(
"server.asyncio.to_thread",
new_callable=AsyncMock,
side_effect=RuntimeError("token fetch failed"),
),
):
with self.assertRaisesRegex(
RuntimeError,
"token fetch failed",
):
await server._agent_headers()
if __name__ == "__main__":
unittest.main()