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/__init__.py +80 -0
- tokentoss/_logging.py +42 -0
- tokentoss/_telemetry.py +13 -0
- tokentoss/auth_manager.py +492 -0
- tokentoss/client.py +250 -0
- tokentoss/configure_widget.py +253 -0
- tokentoss/exceptions.py +56 -0
- tokentoss/setup.py +197 -0
- tokentoss/storage.py +195 -0
- tokentoss/widget.py +786 -0
- tokentoss-0.1.0.dist-info/METADATA +147 -0
- tokentoss-0.1.0.dist-info/RECORD +14 -0
- tokentoss-0.1.0.dist-info/WHEEL +4 -0
- tokentoss-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|