From 395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 20 Sep 2026 15:34:35 +0000 Subject: [PATCH] fix(opax-mcp): authenticate internal agent calls with Cloud Run identity token --- opax-mcp/server.py | 40 ++++++++-- opax-mcp/test_auth.py | 173 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 opax-mcp/test_auth.py diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 0a46536..ae8172f 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -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() diff --git a/opax-mcp/test_auth.py b/opax-mcp/test_auth.py new file mode 100644 index 0000000..f97d480 --- /dev/null +++ b/opax-mcp/test_auth.py @@ -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() \ No newline at end of file