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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ codespeak = console_client.console_client_main:main
3
+ codespeak-cli = console_client.console_client_main:main
@@ -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
+ )