janet-cli 0.2.2__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,360 @@
1
+ """Local HTTP server for OAuth callback."""
2
+
3
+ import threading
4
+ import urllib.parse
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from typing import Optional
7
+
8
+ from janet.utils.console import console
9
+
10
+
11
+ class CallbackHandler(BaseHTTPRequestHandler):
12
+ """HTTP request handler for OAuth callback."""
13
+
14
+ authorization_code: Optional[str] = None
15
+ error: Optional[str] = None
16
+
17
+ def do_GET(self) -> None:
18
+ """Handle GET request to callback URL."""
19
+ # Parse query parameters
20
+ parsed_url = urllib.parse.urlparse(self.path)
21
+ params = urllib.parse.parse_qs(parsed_url.query)
22
+
23
+ if "code" in params:
24
+ # Successful authorization
25
+ CallbackHandler.authorization_code = params["code"][0]
26
+ self.send_success_response()
27
+ elif "error" in params:
28
+ # Authorization error
29
+ CallbackHandler.error = params.get("error_description", ["Unknown error"])[0]
30
+ self.send_error_response()
31
+ else:
32
+ # Invalid callback
33
+ self.send_error_response("Invalid callback parameters")
34
+
35
+ def send_success_response(self) -> None:
36
+ """Send success HTML response."""
37
+ self.send_response(200)
38
+ self.send_header("Content-type", "text/html")
39
+ self.end_headers()
40
+
41
+ html = """
42
+ <!DOCTYPE html>
43
+ <html>
44
+ <head>
45
+ <meta charset="UTF-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
47
+ <title>Janet AI - Authentication Successful</title>
48
+ <link rel="icon" href="https://app.tryjanet.ai/logo-favicon.png">
49
+ <style>
50
+ * {
51
+ margin: 0;
52
+ padding: 0;
53
+ box-sizing: border-box;
54
+ }
55
+ body {
56
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
57
+ display: flex;
58
+ justify-content: center;
59
+ align-items: center;
60
+ min-height: 100vh;
61
+ background: #ffffff;
62
+ color: #171717;
63
+ }
64
+ @media (prefers-color-scheme: dark) {
65
+ body {
66
+ background: rgb(38, 38, 38);
67
+ color: #ededed;
68
+ }
69
+ .container {
70
+ background: rgb(50, 50, 50) !important;
71
+ border: 1px solid #333 !important;
72
+ }
73
+ h1 {
74
+ color: #ededed !important;
75
+ }
76
+ p {
77
+ color: #a3a3a3 !important;
78
+ }
79
+ .logo {
80
+ filter: brightness(0) invert(1);
81
+ }
82
+ }
83
+ .container {
84
+ background: white;
85
+ padding: 3rem 2rem;
86
+ border-radius: 12px;
87
+ border: 1px solid #e5e5e5;
88
+ text-align: center;
89
+ max-width: 480px;
90
+ width: 90%;
91
+ animation: fadeIn 0.3s ease-out;
92
+ }
93
+ .logo {
94
+ width: 180px;
95
+ height: auto;
96
+ margin: 0 auto 2.5rem;
97
+ }
98
+ .success-icon {
99
+ width: 64px;
100
+ height: 64px;
101
+ margin: 0 auto 1.5rem;
102
+ background: #10b981;
103
+ border-radius: 50%;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ }
108
+ .success-icon svg {
109
+ width: 36px;
110
+ height: 36px;
111
+ color: white;
112
+ }
113
+ h1 {
114
+ font-size: 1.5rem;
115
+ font-weight: 600;
116
+ color: #171717;
117
+ margin-bottom: 0.75rem;
118
+ }
119
+ p {
120
+ color: #737373;
121
+ line-height: 1.6;
122
+ font-size: 0.95rem;
123
+ }
124
+ @keyframes fadeIn {
125
+ from {
126
+ opacity: 0;
127
+ transform: translateY(10px);
128
+ }
129
+ to {
130
+ opacity: 1;
131
+ transform: translateY(0);
132
+ }
133
+ }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <div class="container">
138
+ <img src="https://app.tryjanet.ai/Full%20Logo.svg" alt="Janet AI" class="logo">
139
+ <div class="success-icon">
140
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
141
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
142
+ </svg>
143
+ </div>
144
+ <h1>Authentication Successful</h1>
145
+ <p>Return to your terminal to continue.</p>
146
+ </div>
147
+ </body>
148
+ </html>
149
+ """
150
+ self.wfile.write(html.encode())
151
+
152
+ def send_error_response(self, error_message: str = "Authentication failed") -> None:
153
+ """Send error HTML response."""
154
+ self.send_response(400)
155
+ self.send_header("Content-type", "text/html")
156
+ self.end_headers()
157
+
158
+ html = f"""
159
+ <!DOCTYPE html>
160
+ <html>
161
+ <head>
162
+ <meta charset="UTF-8">
163
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
164
+ <title>Janet AI - Authentication Failed</title>
165
+ <link rel="icon" href="https://app.tryjanet.ai/logo-favicon.png">
166
+ <style>
167
+ * {{
168
+ margin: 0;
169
+ padding: 0;
170
+ box-sizing: border-box;
171
+ }}
172
+ body {{
173
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
174
+ display: flex;
175
+ justify-content: center;
176
+ align-items: center;
177
+ min-height: 100vh;
178
+ background: #ffffff;
179
+ color: #171717;
180
+ }}
181
+ @media (prefers-color-scheme: dark) {{
182
+ body {{
183
+ background: rgb(38, 38, 38);
184
+ color: #ededed;
185
+ }}
186
+ .container {{
187
+ background: rgb(50, 50, 50) !important;
188
+ border: 1px solid #333 !important;
189
+ }}
190
+ h1 {{
191
+ color: #ededed !important;
192
+ }}
193
+ p {{
194
+ color: #a3a3a3 !important;
195
+ }}
196
+ .logo {{
197
+ filter: brightness(0) invert(1);
198
+ }}
199
+ }}
200
+ .container {{
201
+ background: white;
202
+ padding: 3rem 2rem;
203
+ border-radius: 12px;
204
+ border: 1px solid #e5e5e5;
205
+ text-align: center;
206
+ max-width: 480px;
207
+ width: 90%;
208
+ animation: fadeIn 0.3s ease-out;
209
+ }}
210
+ .logo {{
211
+ width: 180px;
212
+ height: auto;
213
+ margin: 0 auto 2.5rem;
214
+ }}
215
+ .error-icon {{
216
+ width: 64px;
217
+ height: 64px;
218
+ margin: 0 auto 1.5rem;
219
+ background: #ef4444;
220
+ border-radius: 50%;
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ }}
225
+ .error-icon svg {{
226
+ width: 36px;
227
+ height: 36px;
228
+ color: white;
229
+ }}
230
+ h1 {{
231
+ font-size: 1.5rem;
232
+ font-weight: 600;
233
+ color: #171717;
234
+ margin-bottom: 0.75rem;
235
+ }}
236
+ p {{
237
+ color: #737373;
238
+ line-height: 1.6;
239
+ font-size: 0.95rem;
240
+ }}
241
+ @keyframes fadeIn {{
242
+ from {{
243
+ opacity: 0;
244
+ transform: translateY(10px);
245
+ }}
246
+ to {{
247
+ opacity: 1;
248
+ transform: translateY(0);
249
+ }}
250
+ }}
251
+ </style>
252
+ </head>
253
+ <body>
254
+ <div class="container">
255
+ <img src="https://app.tryjanet.ai/Full%20Logo.svg" alt="Janet AI" class="logo">
256
+ <div class="error-icon">
257
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
258
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
259
+ </svg>
260
+ </div>
261
+ <h1>Authentication Failed</h1>
262
+ <p>Please return to your terminal and try again.</p>
263
+ </div>
264
+ </body>
265
+ </html>
266
+ """
267
+ self.wfile.write(html.encode())
268
+
269
+ def log_message(self, format: str, *args) -> None:
270
+ """Suppress default HTTP server logging."""
271
+ pass
272
+
273
+
274
+ class CallbackServer:
275
+ """Lightweight HTTP server for OAuth callback."""
276
+
277
+ def __init__(self, port: int = 8765):
278
+ """
279
+ Initialize callback server.
280
+
281
+ Args:
282
+ port: Port to listen on (default: 8765)
283
+ """
284
+ self.port = port
285
+ self.server: Optional[HTTPServer] = None
286
+ self.thread: Optional[threading.Thread] = None
287
+
288
+ def start(self) -> None:
289
+ """Start server in background thread."""
290
+ # Reset state
291
+ CallbackHandler.authorization_code = None
292
+ CallbackHandler.error = None
293
+
294
+ # Try to start server on specified port, fallback to next ports if busy
295
+ # Use 127.0.0.1 instead of localhost for WorkOS compatibility
296
+ for attempt_port in range(self.port, self.port + 10):
297
+ try:
298
+ self.server = HTTPServer(("127.0.0.1", attempt_port), CallbackHandler)
299
+ self.port = attempt_port
300
+ break
301
+ except OSError:
302
+ continue
303
+ else:
304
+ raise RuntimeError("Failed to start callback server: all ports busy")
305
+
306
+ # Start server in background thread
307
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
308
+ self.thread.start()
309
+
310
+ def wait_for_code(self, timeout: int = 300) -> str:
311
+ """
312
+ Wait for authorization code from callback.
313
+
314
+ Args:
315
+ timeout: Timeout in seconds (default: 5 minutes)
316
+
317
+ Returns:
318
+ Authorization code
319
+
320
+ Raises:
321
+ TimeoutError: If timeout reached
322
+ RuntimeError: If authorization error occurred
323
+ """
324
+ if not self.server:
325
+ raise RuntimeError("Server not started")
326
+
327
+ import time
328
+
329
+ start_time = time.time()
330
+ while time.time() - start_time < timeout:
331
+ if CallbackHandler.authorization_code:
332
+ code = CallbackHandler.authorization_code
333
+ self.stop()
334
+ return code
335
+
336
+ if CallbackHandler.error:
337
+ error = CallbackHandler.error
338
+ self.stop()
339
+ raise RuntimeError(f"Authorization error: {error}")
340
+
341
+ time.sleep(0.1)
342
+
343
+ self.stop()
344
+ raise TimeoutError("Timeout waiting for authorization callback")
345
+
346
+ def stop(self) -> None:
347
+ """Stop the server."""
348
+ if self.server:
349
+ self.server.shutdown()
350
+ self.server = None
351
+
352
+ def get_redirect_uri(self) -> str:
353
+ """
354
+ Get the redirect URI for this server.
355
+
356
+ Returns:
357
+ Redirect URI string
358
+ """
359
+ # WorkOS requires 127.0.0.1 (not localhost) for http in production
360
+ return f"http://127.0.0.1:{self.port}/callback"
@@ -0,0 +1,276 @@
1
+ """WorkOS OAuth PKCE flow implementation."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ import secrets
7
+ import urllib.parse
8
+ import webbrowser
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict
11
+
12
+ import httpx
13
+
14
+ from janet.auth.callback_server import CallbackServer
15
+ from janet.config.manager import ConfigManager
16
+ from janet.config.models import AuthConfig, OrganizationInfo
17
+ from janet.utils.console import console, print_success, print_error, print_info
18
+ from janet.utils.errors import AuthenticationError
19
+
20
+
21
+ class OAuthFlow:
22
+ """Handles WorkOS OAuth PKCE flow."""
23
+
24
+ def __init__(self, config_manager: ConfigManager):
25
+ """
26
+ Initialize OAuth flow.
27
+
28
+ Args:
29
+ config_manager: Configuration manager instance
30
+ """
31
+ self.config_manager = config_manager
32
+ self.config = config_manager.get()
33
+
34
+ # WorkOS configuration
35
+ # For public distribution: Use production client ID (public, not secret)
36
+ # For development: Override with WORKOS_CLIENT_ID env var
37
+ self.client_id = os.getenv(
38
+ "WORKOS_CLIENT_ID",
39
+ "client_01K3HX06N4GEBHXP0SG87B183V" # Janet AI CLI production client ID
40
+ )
41
+
42
+ self.workos_api_url = os.getenv("WORKOS_API_URL", "https://api.workos.com")
43
+ self.callback_server = CallbackServer(port=8765)
44
+
45
+ def start_login(self) -> Dict:
46
+ """
47
+ Start OAuth flow and return authentication tokens.
48
+
49
+ Returns:
50
+ Dictionary containing access token and user info
51
+
52
+ Raises:
53
+ AuthenticationError: If authentication fails
54
+ """
55
+ try:
56
+ # Generate PKCE verifier and challenge
57
+ verifier = self._generate_pkce_verifier()
58
+ challenge = self._generate_pkce_challenge(verifier)
59
+
60
+ # Start local callback server
61
+ self.callback_server.start()
62
+ redirect_uri = self.callback_server.get_redirect_uri()
63
+
64
+ # Build authorization URL
65
+ auth_url = self._build_auth_url(challenge, redirect_uri)
66
+
67
+ # Open browser for authentication
68
+ console.print(f"\n[bold]Opening browser for authentication...[/bold]")
69
+ console.print(f"If the browser doesn't open, visit:\n{auth_url}\n")
70
+
71
+ if not webbrowser.open(auth_url):
72
+ print_error("Failed to open browser. Please visit the URL above manually.")
73
+
74
+ # Wait for callback with authorization code
75
+ console.print("Waiting for authentication...")
76
+ try:
77
+ auth_code = self.callback_server.wait_for_code(timeout=300)
78
+ except TimeoutError:
79
+ raise AuthenticationError("Authentication timeout. Please try again.")
80
+ except RuntimeError as e:
81
+ raise AuthenticationError(f"Authentication failed: {e}")
82
+
83
+ print_success("Authorization code received!")
84
+
85
+ # Exchange code for tokens
86
+ print_info("Exchanging authorization code for access token...")
87
+ tokens = self._exchange_code_for_tokens(auth_code, verifier, redirect_uri)
88
+
89
+ # Save tokens to config
90
+ self._save_tokens(tokens)
91
+
92
+ print_success("Authentication successful!")
93
+
94
+ return tokens
95
+
96
+ except AuthenticationError:
97
+ raise
98
+ except Exception as e:
99
+ raise AuthenticationError(f"OAuth flow failed: {e}")
100
+
101
+ def _generate_pkce_verifier(self) -> str:
102
+ """
103
+ Generate PKCE code verifier.
104
+
105
+ Returns:
106
+ Base64-encoded random string
107
+ """
108
+ # Generate 43-128 character random string
109
+ random_bytes = secrets.token_bytes(32)
110
+ verifier = base64.urlsafe_b64encode(random_bytes).decode("utf-8").rstrip("=")
111
+ return verifier
112
+
113
+ def _generate_pkce_challenge(self, verifier: str) -> str:
114
+ """
115
+ Generate PKCE code challenge from verifier.
116
+
117
+ Args:
118
+ verifier: PKCE verifier
119
+
120
+ Returns:
121
+ Base64-encoded SHA256 hash of verifier
122
+ """
123
+ challenge_bytes = hashlib.sha256(verifier.encode("utf-8")).digest()
124
+ challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
125
+ return challenge
126
+
127
+ def _build_auth_url(self, challenge: str, redirect_uri: str) -> str:
128
+ """
129
+ Build WorkOS authorization URL.
130
+
131
+ Args:
132
+ challenge: PKCE challenge
133
+ redirect_uri: OAuth callback URL
134
+
135
+ Returns:
136
+ Complete authorization URL
137
+ """
138
+ params = {
139
+ "client_id": self.client_id,
140
+ "redirect_uri": redirect_uri,
141
+ "response_type": "code",
142
+ "provider": "authkit", # Use WorkOS AuthKit provider
143
+ "code_challenge": challenge,
144
+ "code_challenge_method": "S256",
145
+ }
146
+
147
+ query_string = urllib.parse.urlencode(params)
148
+ return f"{self.workos_api_url}/user_management/authorize?{query_string}"
149
+
150
+ def _exchange_code_for_tokens(
151
+ self, code: str, verifier: str, redirect_uri: str
152
+ ) -> Dict:
153
+ """
154
+ Exchange authorization code for access token.
155
+
156
+ Args:
157
+ code: Authorization code
158
+ verifier: PKCE verifier
159
+ redirect_uri: OAuth callback URL
160
+
161
+ Returns:
162
+ Token response dictionary
163
+
164
+ Raises:
165
+ AuthenticationError: If token exchange fails
166
+ """
167
+ token_url = f"{self.workos_api_url}/user_management/authenticate"
168
+
169
+ data = {
170
+ "client_id": self.client_id,
171
+ "code": code,
172
+ "code_verifier": verifier,
173
+ "grant_type": "authorization_code",
174
+ "redirect_uri": redirect_uri,
175
+ }
176
+
177
+ try:
178
+ response = httpx.post(token_url, data=data, timeout=30)
179
+ response.raise_for_status()
180
+ return response.json()
181
+ except httpx.HTTPStatusError as e:
182
+ error_detail = e.response.text
183
+ raise AuthenticationError(f"Token exchange failed: {error_detail}")
184
+ except Exception as e:
185
+ raise AuthenticationError(f"Token exchange failed: {e}")
186
+
187
+ def _save_tokens(self, tokens: Dict) -> None:
188
+ """
189
+ Save tokens to configuration.
190
+
191
+ Args:
192
+ tokens: Token response from WorkOS
193
+ """
194
+ # Extract token information
195
+ access_token = tokens.get("access_token")
196
+ refresh_token = tokens.get("refresh_token")
197
+
198
+ # WorkOS tokens typically expire in 1 hour
199
+ expires_in = tokens.get("expires_in", 3600)
200
+ expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
201
+
202
+ # Extract user information from token
203
+ # For now, we'll get user info from a separate API call
204
+ user_info = self._get_user_info(access_token)
205
+
206
+ # Update config
207
+ config = self.config_manager.get()
208
+ config.auth = AuthConfig(
209
+ access_token=access_token,
210
+ refresh_token=refresh_token,
211
+ expires_at=expires_at,
212
+ user_id=user_info.get("id"),
213
+ user_email=user_info.get("email"),
214
+ )
215
+
216
+ self.config_manager.update(config)
217
+
218
+ def _get_user_info(self, access_token: str) -> Dict:
219
+ """
220
+ Get user information from access token.
221
+
222
+ Args:
223
+ access_token: Access token
224
+
225
+ Returns:
226
+ User information dictionary
227
+ """
228
+ # Use the Janet API to get user profile
229
+ api_base_url = self.config.api.base_url
230
+ headers = {"Authorization": f"Bearer {access_token}"}
231
+
232
+ try:
233
+ response = httpx.get(
234
+ f"{api_base_url}/api/v1/user/profile", headers=headers, timeout=30
235
+ )
236
+ response.raise_for_status()
237
+ return response.json()
238
+ except Exception as e:
239
+ # If user info fetch fails, return minimal info
240
+ console.print(f"[yellow]Warning: Could not fetch user info: {e}[/yellow]")
241
+ return {"id": "unknown", "email": "unknown"}
242
+
243
+ def refresh_token(self) -> None:
244
+ """
245
+ Refresh access token using refresh token.
246
+
247
+ Raises:
248
+ AuthenticationError: If refresh fails
249
+ """
250
+ config = self.config_manager.get()
251
+
252
+ if not config.auth.refresh_token:
253
+ raise AuthenticationError("No refresh token available. Please log in again.")
254
+
255
+ token_url = f"{self.workos_api_url}/user_management/authenticate"
256
+
257
+ data = {
258
+ "client_id": self.client_id,
259
+ "grant_type": "refresh_token",
260
+ "refresh_token": config.auth.refresh_token,
261
+ }
262
+
263
+ try:
264
+ response = httpx.post(token_url, data=data, timeout=30)
265
+ response.raise_for_status()
266
+ tokens = response.json()
267
+
268
+ # Update tokens
269
+ self._save_tokens(tokens)
270
+ print_info("Access token refreshed")
271
+
272
+ except httpx.HTTPStatusError as e:
273
+ error_detail = e.response.text
274
+ raise AuthenticationError(f"Token refresh failed: {error_detail}")
275
+ except Exception as e:
276
+ raise AuthenticationError(f"Token refresh failed: {e}")