fix(opax-mcp): authenticate internal agent calls with Cloud Run identity token
This commit is contained in:
parent
8e8952ce20
commit
395aad5d70
|
|
@ -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
173
opax-mcp/test_auth.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user