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 ADDED
@@ -0,0 +1,80 @@
1
+ """tokentoss - OAuth authentication from Jupyter notebooks for IAP-protected GCP services.
2
+
3
+ Example usage:
4
+ from tokentoss import GoogleAuthWidget, IAPClient
5
+
6
+ # Create and display authentication widget
7
+ widget = GoogleAuthWidget(client_secrets_path="./client_secrets.json")
8
+ display(widget)
9
+
10
+ # After authentication, create client
11
+ client = IAPClient(base_url="https://my-iap-service.run.app")
12
+ data = client.get_json("/api/data")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ from ._logging import disable_debug, enable_debug
20
+ from .auth_manager import DEFAULT_SCOPES, AuthManager, ClientConfig, generate_pkce_pair
21
+ from .exceptions import (
22
+ InsecureFilePermissionsWarning,
23
+ NoCredentialsError,
24
+ StorageError,
25
+ TokenExchangeError,
26
+ TokenRefreshError,
27
+ TokenTossError,
28
+ )
29
+ from .setup import configure, configure_from_credentials, configure_from_file, get_config_path
30
+ from .storage import FileStorage, MemoryStorage, TokenData
31
+
32
+ if TYPE_CHECKING:
33
+ from google.oauth2.credentials import Credentials
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ # Module-level credentials set by AuthManager on successful authentication.
38
+ # Used by IAPClient for automatic credential discovery.
39
+ CREDENTIALS: Credentials | None = None
40
+
41
+ __all__ = [
42
+ "CREDENTIALS",
43
+ "DEFAULT_SCOPES",
44
+ "AuthManager",
45
+ "ClientConfig",
46
+ "FileStorage",
47
+ "InsecureFilePermissionsWarning",
48
+ "MemoryStorage",
49
+ "NoCredentialsError",
50
+ "StorageError",
51
+ "TokenData",
52
+ "TokenExchangeError",
53
+ "TokenRefreshError",
54
+ "TokenTossError",
55
+ "__version__",
56
+ "configure",
57
+ "configure_from_credentials",
58
+ "configure_from_file",
59
+ "disable_debug",
60
+ "enable_debug",
61
+ "generate_pkce_pair",
62
+ "get_config_path",
63
+ ]
64
+
65
+
66
+ def __getattr__(name: str):
67
+ """Lazy import for optional components."""
68
+ if name == "GoogleAuthWidget":
69
+ from .widget import GoogleAuthWidget
70
+
71
+ return GoogleAuthWidget
72
+ if name == "IAPClient":
73
+ from .client import IAPClient
74
+
75
+ return IAPClient
76
+ if name == "ConfigureWidget":
77
+ from .configure_widget import ConfigureWidget
78
+
79
+ return ConfigureWidget
80
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
tokentoss/_logging.py ADDED
@@ -0,0 +1,42 @@
1
+ """Logging configuration for tokentoss."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ # Package-level logger
7
+ _package_logger = logging.getLogger("tokentoss")
8
+ _package_logger.addHandler(logging.NullHandler()) # Library convention
9
+
10
+ # Sentinel for our handler
11
+ _HANDLER_NAME = "_tokentoss_stream"
12
+
13
+
14
+ def enable_debug(level: int = logging.DEBUG) -> None:
15
+ """Enable debug logging for tokentoss.
16
+
17
+ Jupyter-friendly: outputs to stdout (not stderr) to avoid
18
+ red-colored output in notebook cells.
19
+
20
+ Can be called multiple times safely (won't duplicate handlers).
21
+ """
22
+ _package_logger.setLevel(level)
23
+
24
+ # Don't add duplicate handlers
25
+ for h in _package_logger.handlers:
26
+ if getattr(h, "name", None) == _HANDLER_NAME:
27
+ h.setLevel(level)
28
+ return
29
+
30
+ handler = logging.StreamHandler(sys.stdout)
31
+ handler.name = _HANDLER_NAME
32
+ handler.setLevel(level)
33
+ handler.setFormatter(logging.Formatter("[%(name)s %(levelname)s] %(message)s"))
34
+ _package_logger.addHandler(handler)
35
+
36
+
37
+ def disable_debug() -> None:
38
+ """Disable debug logging and remove the tokentoss handler."""
39
+ _package_logger.setLevel(logging.WARNING)
40
+ _package_logger.handlers = [
41
+ h for h in _package_logger.handlers if getattr(h, "name", None) != _HANDLER_NAME
42
+ ]
@@ -0,0 +1,13 @@
1
+ """Telemetry stubs for future instrumentation.
2
+
3
+ All functions are no-ops. When a telemetry backend is added (e.g.
4
+ OpenTelemetry), these will be wired up without changing call sites.
5
+ """
6
+
7
+
8
+ def trace_event(name: str, **attributes) -> None:
9
+ """Record a trace event (no-op)."""
10
+
11
+
12
+ def increment_counter(name: str, value: int = 1, **tags) -> None:
13
+ """Increment a metric counter (no-op)."""
@@ -0,0 +1,492 @@
1
+ """Authentication manager for OAuth token handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import secrets
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ import requests
15
+ from google.oauth2.credentials import Credentials
16
+
17
+ from .exceptions import TokenExchangeError, TokenRefreshError
18
+ from .storage import FileStorage, MemoryStorage, TokenData
19
+
20
+ if TYPE_CHECKING:
21
+ from .storage import FileStorage, MemoryStorage
22
+
23
+
24
+ # Google OAuth endpoints
25
+ GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
26
+ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
27
+
28
+ # Default scopes for IAP access
29
+ DEFAULT_SCOPES = [
30
+ "openid",
31
+ "email",
32
+ "profile",
33
+ ]
34
+
35
+ # Default max session lifetime in hours
36
+ DEFAULT_MAX_SESSION_LIFETIME_HOURS = 24
37
+
38
+
39
+ @dataclass
40
+ class ClientConfig:
41
+ """OAuth client configuration loaded from client_secrets.json."""
42
+
43
+ client_id: str
44
+ client_secret: str
45
+ auth_uri: str = GOOGLE_AUTH_URI
46
+ token_uri: str = GOOGLE_TOKEN_URI
47
+ redirect_uris: list[str] | None = None
48
+
49
+ @classmethod
50
+ def from_file(cls, path: str | Path) -> ClientConfig:
51
+ """Load client config from client_secrets.json file.
52
+
53
+ Args:
54
+ path: Path to the client_secrets.json file.
55
+
56
+ Returns:
57
+ ClientConfig instance.
58
+
59
+ Raises:
60
+ FileNotFoundError: If file doesn't exist.
61
+ ValueError: If file format is invalid.
62
+ """
63
+ path = Path(path)
64
+ if not path.exists():
65
+ raise FileNotFoundError(f"Client secrets file not found: {path}")
66
+
67
+ with open(path) as f:
68
+ data = json.load(f)
69
+
70
+ # Handle both "installed" (desktop app) and "web" formats
71
+ if "installed" in data:
72
+ config = data["installed"]
73
+ elif "web" in data:
74
+ config = data["web"]
75
+ else:
76
+ raise ValueError(
77
+ "Invalid client_secrets.json format. Expected 'installed' or 'web' key."
78
+ )
79
+
80
+ return cls(
81
+ client_id=config["client_id"],
82
+ client_secret=config["client_secret"],
83
+ auth_uri=config.get("auth_uri", GOOGLE_AUTH_URI),
84
+ token_uri=config.get("token_uri", GOOGLE_TOKEN_URI),
85
+ redirect_uris=config.get("redirect_uris"),
86
+ )
87
+
88
+
89
+ def generate_pkce_pair() -> tuple[str, str]:
90
+ """Generate PKCE code verifier and challenge.
91
+
92
+ Returns:
93
+ Tuple of (code_verifier, code_challenge).
94
+ """
95
+ # Generate random 32-byte code verifier
96
+ code_verifier = secrets.token_urlsafe(32)
97
+
98
+ # Create SHA256 hash of verifier
99
+ digest = hashlib.sha256(code_verifier.encode()).digest()
100
+
101
+ # Base64url encode the hash (no padding)
102
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
103
+
104
+ return code_verifier, code_challenge
105
+
106
+
107
+ class AuthManager:
108
+ """Manages OAuth authentication, token exchange, and refresh."""
109
+
110
+ def __init__(
111
+ self,
112
+ client_config: ClientConfig | None = None,
113
+ client_secrets_path: str | Path | None = None,
114
+ storage: FileStorage | MemoryStorage | None = None,
115
+ scopes: list[str] | None = None,
116
+ max_session_lifetime_hours: int = DEFAULT_MAX_SESSION_LIFETIME_HOURS,
117
+ ) -> None:
118
+ """Initialize AuthManager.
119
+
120
+ Args:
121
+ client_config: Pre-loaded client configuration.
122
+ client_secrets_path: Path to client_secrets.json (alternative to client_config).
123
+ storage: Token storage backend. Defaults to FileStorage.
124
+ scopes: OAuth scopes. Defaults to DEFAULT_SCOPES.
125
+ max_session_lifetime_hours: Maximum session lifetime in hours before
126
+ requiring re-authentication. Defaults to 24.
127
+
128
+ Raises:
129
+ ValueError: If neither client_config nor client_secrets_path provided.
130
+ """
131
+ # Load client config
132
+ if client_config is not None:
133
+ self.client_config = client_config
134
+ elif client_secrets_path is not None:
135
+ self.client_config = ClientConfig.from_file(client_secrets_path)
136
+ else:
137
+ # Auto-discover from standard platformdirs location
138
+ from .setup import get_config_path
139
+
140
+ default_path = get_config_path()
141
+ if default_path.exists():
142
+ self.client_config = ClientConfig.from_file(default_path)
143
+ else:
144
+ raise ValueError(
145
+ "No client config provided. Either pass client_config or "
146
+ "client_secrets_path, or run tokentoss.configure() to install "
147
+ f"credentials at {default_path}"
148
+ )
149
+
150
+ # Set up storage
151
+ self.storage = storage if storage is not None else FileStorage()
152
+
153
+ # Set scopes
154
+ self.scopes = scopes if scopes is not None else DEFAULT_SCOPES.copy()
155
+
156
+ # Session lifetime
157
+ self.max_session_lifetime_hours = max_session_lifetime_hours
158
+
159
+ # State
160
+ self._credentials: Credentials | None = None
161
+ self._token_data: TokenData | None = None
162
+ self.last_error: Exception | None = None
163
+
164
+ # Try to load existing tokens
165
+ self._load_from_storage()
166
+
167
+ def _is_session_stale(self, token_data: TokenData) -> bool:
168
+ """Check if the session has exceeded the max lifetime.
169
+
170
+ Returns False if created_at is None (backward compat with old tokens).
171
+ """
172
+ if token_data.created_at is None:
173
+ return False
174
+ created = token_data.created_at_datetime
175
+ if created is None:
176
+ return False
177
+ age = datetime.now(timezone.utc) - created
178
+ return age > timedelta(hours=self.max_session_lifetime_hours)
179
+
180
+ def _load_from_storage(self) -> None:
181
+ """Load tokens from storage if available."""
182
+ try:
183
+ token_data = self.storage.load()
184
+ if token_data is not None:
185
+ # Check if session is stale (max lifetime exceeded)
186
+ if self._is_session_stale(token_data):
187
+ self.storage.clear()
188
+ self.last_error = Exception("Session expired — sign in again")
189
+ return
190
+
191
+ # Check if token is expired and try to refresh
192
+ if token_data.is_expired:
193
+ try:
194
+ self._token_data = token_data
195
+ self._credentials = self._create_credentials(token_data)
196
+ self.refresh_tokens()
197
+ self._set_module_credentials()
198
+ except TokenRefreshError:
199
+ self._credentials = None
200
+ self._token_data = None
201
+ self.storage.clear()
202
+ self.last_error = Exception("Session expired — sign in again")
203
+ return
204
+
205
+ self._token_data = token_data
206
+ self._credentials = self._create_credentials(token_data)
207
+
208
+ # Update module-level variable
209
+ self._set_module_credentials()
210
+
211
+ except Exception as e:
212
+ self.last_error = e
213
+ # Don't raise - just means we need to authenticate
214
+
215
+ def _create_credentials(self, token_data: TokenData) -> Credentials:
216
+ """Create google.oauth2.credentials.Credentials from TokenData."""
217
+ return Credentials(
218
+ token=token_data.access_token,
219
+ refresh_token=token_data.refresh_token,
220
+ id_token=token_data.id_token,
221
+ token_uri=self.client_config.token_uri,
222
+ client_id=self.client_config.client_id,
223
+ client_secret=self.client_config.client_secret,
224
+ scopes=token_data.scopes,
225
+ )
226
+
227
+ def _set_module_credentials(self) -> None:
228
+ """Set the module-level CREDENTIALS variable."""
229
+ import tokentoss
230
+
231
+ tokentoss.CREDENTIALS = self._credentials
232
+
233
+ @property
234
+ def credentials(self) -> Credentials | None:
235
+ """Get current credentials, refreshing if needed."""
236
+ if self._credentials is None:
237
+ return None
238
+
239
+ # Check if expired and refresh if needed
240
+ if self._token_data and self._token_data.is_expired:
241
+ try:
242
+ self.refresh_tokens()
243
+ except TokenRefreshError:
244
+ # Refresh failed, credentials are stale
245
+ pass
246
+
247
+ return self._credentials
248
+
249
+ @property
250
+ def token_data(self) -> TokenData | None:
251
+ """Get current token data."""
252
+ return self._token_data
253
+
254
+ @property
255
+ def user_email(self) -> str | None:
256
+ """Get authenticated user's email."""
257
+ if self._token_data:
258
+ return self._token_data.user_email
259
+ return None
260
+
261
+ @property
262
+ def is_authenticated(self) -> bool:
263
+ """Check if we have valid credentials."""
264
+ return self._credentials is not None
265
+
266
+ @property
267
+ def id_token(self) -> str | None:
268
+ """Get the current ID token for IAP authentication."""
269
+ if self._token_data:
270
+ return self._token_data.id_token
271
+ return None
272
+
273
+ def get_authorization_url(
274
+ self,
275
+ code_challenge: str,
276
+ redirect_uri: str = "http://localhost",
277
+ state: str | None = None,
278
+ ) -> str:
279
+ """Generate OAuth authorization URL.
280
+
281
+ Args:
282
+ code_challenge: PKCE code challenge.
283
+ redirect_uri: OAuth redirect URI.
284
+ state: Optional state parameter for CSRF protection.
285
+
286
+ Returns:
287
+ Authorization URL to open in browser.
288
+ """
289
+ params = {
290
+ "client_id": self.client_config.client_id,
291
+ "redirect_uri": redirect_uri,
292
+ "response_type": "code",
293
+ "scope": " ".join(self.scopes),
294
+ "access_type": "offline", # Get refresh token
295
+ "prompt": "consent", # Force consent to ensure refresh token
296
+ "code_challenge": code_challenge,
297
+ "code_challenge_method": "S256",
298
+ }
299
+
300
+ if state:
301
+ params["state"] = state
302
+
303
+ query = "&".join(f"{k}={requests.utils.quote(str(v))}" for k, v in params.items())
304
+ return f"{self.client_config.auth_uri}?{query}"
305
+
306
+ def exchange_code(
307
+ self,
308
+ auth_code: str,
309
+ code_verifier: str,
310
+ redirect_uri: str = "http://localhost",
311
+ ) -> TokenData:
312
+ """Exchange authorization code for tokens.
313
+
314
+ Args:
315
+ auth_code: Authorization code from OAuth callback.
316
+ code_verifier: PKCE code verifier.
317
+ redirect_uri: Redirect URI used in authorization request.
318
+
319
+ Returns:
320
+ TokenData with all tokens.
321
+
322
+ Raises:
323
+ TokenExchangeError: If exchange fails.
324
+ """
325
+ try:
326
+ response = requests.post(
327
+ self.client_config.token_uri,
328
+ data={
329
+ "client_id": self.client_config.client_id,
330
+ "client_secret": self.client_config.client_secret,
331
+ "code": auth_code,
332
+ "code_verifier": code_verifier,
333
+ "grant_type": "authorization_code",
334
+ "redirect_uri": redirect_uri,
335
+ },
336
+ timeout=30,
337
+ )
338
+
339
+ if response.status_code != 200:
340
+ error_data = response.json() if response.content else {}
341
+ error_msg = error_data.get(
342
+ "error_description", error_data.get("error", "Unknown error")
343
+ )
344
+ raise TokenExchangeError(f"Token exchange failed: {error_msg}")
345
+
346
+ data = response.json()
347
+
348
+ # Calculate expiry time
349
+ expires_in = data.get("expires_in", 3600)
350
+ expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
351
+ expiry = expiry.replace(microsecond=0)
352
+
353
+ # Extract user email from ID token if present
354
+ user_email = self._extract_email_from_id_token(data.get("id_token"))
355
+
356
+ # Create token data
357
+ token_data = TokenData(
358
+ access_token=data["access_token"],
359
+ id_token=data.get("id_token", ""),
360
+ refresh_token=data.get("refresh_token", ""),
361
+ expiry=expiry.isoformat(),
362
+ scopes=data.get("scope", " ".join(self.scopes)).split(),
363
+ user_email=user_email,
364
+ created_at=datetime.now(timezone.utc).isoformat(),
365
+ )
366
+
367
+ # Save tokens
368
+ self._token_data = token_data
369
+ self._credentials = self._create_credentials(token_data)
370
+ self.storage.save(token_data)
371
+ self._set_module_credentials()
372
+
373
+ self.last_error = None
374
+ return token_data
375
+
376
+ except requests.RequestException as e:
377
+ self.last_error = e
378
+ raise TokenExchangeError(f"Network error during token exchange: {e}") from e
379
+ except Exception as e:
380
+ self.last_error = e
381
+ if isinstance(e, TokenExchangeError):
382
+ raise
383
+ raise TokenExchangeError(f"Token exchange failed: {e}") from e
384
+
385
+ def refresh_tokens(self) -> TokenData:
386
+ """Refresh access and ID tokens using refresh token.
387
+
388
+ Returns:
389
+ Updated TokenData.
390
+
391
+ Raises:
392
+ TokenRefreshError: If refresh fails or no refresh token available.
393
+ """
394
+ if not self._token_data or not self._token_data.refresh_token:
395
+ raise TokenRefreshError("No refresh token available")
396
+
397
+ try:
398
+ response = requests.post(
399
+ self.client_config.token_uri,
400
+ data={
401
+ "client_id": self.client_config.client_id,
402
+ "client_secret": self.client_config.client_secret,
403
+ "refresh_token": self._token_data.refresh_token,
404
+ "grant_type": "refresh_token",
405
+ },
406
+ timeout=30,
407
+ )
408
+
409
+ if response.status_code != 200:
410
+ error_data = response.json() if response.content else {}
411
+ error_msg = error_data.get(
412
+ "error_description", error_data.get("error", "Unknown error")
413
+ )
414
+ raise TokenRefreshError(f"Token refresh failed: {error_msg}")
415
+
416
+ data = response.json()
417
+
418
+ # Calculate new expiry
419
+ expires_in = data.get("expires_in", 3600)
420
+ expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
421
+ expiry = expiry.replace(microsecond=0)
422
+
423
+ # Extract user email from new ID token
424
+ user_email = self._extract_email_from_id_token(data.get("id_token"))
425
+ if not user_email:
426
+ user_email = self._token_data.user_email
427
+
428
+ # Update token data (refresh token may or may not be returned)
429
+ self._token_data = TokenData(
430
+ access_token=data["access_token"],
431
+ id_token=data.get("id_token", self._token_data.id_token),
432
+ refresh_token=data.get("refresh_token", self._token_data.refresh_token),
433
+ expiry=expiry.isoformat(),
434
+ scopes=data.get("scope", " ".join(self._token_data.scopes)).split()
435
+ if isinstance(self._token_data.scopes, list)
436
+ else self._token_data.scopes,
437
+ user_email=user_email,
438
+ created_at=self._token_data.created_at,
439
+ )
440
+
441
+ # Update credentials and save
442
+ self._credentials = self._create_credentials(self._token_data)
443
+ self.storage.save(self._token_data)
444
+ self._set_module_credentials()
445
+
446
+ self.last_error = None
447
+ return self._token_data
448
+
449
+ except requests.RequestException as e:
450
+ self.last_error = e
451
+ raise TokenRefreshError(f"Network error during token refresh: {e}") from e
452
+ except Exception as e:
453
+ self.last_error = e
454
+ if isinstance(e, TokenRefreshError):
455
+ raise
456
+ raise TokenRefreshError(f"Token refresh failed: {e}") from e
457
+
458
+ def _extract_email_from_id_token(self, id_token: str | None) -> str | None:
459
+ """Extract email from ID token payload without validation.
460
+
461
+ Note: This is for display purposes only. IAP validates the token.
462
+ """
463
+ if not id_token:
464
+ return None
465
+
466
+ try:
467
+ # ID token is JWT: header.payload.signature
468
+ parts = id_token.split(".")
469
+ if len(parts) != 3:
470
+ return None
471
+
472
+ # Decode payload (add padding if needed)
473
+ payload = parts[1]
474
+ payload += "=" * (4 - len(payload) % 4)
475
+ decoded = base64.urlsafe_b64decode(payload)
476
+ claims = json.loads(decoded)
477
+
478
+ return claims.get("email")
479
+
480
+ except Exception:
481
+ return None
482
+
483
+ def clear(self) -> None:
484
+ """Clear all stored credentials."""
485
+ self._credentials = None
486
+ self._token_data = None
487
+ self.storage.clear()
488
+
489
+ # Clear module-level variable
490
+ import tokentoss
491
+
492
+ tokentoss.CREDENTIALS = None