tokentoss 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.
tokentoss/widget.py ADDED
@@ -0,0 +1,786 @@
1
+ """Google OAuth authentication widget for Jupyter notebooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import secrets
7
+ import socket
8
+ import threading
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from typing import TYPE_CHECKING
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ import anywidget
14
+ import traitlets
15
+
16
+ from .auth_manager import AuthManager, ClientConfig, generate_pkce_pair
17
+ from .exceptions import TokenExchangeError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ if TYPE_CHECKING:
22
+ from .storage import FileStorage, MemoryStorage
23
+
24
+
25
+ # HTML page served by the callback server after successful auth
26
+ CALLBACK_SUCCESS_HTML = """<!DOCTYPE html>
27
+ <html>
28
+ <head>
29
+ <title>Authentication Complete</title>
30
+ <style>
31
+ body {
32
+ font-family: system-ui, -apple-system, sans-serif;
33
+ display: flex;
34
+ justify-content: center;
35
+ align-items: center;
36
+ min-height: 100vh;
37
+ margin: 0;
38
+ background: #f8f9fa;
39
+ }
40
+ .container {
41
+ text-align: center;
42
+ padding: 40px;
43
+ background: white;
44
+ border-radius: 8px;
45
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
46
+ }
47
+ .success { color: #059669; }
48
+ h1 { margin: 0 0 16px; font-size: 24px; }
49
+ p { color: #6b7280; margin: 0; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div class="container">
54
+ <h1 class="success">Authentication Successful</h1>
55
+ <p>You can close this window.</p>
56
+ </div>
57
+ <script>
58
+ // Close the window after a short delay
59
+ setTimeout(function() { window.close(); }, 1500);
60
+ </script>
61
+ </body>
62
+ </html>"""
63
+
64
+
65
+ class _CallbackHandler(BaseHTTPRequestHandler):
66
+ """HTTP request handler for OAuth callback."""
67
+
68
+ def log_message(self, format, *args):
69
+ """Suppress logging."""
70
+ pass
71
+
72
+ def do_GET(self):
73
+ """Handle GET request from OAuth callback."""
74
+ parsed = urlparse(self.path)
75
+ params = parse_qs(parsed.query)
76
+
77
+ # Extract auth code and state
78
+ auth_code = params.get("code", [None])[0]
79
+ state = params.get("state", [None])[0]
80
+ error = params.get("error", [None])[0]
81
+
82
+ # Ignore non-callback requests (e.g. /favicon.ico) that would
83
+ # overwrite the real auth code with None.
84
+ is_callback = auth_code is not None or error is not None
85
+ logger.debug(
86
+ "do_GET %s: code=%s, state=%s, error=%s, is_callback=%s",
87
+ self.path,
88
+ bool(auth_code),
89
+ bool(state),
90
+ error,
91
+ is_callback,
92
+ )
93
+
94
+ if is_callback:
95
+ # Store in server instance
96
+ self.server.auth_code = auth_code
97
+ self.server.state = state
98
+ self.server.error = error
99
+ self.server.callback_received = True
100
+
101
+ # Send response
102
+ self.send_response(200)
103
+ self.send_header("Content-type", "text/html")
104
+ self.end_headers()
105
+
106
+ if error:
107
+ error_html = f"""<!DOCTYPE html>
108
+ <html><head><title>Authentication Error</title></head>
109
+ <body style="font-family: sans-serif; text-align: center; padding: 40px;">
110
+ <h1 style="color: #dc2626;">Authentication Failed</h1>
111
+ <p>Error: {error}</p>
112
+ </body></html>"""
113
+ self.wfile.write(error_html.encode())
114
+ else:
115
+ self.wfile.write(CALLBACK_SUCCESS_HTML.encode())
116
+
117
+
118
+ class CallbackServer:
119
+ """Temporary HTTP server to capture OAuth callback.
120
+
121
+ Starts a local HTTP server on a random available port to receive
122
+ the OAuth authorization code callback.
123
+ """
124
+
125
+ def __init__(self):
126
+ self.port: int | None = None
127
+ self.auth_code: str | None = None
128
+ self.state: str | None = None
129
+ self.error: str | None = None
130
+ self.callback_received: bool = False
131
+ self._server: HTTPServer | None = None
132
+ self._thread: threading.Thread | None = None
133
+
134
+ def start(self) -> bool:
135
+ """Start the callback server on a random available port.
136
+
137
+ Returns:
138
+ True if server started successfully, False otherwise.
139
+ """
140
+ try:
141
+ # Find an available port
142
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
143
+ s.bind(("127.0.0.1", 0))
144
+ self.port = s.getsockname()[1]
145
+
146
+ # Create server
147
+ self._server = HTTPServer(("127.0.0.1", self.port), _CallbackHandler)
148
+ self._server.auth_code = None
149
+ self._server.state = None
150
+ self._server.error = None
151
+ self._server.callback_received = False
152
+
153
+ # Start in background thread
154
+ self._thread = threading.Thread(target=self._serve, daemon=True)
155
+ self._thread.start()
156
+
157
+ logger.debug("Callback server started on port %s", self.port)
158
+ return True
159
+
160
+ except Exception:
161
+ logger.warning("Failed to start callback server", exc_info=True)
162
+ self.port = None
163
+ return False
164
+
165
+ def _serve(self):
166
+ """Serve requests using serve_forever (stoppable via shutdown)."""
167
+ if self._server:
168
+ self._server.serve_forever(poll_interval=0.5)
169
+
170
+ def stop(self):
171
+ """Stop the callback server."""
172
+ server = self._server
173
+ if server:
174
+ logger.debug("Stopping callback server on port %s", self.port)
175
+ # Copy results from server
176
+ self.auth_code = server.auth_code
177
+ self.state = server.state
178
+ self.error = server.error
179
+ self.callback_received = server.callback_received
180
+
181
+ # shutdown() signals serve_forever() to exit
182
+ server.shutdown()
183
+ self._server = None
184
+
185
+ if self._thread:
186
+ self._thread.join(timeout=2)
187
+ self._thread = None
188
+
189
+ def check_callback(self) -> bool:
190
+ """Check if callback has been received.
191
+
192
+ Returns:
193
+ True if callback received, False otherwise.
194
+ """
195
+ if self._server:
196
+ self.auth_code = self._server.auth_code
197
+ self.state = self._server.state
198
+ self.error = self._server.error
199
+ self.callback_received = self._server.callback_received
200
+ return self.callback_received
201
+
202
+ @property
203
+ def redirect_uri(self) -> str:
204
+ """Get the redirect URI for this server."""
205
+ if self.port:
206
+ return f"http://127.0.0.1:{self.port}"
207
+ return "http://localhost"
208
+
209
+ def reset(self):
210
+ """Reset the server state for a new auth flow."""
211
+ self.auth_code = None
212
+ self.state = None
213
+ self.error = None
214
+ self.callback_received = False
215
+ if self._server:
216
+ self._server.auth_code = None
217
+ self._server.state = None
218
+ self._server.error = None
219
+ self._server.callback_received = False
220
+
221
+
222
+ # JavaScript ESM for the widget
223
+ _ESM = """
224
+ function render({ model, el }) {
225
+ // Create widget container
226
+ const container = document.createElement('div');
227
+ container.className = 'tokentoss-widget';
228
+
229
+ // Status display
230
+ const statusEl = document.createElement('div');
231
+ statusEl.className = 'tokentoss-status';
232
+
233
+ // Sign-in button
234
+ const button = document.createElement('button');
235
+ button.className = 'tokentoss-button';
236
+ button.innerHTML = `
237
+ <svg viewBox="0 0 24 24" width="18" height="18" style="margin-right: 8px;">
238
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
239
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
240
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
241
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
242
+ </svg>
243
+ Sign in with Google`;
244
+
245
+ // Manual input section (hidden by default)
246
+ const manualSection = document.createElement('div');
247
+ manualSection.className = 'tokentoss-manual';
248
+ manualSection.innerHTML = `
249
+ <p>After signing in, copy the URL from the popup's address bar and paste it here:</p>
250
+ <input type="text" class="tokentoss-manual-input" placeholder="http://localhost?code=...">
251
+ <button class="tokentoss-manual-submit">Submit</button>
252
+ `;
253
+
254
+ // Sign-out button
255
+ const signOutButton = document.createElement('button');
256
+ signOutButton.className = 'tokentoss-signout';
257
+ signOutButton.textContent = 'Sign out';
258
+
259
+ // Error display
260
+ const errorEl = document.createElement('div');
261
+ errorEl.className = 'tokentoss-error';
262
+
263
+ // Assemble DOM
264
+ container.appendChild(statusEl);
265
+ container.appendChild(button);
266
+ container.appendChild(manualSection);
267
+ container.appendChild(signOutButton);
268
+ container.appendChild(errorEl);
269
+ el.appendChild(container);
270
+
271
+ // State
272
+ let popup = null;
273
+ let pollInterval = null;
274
+
275
+ // Update UI based on model state
276
+ function updateUI() {
277
+ const isAuthenticated = model.get('is_authenticated');
278
+ const status = model.get('status');
279
+ const error = model.get('error');
280
+ const showManual = model.get('show_manual_input');
281
+
282
+ statusEl.textContent = status;
283
+ errorEl.textContent = error;
284
+ errorEl.style.display = error ? 'block' : 'none';
285
+
286
+ if (isAuthenticated) {
287
+ button.style.display = 'none';
288
+ manualSection.style.display = 'none';
289
+ signOutButton.style.display = 'inline-block';
290
+ } else {
291
+ button.style.display = 'inline-flex';
292
+ signOutButton.style.display = 'none';
293
+ manualSection.style.display = showManual ? 'block' : 'none';
294
+ }
295
+ }
296
+
297
+ // Handle sign-in button click
298
+ button.addEventListener('click', () => {
299
+ model.send({ type: 'prepare_auth' });
300
+ });
301
+
302
+ // Handle sign-out click
303
+ signOutButton.addEventListener('click', () => {
304
+ model.send({ type: 'sign_out' });
305
+ });
306
+
307
+ // Handle manual URL submission
308
+ const manualInput = manualSection.querySelector('.tokentoss-manual-input');
309
+ const manualSubmit = manualSection.querySelector('.tokentoss-manual-submit');
310
+
311
+ manualSubmit.addEventListener('click', () => {
312
+ const input = manualInput.value.trim();
313
+ if (!input) return;
314
+
315
+ try {
316
+ const url = new URL(input);
317
+ const code = url.searchParams.get('code');
318
+ const state = url.searchParams.get('state');
319
+
320
+ if (code) {
321
+ model.set('received_state', state || '');
322
+ model.set('auth_code', code);
323
+ model.save_changes();
324
+ manualInput.value = '';
325
+ }
326
+ } catch (e) {
327
+ // Not a valid URL, treat as raw auth code
328
+ model.set('auth_code', input);
329
+ model.save_changes();
330
+ manualInput.value = '';
331
+ }
332
+ });
333
+
334
+ // Open popup when auth_url changes
335
+ function onAuthUrlChange() {
336
+ const authUrl = model.get('auth_url');
337
+ if (!authUrl) return;
338
+
339
+ // Open popup
340
+ const width = 500;
341
+ const height = 600;
342
+ const left = (screen.width - width) / 2;
343
+ const top = (screen.height - height) / 2;
344
+
345
+ popup = window.open(
346
+ authUrl,
347
+ 'tokentoss-oauth',
348
+ `width=${width},height=${height},left=${left},top=${top},popup=yes`
349
+ );
350
+
351
+ // Poll for popup close
352
+ startPolling();
353
+ }
354
+
355
+ function startPolling() {
356
+ stopPolling();
357
+ let closedCount = 0;
358
+ pollInterval = setInterval(() => {
359
+ if (popup && popup.closed) {
360
+ closedCount++;
361
+ if (closedCount >= 2) {
362
+ stopPolling();
363
+ popup = null;
364
+ model.send({ type: 'check_callback' });
365
+ }
366
+ } else {
367
+ closedCount = 0;
368
+ }
369
+ }, 500);
370
+ }
371
+
372
+ function stopPolling() {
373
+ if (pollInterval) {
374
+ clearInterval(pollInterval);
375
+ pollInterval = null;
376
+ }
377
+ }
378
+
379
+ // Model change observers
380
+ model.on('change:auth_url', onAuthUrlChange);
381
+ model.on('change:status', updateUI);
382
+ model.on('change:error', updateUI);
383
+ model.on('change:is_authenticated', updateUI);
384
+ model.on('change:show_manual_input', updateUI);
385
+
386
+ // Initial render
387
+ updateUI();
388
+
389
+ // Cleanup on destroy
390
+ return () => {
391
+ stopPolling();
392
+ if (popup && !popup.closed) {
393
+ popup.close();
394
+ }
395
+ };
396
+ }
397
+
398
+ export default { render };
399
+ """
400
+
401
+ # CSS styles for the widget
402
+ _CSS = """
403
+ .tokentoss-widget {
404
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
405
+ padding: 16px;
406
+ border: 1px solid #e5e7eb;
407
+ border-radius: 8px;
408
+ background: #ffffff;
409
+ max-width: 400px;
410
+ }
411
+
412
+ .tokentoss-status {
413
+ margin-bottom: 12px;
414
+ font-size: 14px;
415
+ color: #374151;
416
+ }
417
+
418
+ .tokentoss-button {
419
+ display: inline-flex;
420
+ align-items: center;
421
+ padding: 10px 20px;
422
+ font-size: 14px;
423
+ font-weight: 500;
424
+ color: #374151;
425
+ background: #ffffff;
426
+ border: 1px solid #dadce0;
427
+ border-radius: 4px;
428
+ cursor: pointer;
429
+ transition: background 0.2s, box-shadow 0.2s;
430
+ }
431
+
432
+ .tokentoss-button:hover {
433
+ background: #f8f9fa;
434
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
435
+ }
436
+
437
+ .tokentoss-button:active {
438
+ background: #f1f3f4;
439
+ }
440
+
441
+ .tokentoss-manual {
442
+ display: none;
443
+ margin-top: 16px;
444
+ padding-top: 16px;
445
+ border-top: 1px solid #e5e7eb;
446
+ }
447
+
448
+ .tokentoss-manual p {
449
+ margin: 0 0 8px;
450
+ font-size: 13px;
451
+ color: #6b7280;
452
+ }
453
+
454
+ .tokentoss-manual-input {
455
+ width: 100%;
456
+ padding: 8px 12px;
457
+ font-size: 13px;
458
+ border: 1px solid #d1d5db;
459
+ border-radius: 4px;
460
+ box-sizing: border-box;
461
+ }
462
+
463
+ .tokentoss-manual-input:focus {
464
+ outline: none;
465
+ border-color: #4285f4;
466
+ box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
467
+ }
468
+
469
+ .tokentoss-manual-submit {
470
+ margin-top: 8px;
471
+ padding: 8px 16px;
472
+ font-size: 13px;
473
+ font-weight: 500;
474
+ color: #ffffff;
475
+ background: #4285f4;
476
+ border: none;
477
+ border-radius: 4px;
478
+ cursor: pointer;
479
+ }
480
+
481
+ .tokentoss-manual-submit:hover {
482
+ background: #3574e2;
483
+ }
484
+
485
+ .tokentoss-signout {
486
+ display: none;
487
+ padding: 8px 16px;
488
+ font-size: 13px;
489
+ font-weight: 500;
490
+ color: #ffffff;
491
+ background: #dc2626;
492
+ border: 1px solid #dc2626;
493
+ border-radius: 4px;
494
+ cursor: pointer;
495
+ transition: background 0.2s;
496
+ }
497
+
498
+ .tokentoss-signout:hover {
499
+ background: #b91c1c;
500
+ border-color: #b91c1c;
501
+ }
502
+
503
+ .tokentoss-signout:active {
504
+ background: #991b1b;
505
+ border-color: #991b1b;
506
+ }
507
+
508
+ .tokentoss-error {
509
+ display: none;
510
+ margin-top: 12px;
511
+ padding: 10px 12px;
512
+ font-size: 13px;
513
+ color: #dc2626;
514
+ background: #fef2f2;
515
+ border: 1px solid #fecaca;
516
+ border-radius: 4px;
517
+ }
518
+ """
519
+
520
+
521
+ class GoogleAuthWidget(anywidget.AnyWidget):
522
+ """Interactive Google OAuth widget for Jupyter notebooks.
523
+
524
+ Displays a "Sign in with Google" button that initiates the OAuth flow
525
+ in a popup window. After authentication, tokens are automatically
526
+ exchanged and stored.
527
+
528
+ Example:
529
+ widget = GoogleAuthWidget(client_secrets_path="./client_secrets.json")
530
+ display(widget)
531
+ # Click button, complete OAuth
532
+ # widget.credentials now contains the tokens
533
+ """
534
+
535
+ # --- Synced Traitlets ---
536
+
537
+ # OAuth flow
538
+ auth_url = traitlets.Unicode("").tag(sync=True)
539
+ auth_code = traitlets.Unicode("").tag(sync=True)
540
+ received_state = traitlets.Unicode("").tag(sync=True)
541
+ state = traitlets.Unicode("").tag(sync=True)
542
+
543
+ # Status display
544
+ status = traitlets.Unicode("Click to sign in").tag(sync=True)
545
+ error = traitlets.Unicode("").tag(sync=True)
546
+ user_email = traitlets.Unicode("").tag(sync=True)
547
+ is_authenticated = traitlets.Bool(False).tag(sync=True)
548
+ show_manual_input = traitlets.Bool(False).tag(sync=True)
549
+
550
+ # --- JavaScript and CSS ---
551
+ _esm = _ESM
552
+ _css = _CSS
553
+
554
+ def __init__(
555
+ self,
556
+ client_secrets_path: str | None = None,
557
+ client_config: ClientConfig | None = None,
558
+ auth_manager: AuthManager | None = None,
559
+ storage: FileStorage | MemoryStorage | None = None,
560
+ scopes: list[str] | None = None,
561
+ max_session_lifetime_hours: int | None = None,
562
+ **kwargs,
563
+ ):
564
+ """Initialize the authentication widget.
565
+
566
+ Args:
567
+ client_secrets_path: Path to client_secrets.json file.
568
+ client_config: Pre-loaded ClientConfig (alternative to path).
569
+ auth_manager: Existing AuthManager instance (alternative to above).
570
+ storage: Token storage backend (default: FileStorage).
571
+ scopes: OAuth scopes (default: openid, email, profile).
572
+ max_session_lifetime_hours: Maximum session lifetime in hours.
573
+ Only used when creating a new AuthManager (ignored if auth_manager provided).
574
+ """
575
+ super().__init__(**kwargs)
576
+
577
+ # Set up AuthManager
578
+ if auth_manager is not None:
579
+ self._auth_manager = auth_manager
580
+ else:
581
+ am_kwargs: dict = {
582
+ "client_secrets_path": client_secrets_path,
583
+ "client_config": client_config,
584
+ "storage": storage,
585
+ "scopes": scopes,
586
+ }
587
+ if max_session_lifetime_hours is not None:
588
+ am_kwargs["max_session_lifetime_hours"] = max_session_lifetime_hours
589
+ self._auth_manager = AuthManager(**am_kwargs)
590
+
591
+ # PKCE state
592
+ self._code_verifier: str | None = None
593
+
594
+ # Callback server
595
+ self._callback_server: CallbackServer | None = None
596
+ self._server_available = False
597
+
598
+ # Try to start callback server
599
+ self._try_start_server()
600
+
601
+ # Check if already authenticated
602
+ if self._auth_manager.is_authenticated:
603
+ self._set_authenticated_state()
604
+ elif self._auth_manager.last_error is not None:
605
+ self.status = str(self._auth_manager.last_error)
606
+
607
+ # Set up observers
608
+ self.observe(self._on_auth_code_change, names=["auth_code"])
609
+
610
+ # Set up message handler
611
+ self.on_msg(self._handle_message)
612
+
613
+ def _try_start_server(self) -> None:
614
+ """Try to start the callback server."""
615
+ if self._callback_server:
616
+ self._callback_server.stop()
617
+ logger.debug("Stopped old callback server on port %s", self._callback_server.port)
618
+ self._callback_server = CallbackServer()
619
+ self._server_available = self._callback_server.start()
620
+ if self._server_available:
621
+ logger.debug("Started callback server on port %s", self._callback_server.port)
622
+ else:
623
+ logger.warning("Failed to start callback server")
624
+
625
+ @property
626
+ def auth_manager(self) -> AuthManager:
627
+ """Get the underlying AuthManager instance."""
628
+ return self._auth_manager
629
+
630
+ @property
631
+ def credentials(self):
632
+ """Get current credentials (convenience accessor)."""
633
+ return self._auth_manager.credentials
634
+
635
+ def prepare_auth(self) -> None:
636
+ """Prepare for OAuth flow by generating PKCE pair and auth URL.
637
+
638
+ Called automatically when user clicks the sign-in button.
639
+ """
640
+ # Reset server state if reusing
641
+ if self._callback_server:
642
+ self._callback_server.reset()
643
+
644
+ # Generate PKCE pair
645
+ self._code_verifier, code_challenge = generate_pkce_pair()
646
+
647
+ # Generate state for CSRF protection
648
+ self.state = secrets.token_urlsafe(16)
649
+
650
+ # Determine redirect URI
651
+ if self._server_available and self._callback_server:
652
+ redirect_uri = self._callback_server.redirect_uri
653
+ self.show_manual_input = False
654
+ else:
655
+ redirect_uri = "http://localhost"
656
+ self.show_manual_input = True
657
+
658
+ # Generate authorization URL
659
+ self.auth_url = self._auth_manager.get_authorization_url(
660
+ code_challenge=code_challenge,
661
+ redirect_uri=redirect_uri,
662
+ state=self.state,
663
+ )
664
+
665
+ logger.debug("prepare_auth: redirect_uri=%s, state=%s", redirect_uri, self.state)
666
+
667
+ self.status = "Waiting for authentication..."
668
+ self.error = ""
669
+
670
+ def _check_callback(self) -> None:
671
+ """Check if the callback server received the auth code."""
672
+ if not self._callback_server:
673
+ return
674
+
675
+ logger.debug(
676
+ "Checking callback: port=%s, received=%s",
677
+ self._callback_server.port,
678
+ self._callback_server.callback_received,
679
+ )
680
+
681
+ if self._callback_server.check_callback():
682
+ if self._callback_server.error:
683
+ self.error = f"Authentication error: {self._callback_server.error}"
684
+ self.status = "Authentication failed"
685
+ self._code_verifier = None
686
+ elif self._callback_server.auth_code:
687
+ # Validate state
688
+ if self._callback_server.state and self._callback_server.state != self.state:
689
+ self.error = "Invalid state - possible CSRF attack"
690
+ self.status = "Authentication failed"
691
+ self._code_verifier = None
692
+ return
693
+
694
+ # Exchange code
695
+ self._exchange_code(
696
+ self._callback_server.auth_code,
697
+ self._callback_server.redirect_uri,
698
+ )
699
+ else:
700
+ # No code received - user may have closed popup
701
+ self.status = "Click to sign in"
702
+ self._code_verifier = None
703
+
704
+ # Reset state but keep server alive on same port
705
+ self._callback_server.reset()
706
+ else:
707
+ # Callback not received - show manual input
708
+ self.show_manual_input = True
709
+ self.status = "Paste the redirect URL below"
710
+
711
+ def _on_auth_code_change(self, change) -> None:
712
+ """Handle auth_code traitlet change from manual input."""
713
+ auth_code = change["new"]
714
+ if not auth_code:
715
+ return
716
+
717
+ # Validate state (if provided)
718
+ if self.received_state and self.received_state != self.state:
719
+ self.error = "Invalid state - possible CSRF attack"
720
+ self.status = "Authentication failed"
721
+ self._code_verifier = None
722
+ self.auth_code = ""
723
+ return
724
+
725
+ # Determine redirect URI
726
+ if self._server_available and self._callback_server:
727
+ redirect_uri = self._callback_server.redirect_uri
728
+ else:
729
+ redirect_uri = "http://localhost"
730
+
731
+ self._exchange_code(auth_code, redirect_uri)
732
+ self.auth_code = "" # Clear for security
733
+
734
+ def _exchange_code(self, auth_code: str, redirect_uri: str) -> None:
735
+ """Exchange authorization code for tokens."""
736
+ if not self._code_verifier:
737
+ self.error = "No code verifier - please try signing in again"
738
+ self.status = "Authentication failed"
739
+ return
740
+
741
+ try:
742
+ self.status = "Exchanging authorization code..."
743
+ self._auth_manager.exchange_code(
744
+ auth_code=auth_code,
745
+ code_verifier=self._code_verifier,
746
+ redirect_uri=redirect_uri,
747
+ )
748
+ self._set_authenticated_state()
749
+
750
+ except TokenExchangeError as e:
751
+ self.error = str(e)
752
+ self.status = "Authentication failed"
753
+ self._code_verifier = None
754
+
755
+ def _set_authenticated_state(self) -> None:
756
+ """Update widget state after successful authentication."""
757
+ self.is_authenticated = True
758
+ self.user_email = self._auth_manager.user_email or ""
759
+ self.status = f"Signed in as {self.user_email}" if self.user_email else "Signed in"
760
+ self.error = ""
761
+ self.show_manual_input = False
762
+ self._code_verifier = None
763
+
764
+ def sign_out(self) -> None:
765
+ """Sign out and clear stored credentials."""
766
+ self._auth_manager.clear()
767
+ self.is_authenticated = False
768
+ self.user_email = ""
769
+ self.status = "Click to sign in"
770
+ self.error = ""
771
+ self.auth_code = ""
772
+ self.auth_url = ""
773
+ self.show_manual_input = False
774
+ self._code_verifier = None
775
+
776
+ def _handle_message(self, widget, content, buffers):
777
+ """Handle custom messages from JavaScript."""
778
+ msg_type = content.get("type")
779
+ logger.debug("Received message: %s", msg_type)
780
+
781
+ if msg_type == "prepare_auth":
782
+ self.prepare_auth()
783
+ elif msg_type == "sign_out":
784
+ self.sign_out()
785
+ elif msg_type == "check_callback":
786
+ self._check_callback()