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.
- janet/__init__.py +3 -0
- janet/__main__.py +6 -0
- janet/api/__init__.py +0 -0
- janet/api/client.py +128 -0
- janet/api/models.py +92 -0
- janet/api/organizations.py +57 -0
- janet/api/projects.py +57 -0
- janet/api/tickets.py +125 -0
- janet/auth/__init__.py +0 -0
- janet/auth/callback_server.py +360 -0
- janet/auth/oauth_flow.py +276 -0
- janet/auth/token_manager.py +92 -0
- janet/cli.py +602 -0
- janet/config/__init__.py +0 -0
- janet/config/manager.py +116 -0
- janet/config/models.py +66 -0
- janet/markdown/__init__.py +0 -0
- janet/markdown/generator.py +272 -0
- janet/markdown/yjs_converter.py +225 -0
- janet/sync/__init__.py +0 -0
- janet/sync/file_manager.py +199 -0
- janet/sync/readme_generator.py +174 -0
- janet/sync/sync_engine.py +271 -0
- janet/utils/__init__.py +0 -0
- janet/utils/console.py +39 -0
- janet/utils/errors.py +49 -0
- janet/utils/paths.py +66 -0
- janet_cli-0.2.2.dist-info/METADATA +220 -0
- janet_cli-0.2.2.dist-info/RECORD +33 -0
- janet_cli-0.2.2.dist-info/WHEEL +5 -0
- janet_cli-0.2.2.dist-info/entry_points.txt +2 -0
- janet_cli-0.2.2.dist-info/licenses/LICENSE +21 -0
- janet_cli-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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"
|
janet/auth/oauth_flow.py
ADDED
|
@@ -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}")
|