diff --git a/src/convox/_http.py b/src/convox/_http.py index 729b79e..1874584 100644 --- a/src/convox/_http.py +++ b/src/convox/_http.py @@ -8,6 +8,7 @@ import logging import random +import re import time from dataclasses import dataclass from email.utils import parsedate_to_datetime @@ -17,6 +18,7 @@ from ._version import __version__ from .exceptions import ( + ConvoxAuthError, ConvoxConnectionError, ConvoxTimeoutError, exception_for_response, @@ -26,6 +28,10 @@ _DEFAULT_RETRYABLE: frozenset[int] = frozenset({429, 502, 503, 504}) +# Matches the console's session challenge header. +# Example: Session path="/session" token="false" +_RE_SESSION_CHALLENGE = re.compile(r'^Session path="([^"]+)" token="([^"]+)"$') + @dataclass(frozen=True) class RetryConfig: @@ -168,6 +174,7 @@ def __init__( retry: RetryConfig | None = None, timeout: float = 30.0, rack: str | None = None, + session: str | None = None, ) -> None: self.base_url = base_url.rstrip("/") retry_config = retry if retry is not None else RetryConfig() @@ -186,6 +193,9 @@ def __init__( else: headers["User-Agent"] = f"convox-python/{__version__}" + if session: + headers["Session"] = session + self._client = httpx.Client( base_url=self.base_url, auth=auth, @@ -232,6 +242,31 @@ def request( except httpx.TimeoutException as exc: raise ConvoxTimeoutError(str(exc)) from exc + # Session refresh: the console returns 401 with a + # WWW-Authenticate Session challenge when the server-side + # session has expired. If the underlying CLI token is still + # valid we can POST to the session endpoint to obtain a fresh + # session ID and retry the original request. + if response.status_code == 401: + new_session = self._try_session_refresh(response) + if new_session is not None: + self._client.headers["Session"] = new_session + try: + response = self._client.request( + method, + path, + params=params, + data=data, + json=json, + content=content, + headers=headers, + timeout=timeout, + ) + except httpx.ConnectError as exc: + raise ConvoxConnectionError(str(exc)) from exc + except httpx.TimeoutException as exc: + raise ConvoxTimeoutError(str(exc)) from exc + if response.status_code >= 400: message = _extract_error_message(response) request_id = response.headers.get("request-id") @@ -293,6 +328,43 @@ def stream( raise ConvoxTimeoutError(str(exc)) from exc return response + def _try_session_refresh(self, response: httpx.Response) -> str | None: + """Attempt to refresh an expired console session. + + Returns the new session ID on success, or ``None`` if the + response is not a session challenge (e.g. bad credentials). + """ + www_auth = response.headers.get("www-authenticate", "") + m = _RE_SESSION_CHALLENGE.match(www_auth) + if m is None: + return None + + session_path, token_flag = m.groups() + + if token_flag == "true": + # MFA/hardware-token required — cannot be handled by the SDK. + # The user must establish a session via the CLI first. + raise ConvoxAuthError( + 401, + "MFA authentication required. Run a Convox CLI command " + "against this rack first to establish a session " + "(e.g. `convox rack -r `), then retry.", + ) + + try: + resp = self._client.post(session_path) + if resp.status_code != 200: + logger.debug("Session refresh POST returned %d", resp.status_code) + return None + data = resp.json() + session_id: str | None = data.get("id") + if session_id: + logger.debug("Session refreshed successfully") + return session_id + except Exception: + logger.debug("Session refresh failed", exc_info=True) + return None + def close(self) -> None: self._client.close() diff --git a/src/convox/auth.py b/src/convox/auth.py index 8ac0ba6..a3ddddf 100644 --- a/src/convox/auth.py +++ b/src/convox/auth.py @@ -97,6 +97,32 @@ def credentials_from_cli_config( return lookup, password +def session_from_cli_config( + config_dir: Path, + host: str, +) -> str | None: + """Read a CLI session ID for *host* from the ``session`` file. + + The Convox console requires a ``Session`` header alongside basic + auth when authenticating with a CLI login token (as opposed to a + deploy key). The session ID is written by ``convox login`` into a + ``session`` file in the same directory as the ``auth`` file. + + Returns ``None`` when the session file is missing or does not + contain an entry for *host*. This is expected for deploy-key + users who never go through the interactive login flow. + """ + session_path = config_dir / "session" + if not session_path.exists(): + return None + try: + with open(session_path) as f: + data: dict[str, str] = json.load(f) + return data.get(host) + except (json.JSONDecodeError, OSError): + return None + + def normalize_host(host: str) -> str: """Ensure host has an https:// scheme prefix.""" if host.startswith("http://") or host.startswith("https://"): diff --git a/src/convox/client.py b/src/convox/client.py index 04fc640..71446c9 100644 --- a/src/convox/client.py +++ b/src/convox/client.py @@ -3,7 +3,12 @@ from __future__ import annotations from ._http import HttpClient, RetryConfig -from .auth import credentials_from_cli_config, credentials_from_env, normalize_host +from .auth import ( + credentials_from_cli_config, + credentials_from_env, + normalize_host, + session_from_cli_config, +) from .resources.apps import Apps from .resources.builds import Builds from .resources.certificates import Certificates @@ -73,6 +78,7 @@ def __init__( api_key: str, *, rack: str | None = None, + session: str | None = None, retry: RetryConfig | None = None, timeout: float = 30.0, ) -> None: @@ -83,6 +89,7 @@ def __init__( retry=retry, timeout=timeout, rack=rack, + session=session, ) self.apps = Apps(self._http) @@ -126,7 +133,9 @@ def from_cli_config( """Create a client from the CLI config file. Checks ``~/.config/convox/auth`` (v3 CLI) first, then falls back - to ``~/.convox/auth`` (v2 CLI). + to ``~/.convox/auth`` (v2 CLI). If a ``session`` file exists in + the same directory, the session ID is sent alongside basic auth + so that CLI login tokens (not just deploy keys) work. Parameters: rack: Rack to target (e.g. ``org/rack-name``). Required for @@ -134,11 +143,31 @@ def from_cli_config( host: Console or rack host to look up in the auth file. If not provided, falls back to *rack*, then ``CONVOX_HOST``. """ + from pathlib import Path + auth_host, api_key = credentials_from_cli_config( config_path=config_path, host=host or rack, ) - return cls(auth_host, api_key, rack=rack, retry=retry, timeout=timeout) + + # Resolve the config directory so we can look for the session + # file next to the auth file. + if config_path: + config_dir = Path(config_path).parent + else: + config_dir = Path.home() / ".config" / "convox" + if not (config_dir / "auth").exists(): + config_dir = Path.home() / ".convox" + + session = session_from_cli_config(config_dir, auth_host) + return cls( + auth_host, + api_key, + rack=rack, + session=session, + retry=retry, + timeout=timeout, + ) def stream( self, diff --git a/tests/test_client.py b/tests/test_client.py index 202db10..0a2dfef 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -130,6 +130,49 @@ def test_console_accept_json_header(self, httpx_mock: HTTPXMock) -> None: client.close() +class TestSessionHeader: + def test_session_header_sent(self, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(json=[]) + client = ConvoxClient( + host="https://console.test.com", + api_key="key", + rack="org/myrack", + session="my-session-id", + retry=RetryConfig(max_retries=0), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") == "my-session-id" + client.close() + + def test_no_session_header_when_none(self, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(json=[]) + client = ConvoxClient( + host="https://console.test.com", + api_key="key", + rack="org/myrack", + retry=RetryConfig(max_retries=0), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") is None + client.close() + + def test_no_session_header_when_empty_string(self, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(json=[]) + client = ConvoxClient( + host="https://console.test.com", + api_key="key", + rack="org/myrack", + session="", + retry=RetryConfig(max_retries=0), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") is None + client.close() + + class TestFromEnv: def test_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("CONVOX_HOST", "rack.test.com") @@ -246,6 +289,56 @@ def test_host_overrides_rack_for_lookup(self, tmp_path: Path) -> None: assert client._http.base_url == "https://console.test.com" client.close() + def test_reads_session_file(self, tmp_path: Path, httpx_mock: HTTPXMock) -> None: + """from_cli_config reads the session file next to auth and sends + the Session header alongside basic auth.""" + auth_file = tmp_path / "auth" + auth_file.write_text(json.dumps({"console.test.com": "my-key"})) + session_file = tmp_path / "session" + session_file.write_text(json.dumps({"console.test.com": "sess-123"})) + httpx_mock.add_response(json=[]) + client = ConvoxClient.from_cli_config( + host="console.test.com", + rack="org/myrack", + config_path=str(auth_file), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") == "sess-123" + client.close() + + def test_missing_session_file_no_error(self, tmp_path: Path, httpx_mock: HTTPXMock) -> None: + """No session file is normal for deploy-key users.""" + auth_file = tmp_path / "auth" + auth_file.write_text(json.dumps({"console.test.com": "my-key"})) + httpx_mock.add_response(json=[]) + client = ConvoxClient.from_cli_config( + host="console.test.com", + rack="org/myrack", + config_path=str(auth_file), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") is None + client.close() + + def test_session_host_mismatch(self, tmp_path: Path, httpx_mock: HTTPXMock) -> None: + """Session file exists but has no entry for this host.""" + auth_file = tmp_path / "auth" + auth_file.write_text(json.dumps({"console.test.com": "my-key"})) + session_file = tmp_path / "session" + session_file.write_text(json.dumps({"other.console.com": "sess-456"})) + httpx_mock.add_response(json=[]) + client = ConvoxClient.from_cli_config( + host="console.test.com", + rack="org/myrack", + config_path=str(auth_file), + ) + client.apps.list() + req = httpx_mock.get_requests()[0] + assert req.headers.get("session") is None + client.close() + class TestCliConfigPathFallback: """Verify credentials_from_cli_config checks v3 path first, then v2.""" diff --git a/tests/test_session_refresh.py b/tests/test_session_refresh.py new file mode 100644 index 0000000..709ea72 --- /dev/null +++ b/tests/test_session_refresh.py @@ -0,0 +1,177 @@ +"""Tests for console session refresh flow.""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock + +from convox import ConvoxAuthError, ConvoxClient, RetryConfig + + +@pytest.fixture +def console_client() -> ConvoxClient: + c = ConvoxClient( + host="https://console.test.com", + api_key="cli-token", + rack="org/myrack", + session="old-session-id", + retry=RetryConfig(max_retries=0), + ) + yield c + c.close() + + +class TestSessionRefresh: + def test_refresh_on_expired_session( + self, console_client: ConvoxClient, httpx_mock: HTTPXMock + ) -> None: + """401 with Session challenge triggers refresh and retry.""" + # First call: 401 session expired + httpx_mock.add_response( + status_code=401, + text="session required", + headers={"WWW-Authenticate": 'Session path="/session" token="false"'}, + ) + # Session refresh POST to /session + httpx_mock.add_response( + status_code=200, + json={"id": "new-session-id"}, + ) + # Retry of original request succeeds + httpx_mock.add_response( + status_code=200, + json={ + "name": "test-rack", + "version": "3.24.1", + "provider": "aws", + "status": "running", + "region": "us-east-1", + }, + ) + + sys = console_client.system.get() + assert sys.name == "test-rack" + + reqs = httpx_mock.get_requests() + assert len(reqs) == 3 + assert reqs[0].url.path == "/system" + assert reqs[1].url.path == "/session" + assert reqs[1].method == "POST" + assert reqs[2].url.path == "/system" + assert reqs[2].headers.get("session") == "new-session-id" + + def test_no_refresh_on_bad_credentials( + self, console_client: ConvoxClient, httpx_mock: HTTPXMock + ) -> None: + """401 without Session challenge is a normal auth error.""" + httpx_mock.add_response( + status_code=401, + text="authentication failed", + ) + + with pytest.raises(ConvoxAuthError, match="authentication failed"): + console_client.system.get() + + assert len(httpx_mock.get_requests()) == 1 + + def test_no_refresh_when_mfa_required( + self, console_client: ConvoxClient, httpx_mock: HTTPXMock + ) -> None: + """token="true" means MFA is required — SDK cannot handle this.""" + httpx_mock.add_response( + status_code=401, + text="session required", + headers={"WWW-Authenticate": 'Session path="/session" token="true"'}, + ) + + with pytest.raises(ConvoxAuthError, match=r"MFA authentication required.*convox rack"): + console_client.system.get() + + # Should NOT have attempted a POST to /session + assert len(httpx_mock.get_requests()) == 1 + + def test_refresh_failure_raises_original_error( + self, console_client: ConvoxClient, httpx_mock: HTTPXMock + ) -> None: + """If session refresh POST fails, raise the original 401.""" + # Session expired + httpx_mock.add_response( + status_code=401, + text="session required", + headers={"WWW-Authenticate": 'Session path="/session" token="false"'}, + ) + # Session refresh POST fails (e.g. token also expired) + httpx_mock.add_response( + status_code=401, + text="cli token is expired", + ) + + with pytest.raises(ConvoxAuthError, match="session required"): + console_client.system.get() + + def test_no_refresh_without_session_header(self, httpx_mock: HTTPXMock) -> None: + """Clients without a session (deploy key users) never attempt refresh.""" + client = ConvoxClient( + host="https://console.test.com", + api_key="deploy-key", + rack="org/myrack", + retry=RetryConfig(max_retries=0), + ) + httpx_mock.add_response( + status_code=401, + text="authentication failed", + headers={"WWW-Authenticate": 'Session path="/session" token="false"'}, + ) + # Even with a Session challenge header, deploy-key clients + # should still get the refresh attempt (it won't hurt), but + # the important thing is the final behavior is correct. + # The refresh POST will also fail with 401, and the original + # error is raised. + httpx_mock.add_response( + status_code=401, + text="authentication failed", + ) + + with pytest.raises(ConvoxAuthError, match="authentication failed"): + client.system.get() + client.close() + + def test_refresh_updates_session_for_subsequent_calls( + self, console_client: ConvoxClient, httpx_mock: HTTPXMock + ) -> None: + """After a successful refresh, subsequent calls use the new session.""" + # First call: session expired + refresh + httpx_mock.add_response( + status_code=401, + text="session required", + headers={"WWW-Authenticate": 'Session path="/session" token="false"'}, + ) + httpx_mock.add_response(status_code=200, json={"id": "refreshed-session"}) + httpx_mock.add_response( + status_code=200, + json={ + "name": "test-rack", + "version": "3.24.1", + "provider": "aws", + "status": "running", + "region": "us-east-1", + }, + ) + # Second call: should use the new session directly + httpx_mock.add_response( + status_code=200, + json={ + "name": "test-rack", + "version": "3.24.1", + "provider": "aws", + "status": "running", + "region": "us-east-1", + }, + ) + + console_client.system.get() + console_client.system.get() + + reqs = httpx_mock.get_requests() + # Second system.get() should have the refreshed session + assert reqs[3].headers.get("session") == "refreshed-session"