Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/convox/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import logging
import random
import re
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
Expand All @@ -17,6 +18,7 @@

from ._version import __version__
from .exceptions import (
ConvoxAuthError,
ConvoxConnectionError,
ConvoxTimeoutError,
exception_for_response,
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <rack>`), 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()

Expand Down
26 changes: 26 additions & 0 deletions src/convox/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"):
Expand Down
35 changes: 32 additions & 3 deletions src/convox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -83,6 +89,7 @@ def __init__(
retry=retry,
timeout=timeout,
rack=rack,
session=session,
)

self.apps = Apps(self._http)
Expand Down Expand Up @@ -126,19 +133,41 @@ 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
console-managed racks.
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,
Expand Down
93 changes: 93 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading