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/__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
|
+
]
|
tokentoss/_telemetry.py
ADDED
|
@@ -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
|