diff --git a/README.md b/README.md index 7359ae8..cc00865 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,17 @@ Two ways to authenticate: - **Session cookie (user context).** Log into Mantis, open devtools → Network, copy the `cookie` header from any authenticated request, and pass it as `cookie`. Required keys include `next-auth.session-token` and `sessionid`. -- **Internal-service (backend-to-backend).** Set `config.internal_user_id` (or `MANTIS_INTERNAL_USER_ID`); - the SDK then sends `X-Internal-Service: true` + `X-Internal-User-Id` instead of a cookie. +- **Internal-service (backend-to-backend).** Set `config.internal_user_id` and + `config.internal_service_secret` (or `MANTIS_INTERNAL_USER_ID` and `MANTIS_INTERNAL_SERVICE_SECRET`); + the SDK then sends authenticated internal-service headers. A cookie is not required. + +If both methods are configured, the SDK sends both sets of headers and the backend uses the +authenticated internal-service identity. If a cookie is present but the internal user ID has no +service secret, the SDK sends only the cookie; without a cookie, incomplete internal configuration +fails before making a request. `MantisClient.from_env()` reads `MANTIS_HOST`, `MANTIS_BACKEND_HOST`, `MANTIS_COOKIE`, -`MANTIS_BASE_URL`, and `MANTIS_INTERNAL_USER_ID`. +`MANTIS_BASE_URL`, `MANTIS_INTERNAL_USER_ID`, and `MANTIS_INTERNAL_SERVICE_SECRET`. ## Quick start diff --git a/mantis_sdk/_http.py b/mantis_sdk/_http.py index 6a2582a..2b3bee8 100644 --- a/mantis_sdk/_http.py +++ b/mantis_sdk/_http.py @@ -7,6 +7,7 @@ from typing import Any from .config import ConfigurationManager +from .exceptions import ConfigurationError from .transport import Transport logger = logging.getLogger("mantis_sdk") @@ -17,7 +18,7 @@ class HttpClient: auth resolution (either or both may apply): - cookie: a browser session cookie string (canonical for user auth). - - config.internal_user_id: enables X-Internal-Service backend-to-backend auth. + - config.internal_user_id + internal_service_secret: backend-to-backend auth. """ def __init__( @@ -55,8 +56,16 @@ def auth_headers(self) -> dict[str, str]: if self.cookie: headers["cookie"] = self.cookie if self.config.internal_user_id: - headers["X-Internal-Service"] = "true" - headers["X-Internal-User-Id"] = str(self.config.internal_user_id) + if not self.config.internal_service_secret: + # preserve cookie auth when only the ambient internal id is set. + if not self.cookie: + raise ConfigurationError( + "internal_service_secret is required when internal_user_id is set" + ) + else: + headers["X-Internal-Service"] = "true" + headers["X-Internal-Secret"] = self.config.internal_service_secret + headers["X-Internal-User-Id"] = str(self.config.internal_user_id) return headers def request( diff --git a/mantis_sdk/client.py b/mantis_sdk/client.py index 4339458..a8d616a 100644 --- a/mantis_sdk/client.py +++ b/mantis_sdk/client.py @@ -60,8 +60,9 @@ def __init__( if cookie is None and not self.config.internal_user_id: raise ConfigurationError( - "no auth provided: pass a session cookie, or set internal_user_id on the config " - "(MANTIS_INTERNAL_USER_ID) for backend-to-backend auth." + "no auth provided: pass a session cookie, or set internal_user_id " + "(MANTIS_INTERNAL_USER_ID) and internal_service_secret " + "(MANTIS_INTERNAL_SERVICE_SECRET) for backend-to-backend auth." ) self.http = HttpClient(base_url=base_url, cookie=cookie, config=self.config) @@ -81,7 +82,7 @@ def __init__( @classmethod def from_env(cls, base_url: str | None = None) -> MantisClient: """build a client from MANTIS_* environment variables. - uses MANTIS_COOKIE for auth (or MANTIS_INTERNAL_USER_ID via the config).""" + uses MANTIS_COOKIE or the internal user id and service secret via the config.""" import os config = ConfigurationManager() diff --git a/mantis_sdk/config.py b/mantis_sdk/config.py index bb5dfa9..eff3909 100644 --- a/mantis_sdk/config.py +++ b/mantis_sdk/config.py @@ -25,10 +25,9 @@ def __init__(self) -> None: # browser-side flag the sdk waits on before a space is considered ready. self.wait_for = os.getenv("MANTIS_WAIT_FOR", "isLoaded") - # internal-service auth: set these to authenticate backend-to-backend without - # a session cookie. when internal_user_id is set the transport sends - # X-Internal-Service: true and X-Internal-User-Id headers. + # internal-service auth requires both identity and a provisioned credential. self.internal_user_id: str | None = os.getenv("MANTIS_INTERNAL_USER_ID") + self.internal_service_secret: str | None = os.getenv("MANTIS_INTERNAL_SERVICE_SECRET") # the agent runtime (client.agents) keys identity + capability gating on email, not # user_id. set this (or MANTIS_USER_EMAIL) so agents.session() can default user_email. diff --git a/mantis_sdk/transport.py b/mantis_sdk/transport.py index 69af88a..835519b 100644 --- a/mantis_sdk/transport.py +++ b/mantis_sdk/transport.py @@ -28,7 +28,13 @@ def _redact(headers: dict[str, str]) -> dict[str, str]: """copy headers with auth-bearing values masked, for safe logging.""" redacted = dict(headers) for key in list(redacted): - if key.lower() in {"cookie", "x-internal-user-id", "x-notebook-auth", "x-csrftoken"}: + if key.lower() in { + "cookie", + "x-internal-secret", + "x-internal-user-id", + "x-notebook-auth", + "x-csrftoken", + }: redacted[key] = "" return redacted diff --git a/tests/conftest.py b/tests/conftest.py index e4a6585..a980bc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ def transport() -> RecordingTransport: def client(transport: RecordingTransport) -> MantisClient: config = ConfigurationManager() config.internal_user_id = "11111111-1111-1111-1111-111111111111" + config.internal_service_secret = "test-internal-service-secret" c = MantisClient("/api/proxy/", cookie=None, config=config) # swap in the recording transport so nothing touches the network. c.http.transport = transport diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 289bff0..88eb024 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,12 +1,15 @@ """url building, trailing-slash handling, and auth header construction.""" -from mantis_sdk import ConfigurationManager +import pytest + +from mantis_sdk import ConfigurationError, ConfigurationManager, MantisClient from mantis_sdk._http import HttpClient -def _http(base, cookie=None, internal=None): +def _http(base, cookie=None, internal=None, secret=None): cfg = ConfigurationManager() cfg.host = "http://localhost:3000" cfg.internal_user_id = internal + cfg.internal_service_secret = secret return HttpClient(base, cookie, cfg) @@ -31,7 +34,50 @@ def test_cookie_auth_header(): def test_internal_service_auth_header(): - h = _http("/api/proxy/", internal="user-123") + h = _http("/api/proxy/", internal="user-123", secret="service-secret") headers = h.auth_headers() assert headers["X-Internal-Service"] == "true" + assert headers["X-Internal-Secret"] == "service-secret" assert headers["X-Internal-User-Id"] == "user-123" + + +def test_internal_service_auth_requires_secret(): + h = _http("/api/proxy/", internal="user-123") + + with pytest.raises(ConfigurationError, match="internal_service_secret is required"): + h.auth_headers() + + +def test_cookie_auth_ignores_incomplete_ambient_internal_auth(): + h = _http("/api/proxy/", cookie="sessionid=abc", internal="user-123") + + assert h.auth_headers() == {"cookie": "sessionid=abc"} + + +def test_complete_internal_auth_takes_priority_when_cookie_is_also_present(): + h = _http( + "/api/proxy/", cookie="sessionid=abc", internal="user-123", secret="service-secret" + ) + + assert h.auth_headers() == { + "cookie": "sessionid=abc", + "X-Internal-Service": "true", + "X-Internal-Secret": "service-secret", + "X-Internal-User-Id": "user-123", + } + + +def test_missing_auth_error_describes_full_internal_contract(): + config = ConfigurationManager() + config.internal_user_id = None + + with pytest.raises(ConfigurationError, match="MANTIS_INTERNAL_SERVICE_SECRET"): + MantisClient("/api/proxy/", config=config) + + +def test_internal_service_secret_loads_from_environment(monkeypatch): + monkeypatch.setenv("MANTIS_INTERNAL_SERVICE_SECRET", "service-secret") + + config = ConfigurationManager() + + assert config.internal_service_secret == "service-secret" diff --git a/tests/test_transport.py b/tests/test_transport.py index cfd7577..7b847d9 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,4 +1,5 @@ """transport maps http status codes to typed exceptions and retries idempotent calls.""" +import logging from unittest.mock import MagicMock import pytest @@ -51,8 +52,29 @@ def test_connection_error_wrapped(): t.request("GET", "http://x/y") -def test_redact_masks_cookie(): - redacted = _redact({"cookie": "secret", "X-Internal-User-Id": "u", "Accept": "json"}) +def test_redact_masks_auth_headers(): + redacted = _redact({ + "cookie": "secret", + "X-Internal-Secret": "service-secret", + "X-Internal-User-Id": "u", + "Accept": "json", + }) assert redacted["cookie"] == "" + assert redacted["X-Internal-Secret"] == "" assert redacted["X-Internal-User-Id"] == "" assert redacted["Accept"] == "json" + + +def test_debug_log_redacts_internal_service_secret(caplog): + transport = Transport() + transport.session.request = MagicMock(return_value=_response(200, {"ok": True})) + caplog.set_level(logging.DEBUG, logger="mantis_sdk") + + transport.request( + "GET", + "http://x/y", + headers={"X-Internal-Secret": "service-secret"}, + ) + + assert "service-secret" not in caplog.text + assert "" in caplog.text