kater-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.
- kater_cli/.gitkeep +0 -0
- kater_cli/auth/__init__.py +6 -0
- kater_cli/auth/oauth.py +211 -0
- kater_cli/auth/token_manager.py +338 -0
- kater_cli/client.py +82 -0
- kater_cli/commands/.gitkeep +0 -0
- kater_cli/commands/__init__.py +6 -0
- kater_cli/commands/_shared.py +281 -0
- kater_cli/commands/auth.py +78 -0
- kater_cli/commands/compile.py +379 -0
- kater_cli/commands/config/__init__.py +10 -0
- kater_cli/commands/config/app.py +40 -0
- kater_cli/commands/config/default_connection.py +202 -0
- kater_cli/commands/dev.py +382 -0
- kater_cli/commands/import_/__init__.py +6 -0
- kater_cli/commands/import_/app.py +16 -0
- kater_cli/commands/import_/tenants.py +520 -0
- kater_cli/commands/install.py +212 -0
- kater_cli/commands/run.py +335 -0
- kater_cli/commands/scaffold/__init__.py +0 -0
- kater_cli/commands/scaffold/app.py +17 -0
- kater_cli/commands/scaffold/topic.py +85 -0
- kater_cli/commands/validate.py +381 -0
- kater_cli/config.py +39 -0
- kater_cli/main.py +48 -0
- kater_cli/preferences/__init__.py +5 -0
- kater_cli/preferences/preference_manager.py +75 -0
- kater_cli/prompts.py +89 -0
- kater_cli/repo.py +101 -0
- kater_cli/services/__init__.py +12 -0
- kater_cli/services/dev_file_watcher.py +232 -0
- kater_cli/services/file_sync.py +238 -0
- kater_cli/services/ws_dev_client.py +410 -0
- kater_cli-0.1.0.dist-info/METADATA +14 -0
- kater_cli-0.1.0.dist-info/RECORD +42 -0
- kater_cli-0.1.0.dist-info/WHEEL +4 -0
- kater_cli-0.1.0.dist-info/entry_points.txt +2 -0
- kater_utils/__init__.py +37 -0
- kater_utils/file_utils.py +72 -0
- kater_utils/logging.py +159 -0
- kater_utils/repo.py +77 -0
- kater_utils/scaffold_templates.py +398 -0
kater_cli/.gitkeep
ADDED
|
File without changes
|
kater_cli/auth/oauth.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""OAuth 2.0 Authorization Code flow with PKCE for CLI authentication."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from threading import Thread
|
|
9
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from kater_cli.auth.token_manager import TokenManager
|
|
15
|
+
from kater_cli.config import get_settings
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
21
|
+
"""HTTP handler for OAuth callback."""
|
|
22
|
+
|
|
23
|
+
authorization_code: str | None = None
|
|
24
|
+
state: str | None = None
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
28
|
+
"""Suppress server logs."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def do_GET(self) -> None:
|
|
32
|
+
"""Handle GET request from OAuth callback."""
|
|
33
|
+
parsed = urlparse(self.path)
|
|
34
|
+
if parsed.path != "/callback":
|
|
35
|
+
self.send_response(404)
|
|
36
|
+
self.end_headers()
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
params = parse_qs(parsed.query)
|
|
40
|
+
|
|
41
|
+
if "error" in params:
|
|
42
|
+
OAuthCallbackHandler.error = params["error"][0]
|
|
43
|
+
self._send_error_response(params.get("error_description", ["Unknown error"])[0])
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if "code" not in params or "state" not in params:
|
|
47
|
+
OAuthCallbackHandler.error = "missing_params"
|
|
48
|
+
self._send_error_response("Missing authorization code or state")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
OAuthCallbackHandler.authorization_code = params["code"][0]
|
|
52
|
+
OAuthCallbackHandler.state = params["state"][0]
|
|
53
|
+
self._send_success_response()
|
|
54
|
+
|
|
55
|
+
def _send_success_response(self) -> None:
|
|
56
|
+
"""Send success HTML response."""
|
|
57
|
+
self.send_response(200)
|
|
58
|
+
self.send_header("Content-type", "text/html")
|
|
59
|
+
self.end_headers()
|
|
60
|
+
html = """
|
|
61
|
+
<!DOCTYPE html>
|
|
62
|
+
<html>
|
|
63
|
+
<head><title>Login Successful</title></head>
|
|
64
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
65
|
+
<h1>Login Successful!</h1>
|
|
66
|
+
<p>You can close this window and return to the terminal.</p>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
"""
|
|
70
|
+
self.wfile.write(html.encode())
|
|
71
|
+
|
|
72
|
+
def _send_error_response(self, message: str) -> None:
|
|
73
|
+
"""Send error HTML response."""
|
|
74
|
+
self.send_response(400)
|
|
75
|
+
self.send_header("Content-type", "text/html")
|
|
76
|
+
self.end_headers()
|
|
77
|
+
html = f"""
|
|
78
|
+
<!DOCTYPE html>
|
|
79
|
+
<html>
|
|
80
|
+
<head><title>Login Failed</title></head>
|
|
81
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
82
|
+
<h1>Login Failed</h1>
|
|
83
|
+
<p>{message}</p>
|
|
84
|
+
<p>Please try again from the terminal.</p>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
"""
|
|
88
|
+
self.wfile.write(html.encode())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _generate_pkce_pair() -> tuple[str, str]:
|
|
92
|
+
"""Generate PKCE code verifier and challenge.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (code_verifier, code_challenge)
|
|
96
|
+
"""
|
|
97
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
98
|
+
code_challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
|
|
99
|
+
code_challenge = base64.urlsafe_b64encode(code_challenge_bytes).rstrip(b"=").decode()
|
|
100
|
+
return code_verifier, code_challenge
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _generate_state() -> str:
|
|
104
|
+
"""Generate random state parameter for CSRF protection."""
|
|
105
|
+
return secrets.token_urlsafe(16)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_oauth_flow() -> bool:
|
|
109
|
+
"""Run the OAuth 2.0 authorization code flow with PKCE.
|
|
110
|
+
|
|
111
|
+
Opens browser for user authentication and captures the callback.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if login successful, False otherwise.
|
|
115
|
+
"""
|
|
116
|
+
settings = get_settings()
|
|
117
|
+
|
|
118
|
+
if not settings.oauth_client_id:
|
|
119
|
+
console.print("[red]Error: KATER_OAUTH_CLIENT_ID environment variable is not set.[/red]")
|
|
120
|
+
console.print("Please set it to your PropelAuth OAuth client ID.")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
# Reset handler state
|
|
124
|
+
OAuthCallbackHandler.authorization_code = None
|
|
125
|
+
OAuthCallbackHandler.state = None
|
|
126
|
+
OAuthCallbackHandler.error = None
|
|
127
|
+
|
|
128
|
+
# Generate PKCE pair and state
|
|
129
|
+
code_verifier, code_challenge = _generate_pkce_pair()
|
|
130
|
+
state = _generate_state()
|
|
131
|
+
|
|
132
|
+
# Build authorization URL
|
|
133
|
+
redirect_uri = f"http://localhost:{settings.oauth_callback_port}/callback"
|
|
134
|
+
auth_params = {
|
|
135
|
+
"client_id": settings.oauth_client_id,
|
|
136
|
+
"response_type": "code",
|
|
137
|
+
"redirect_uri": redirect_uri,
|
|
138
|
+
"scope": "openid email profile",
|
|
139
|
+
"state": state,
|
|
140
|
+
"code_challenge": code_challenge,
|
|
141
|
+
"code_challenge_method": "S256",
|
|
142
|
+
}
|
|
143
|
+
auth_url = f"{settings.oauth_auth_url}?{urlencode(auth_params)}"
|
|
144
|
+
|
|
145
|
+
# Start local callback server
|
|
146
|
+
server = HTTPServer(("localhost", settings.oauth_callback_port), OAuthCallbackHandler)
|
|
147
|
+
|
|
148
|
+
def handle_request() -> None:
|
|
149
|
+
server.handle_request()
|
|
150
|
+
|
|
151
|
+
server_thread = Thread(target=handle_request)
|
|
152
|
+
server_thread.start()
|
|
153
|
+
|
|
154
|
+
# Open browser for authentication
|
|
155
|
+
console.print("Opening browser for authentication...")
|
|
156
|
+
console.print(f"[dim]If browser doesn't open, visit: {auth_url}[/dim]")
|
|
157
|
+
webbrowser.open(auth_url)
|
|
158
|
+
|
|
159
|
+
# Wait for callback
|
|
160
|
+
console.print("Waiting for authentication...")
|
|
161
|
+
server_thread.join(timeout=120)
|
|
162
|
+
server.server_close()
|
|
163
|
+
|
|
164
|
+
# Check for errors
|
|
165
|
+
if OAuthCallbackHandler.error:
|
|
166
|
+
console.print(f"[red]Authentication failed: {OAuthCallbackHandler.error}[/red]")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
if not OAuthCallbackHandler.authorization_code:
|
|
170
|
+
console.print("[red]Authentication timed out. Please try again.[/red]")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
received_state = OAuthCallbackHandler.state
|
|
174
|
+
if received_state is None or received_state != state:
|
|
175
|
+
console.print("[red]Invalid state parameter. Possible CSRF attack.[/red]")
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
# Exchange code for tokens
|
|
179
|
+
try:
|
|
180
|
+
response = httpx.post(
|
|
181
|
+
settings.oauth_token_url,
|
|
182
|
+
data={
|
|
183
|
+
"grant_type": "authorization_code",
|
|
184
|
+
"code": OAuthCallbackHandler.authorization_code,
|
|
185
|
+
"redirect_uri": redirect_uri,
|
|
186
|
+
"client_id": settings.oauth_client_id,
|
|
187
|
+
"code_verifier": code_verifier,
|
|
188
|
+
},
|
|
189
|
+
timeout=30.0,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if response.status_code != 200:
|
|
193
|
+
console.print(f"[red]Token exchange failed: {response.text}[/red]")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
data = response.json()
|
|
197
|
+
|
|
198
|
+
# Store tokens
|
|
199
|
+
token_manager = TokenManager()
|
|
200
|
+
token_manager.store_tokens(
|
|
201
|
+
access_token=data["access_token"],
|
|
202
|
+
refresh_token=data["refresh_token"],
|
|
203
|
+
expires_in=data["expires_in"],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
console.print("[green]Successfully logged in![/green]")
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
except httpx.RequestError as e:
|
|
210
|
+
console.print(f"[red]Network error during token exchange: {e}[/red]")
|
|
211
|
+
return False
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Token management using system keyring for secure storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Generator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import keyring
|
|
14
|
+
|
|
15
|
+
from kater_cli.config import get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _TokenRefreshRejected(Exception):
|
|
19
|
+
"""Auth server definitively rejected the refresh token (401/403)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# CLI Identity key for keyring storage
|
|
23
|
+
CLI_ID_KEY = "cli_id"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_or_create_cli_id() -> str:
|
|
27
|
+
"""Get existing cli_id from keyring or generate a new one.
|
|
28
|
+
|
|
29
|
+
The cli_id is a persistent identifier stored in the system keyring
|
|
30
|
+
that uniquely identifies a developer's machine. It is used as the
|
|
31
|
+
HTTP header X-Kater-CLI-ID to route API requests to the correct
|
|
32
|
+
Virtual Filesystem (VFS).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The cli_id string (format: cli_ + 12-char hex)
|
|
36
|
+
"""
|
|
37
|
+
settings = get_settings()
|
|
38
|
+
|
|
39
|
+
# Try to get existing cli_id
|
|
40
|
+
cli_id = keyring.get_password(settings.keyring_service, CLI_ID_KEY)
|
|
41
|
+
if cli_id is not None:
|
|
42
|
+
return cli_id
|
|
43
|
+
|
|
44
|
+
# Generate new cli_id: cli_ prefix + 12 hex characters
|
|
45
|
+
cli_id = f"cli_{secrets.token_hex(6)}"
|
|
46
|
+
keyring.set_password(settings.keyring_service, CLI_ID_KEY, cli_id)
|
|
47
|
+
return cli_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TokenData:
|
|
52
|
+
"""Stored token data."""
|
|
53
|
+
|
|
54
|
+
access_token: str
|
|
55
|
+
refresh_token: str
|
|
56
|
+
expires_at: float # Unix timestamp
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TokenManager:
|
|
60
|
+
"""Manages OAuth tokens with secure keyring storage and automatic refresh."""
|
|
61
|
+
|
|
62
|
+
KEYRING_USERNAME = "oauth_tokens"
|
|
63
|
+
REFRESH_BUFFER_SECONDS = 300 # Refresh 5 minutes before expiration
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._settings = get_settings()
|
|
67
|
+
self._service = self._settings.keyring_service
|
|
68
|
+
self._background_task: asyncio.Task[None] | None = None
|
|
69
|
+
self._cached_tokens: TokenData | None = None
|
|
70
|
+
self._cache_loaded: bool = False
|
|
71
|
+
|
|
72
|
+
def _get_stored_tokens(self) -> TokenData | None:
|
|
73
|
+
"""Retrieve tokens, using in-memory cache to avoid repeated keyring prompts."""
|
|
74
|
+
if self._cache_loaded:
|
|
75
|
+
return self._cached_tokens
|
|
76
|
+
data = keyring.get_password(self._service, self.KEYRING_USERNAME)
|
|
77
|
+
if not data:
|
|
78
|
+
self._cached_tokens = None
|
|
79
|
+
self._cache_loaded = True
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
parsed = json.loads(data)
|
|
83
|
+
self._cached_tokens = TokenData(
|
|
84
|
+
access_token=parsed["access_token"],
|
|
85
|
+
refresh_token=parsed["refresh_token"],
|
|
86
|
+
expires_at=parsed["expires_at"],
|
|
87
|
+
)
|
|
88
|
+
except (json.JSONDecodeError, KeyError):
|
|
89
|
+
self._cached_tokens = None
|
|
90
|
+
self._cache_loaded = True
|
|
91
|
+
return self._cached_tokens
|
|
92
|
+
|
|
93
|
+
def store_tokens(
|
|
94
|
+
self,
|
|
95
|
+
access_token: str,
|
|
96
|
+
refresh_token: str,
|
|
97
|
+
expires_in: int,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Store tokens in keyring.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
access_token: The OAuth access token
|
|
103
|
+
refresh_token: The OAuth refresh token
|
|
104
|
+
expires_in: Token lifetime in seconds
|
|
105
|
+
"""
|
|
106
|
+
expires_at = time.time() + expires_in
|
|
107
|
+
data = json.dumps(
|
|
108
|
+
{
|
|
109
|
+
"access_token": access_token,
|
|
110
|
+
"refresh_token": refresh_token,
|
|
111
|
+
"expires_at": expires_at,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
keyring.set_password(self._service, self.KEYRING_USERNAME, data)
|
|
115
|
+
# Update in-memory cache so subsequent reads skip the keyring
|
|
116
|
+
self._cached_tokens = TokenData(
|
|
117
|
+
access_token=access_token,
|
|
118
|
+
refresh_token=refresh_token,
|
|
119
|
+
expires_at=expires_at,
|
|
120
|
+
)
|
|
121
|
+
self._cache_loaded = True
|
|
122
|
+
|
|
123
|
+
def clear_tokens(self) -> None:
|
|
124
|
+
"""Remove tokens from keyring."""
|
|
125
|
+
self._cached_tokens = None
|
|
126
|
+
self._cache_loaded = True
|
|
127
|
+
try:
|
|
128
|
+
keyring.delete_password(self._service, self.KEYRING_USERNAME)
|
|
129
|
+
except Exception:
|
|
130
|
+
# Password may not exist, which is fine
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def _is_token_expired(self, token_data: TokenData) -> bool:
|
|
134
|
+
"""Check if token is expired or will expire soon."""
|
|
135
|
+
return time.time() >= (token_data.expires_at - self.REFRESH_BUFFER_SECONDS)
|
|
136
|
+
|
|
137
|
+
def _refresh_tokens(self, refresh_token: str) -> TokenData | None:
|
|
138
|
+
"""Exchange refresh token for new access token.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
TokenData on success, None on failure.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
_TokenRefreshRejected: When the auth server definitively rejects the
|
|
145
|
+
refresh token (401/403), meaning the token is permanently invalid.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
response = httpx.post(
|
|
149
|
+
self._settings.oauth_token_url,
|
|
150
|
+
data={
|
|
151
|
+
"grant_type": "refresh_token",
|
|
152
|
+
"refresh_token": refresh_token,
|
|
153
|
+
"client_id": self._settings.oauth_client_id,
|
|
154
|
+
},
|
|
155
|
+
timeout=30.0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if response.status_code in (401, 403):
|
|
159
|
+
raise _TokenRefreshRejected
|
|
160
|
+
if response.status_code != 200:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
data = response.json()
|
|
164
|
+
self.store_tokens(
|
|
165
|
+
access_token=data["access_token"],
|
|
166
|
+
refresh_token=data.get("refresh_token", refresh_token),
|
|
167
|
+
expires_in=data["expires_in"],
|
|
168
|
+
)
|
|
169
|
+
return self._cached_tokens
|
|
170
|
+
except httpx.RequestError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def get_access_token(self) -> str | None:
|
|
174
|
+
"""Get a valid access token, refreshing if needed.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The access token if valid/refreshable, None if not authenticated.
|
|
178
|
+
"""
|
|
179
|
+
token_data = self._get_stored_tokens()
|
|
180
|
+
if not token_data:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
if self._is_token_expired(token_data):
|
|
184
|
+
try:
|
|
185
|
+
refreshed = self._refresh_tokens(token_data.refresh_token)
|
|
186
|
+
except _TokenRefreshRejected:
|
|
187
|
+
# Refresh token is permanently invalid — clear and require re-login
|
|
188
|
+
self.clear_tokens()
|
|
189
|
+
return None
|
|
190
|
+
if not refreshed:
|
|
191
|
+
# Transient failure (network, 500, etc.) — return the expired
|
|
192
|
+
# token rather than nuking the keyring. The caller or background
|
|
193
|
+
# refresh can retry later.
|
|
194
|
+
return token_data.access_token
|
|
195
|
+
token_data = refreshed
|
|
196
|
+
|
|
197
|
+
return token_data.access_token
|
|
198
|
+
|
|
199
|
+
def has_tokens(self) -> bool:
|
|
200
|
+
"""Check if tokens are stored (may be expired)."""
|
|
201
|
+
return self._get_stored_tokens() is not None
|
|
202
|
+
|
|
203
|
+
def get_token_status(self) -> tuple[bool, float | None]:
|
|
204
|
+
"""Get authentication status.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Tuple of (is_authenticated, expires_at_timestamp or None)
|
|
208
|
+
"""
|
|
209
|
+
token_data = self._get_stored_tokens()
|
|
210
|
+
if not token_data:
|
|
211
|
+
return False, None
|
|
212
|
+
return True, token_data.expires_at
|
|
213
|
+
|
|
214
|
+
def get_token_data(self) -> TokenData | None:
|
|
215
|
+
"""Get current token data (for auth flow use).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
TokenData if tokens are stored, None otherwise.
|
|
219
|
+
"""
|
|
220
|
+
return self._get_stored_tokens()
|
|
221
|
+
|
|
222
|
+
def refresh_tokens(self, refresh_token: str) -> TokenData | None:
|
|
223
|
+
"""Attempt to refresh tokens using the provided refresh token.
|
|
224
|
+
|
|
225
|
+
This is the public interface for token refresh, used by
|
|
226
|
+
TokenRefreshAuth for 401 retry flow.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
refresh_token: The refresh token to exchange for new tokens.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
TokenData with new tokens if successful, None if refresh failed.
|
|
233
|
+
"""
|
|
234
|
+
return self._refresh_tokens(refresh_token)
|
|
235
|
+
|
|
236
|
+
def start_background_refresh(
|
|
237
|
+
self, callback: Callable[[], None] | None = None
|
|
238
|
+
) -> asyncio.Task[None]:
|
|
239
|
+
"""Start background token refresh timer.
|
|
240
|
+
|
|
241
|
+
Proactively refreshes tokens before they expire to ensure
|
|
242
|
+
uninterrupted WebSocket connections.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
callback: Optional callback invoked after successful refresh.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The asyncio Task running the refresh loop.
|
|
249
|
+
"""
|
|
250
|
+
self._background_task = asyncio.create_task(self._refresh_loop(callback))
|
|
251
|
+
return self._background_task
|
|
252
|
+
|
|
253
|
+
def stop_background_refresh(self) -> None:
|
|
254
|
+
"""Stop the background refresh timer."""
|
|
255
|
+
if self._background_task is not None:
|
|
256
|
+
self._background_task.cancel()
|
|
257
|
+
self._background_task = None
|
|
258
|
+
|
|
259
|
+
async def _refresh_loop(self, callback: Callable[[], None] | None = None) -> None:
|
|
260
|
+
"""Background loop that checks and refreshes tokens before expiry.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
callback: Optional callback invoked after successful refresh.
|
|
264
|
+
"""
|
|
265
|
+
check_interval = 60 # Check every 60 seconds
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
while True:
|
|
269
|
+
token_data = self._get_stored_tokens()
|
|
270
|
+
if token_data and self._is_token_expired(token_data):
|
|
271
|
+
try:
|
|
272
|
+
# Run the blocking HTTP call in a thread to avoid freezing the event loop
|
|
273
|
+
refreshed = await asyncio.to_thread(
|
|
274
|
+
self._refresh_tokens, token_data.refresh_token
|
|
275
|
+
)
|
|
276
|
+
except _TokenRefreshRejected:
|
|
277
|
+
# Auth server definitively rejected the refresh token — clear and stop
|
|
278
|
+
self.clear_tokens()
|
|
279
|
+
return
|
|
280
|
+
if refreshed and callback:
|
|
281
|
+
callback()
|
|
282
|
+
# None means transient failure; will retry next interval
|
|
283
|
+
|
|
284
|
+
await asyncio.sleep(check_interval)
|
|
285
|
+
except asyncio.CancelledError:
|
|
286
|
+
# Clean shutdown
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class TokenRefreshAuth(httpx.Auth):
|
|
291
|
+
"""httpx Auth class that handles automatic token refresh on 401.
|
|
292
|
+
|
|
293
|
+
This auth class:
|
|
294
|
+
1. Sets the Authorization header with the current access token
|
|
295
|
+
2. If a 401 response is received, attempts to refresh the token
|
|
296
|
+
3. If refresh succeeds, retries the request with the new token
|
|
297
|
+
4. If refresh fails, clears tokens and returns the 401 response
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(self, token_manager: TokenManager) -> None:
|
|
301
|
+
"""Initialize with a TokenManager instance.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
token_manager: The TokenManager to use for token operations.
|
|
305
|
+
"""
|
|
306
|
+
self._token_manager = token_manager
|
|
307
|
+
|
|
308
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
309
|
+
"""Implement the httpx auth flow with 401 retry.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
request: The outgoing request.
|
|
313
|
+
|
|
314
|
+
Yields:
|
|
315
|
+
The request (possibly retried with new token).
|
|
316
|
+
"""
|
|
317
|
+
# Get current token and set auth header
|
|
318
|
+
token = self._token_manager.get_access_token()
|
|
319
|
+
if token:
|
|
320
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
321
|
+
|
|
322
|
+
# Send the request
|
|
323
|
+
response = yield request
|
|
324
|
+
|
|
325
|
+
# Handle 401 - attempt refresh and retry
|
|
326
|
+
if response.status_code == 401:
|
|
327
|
+
token_data = self._token_manager.get_token_data()
|
|
328
|
+
if token_data:
|
|
329
|
+
try:
|
|
330
|
+
refreshed = self._token_manager.refresh_tokens(token_data.refresh_token)
|
|
331
|
+
except _TokenRefreshRejected:
|
|
332
|
+
self._token_manager.clear_tokens()
|
|
333
|
+
return
|
|
334
|
+
if refreshed:
|
|
335
|
+
# Update header with new token and retry
|
|
336
|
+
request.headers["Authorization"] = f"Bearer {refreshed.access_token}"
|
|
337
|
+
yield request
|
|
338
|
+
# Transient failure — don't clear tokens
|
kater_cli/client.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Authenticated API client factory for the Kater CLI."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import ParamSpec, TypeVar
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from kater import DefaultHttpxClient, Kater
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from kater_cli.auth.token_manager import TokenManager, TokenRefreshAuth
|
|
12
|
+
from kater_cli.config import get_settings
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
P = ParamSpec("P")
|
|
17
|
+
R = TypeVar("R")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_authenticated_client() -> Kater:
|
|
21
|
+
"""Get an authenticated API client.
|
|
22
|
+
|
|
23
|
+
Retrieves a valid access token (refreshing if needed) and creates
|
|
24
|
+
a Kater client configured for the API with automatic token
|
|
25
|
+
refresh on 401 responses.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
typer.Exit: If not logged in or token refresh failed.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Configured Kater client instance.
|
|
32
|
+
"""
|
|
33
|
+
token_manager = TokenManager()
|
|
34
|
+
|
|
35
|
+
# Check if we have any tokens before creating the client
|
|
36
|
+
if not token_manager.has_tokens():
|
|
37
|
+
console.print("[red]Not logged in. Run `kater login` to authenticate.[/red]")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
settings = get_settings()
|
|
41
|
+
|
|
42
|
+
# Get the current access token for SDK header validation.
|
|
43
|
+
# TokenRefreshAuth also sets the header at the httpx layer and
|
|
44
|
+
# automatically refreshes the token on 401 responses.
|
|
45
|
+
access_token = token_manager.get_access_token()
|
|
46
|
+
if not access_token:
|
|
47
|
+
console.print("[red]Session expired. Run `kater login` to re-authenticate.[/red]")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
return Kater(
|
|
51
|
+
base_url=settings.api_url,
|
|
52
|
+
auth_token=access_token,
|
|
53
|
+
http_client=DefaultHttpxClient(auth=TokenRefreshAuth(token_manager)),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_auth(func: Callable[P, R]) -> Callable[P, R]:
|
|
58
|
+
"""Decorator for commands that require authentication.
|
|
59
|
+
|
|
60
|
+
Checks for a valid token before running the command. If the token
|
|
61
|
+
is missing or invalid, displays an error and exits.
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
@app.command()
|
|
65
|
+
@require_auth
|
|
66
|
+
def my_command():
|
|
67
|
+
client = get_authenticated_client()
|
|
68
|
+
...
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@wraps(func)
|
|
72
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
73
|
+
token_manager = TokenManager()
|
|
74
|
+
token = token_manager.get_access_token()
|
|
75
|
+
|
|
76
|
+
if not token:
|
|
77
|
+
console.print("[red]Not logged in. Run `kater login` to authenticate.[/red]")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
return func(*args, **kwargs)
|
|
81
|
+
|
|
82
|
+
return wrapper
|
|
File without changes
|