sweatstack-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """SweatStack CLI — Command-line interface for the SweatStack platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """API client for SweatStack."""
2
+
3
+ from sweatstack_cli.api.client import APIClient
4
+
5
+ __all__ = ["APIClient"]
@@ -0,0 +1,192 @@
1
+ """Authenticated HTTP client for SweatStack API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Generator
6
+ from contextlib import contextmanager
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from sweatstack_cli.auth import Authenticator
12
+ from sweatstack_cli.config import get_settings
13
+ from sweatstack_cli.exceptions import APIError, AuthenticationError
14
+
15
+
16
+ class APIClient:
17
+ """
18
+ HTTP client with automatic authentication.
19
+
20
+ Handles token refresh transparently before each request.
21
+ All API errors are translated to appropriate exceptions.
22
+
23
+ Example:
24
+ client = APIClient()
25
+ user = client.get("/api/v1/oauth/userinfo")
26
+ print(user["name"])
27
+ """
28
+
29
+ def __init__(self, authenticator: Authenticator | None = None) -> None:
30
+ """
31
+ Initialize API client.
32
+
33
+ Args:
34
+ authenticator: Custom authenticator instance.
35
+ Defaults to a new Authenticator with file storage.
36
+ """
37
+ self._auth = authenticator or Authenticator()
38
+ self._settings = get_settings()
39
+
40
+ @contextmanager
41
+ def _http(self) -> Generator[httpx.Client]:
42
+ """
43
+ Provide an authenticated httpx client.
44
+
45
+ Raises:
46
+ AuthenticationError: If not authenticated.
47
+ """
48
+ tokens = self._auth.get_valid_tokens()
49
+ if tokens is None:
50
+ raise AuthenticationError("Not authenticated. Run 'sweatstack login' first.")
51
+
52
+ with httpx.Client(
53
+ base_url=self._settings.api_url,
54
+ headers={
55
+ "Authorization": f"Bearer {tokens.access_token}",
56
+ "User-Agent": f"sweatstack-cli/{self._settings.version}",
57
+ "Accept": "application/json",
58
+ },
59
+ timeout=30.0,
60
+ ) as client:
61
+ yield client
62
+
63
+ def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
64
+ """
65
+ Make authenticated GET request.
66
+
67
+ Args:
68
+ path: API path (e.g., "/api/v1/oauth/userinfo").
69
+ **kwargs: Additional arguments passed to httpx.Client.get().
70
+
71
+ Returns:
72
+ Parsed JSON response.
73
+
74
+ Raises:
75
+ AuthenticationError: If not authenticated or session expired.
76
+ APIError: If API returns an error response.
77
+ """
78
+ with self._http() as client:
79
+ response = client.get(path, **kwargs)
80
+ return self._handle_response(response)
81
+
82
+ def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
83
+ """
84
+ Make authenticated POST request.
85
+
86
+ Args:
87
+ path: API path.
88
+ **kwargs: Additional arguments passed to httpx.Client.post().
89
+
90
+ Returns:
91
+ Parsed JSON response.
92
+
93
+ Raises:
94
+ AuthenticationError: If not authenticated or session expired.
95
+ APIError: If API returns an error response.
96
+ """
97
+ with self._http() as client:
98
+ response = client.post(path, **kwargs)
99
+ return self._handle_response(response)
100
+
101
+ def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
102
+ """
103
+ Make authenticated PUT request.
104
+
105
+ Args:
106
+ path: API path.
107
+ **kwargs: Additional arguments passed to httpx.Client.put().
108
+
109
+ Returns:
110
+ Parsed JSON response.
111
+
112
+ Raises:
113
+ AuthenticationError: If not authenticated or session expired.
114
+ APIError: If API returns an error response.
115
+ """
116
+ with self._http() as client:
117
+ response = client.put(path, **kwargs)
118
+ return self._handle_response(response)
119
+
120
+ def delete(self, path: str, **kwargs: Any) -> dict[str, Any] | None:
121
+ """
122
+ Make authenticated DELETE request.
123
+
124
+ Args:
125
+ path: API path.
126
+ **kwargs: Additional arguments passed to httpx.Client.delete().
127
+
128
+ Returns:
129
+ Parsed JSON response, or None for 204 No Content.
130
+
131
+ Raises:
132
+ AuthenticationError: If not authenticated or session expired.
133
+ APIError: If API returns an error response.
134
+ """
135
+ with self._http() as client:
136
+ response = client.delete(path, **kwargs)
137
+ if response.status_code == 204:
138
+ return None
139
+ return self._handle_response(response)
140
+
141
+ def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
142
+ """
143
+ Handle API response, raising appropriate errors.
144
+
145
+ Args:
146
+ response: HTTP response object.
147
+
148
+ Returns:
149
+ Parsed JSON response body.
150
+
151
+ Raises:
152
+ AuthenticationError: For 401 responses.
153
+ APIError: For other error responses.
154
+ """
155
+ if response.status_code == 401:
156
+ raise AuthenticationError("Session expired. Run 'sweatstack login' again.")
157
+
158
+ if response.status_code >= 400:
159
+ detail = self._extract_error_detail(response)
160
+ raise APIError(f"API error ({response.status_code}): {detail}")
161
+
162
+ # Handle empty responses
163
+ if not response.content:
164
+ return {}
165
+
166
+ result: dict[str, Any] = response.json()
167
+ return result
168
+
169
+ def _extract_error_detail(self, response: httpx.Response) -> str:
170
+ """Extract error message from API response."""
171
+ try:
172
+ data = response.json()
173
+ # FastAPI-style error response
174
+ if "detail" in data:
175
+ detail = data["detail"]
176
+ if isinstance(detail, str):
177
+ return detail
178
+ if isinstance(detail, list):
179
+ # Validation errors
180
+ return "; ".join(
181
+ f"{err.get('loc', ['?'])[-1]}: {err.get('msg', '?')}" for err in detail
182
+ )
183
+ return str(detail)
184
+ # Other error formats
185
+ if "error" in data:
186
+ return str(data["error"])
187
+ if "message" in data:
188
+ return str(data["message"])
189
+ except Exception:
190
+ pass
191
+
192
+ return response.text or f"HTTP {response.status_code}"
@@ -0,0 +1,216 @@
1
+ """OAuth2 PKCE authentication for SweatStack CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import webbrowser
7
+ from typing import TYPE_CHECKING
8
+ from urllib.parse import urlencode
9
+
10
+ import httpx
11
+
12
+ from sweatstack_cli.auth.callback_server import CallbackServer
13
+ from sweatstack_cli.auth.pkce import PKCEChallenge, generate_pkce
14
+ from sweatstack_cli.auth.tokens import FileTokenStorage, TokenPair
15
+ from sweatstack_cli.config import get_settings
16
+ from sweatstack_cli.exceptions import AuthenticationError
17
+
18
+ if TYPE_CHECKING:
19
+ from sweatstack_cli.auth.tokens import TokenStorage
20
+
21
+ # Public OAuth2 client ID for CLI applications (designed for public clients)
22
+ CLIENT_ID = "5382f68b0d254378"
23
+
24
+ # Default OAuth2 scopes
25
+ SCOPES = "data:read data:write profile"
26
+
27
+
28
+ class Authenticator:
29
+ """
30
+ OAuth2 PKCE authentication flow orchestrator.
31
+
32
+ Handles browser-based login, token storage, and automatic refresh.
33
+ Thread-safe for token operations.
34
+
35
+ Example:
36
+ auth = Authenticator()
37
+ tokens = auth.login()
38
+ # Later...
39
+ tokens = auth.get_valid_tokens() # Auto-refreshes if expired
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ storage: TokenStorage | None = None,
45
+ client_id: str = CLIENT_ID,
46
+ scopes: str = SCOPES,
47
+ ) -> None:
48
+ """
49
+ Initialize authenticator.
50
+
51
+ Args:
52
+ storage: Token storage backend. Defaults to FileTokenStorage.
53
+ client_id: OAuth2 client ID.
54
+ scopes: Space-separated OAuth2 scopes.
55
+ """
56
+ self._storage = storage or FileTokenStorage()
57
+ self._client_id = client_id
58
+ self._scopes = scopes
59
+ self._settings = get_settings()
60
+
61
+ def login(self, force: bool = False) -> TokenPair:
62
+ """
63
+ Authenticate user via browser OAuth2 PKCE flow.
64
+
65
+ Opens the user's default browser to the authorization page.
66
+ Waits for the callback on a local HTTP server.
67
+
68
+ Args:
69
+ force: If True, re-authenticate even if valid tokens exist.
70
+
71
+ Returns:
72
+ TokenPair with access and refresh tokens.
73
+
74
+ Raises:
75
+ AuthenticationError: If authentication fails or times out.
76
+ """
77
+ if not force:
78
+ tokens = self.get_valid_tokens()
79
+ if tokens:
80
+ return tokens
81
+
82
+ server = CallbackServer(timeout=120.0)
83
+ redirect_uri = server.get_redirect_uri()
84
+
85
+ try:
86
+ pkce = generate_pkce()
87
+ auth_url = self._build_auth_url(redirect_uri, pkce)
88
+
89
+ if not webbrowser.open(auth_url):
90
+ raise AuthenticationError(f"Could not open browser. Please visit:\n{auth_url}")
91
+
92
+ result = server.wait_for_callback(expected_state=pkce.state)
93
+
94
+ tokens = self._exchange_code(
95
+ code=result.code,
96
+ redirect_uri=redirect_uri,
97
+ code_verifier=pkce.verifier,
98
+ )
99
+
100
+ self._storage.save(tokens)
101
+ return tokens
102
+
103
+ finally:
104
+ server.shutdown()
105
+
106
+ def get_valid_tokens(self) -> TokenPair | None:
107
+ """
108
+ Get tokens, refreshing if expired.
109
+
110
+ Checks token expiry with a 30-second margin to avoid
111
+ edge cases where token expires during a request.
112
+
113
+ Returns:
114
+ Valid TokenPair, or None if not authenticated or refresh fails.
115
+ """
116
+ tokens = self._load_tokens()
117
+ if tokens is None:
118
+ return None
119
+
120
+ if tokens.is_expired(margin_seconds=30):
121
+ try:
122
+ tokens = self._refresh_tokens(tokens.refresh_token)
123
+ self._storage.save(tokens)
124
+ except AuthenticationError:
125
+ return None
126
+
127
+ return tokens
128
+
129
+ def logout(self) -> None:
130
+ """Remove stored credentials."""
131
+ self._storage.delete()
132
+
133
+ def _load_tokens(self) -> TokenPair | None:
134
+ """
135
+ Load tokens from environment or persistent storage.
136
+
137
+ Environment variables take precedence for CI/CD use cases.
138
+ """
139
+ access = os.environ.get("SWEATSTACK_API_KEY")
140
+ refresh = os.environ.get("SWEATSTACK_REFRESH_TOKEN")
141
+
142
+ if access and refresh:
143
+ return TokenPair(access_token=access, refresh_token=refresh)
144
+
145
+ return self._storage.load()
146
+
147
+ def _build_auth_url(self, redirect_uri: str, pkce: PKCEChallenge) -> str:
148
+ """Construct OAuth2 authorization URL with PKCE challenge."""
149
+ params = {
150
+ "client_id": self._client_id,
151
+ "redirect_uri": redirect_uri,
152
+ "response_type": "code",
153
+ "scope": self._scopes,
154
+ "code_challenge": pkce.challenge,
155
+ "code_challenge_method": "S256",
156
+ "state": pkce.state,
157
+ }
158
+ return f"{self._settings.api_url}/oauth/authorize?{urlencode(params)}"
159
+
160
+ def _exchange_code(
161
+ self,
162
+ code: str,
163
+ redirect_uri: str,
164
+ code_verifier: str,
165
+ ) -> TokenPair:
166
+ """Exchange authorization code for tokens."""
167
+ with httpx.Client(timeout=30.0) as client:
168
+ response = client.post(
169
+ f"{self._settings.api_url}/api/v1/oauth/token",
170
+ data={
171
+ "grant_type": "authorization_code",
172
+ "client_id": self._client_id,
173
+ "code": code,
174
+ "code_verifier": code_verifier,
175
+ "redirect_uri": redirect_uri,
176
+ },
177
+ )
178
+
179
+ if response.status_code != 200:
180
+ try:
181
+ detail = response.json().get("detail", response.text)
182
+ except Exception:
183
+ detail = response.text
184
+ raise AuthenticationError(f"Token exchange failed: {detail}")
185
+
186
+ data = response.json()
187
+ return TokenPair(
188
+ access_token=data["access_token"],
189
+ refresh_token=data["refresh_token"],
190
+ )
191
+
192
+ def _refresh_tokens(self, refresh_token: str) -> TokenPair:
193
+ """Refresh expired access token using refresh token."""
194
+ with httpx.Client(timeout=30.0) as client:
195
+ response = client.post(
196
+ f"{self._settings.api_url}/api/v1/oauth/token",
197
+ data={
198
+ "grant_type": "refresh_token",
199
+ "client_id": self._client_id,
200
+ "refresh_token": refresh_token,
201
+ },
202
+ )
203
+
204
+ if response.status_code != 200:
205
+ raise AuthenticationError("Token refresh failed — please login again")
206
+
207
+ data = response.json()
208
+ return TokenPair(
209
+ access_token=data["access_token"],
210
+ # Some OAuth servers return a new refresh token, some don't
211
+ refresh_token=data.get("refresh_token", refresh_token),
212
+ )
213
+
214
+
215
+ # Re-export commonly used types
216
+ __all__ = ["AuthenticationError", "Authenticator", "FileTokenStorage", "TokenPair"]
@@ -0,0 +1,216 @@
1
+ """Local HTTP server for OAuth2 callback handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from typing import Any
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ from sweatstack_cli.exceptions import AuthenticationError
11
+
12
+ # Port range for callback server
13
+ PORT_RANGE_START = 8400
14
+ PORT_RANGE_END = 8500
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class AuthorizationResult:
19
+ """Result from successful OAuth2 callback."""
20
+
21
+ code: str
22
+ state: str
23
+
24
+
25
+ class _CallbackHandler(BaseHTTPRequestHandler):
26
+ """HTTP request handler for OAuth2 redirect callback."""
27
+
28
+ server: _CallbackHTTPServer
29
+
30
+ def do_GET(self) -> None:
31
+ """Handle GET request from OAuth2 redirect."""
32
+ query = parse_qs(urlparse(self.path).query)
33
+
34
+ # Check for OAuth error response
35
+ if "error" in query:
36
+ error = query["error"][0]
37
+ description = query.get("error_description", ["Unknown error"])[0]
38
+ self.server.auth_error = f"{error}: {description}"
39
+ self._respond_error(description)
40
+ return
41
+
42
+ # Validate required parameters
43
+ if "code" not in query or "state" not in query:
44
+ self.server.auth_error = "Missing code or state parameter"
45
+ self._respond_error("Invalid callback: missing required parameters")
46
+ return
47
+
48
+ # Store successful result
49
+ self.server.auth_result = AuthorizationResult(
50
+ code=query["code"][0],
51
+ state=query["state"][0],
52
+ )
53
+ self._respond_success()
54
+
55
+ def _respond_success(self) -> None:
56
+ """Send success response to browser."""
57
+ self.send_response(200)
58
+ self.send_header("Content-Type", "text/html; charset=utf-8")
59
+ self.end_headers()
60
+
61
+ html = """<!DOCTYPE html>
62
+ <html lang="en">
63
+ <head>
64
+ <meta charset="UTF-8">
65
+ <title>SweatStack CLI</title>
66
+ </head>
67
+ <body>
68
+ <h1>Authentication successful</h1>
69
+ <p>You can close this window and return to the terminal.</p>
70
+ </body>
71
+ </html>"""
72
+
73
+ self.wfile.write(html.encode("utf-8"))
74
+
75
+ def _respond_error(self, message: str) -> None:
76
+ """Send error response to browser."""
77
+ self.send_response(400)
78
+ self.send_header("Content-Type", "text/html; charset=utf-8")
79
+ self.end_headers()
80
+
81
+ # Escape HTML to prevent XSS
82
+ safe_message = (
83
+ message.replace("&", "&amp;")
84
+ .replace("<", "&lt;")
85
+ .replace(">", "&gt;")
86
+ .replace('"', "&quot;")
87
+ )
88
+
89
+ html = f"""<!DOCTYPE html>
90
+ <html lang="en">
91
+ <head>
92
+ <meta charset="UTF-8">
93
+ <title>SweatStack CLI - Error</title>
94
+ </head>
95
+ <body>
96
+ <h1>Authentication failed</h1>
97
+ <p>{safe_message}</p>
98
+ </body>
99
+ </html>"""
100
+
101
+ self.wfile.write(html.encode("utf-8"))
102
+
103
+ def log_message(self, format: str, *args: Any) -> None:
104
+ """Silence default HTTP request logging."""
105
+ pass
106
+
107
+
108
+ class _CallbackHTTPServer(HTTPServer):
109
+ """HTTPServer with attributes for storing auth result."""
110
+
111
+ auth_result: AuthorizationResult | None = None
112
+ auth_error: str | None = None
113
+
114
+
115
+ class CallbackServer:
116
+ """
117
+ Local HTTP server to receive OAuth2 authorization callback.
118
+
119
+ Finds an available port in the configured range and waits for
120
+ the OAuth2 provider to redirect the user back after authentication.
121
+
122
+ Example:
123
+ server = CallbackServer(timeout=120.0)
124
+ redirect_uri = server.get_redirect_uri()
125
+ # ... open browser to auth URL with redirect_uri ...
126
+ result = server.wait_for_callback(expected_state=state)
127
+ server.shutdown()
128
+ """
129
+
130
+ def __init__(self, timeout: float = 120.0) -> None:
131
+ """
132
+ Initialize callback server.
133
+
134
+ Args:
135
+ timeout: Maximum seconds to wait for callback.
136
+ """
137
+ self._timeout = timeout
138
+ self._server: _CallbackHTTPServer | None = None
139
+ self._port: int | None = None
140
+
141
+ def get_redirect_uri(self) -> str:
142
+ """
143
+ Start server and return the redirect URI.
144
+
145
+ Finds an available port in the range 8400-8499.
146
+
147
+ Returns:
148
+ Redirect URI to use in OAuth2 authorization request.
149
+
150
+ Raises:
151
+ RuntimeError: If no port is available.
152
+ """
153
+ for port in range(PORT_RANGE_START, PORT_RANGE_END):
154
+ try:
155
+ self._server = _CallbackHTTPServer(
156
+ ("localhost", port),
157
+ _CallbackHandler,
158
+ )
159
+ self._port = port
160
+ return f"http://localhost:{port}/callback"
161
+ except OSError:
162
+ continue
163
+
164
+ raise RuntimeError(
165
+ f"No available port in range {PORT_RANGE_START}-{PORT_RANGE_END} "
166
+ "for OAuth callback server"
167
+ )
168
+
169
+ def wait_for_callback(self, expected_state: str) -> AuthorizationResult:
170
+ """
171
+ Block until callback received or timeout.
172
+
173
+ Validates the state parameter to prevent CSRF attacks.
174
+
175
+ Args:
176
+ expected_state: The state value sent in the authorization request.
177
+
178
+ Returns:
179
+ AuthorizationResult with code and state.
180
+
181
+ Raises:
182
+ AuthenticationError: If callback fails, times out, or state mismatches.
183
+ """
184
+ if self._server is None:
185
+ raise RuntimeError("Server not started. Call get_redirect_uri() first.")
186
+
187
+ self._server.timeout = self._timeout
188
+
189
+ # Handle requests until we get a result or error
190
+ while self._server.auth_result is None and self._server.auth_error is None:
191
+ self._server.handle_request()
192
+
193
+ # Check for timeout (handle_request returns after timeout)
194
+ if self._server.auth_result is None and self._server.auth_error is None:
195
+ raise AuthenticationError("Authentication timed out. Please try again.")
196
+
197
+ if self._server.auth_error:
198
+ raise AuthenticationError(self._server.auth_error)
199
+
200
+ result = self._server.auth_result
201
+ assert result is not None # For type checker
202
+
203
+ # Validate state to prevent CSRF
204
+ if result.state != expected_state:
205
+ raise AuthenticationError(
206
+ "State parameter mismatch — possible CSRF attack. Please try again."
207
+ )
208
+
209
+ return result
210
+
211
+ def shutdown(self) -> None:
212
+ """Stop the server and release the port."""
213
+ if self._server is not None:
214
+ self._server.server_close()
215
+ self._server = None
216
+ self._port = None
@@ -0,0 +1,50 @@
1
+ """Minimal JWT payload decoding for token expiry checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def decode_jwt_payload(token: str) -> dict[str, Any]:
11
+ """
12
+ Decode JWT payload without signature verification.
13
+
14
+ We only need to read claims (exp, sub) for local expiry decisions.
15
+ The API server validates signatures on every request, so we don't
16
+ need to verify signatures client-side.
17
+
18
+ Args:
19
+ token: JWT string in the format "header.payload.signature".
20
+
21
+ Returns:
22
+ Decoded payload as a dictionary.
23
+
24
+ Raises:
25
+ ValueError: If the token format is invalid.
26
+
27
+ Example:
28
+ >>> payload = decode_jwt_payload(token)
29
+ >>> exp_timestamp = payload["exp"]
30
+ """
31
+ try:
32
+ # JWT format: header.payload.signature
33
+ parts = token.split(".")
34
+ if len(parts) != 3:
35
+ raise ValueError("JWT must have exactly 3 parts")
36
+
37
+ payload_b64 = parts[1]
38
+
39
+ # Base64url decode with padding normalization
40
+ # JWT uses base64url encoding without padding, so we add it back
41
+ padding_needed = 4 - (len(payload_b64) % 4)
42
+ if padding_needed != 4:
43
+ payload_b64 += "=" * padding_needed
44
+
45
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
46
+ result: dict[str, Any] = json.loads(payload_bytes)
47
+ return result
48
+
49
+ except (IndexError, ValueError, json.JSONDecodeError) as e:
50
+ raise ValueError(f"Invalid JWT format: {e}") from e