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/client.py ADDED
@@ -0,0 +1,250 @@
1
+ """IAP-authenticated HTTP client.
2
+
3
+ Provides IAPClient which automatically handles Google ID token
4
+ injection for requests to IAP-protected services.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import requests
13
+ from google.auth.transport.requests import Request
14
+
15
+ from .exceptions import NoCredentialsError
16
+ from .storage import FileStorage
17
+
18
+ if TYPE_CHECKING:
19
+ from .auth_manager import AuthManager
20
+
21
+
22
+ class IAPClient:
23
+ """HTTP client that adds IAP authentication tokens automatically.
24
+
25
+ Discovers credentials via a fallback chain:
26
+ 1. Explicit AuthManager (passed to constructor)
27
+ 2. Module-level tokentoss.CREDENTIALS (set by AuthManager on login)
28
+ 3. Token file (TOKENTOSS_TOKEN_FILE env var or default platformdirs path)
29
+
30
+ Automatically retries once on 401 by refreshing the token.
31
+
32
+ Usage:
33
+ client = IAPClient(base_url="https://my-iap-service.run.app")
34
+ data = client.get_json("/api/data")
35
+
36
+ Or as a context manager:
37
+ with IAPClient(base_url="https://my-service.run.app") as client:
38
+ data = client.get_json("/api/data")
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ base_url: str | None = None,
44
+ auth_manager: AuthManager | None = None,
45
+ timeout: int = 30,
46
+ ) -> None:
47
+ """Initialize IAPClient.
48
+
49
+ Args:
50
+ base_url: Optional base URL for relative paths (e.g. "https://my-service.run.app").
51
+ auth_manager: Optional AuthManager for credential discovery.
52
+ If not provided, falls back to module-level CREDENTIALS or token file.
53
+ timeout: Request timeout in seconds. Default 30.
54
+ """
55
+ self.base_url = base_url.rstrip("/") if base_url else None
56
+ self.timeout = timeout
57
+ self._auth_manager = auth_manager
58
+ self._session = requests.Session()
59
+ self._fallback_storage: FileStorage | None = None
60
+
61
+ def _get_fallback_storage(self) -> FileStorage:
62
+ """Get or create cached FileStorage for token discovery."""
63
+ if self._fallback_storage is None:
64
+ token_path = os.environ.get("TOKENTOSS_TOKEN_FILE")
65
+ self._fallback_storage = FileStorage(path=token_path)
66
+ return self._fallback_storage
67
+
68
+ def _get_id_token(self, force_refresh: bool = False) -> str:
69
+ """Find and return a valid ID token.
70
+
71
+ Discovery chain:
72
+ 1. Explicit auth_manager passed in constructor
73
+ 2. Module-level tokentoss.CREDENTIALS variable
74
+ 3. Token file (TOKENTOSS_TOKEN_FILE env var or default location)
75
+
76
+ Args:
77
+ force_refresh: Force a token refresh before returning.
78
+
79
+ Returns:
80
+ A valid ID token string.
81
+
82
+ Raises:
83
+ NoCredentialsError: If no valid credentials found anywhere.
84
+ """
85
+ # 1. Explicit AuthManager
86
+ token = self._try_auth_manager(force_refresh)
87
+ if token:
88
+ return token
89
+
90
+ # 2. Module-level credentials
91
+ token = self._try_module_credentials(force_refresh)
92
+ if token:
93
+ return token
94
+
95
+ # 3. Token file (env var or default path)
96
+ token = self._try_storage()
97
+ if token:
98
+ return token
99
+
100
+ raise NoCredentialsError(
101
+ "No valid credentials found. Use GoogleAuthWidget to authenticate."
102
+ )
103
+
104
+ def _try_auth_manager(self, force_refresh: bool) -> str | None:
105
+ """Try to get ID token from explicit AuthManager."""
106
+ if self._auth_manager is None:
107
+ return None
108
+
109
+ if force_refresh:
110
+ self._auth_manager.refresh_tokens()
111
+
112
+ return self._auth_manager.id_token
113
+
114
+ def _try_module_credentials(self, force_refresh: bool) -> str | None:
115
+ """Try to get ID token from module-level CREDENTIALS."""
116
+ import tokentoss
117
+
118
+ creds = tokentoss.CREDENTIALS
119
+ if creds is None:
120
+ return None
121
+
122
+ if force_refresh or (hasattr(creds, "expired") and creds.expired):
123
+ creds.refresh(Request())
124
+
125
+ return getattr(creds, "id_token", None)
126
+
127
+ def _try_storage(self) -> str | None:
128
+ """Try to get a non-expired ID token from file storage.
129
+
130
+ Note: Storage alone cannot refresh tokens (no client config available).
131
+ If the stored token is expired, returns None to let the caller raise.
132
+ """
133
+ storage = self._get_fallback_storage()
134
+ try:
135
+ token_data = storage.load()
136
+ except Exception:
137
+ return None
138
+
139
+ if token_data is None:
140
+ return None
141
+
142
+ if token_data.is_expired:
143
+ return None
144
+
145
+ return token_data.id_token or None
146
+
147
+ def _build_url(self, path: str) -> str:
148
+ """Construct full URL from base_url and path.
149
+
150
+ Args:
151
+ path: Absolute URL or relative path.
152
+
153
+ Returns:
154
+ Full URL string.
155
+
156
+ Raises:
157
+ ValueError: If path is relative and no base_url is set.
158
+ """
159
+ if path.startswith(("http://", "https://")):
160
+ return path
161
+
162
+ if self.base_url is None:
163
+ raise ValueError(
164
+ f"Relative path {path!r} requires a base_url. "
165
+ "Pass base_url to IAPClient() or use an absolute URL."
166
+ )
167
+
168
+ return f"{self.base_url}/{path.lstrip('/')}"
169
+
170
+ def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
171
+ """Make an authenticated request with auto-retry on 401.
172
+
173
+ Args:
174
+ method: HTTP method (GET, POST, etc.).
175
+ path: URL path or absolute URL.
176
+ **kwargs: Passed to requests.Session.request().
177
+
178
+ Returns:
179
+ requests.Response object.
180
+ """
181
+ url = self._build_url(path)
182
+
183
+ # Set timeout if not explicitly provided
184
+ kwargs.setdefault("timeout", self.timeout)
185
+
186
+ # Get token and make request
187
+ id_token = self._get_id_token()
188
+ headers = kwargs.pop("headers", None) or {}
189
+ headers["Authorization"] = f"Bearer {id_token}"
190
+ kwargs["headers"] = headers
191
+
192
+ response = self._session.request(method, url, **kwargs)
193
+
194
+ # Retry once on 401 with forced refresh
195
+ if response.status_code == 401:
196
+ try:
197
+ refreshed_token = self._get_id_token(force_refresh=True)
198
+ headers["Authorization"] = f"Bearer {refreshed_token}"
199
+ kwargs["headers"] = headers
200
+ response = self._session.request(method, url, **kwargs)
201
+ except (NoCredentialsError, Exception):
202
+ pass # Return original 401 response
203
+
204
+ return response
205
+
206
+ # -- Public HTTP methods --
207
+
208
+ def get(self, path: str, **kwargs: Any) -> requests.Response:
209
+ """GET request with IAP authentication."""
210
+ return self._request("GET", path, **kwargs)
211
+
212
+ def post(self, path: str, **kwargs: Any) -> requests.Response:
213
+ """POST request with IAP authentication."""
214
+ return self._request("POST", path, **kwargs)
215
+
216
+ def put(self, path: str, **kwargs: Any) -> requests.Response:
217
+ """PUT request with IAP authentication."""
218
+ return self._request("PUT", path, **kwargs)
219
+
220
+ def delete(self, path: str, **kwargs: Any) -> requests.Response:
221
+ """DELETE request with IAP authentication."""
222
+ return self._request("DELETE", path, **kwargs)
223
+
224
+ def patch(self, path: str, **kwargs: Any) -> requests.Response:
225
+ """PATCH request with IAP authentication."""
226
+ return self._request("PATCH", path, **kwargs)
227
+
228
+ def get_json(self, path: str, **kwargs: Any) -> Any:
229
+ """GET request, return parsed JSON. Raises on non-2xx status."""
230
+ response = self.get(path, **kwargs)
231
+ response.raise_for_status()
232
+ return response.json()
233
+
234
+ def post_json(self, path: str, json: Any = None, **kwargs: Any) -> Any:
235
+ """POST with JSON body, return parsed JSON. Raises on non-2xx status."""
236
+ response = self.post(path, json=json, **kwargs)
237
+ response.raise_for_status()
238
+ return response.json()
239
+
240
+ # -- Lifecycle --
241
+
242
+ def close(self) -> None:
243
+ """Close the underlying requests session."""
244
+ self._session.close()
245
+
246
+ def __enter__(self) -> IAPClient:
247
+ return self
248
+
249
+ def __exit__(self, *args: Any) -> None:
250
+ self.close()
@@ -0,0 +1,253 @@
1
+ """Configuration widget for setting up OAuth client credentials in Jupyter notebooks.
2
+
3
+ Provides a password-safe input widget so credentials are entered at runtime
4
+ and never appear in .ipynb source or version control.
5
+
6
+ Usage:
7
+ from tokentoss import ConfigureWidget
8
+ display(ConfigureWidget())
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import anywidget
14
+ import traitlets
15
+
16
+ from .setup import configure
17
+
18
+ _ESM = """
19
+ function render({ model, el }) {
20
+ const container = document.createElement('div');
21
+ container.className = 'tokentoss-configure';
22
+
23
+ // Client ID field
24
+ const idLabel = document.createElement('label');
25
+ idLabel.className = 'tokentoss-configure-label';
26
+ idLabel.textContent = 'Client ID';
27
+ const idInput = document.createElement('input');
28
+ idInput.type = 'text';
29
+ idInput.className = 'tokentoss-configure-input';
30
+ idInput.placeholder = '123456789.apps.googleusercontent.com';
31
+
32
+ // Client Secret field
33
+ const secretLabel = document.createElement('label');
34
+ secretLabel.className = 'tokentoss-configure-label';
35
+ secretLabel.textContent = 'Client Secret';
36
+ const secretInput = document.createElement('input');
37
+ secretInput.type = 'password';
38
+ secretInput.className = 'tokentoss-configure-input';
39
+ secretInput.placeholder = 'GOCSPX-...';
40
+
41
+ // Advanced (optional) section
42
+ const advancedHeader = document.createElement('div');
43
+ advancedHeader.className = 'tokentoss-configure-advanced-header';
44
+ advancedHeader.innerHTML = '▶ Advanced (optional)';
45
+ let advancedOpen = false;
46
+
47
+ const advancedContent = document.createElement('div');
48
+ advancedContent.className = 'tokentoss-configure-advanced-content';
49
+ advancedContent.style.display = 'none';
50
+
51
+ const projectLabel = document.createElement('label');
52
+ projectLabel.className = 'tokentoss-configure-label';
53
+ projectLabel.textContent = 'Project ID';
54
+ const projectInput = document.createElement('input');
55
+ projectInput.type = 'text';
56
+ projectInput.className = 'tokentoss-configure-input';
57
+ projectInput.placeholder = 'my-gcp-project';
58
+
59
+ advancedContent.appendChild(projectLabel);
60
+ advancedContent.appendChild(projectInput);
61
+
62
+ advancedHeader.addEventListener('click', () => {
63
+ advancedOpen = !advancedOpen;
64
+ advancedContent.style.display = advancedOpen ? 'block' : 'none';
65
+ advancedHeader.innerHTML = (advancedOpen ? '▼' : '▶') + ' Advanced (optional)';
66
+ });
67
+
68
+ // Submit button
69
+ const button = document.createElement('button');
70
+ button.className = 'tokentoss-configure-button';
71
+ button.textContent = 'Configure';
72
+
73
+ // Status display
74
+ const statusEl = document.createElement('div');
75
+ statusEl.className = 'tokentoss-configure-status';
76
+
77
+ // Assemble DOM
78
+ container.appendChild(idLabel);
79
+ container.appendChild(idInput);
80
+ container.appendChild(secretLabel);
81
+ container.appendChild(secretInput);
82
+ container.appendChild(advancedHeader);
83
+ container.appendChild(advancedContent);
84
+ container.appendChild(button);
85
+ container.appendChild(statusEl);
86
+ el.appendChild(container);
87
+
88
+ function updateStatus() {
89
+ const status = model.get('status');
90
+ const configured = model.get('configured');
91
+ statusEl.textContent = status;
92
+ statusEl.className = 'tokentoss-configure-status' +
93
+ (configured ? ' tokentoss-configure-success' : '');
94
+ if (status.startsWith('Error')) {
95
+ statusEl.className = 'tokentoss-configure-status tokentoss-configure-error';
96
+ }
97
+ }
98
+
99
+ button.addEventListener('click', () => {
100
+ model.set('client_id', idInput.value);
101
+ model.set('client_secret', secretInput.value);
102
+ model.set('project_id', projectInput.value);
103
+ model.set('_submit', model.get('_submit') + 1);
104
+ model.save_changes();
105
+ });
106
+
107
+ model.on('change:status', updateStatus);
108
+ model.on('change:configured', updateStatus);
109
+
110
+ updateStatus();
111
+ }
112
+
113
+ export default { render };
114
+ """
115
+
116
+ _CSS = """
117
+ .tokentoss-configure {
118
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
119
+ padding: 16px;
120
+ border: 1px solid #e5e7eb;
121
+ border-radius: 8px;
122
+ background: #ffffff;
123
+ max-width: 400px;
124
+ }
125
+
126
+ .tokentoss-configure-label {
127
+ display: block;
128
+ font-size: 13px;
129
+ font-weight: 500;
130
+ color: #374151;
131
+ margin-bottom: 4px;
132
+ margin-top: 12px;
133
+ }
134
+
135
+ .tokentoss-configure-label:first-child {
136
+ margin-top: 0;
137
+ }
138
+
139
+ .tokentoss-configure-input {
140
+ width: 100%;
141
+ padding: 8px 12px;
142
+ font-size: 13px;
143
+ border: 1px solid #d1d5db;
144
+ border-radius: 4px;
145
+ box-sizing: border-box;
146
+ }
147
+
148
+ .tokentoss-configure-input:focus {
149
+ outline: none;
150
+ border-color: #4285f4;
151
+ box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
152
+ }
153
+
154
+ .tokentoss-configure-button {
155
+ margin-top: 16px;
156
+ padding: 10px 20px;
157
+ font-size: 14px;
158
+ font-weight: 500;
159
+ color: #ffffff;
160
+ background: #4285f4;
161
+ border: none;
162
+ border-radius: 4px;
163
+ cursor: pointer;
164
+ transition: background 0.2s;
165
+ }
166
+
167
+ .tokentoss-configure-button:hover {
168
+ background: #3574e2;
169
+ }
170
+
171
+ .tokentoss-configure-status {
172
+ margin-top: 12px;
173
+ font-size: 13px;
174
+ color: #6b7280;
175
+ }
176
+
177
+ .tokentoss-configure-success {
178
+ color: #059669;
179
+ }
180
+
181
+ .tokentoss-configure-error {
182
+ color: #dc2626;
183
+ }
184
+
185
+ .tokentoss-configure-advanced-header {
186
+ margin-top: 16px;
187
+ font-size: 13px;
188
+ color: #6b7280;
189
+ cursor: pointer;
190
+ user-select: none;
191
+ }
192
+
193
+ .tokentoss-configure-advanced-header:hover {
194
+ color: #374151;
195
+ }
196
+
197
+ .tokentoss-configure-advanced-content {
198
+ margin-top: 4px;
199
+ }
200
+ """
201
+
202
+
203
+ class ConfigureWidget(anywidget.AnyWidget):
204
+ """Widget for configuring OAuth client credentials in Jupyter notebooks.
205
+
206
+ Provides password-style input fields for client_id and client_secret,
207
+ so credentials are entered at runtime and never appear in notebook source.
208
+
209
+ Example:
210
+ from tokentoss import ConfigureWidget
211
+ display(ConfigureWidget())
212
+ # Enter credentials and click Configure
213
+ """
214
+
215
+ client_id = traitlets.Unicode("").tag(sync=True)
216
+ client_secret = traitlets.Unicode("").tag(sync=True)
217
+ project_id = traitlets.Unicode("").tag(sync=True)
218
+ status = traitlets.Unicode("Enter credentials").tag(sync=True)
219
+ configured = traitlets.Bool(False).tag(sync=True)
220
+ _submit = traitlets.Int(0).tag(sync=True)
221
+
222
+ _esm = _ESM
223
+ _css = _CSS
224
+
225
+ def __init__(self, **kwargs):
226
+ super().__init__(**kwargs)
227
+ self.observe(self._on_submit, names=["_submit"])
228
+
229
+ def _on_submit(self, change):
230
+ """Handle submit button press."""
231
+ if change["new"] == 0:
232
+ return
233
+
234
+ client_id = self.client_id.strip()
235
+ client_secret = self.client_secret.strip()
236
+
237
+ if not client_id or not client_secret:
238
+ self.status = "Error: both Client ID and Client Secret are required"
239
+ self.configured = False
240
+ return
241
+
242
+ try:
243
+ project_id = self.project_id.strip() or None
244
+ path = configure(
245
+ client_id=client_id,
246
+ client_secret=client_secret,
247
+ project_id=project_id,
248
+ )
249
+ self.status = f"Configured! Saved to {path}"
250
+ self.configured = True
251
+ except Exception as e:
252
+ self.status = f"Error: {e}"
253
+ self.configured = False
@@ -0,0 +1,56 @@
1
+ """Custom exceptions for tokentoss."""
2
+
3
+
4
+ class TokenTossError(Exception):
5
+ """Base exception for tokentoss."""
6
+
7
+ pass
8
+
9
+
10
+ class NoCredentialsError(TokenTossError):
11
+ """Raised when no valid credentials are found.
12
+
13
+ This typically means:
14
+ - No AuthManager was passed to IAPClient
15
+ - tokentoss.CREDENTIALS module variable is not set
16
+ - No token file exists at the default location
17
+ - TOKENTOSS_TOKEN_FILE environment variable is not set or file doesn't exist
18
+
19
+ To authenticate, use the GoogleAuthWidget:
20
+ from tokentoss import GoogleAuthWidget
21
+ widget = GoogleAuthWidget(client_secrets_path="./client_secrets.json")
22
+ display(widget)
23
+ # Click "Sign in with Google" and complete the flow
24
+ """
25
+
26
+ def __init__(self, message: str | None = None):
27
+ if message is None:
28
+ message = (
29
+ "No valid credentials found. "
30
+ "Use GoogleAuthWidget to authenticate or provide credentials explicitly."
31
+ )
32
+ super().__init__(message)
33
+
34
+
35
+ class TokenRefreshError(TokenTossError):
36
+ """Raised when token refresh fails."""
37
+
38
+ pass
39
+
40
+
41
+ class TokenExchangeError(TokenTossError):
42
+ """Raised when authorization code exchange fails."""
43
+
44
+ pass
45
+
46
+
47
+ class StorageError(TokenTossError):
48
+ """Raised when token storage operations fail."""
49
+
50
+ pass
51
+
52
+
53
+ class InsecureFilePermissionsWarning(UserWarning):
54
+ """Warning issued when token file has insecure permissions."""
55
+
56
+ pass