codespeak-cli 0.2.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.
- codespeak_cli-0.2.0.dist-info/METADATA +11 -0
- codespeak_cli-0.2.0.dist-info/RECORD +19 -0
- codespeak_cli-0.2.0.dist-info/WHEEL +4 -0
- codespeak_cli-0.2.0.dist-info/entry_points.txt +3 -0
- console_client/__init__.py +1 -0
- console_client/auth/__init__.py +1 -0
- console_client/auth/auth_manager.py +156 -0
- console_client/auth/callback_server.py +170 -0
- console_client/auth/exceptions.py +83 -0
- console_client/auth/oauth_pkce.py +134 -0
- console_client/auth/token_storage.py +122 -0
- console_client/build_client.py +318 -0
- console_client/build_client_event_converter.py +156 -0
- console_client/client_feature_flags.py +23 -0
- console_client/console_client_logging.py +220 -0
- console_client/console_client_main.py +348 -0
- console_client/os_environment_servicer.py +676 -0
- console_client/sequence_reorder_buffer.py +93 -0
- console_client/version.py +10 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codespeak-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CodeSpeak Console Client
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: codespeak-api-stubs==0.2.0
|
|
7
|
+
Requires-Dist: codespeak-shared==0.2.0
|
|
8
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
9
|
+
Requires-Dist: requests>=2.32.4
|
|
10
|
+
Requires-Dist: rich>=13.0.0
|
|
11
|
+
Requires-Dist: ripgrep==14.1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
console_client/__init__.py,sha256=sY7kWaw7NGBMJAeOJvlMlwpiulhQpaa8_841H_nkxiU,42
|
|
2
|
+
console_client/build_client.py,sha256=P58nJ1AZMMgw2aD9BvCji1MxEwnQghaZzi_nDxN-Clk,14019
|
|
3
|
+
console_client/build_client_event_converter.py,sha256=SBVPkunBKqhyeIqEGiLU2nuTCSdC_Hma3as79GkEP18,6848
|
|
4
|
+
console_client/client_feature_flags.py,sha256=hDvab8bgDqPedpRJNgFdS41krO8bAcUBEw417aBY1Pk,966
|
|
5
|
+
console_client/console_client_logging.py,sha256=9xWdmAg_0qPJMpkY1pQulzw3wfVqmcTYH8RnkYJnhb0,7817
|
|
6
|
+
console_client/console_client_main.py,sha256=gf3osr90ZMnpwsjeA2mnbN7E75QkzPYFa0Er_bWBbPA,14248
|
|
7
|
+
console_client/os_environment_servicer.py,sha256=Y7_rno1iWExIA3s_KWxIUF-KOnGgn_ecRyMOKa102Xs,31194
|
|
8
|
+
console_client/sequence_reorder_buffer.py,sha256=thLpyk0jLT7yCWSWT2A8LV_YyRWtmDiFN71DCZMGKjk,3406
|
|
9
|
+
console_client/version.py,sha256=JlndyH3QDUIEcgChbg7FLBX75b87VrU47TQP7Scgh9M,329
|
|
10
|
+
console_client/auth/__init__.py,sha256=5bTbyJoj2lMXSpb1_3tVtwSswYqIkWH4HgNn8gfWfwY,47
|
|
11
|
+
console_client/auth/auth_manager.py,sha256=hvaADY1P7DcMZkzi4vgTd666OnOIBChCDgr2gR-De7k,6188
|
|
12
|
+
console_client/auth/callback_server.py,sha256=sclAiQPbcqJo_B43E2XlNzb5U8X4DDyZuBP5RyoprPc,6300
|
|
13
|
+
console_client/auth/exceptions.py,sha256=QlP2tE7MIMZaxzG3LqevYNMgaU8qR3w34oz-UzRGst4,3001
|
|
14
|
+
console_client/auth/oauth_pkce.py,sha256=Ok6g1HGSe_O0VZ1rMEuULgADSPPGUMOQTuh71ZUDWZI,4008
|
|
15
|
+
console_client/auth/token_storage.py,sha256=b-SlDFyHpbH3XYlh6COmI3TXe-LUskHPlmr0B5miyxw,3364
|
|
16
|
+
codespeak_cli-0.2.0.dist-info/METADATA,sha256=0gDpsr88qXjVqMBbiHCiZ9dUZOhrST7xoUBahq9Ksfk,321
|
|
17
|
+
codespeak_cli-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
18
|
+
codespeak_cli-0.2.0.dist-info/entry_points.txt,sha256=Vcsd4x8g7uMTL4g9E6NwKDIeRMNToU5ltoBhOcQARHM,126
|
|
19
|
+
codespeak_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Build client package for CodeSpeak."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication module for CodeSpeak CLI."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Authentication manager for orchestrating the OAuth login flow."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from console_client.auth.callback_server import OAuthCallbackServer
|
|
11
|
+
from console_client.auth.exceptions import (
|
|
12
|
+
Auth0CallbackError,
|
|
13
|
+
LoginTimeoutUserError,
|
|
14
|
+
StateMismatchUserError,
|
|
15
|
+
TokenExchangeFailedUserError,
|
|
16
|
+
TokenStorageFailedUserError,
|
|
17
|
+
)
|
|
18
|
+
from console_client.auth.oauth_pkce import (
|
|
19
|
+
OAuthConfig,
|
|
20
|
+
build_authorization_url,
|
|
21
|
+
exchange_code_for_token,
|
|
22
|
+
generate_code_challenge,
|
|
23
|
+
generate_code_verifier,
|
|
24
|
+
)
|
|
25
|
+
from console_client.auth.token_storage import save_token
|
|
26
|
+
from console_client.client_feature_flags import ConsoleClientFeatureFlags
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def login(console: Console, client_feature_flags: ConsoleClientFeatureFlags) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Orchestrate the OAuth PKCE login flow with Auth0.
|
|
32
|
+
|
|
33
|
+
Steps:
|
|
34
|
+
1. Read Auth0 configuration from feature flags
|
|
35
|
+
2. Start local callback server
|
|
36
|
+
3. Generate PKCE parameters (code_verifier, code_challenge, state)
|
|
37
|
+
4. Build authorization URL
|
|
38
|
+
5. Open browser to Auth0 login page
|
|
39
|
+
6. Wait for callback
|
|
40
|
+
7. Validate state parameter
|
|
41
|
+
8. Exchange authorization code for token
|
|
42
|
+
9. Save access token to ~/.codespeak/token
|
|
43
|
+
10. Display success message
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
console: Rich console for user output.
|
|
47
|
+
client_feature_flags: Feature flags instance for reading Auth0 configuration.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
Auth0NotConfiguredUserError: If Auth0 configuration is missing.
|
|
51
|
+
LoginTimeoutUserError: If user doesn't complete login in time.
|
|
52
|
+
StateMismatchUserError: If OAuth state validation fails.
|
|
53
|
+
TokenExchangeFailedUserError: If code-to-token exchange fails.
|
|
54
|
+
TokenStorageFailedUserError: If token cannot be saved.
|
|
55
|
+
Auth0CallbackError: If Auth0 returns an error.
|
|
56
|
+
"""
|
|
57
|
+
# Step 1: Read Auth0 configuration from feature flags
|
|
58
|
+
auth0_domain = client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.CONSOLE_CLIENT_AUTH0_DOMAIN)
|
|
59
|
+
client_id = client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.CONSOLE_CLIENT_AUTH0_CLIENT_ID)
|
|
60
|
+
scopes_str = client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.CONSOLE_CLIENT_AUTH0_SCOPES)
|
|
61
|
+
scopes = scopes_str.split()
|
|
62
|
+
callback_timeout = client_feature_flags.get_flag_value(
|
|
63
|
+
ConsoleClientFeatureFlags.CONSOLE_CLIENT_AUTH0_CALLBACK_TIMEOUT
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Step 2: Start local callback server
|
|
67
|
+
console.print("[blue]Starting local callback server...[/blue]")
|
|
68
|
+
callback_server = OAuthCallbackServer(timeout=callback_timeout)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
redirect_uri = callback_server.start()
|
|
72
|
+
console.print(f"[dim]Callback server listening on {redirect_uri}[/dim]")
|
|
73
|
+
|
|
74
|
+
# Step 3: Generate PKCE parameters
|
|
75
|
+
code_verifier = generate_code_verifier()
|
|
76
|
+
code_challenge = generate_code_challenge(code_verifier)
|
|
77
|
+
state = secrets.token_urlsafe(32)
|
|
78
|
+
|
|
79
|
+
# Step 4: Build authorization URL
|
|
80
|
+
oauth_config = OAuthConfig(
|
|
81
|
+
auth0_domain=auth0_domain,
|
|
82
|
+
client_id=client_id,
|
|
83
|
+
redirect_uri=redirect_uri,
|
|
84
|
+
scopes=scopes,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
auth_url = build_authorization_url(oauth_config, code_challenge, state)
|
|
88
|
+
|
|
89
|
+
# Step 5: Open browser
|
|
90
|
+
console.print(f"\n[bold green]Opening browser for authentication...[/bold green]")
|
|
91
|
+
console.print(f"[dim]If browser doesn't open automatically, visit:[/dim]")
|
|
92
|
+
console.print(f"[dim]{auth_url}[/dim]\n")
|
|
93
|
+
|
|
94
|
+
browser_opened = webbrowser.open(auth_url)
|
|
95
|
+
if not browser_opened:
|
|
96
|
+
console.print("[yellow]Warning: Failed to open browser automatically.[/yellow]")
|
|
97
|
+
console.print(f"[yellow]Please manually visit: {auth_url}[/yellow]\n")
|
|
98
|
+
|
|
99
|
+
# Step 6: Wait for callback
|
|
100
|
+
console.print("[blue]Waiting for authentication...[/blue]")
|
|
101
|
+
console.print("[dim](You can press Ctrl+C to cancel)[/dim]\n")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
authorization_code, state_received, error = callback_server.wait_for_callback()
|
|
105
|
+
except TimeoutError:
|
|
106
|
+
raise LoginTimeoutUserError(callback_timeout) from None
|
|
107
|
+
|
|
108
|
+
# Check for Auth0 error
|
|
109
|
+
if error:
|
|
110
|
+
error_description = os.environ.get("error_description")
|
|
111
|
+
raise Auth0CallbackError(error, error_description)
|
|
112
|
+
|
|
113
|
+
# Step 7: Validate state
|
|
114
|
+
if state_received != state:
|
|
115
|
+
raise StateMismatchUserError()
|
|
116
|
+
|
|
117
|
+
# Step 8: Exchange code for token
|
|
118
|
+
console.print("[blue]Exchanging authorization code for token...[/blue]")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
tokens = exchange_code_for_token(oauth_config, authorization_code, code_verifier) # type: ignore
|
|
122
|
+
except requests.RequestException as e:
|
|
123
|
+
error_detail = str(e)
|
|
124
|
+
if hasattr(e, "response") and e.response is not None:
|
|
125
|
+
try:
|
|
126
|
+
error_json = e.response.json()
|
|
127
|
+
error_detail = error_json.get("error_description", error_json.get("error", str(e)))
|
|
128
|
+
except Exception: # noqa: BLE001
|
|
129
|
+
error_detail = e.response.text or str(e)
|
|
130
|
+
raise TokenExchangeFailedUserError(error_detail) from e
|
|
131
|
+
|
|
132
|
+
# Step 9: Save token
|
|
133
|
+
console.print("[blue]Saving authentication token...[/blue]")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
save_token(tokens.access_token, tokens.expires_in)
|
|
137
|
+
except OSError as e:
|
|
138
|
+
raise TokenStorageFailedUserError(str(e)) from e
|
|
139
|
+
|
|
140
|
+
# Step 10: Display success
|
|
141
|
+
console.print("\n[bold green]✓ Authentication successful![/bold green]")
|
|
142
|
+
console.print("[green]Token saved to ~/.codespeak/token.json[/green]")
|
|
143
|
+
|
|
144
|
+
if tokens.expires_in > 0:
|
|
145
|
+
hours = tokens.expires_in // 3600
|
|
146
|
+
minutes = (tokens.expires_in % 3600) // 60
|
|
147
|
+
if hours > 0:
|
|
148
|
+
console.print(f"[dim]Token expires in {hours}h {minutes}m[/dim]")
|
|
149
|
+
else:
|
|
150
|
+
console.print(f"[dim]Token expires in {minutes}m[/dim]")
|
|
151
|
+
|
|
152
|
+
console.print("\n[green]You can now use CodeSpeak commands that require authentication.[/green]")
|
|
153
|
+
|
|
154
|
+
finally:
|
|
155
|
+
# Always shutdown the callback server
|
|
156
|
+
callback_server.shutdown()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Local HTTP server for OAuth callback handling."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
from codespeak_shared.utils.ports import is_port_free
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OAuthCallbackServer:
|
|
12
|
+
"""Local HTTP server to receive OAuth callback."""
|
|
13
|
+
|
|
14
|
+
# Ports to try (must all be configured in Auth0's Allowed Callback URLs)
|
|
15
|
+
# Configure these in Auth0: http://localhost:8080/callback, http://localhost:8081/callback, etc.
|
|
16
|
+
ALLOWED_PORTS = [8080, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089]
|
|
17
|
+
|
|
18
|
+
# Class variables shared with handler (intentionally not private)
|
|
19
|
+
authorization_code: ClassVar[str | None] = None
|
|
20
|
+
state_received: ClassVar[str | None] = None
|
|
21
|
+
error: ClassVar[str | None] = None
|
|
22
|
+
callback_received: ClassVar[threading.Event] = threading.Event()
|
|
23
|
+
|
|
24
|
+
def __init__(self, timeout: int = 300):
|
|
25
|
+
"""
|
|
26
|
+
Initialize the OAuth callback server.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
timeout: Maximum time to wait for callback in seconds (default: 300 = 5 minutes).
|
|
30
|
+
"""
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._port: int = 0
|
|
33
|
+
self._server: HTTPServer | None = None
|
|
34
|
+
self._server_thread: threading.Thread | None = None
|
|
35
|
+
|
|
36
|
+
def start(self) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Start the callback server on a free port.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The redirect URI (e.g., "http://localhost:8080/callback").
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError: If the server fails to start.
|
|
45
|
+
"""
|
|
46
|
+
# Reset class variables
|
|
47
|
+
OAuthCallbackServer.authorization_code = None
|
|
48
|
+
OAuthCallbackServer.state_received = None
|
|
49
|
+
OAuthCallbackServer.error = None
|
|
50
|
+
OAuthCallbackServer.callback_received.clear()
|
|
51
|
+
|
|
52
|
+
# Try each allowed port in sequence
|
|
53
|
+
server_started = False
|
|
54
|
+
for port in self.ALLOWED_PORTS:
|
|
55
|
+
if is_port_free(port):
|
|
56
|
+
try:
|
|
57
|
+
self._server = HTTPServer(("localhost", port), _OAuthCallbackHandler)
|
|
58
|
+
self._port = port
|
|
59
|
+
server_started = True
|
|
60
|
+
break
|
|
61
|
+
except OSError:
|
|
62
|
+
# Port became busy between check and bind, try next
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if not server_started:
|
|
66
|
+
ports_str = ", ".join(str(p) for p in self.ALLOWED_PORTS)
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"Failed to start callback server. All allowed ports are busy: {ports_str}\n"
|
|
69
|
+
f"Please close other applications using these ports and try again.\n"
|
|
70
|
+
f"These ports must be configured in Auth0's Allowed Callback URLs."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Start server in background thread
|
|
74
|
+
assert self._server is not None # Ensured by server_started check above
|
|
75
|
+
self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
76
|
+
self._server_thread.start()
|
|
77
|
+
|
|
78
|
+
return f"http://localhost:{self._port}/callback"
|
|
79
|
+
|
|
80
|
+
def wait_for_callback(self) -> tuple[str | None, str | None, str | None]:
|
|
81
|
+
"""
|
|
82
|
+
Wait for the OAuth callback.
|
|
83
|
+
|
|
84
|
+
Blocks until callback is received or timeout occurs.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (authorization_code, state_received, error).
|
|
88
|
+
If timeout, all values will be None.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
TimeoutError: If no callback is received within the timeout period.
|
|
92
|
+
"""
|
|
93
|
+
# Wait for callback with timeout
|
|
94
|
+
callback_received = OAuthCallbackServer.callback_received.wait(timeout=self._timeout)
|
|
95
|
+
|
|
96
|
+
if not callback_received:
|
|
97
|
+
raise TimeoutError(f"No callback received within {self._timeout} seconds")
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
OAuthCallbackServer.authorization_code,
|
|
101
|
+
OAuthCallbackServer.state_received,
|
|
102
|
+
OAuthCallbackServer.error,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def shutdown(self) -> None:
|
|
106
|
+
"""Shutdown the callback server."""
|
|
107
|
+
if self._server:
|
|
108
|
+
self._server.shutdown()
|
|
109
|
+
self._server = None
|
|
110
|
+
if self._server_thread:
|
|
111
|
+
self._server_thread.join(timeout=1)
|
|
112
|
+
self._server_thread = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
116
|
+
"""HTTP request handler for OAuth callback."""
|
|
117
|
+
|
|
118
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: ARG002, A002
|
|
119
|
+
"""Suppress default HTTP server logging."""
|
|
120
|
+
|
|
121
|
+
def do_GET(self) -> None: # noqa: N802
|
|
122
|
+
"""Handle GET request to /callback."""
|
|
123
|
+
# Parse URL
|
|
124
|
+
parsed_url = urlparse(self.path)
|
|
125
|
+
|
|
126
|
+
if parsed_url.path == "/callback":
|
|
127
|
+
# Parse query parameters
|
|
128
|
+
query_params = parse_qs(parsed_url.query)
|
|
129
|
+
|
|
130
|
+
# Extract authorization code, state, and error
|
|
131
|
+
OAuthCallbackServer.authorization_code = query_params.get("code", [None])[0]
|
|
132
|
+
OAuthCallbackServer.state_received = query_params.get("state", [None])[0]
|
|
133
|
+
OAuthCallbackServer.error = query_params.get("error", [None])[0]
|
|
134
|
+
|
|
135
|
+
# Signal that callback was received
|
|
136
|
+
OAuthCallbackServer.callback_received.set()
|
|
137
|
+
|
|
138
|
+
# Send success response
|
|
139
|
+
self.send_response(200)
|
|
140
|
+
self.send_header("Content-type", "text/html")
|
|
141
|
+
self.end_headers()
|
|
142
|
+
|
|
143
|
+
# Display success page
|
|
144
|
+
if OAuthCallbackServer.error:
|
|
145
|
+
html = f"""
|
|
146
|
+
<html>
|
|
147
|
+
<head><title>Authentication Error</title></head>
|
|
148
|
+
<body>
|
|
149
|
+
<h1>Authentication Error</h1>
|
|
150
|
+
<p>Error: {OAuthCallbackServer.error}</p>
|
|
151
|
+
<p>You can close this window now.</p>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
"""
|
|
155
|
+
else:
|
|
156
|
+
html = """
|
|
157
|
+
<html>
|
|
158
|
+
<head><title>Authentication Successful</title></head>
|
|
159
|
+
<body>
|
|
160
|
+
<h1>Authentication Successful!</h1>
|
|
161
|
+
<p>You have been successfully authenticated with CodeSpeak.</p>
|
|
162
|
+
<p>You can close this window now and return to the terminal.</p>
|
|
163
|
+
</body>
|
|
164
|
+
</html>
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
self.wfile.write(html.encode("utf-8"))
|
|
168
|
+
else:
|
|
169
|
+
# Return 404 for other paths
|
|
170
|
+
self.send_error(404)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Authentication-specific exceptions for CodeSpeak CLI."""
|
|
2
|
+
|
|
3
|
+
from codespeak_shared.exceptions import CodespeakUserError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Auth0NotConfiguredUserError(CodespeakUserError):
|
|
7
|
+
"""Raised when Auth0 configuration is missing or incomplete."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, missing_config: str):
|
|
10
|
+
message = (
|
|
11
|
+
f"Auth0 is not configured. Missing: {missing_config}\n\n"
|
|
12
|
+
"Please set the following environment variables:\n"
|
|
13
|
+
" - CODESPEAK_AUTH0_CLIENT_ID (required)\n"
|
|
14
|
+
" - CODESPEAK_AUTH0_DOMAIN (optional, defaults to codespeak.us.auth0.com)\n\n"
|
|
15
|
+
"See documentation for Auth0 setup instructions."
|
|
16
|
+
)
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoginTimeoutUserError(CodespeakUserError):
|
|
21
|
+
"""Raised when the user doesn't complete login within the timeout period."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, timeout_seconds: int):
|
|
24
|
+
message = f"Login timed out after {timeout_seconds} seconds.\nPlease try again with: codespeak login"
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LoginCancelledUserError(CodespeakUserError):
|
|
29
|
+
"""Raised when the user cancels the login process."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
message = "Login cancelled by user."
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StateMismatchUserError(CodespeakUserError):
|
|
37
|
+
"""Raised when the OAuth state parameter doesn't match (potential CSRF attack)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
message = (
|
|
41
|
+
"Security error: OAuth state mismatch detected.\n"
|
|
42
|
+
"This could indicate a CSRF attack. Please try logging in again."
|
|
43
|
+
)
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TokenExchangeFailedUserError(CodespeakUserError):
|
|
48
|
+
"""Raised when the authorization code to token exchange fails."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, error_detail: str):
|
|
51
|
+
message = f"Failed to exchange authorization code for token.\nError: {error_detail}"
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TokenStorageFailedUserError(CodespeakUserError):
|
|
56
|
+
"""Raised when the token cannot be saved to the filesystem."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, error_detail: str):
|
|
59
|
+
message = (
|
|
60
|
+
f"Failed to save authentication token to ~/.codespeak/token.json\n"
|
|
61
|
+
f"Error: {error_detail}\n\n"
|
|
62
|
+
"Please check file permissions and disk space."
|
|
63
|
+
)
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Auth0CallbackError(CodespeakUserError):
|
|
68
|
+
"""Raised when Auth0 returns an error in the callback."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, error: str, error_description: str | None = None):
|
|
71
|
+
if error_description:
|
|
72
|
+
message = f"Authentication failed: {error}\nDetails: {error_description}"
|
|
73
|
+
else:
|
|
74
|
+
message = f"Authentication failed: {error}"
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TokenExpiredUserError(CodespeakUserError):
|
|
79
|
+
"""Raised when the user's access token has expired."""
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
message = "Your authentication token has expired. Please run 'codespeak login' to re-authenticate."
|
|
83
|
+
super().__init__(message)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# ruff: noqa: TID251
|
|
2
|
+
"""OAuth 2.0 Authorization Code with PKCE flow implementation."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OAuthConfig:
|
|
15
|
+
"""OAuth configuration for Auth0 authentication."""
|
|
16
|
+
|
|
17
|
+
auth0_domain: str # e.g., "codespeak.us.auth0.com"
|
|
18
|
+
client_id: str # Auth0 application client ID
|
|
19
|
+
redirect_uri: str # e.g., "http://localhost:8080/callback"
|
|
20
|
+
scopes: list[str] # e.g., ["openid", "profile", "email"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class OAuthTokens:
|
|
25
|
+
"""OAuth tokens returned from token exchange."""
|
|
26
|
+
|
|
27
|
+
access_token: str
|
|
28
|
+
id_token: str | None
|
|
29
|
+
refresh_token: str | None
|
|
30
|
+
expires_in: int
|
|
31
|
+
token_type: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_code_verifier() -> str:
|
|
35
|
+
"""
|
|
36
|
+
Generate a cryptographically random code verifier for PKCE.
|
|
37
|
+
|
|
38
|
+
The verifier is a random string between 43-128 characters using
|
|
39
|
+
unreserved characters [A-Z, a-z, 0-9, -, ., _, ~].
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Base64url-encoded random string (64 characters).
|
|
43
|
+
"""
|
|
44
|
+
# Generate 32 random bytes and base64url encode (results in 43 chars without padding)
|
|
45
|
+
# Using 48 bytes to get ~64 characters for better security
|
|
46
|
+
random_bytes = secrets.token_bytes(48)
|
|
47
|
+
# Base64url encoding without padding
|
|
48
|
+
return base64.urlsafe_b64encode(random_bytes).decode("utf-8").rstrip("=")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_code_challenge(code_verifier: str) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Generate the code challenge from the code verifier for PKCE.
|
|
54
|
+
|
|
55
|
+
The challenge is the SHA256 hash of the verifier, base64url encoded.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
code_verifier: The code verifier string.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Base64url-encoded SHA256 hash of the code verifier.
|
|
62
|
+
"""
|
|
63
|
+
# SHA256 hash of the verifier
|
|
64
|
+
sha256_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
65
|
+
# Base64url encoding without padding
|
|
66
|
+
return base64.urlsafe_b64encode(sha256_hash).decode("utf-8").rstrip("=")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_authorization_url(config: OAuthConfig, code_challenge: str, state: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Build the Auth0 authorization URL for the OAuth flow.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config: OAuth configuration.
|
|
75
|
+
code_challenge: The PKCE code challenge.
|
|
76
|
+
state: Random state parameter for CSRF protection.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Complete authorization URL to open in browser.
|
|
80
|
+
"""
|
|
81
|
+
params = {
|
|
82
|
+
"response_type": "code",
|
|
83
|
+
"client_id": config.client_id,
|
|
84
|
+
"redirect_uri": config.redirect_uri,
|
|
85
|
+
"scope": " ".join(config.scopes),
|
|
86
|
+
"state": state,
|
|
87
|
+
"code_challenge": code_challenge,
|
|
88
|
+
"code_challenge_method": "S256",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
base_url = f"https://{config.auth0_domain}/authorize"
|
|
92
|
+
return f"{base_url}?{urlencode(params)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exchange_code_for_token(config: OAuthConfig, code: str, code_verifier: str) -> OAuthTokens:
|
|
96
|
+
"""
|
|
97
|
+
Exchange the authorization code for access token.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
config: OAuth configuration.
|
|
101
|
+
code: Authorization code from callback.
|
|
102
|
+
code_verifier: The original code verifier for PKCE.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
OAuth tokens including access_token.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
requests.RequestException: If the token exchange request fails.
|
|
109
|
+
KeyError: If the response doesn't contain expected fields.
|
|
110
|
+
"""
|
|
111
|
+
token_url = f"https://{config.auth0_domain}/oauth/token"
|
|
112
|
+
|
|
113
|
+
payload = {
|
|
114
|
+
"grant_type": "authorization_code",
|
|
115
|
+
"client_id": config.client_id,
|
|
116
|
+
"code": code,
|
|
117
|
+
"redirect_uri": config.redirect_uri,
|
|
118
|
+
"code_verifier": code_verifier,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
122
|
+
|
|
123
|
+
response = requests.post(token_url, data=payload, headers=headers, timeout=30)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
|
|
126
|
+
data = response.json()
|
|
127
|
+
|
|
128
|
+
return OAuthTokens(
|
|
129
|
+
access_token=data["access_token"],
|
|
130
|
+
id_token=data.get("id_token"),
|
|
131
|
+
refresh_token=data.get("refresh_token"),
|
|
132
|
+
expires_in=data.get("expires_in", 0),
|
|
133
|
+
token_type=data.get("token_type", "Bearer"),
|
|
134
|
+
)
|