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.
- sweatstack_cli/__init__.py +3 -0
- sweatstack_cli/api/__init__.py +5 -0
- sweatstack_cli/api/client.py +192 -0
- sweatstack_cli/auth/__init__.py +216 -0
- sweatstack_cli/auth/callback_server.py +216 -0
- sweatstack_cli/auth/jwt.py +50 -0
- sweatstack_cli/auth/pkce.py +50 -0
- sweatstack_cli/auth/tokens.py +178 -0
- sweatstack_cli/commands/__init__.py +1 -0
- sweatstack_cli/commands/auth.py +127 -0
- sweatstack_cli/commands/pages.py +130 -0
- sweatstack_cli/config.py +32 -0
- sweatstack_cli/console.py +6 -0
- sweatstack_cli/exceptions.py +30 -0
- sweatstack_cli/main.py +72 -0
- sweatstack_cli/py.typed +0 -0
- sweatstack_cli-0.1.0.dist-info/METADATA +132 -0
- sweatstack_cli-0.1.0.dist-info/RECORD +20 -0
- sweatstack_cli-0.1.0.dist-info/WHEEL +4 -0
- sweatstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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("&", "&")
|
|
84
|
+
.replace("<", "<")
|
|
85
|
+
.replace(">", ">")
|
|
86
|
+
.replace('"', """)
|
|
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
|