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/setup.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Client secrets configuration for tokentoss.
|
|
2
|
+
|
|
3
|
+
Provides functions to install OAuth client credentials to a standard
|
|
4
|
+
platform-specific location so AuthManager can auto-discover them.
|
|
5
|
+
|
|
6
|
+
Usage from JupyterLab:
|
|
7
|
+
import tokentoss
|
|
8
|
+
|
|
9
|
+
# From direct credentials (copy-paste from GCP console)
|
|
10
|
+
tokentoss.configure(client_id="...", client_secret="...")
|
|
11
|
+
|
|
12
|
+
# From a downloaded client_secrets.json file
|
|
13
|
+
tokentoss.configure(path="./client_secrets.json")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import platformdirs
|
|
23
|
+
|
|
24
|
+
from .exceptions import StorageError
|
|
25
|
+
|
|
26
|
+
# Application name for platformdirs paths
|
|
27
|
+
APP_NAME = "tokentoss"
|
|
28
|
+
|
|
29
|
+
# Hardcoded Google OAuth boilerplate - same for all Google OAuth apps
|
|
30
|
+
GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
|
|
31
|
+
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
32
|
+
GOOGLE_CERT_URL = "https://www.googleapis.com/oauth2/v1/certs"
|
|
33
|
+
DEFAULT_REDIRECT_URIS = ["http://localhost"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config_path() -> Path:
|
|
37
|
+
"""Get the standard client_secrets.json location.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Path to ~/.config/tokentoss/client_secrets.json (or platform equivalent).
|
|
41
|
+
"""
|
|
42
|
+
config_dir = platformdirs.user_config_dir(APP_NAME)
|
|
43
|
+
return Path(config_dir) / "client_secrets.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def configure(
|
|
47
|
+
client_id: str | None = None,
|
|
48
|
+
client_secret: str | None = None,
|
|
49
|
+
path: str | Path | None = None,
|
|
50
|
+
project_id: str | None = None,
|
|
51
|
+
) -> Path:
|
|
52
|
+
"""Install client secrets to the standard tokentoss config location.
|
|
53
|
+
|
|
54
|
+
Supports two input modes:
|
|
55
|
+
- Direct credentials: provide client_id and client_secret
|
|
56
|
+
- Existing file: provide path to a client_secrets.json
|
|
57
|
+
|
|
58
|
+
Always writes to the standard platformdirs location
|
|
59
|
+
(~/.config/tokentoss/client_secrets.json on macOS/Linux).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client_id: Google OAuth client ID.
|
|
63
|
+
client_secret: Google OAuth client secret.
|
|
64
|
+
path: Path to an existing client_secrets.json file.
|
|
65
|
+
project_id: Optional GCP project ID (only used with client_id/client_secret).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Path where client_secrets.json was installed.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If arguments are invalid or missing.
|
|
72
|
+
StorageError: If the file cannot be written.
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
>>> import tokentoss
|
|
76
|
+
>>> # From GCP console copy-paste
|
|
77
|
+
>>> tokentoss.configure(client_id="123.apps.googleusercontent.com", client_secret="GOCSPX-...")
|
|
78
|
+
>>> # From downloaded file
|
|
79
|
+
>>> tokentoss.configure(path="./client_secrets.json")
|
|
80
|
+
"""
|
|
81
|
+
if path is not None:
|
|
82
|
+
return configure_from_file(path)
|
|
83
|
+
elif client_id is not None and client_secret is not None:
|
|
84
|
+
return configure_from_credentials(client_id, client_secret, project_id=project_id)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Provide either (client_id, client_secret) or path to an existing "
|
|
88
|
+
"client_secrets.json file."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def configure_from_credentials(
|
|
93
|
+
client_id: str,
|
|
94
|
+
client_secret: str,
|
|
95
|
+
project_id: str | None = None,
|
|
96
|
+
) -> Path:
|
|
97
|
+
"""Build client_secrets.json from credentials and install to standard location.
|
|
98
|
+
|
|
99
|
+
Merges the provided credentials with hardcoded Google OAuth boilerplate
|
|
100
|
+
(auth_uri, token_uri, etc.) so the user only needs client_id and client_secret.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
client_id: Google OAuth client ID.
|
|
104
|
+
client_secret: Google OAuth client secret.
|
|
105
|
+
project_id: Optional GCP project ID.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Path where client_secrets.json was written.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If client_id or client_secret is empty.
|
|
112
|
+
StorageError: If file cannot be written.
|
|
113
|
+
"""
|
|
114
|
+
if not client_id or not client_id.strip():
|
|
115
|
+
raise ValueError("client_id cannot be empty")
|
|
116
|
+
if not client_secret or not client_secret.strip():
|
|
117
|
+
raise ValueError("client_secret cannot be empty")
|
|
118
|
+
|
|
119
|
+
config_data: dict = {
|
|
120
|
+
"installed": {
|
|
121
|
+
"client_id": client_id.strip(),
|
|
122
|
+
"client_secret": client_secret.strip(),
|
|
123
|
+
"auth_uri": GOOGLE_AUTH_URI,
|
|
124
|
+
"token_uri": GOOGLE_TOKEN_URI,
|
|
125
|
+
"auth_provider_x509_cert_url": GOOGLE_CERT_URL,
|
|
126
|
+
"redirect_uris": DEFAULT_REDIRECT_URIS.copy(),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if project_id:
|
|
131
|
+
config_data["installed"]["project_id"] = project_id.strip()
|
|
132
|
+
|
|
133
|
+
return _write_config(config_data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def configure_from_file(source_path: str | Path) -> Path:
|
|
137
|
+
"""Copy an existing client_secrets.json to the standard location.
|
|
138
|
+
|
|
139
|
+
Validates the file format before copying. Supports both "installed"
|
|
140
|
+
(desktop app) and "web" format client_secrets.json files.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
source_path: Path to the source client_secrets.json file.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Path where client_secrets.json was installed.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
FileNotFoundError: If source file doesn't exist.
|
|
150
|
+
ValueError: If file format is invalid.
|
|
151
|
+
StorageError: If file cannot be written.
|
|
152
|
+
"""
|
|
153
|
+
source_path = Path(source_path)
|
|
154
|
+
if not source_path.exists():
|
|
155
|
+
raise FileNotFoundError(f"Client secrets file not found: {source_path}")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with open(source_path) as f:
|
|
159
|
+
config_data = json.load(f)
|
|
160
|
+
except json.JSONDecodeError as e:
|
|
161
|
+
raise ValueError(f"Invalid JSON in {source_path}: {e}") from e
|
|
162
|
+
|
|
163
|
+
# Validate structure
|
|
164
|
+
if "installed" not in config_data and "web" not in config_data:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Invalid client_secrets.json format in {source_path}. "
|
|
167
|
+
"Expected 'installed' or 'web' key."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
section = config_data.get("installed") or config_data.get("web")
|
|
171
|
+
if "client_id" not in section or "client_secret" not in section:
|
|
172
|
+
raise ValueError(f"Missing client_id or client_secret in {source_path}.")
|
|
173
|
+
|
|
174
|
+
return _write_config(config_data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _write_config(config_data: dict) -> Path:
|
|
178
|
+
"""Write config data to the standard location with secure permissions.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
config_data: The client_secrets.json structure to write.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Path where the file was written.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
StorageError: If file cannot be written.
|
|
188
|
+
"""
|
|
189
|
+
dest = get_config_path()
|
|
190
|
+
try:
|
|
191
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
dest.write_text(json.dumps(config_data, indent=2))
|
|
193
|
+
os.chmod(dest, 0o600)
|
|
194
|
+
except OSError as e:
|
|
195
|
+
raise StorageError(f"Failed to write config to {dest}: {e}") from e
|
|
196
|
+
|
|
197
|
+
return dest
|
tokentoss/storage.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Token storage implementations for tokentoss."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import warnings
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import platformdirs
|
|
15
|
+
|
|
16
|
+
from .exceptions import InsecureFilePermissionsWarning, StorageError
|
|
17
|
+
|
|
18
|
+
# Default application name for platformdirs
|
|
19
|
+
APP_NAME = "tokentoss"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TokenData:
|
|
24
|
+
"""Container for OAuth token data."""
|
|
25
|
+
|
|
26
|
+
access_token: str
|
|
27
|
+
id_token: str
|
|
28
|
+
refresh_token: str
|
|
29
|
+
expiry: str # ISO format datetime string
|
|
30
|
+
scopes: list[str]
|
|
31
|
+
user_email: str | None = None
|
|
32
|
+
created_at: str | None = None # ISO format datetime string
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
"""Convert to dictionary for JSON serialization."""
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict[str, Any]) -> TokenData:
|
|
40
|
+
"""Create TokenData from dictionary."""
|
|
41
|
+
return cls(
|
|
42
|
+
access_token=data["access_token"],
|
|
43
|
+
id_token=data["id_token"],
|
|
44
|
+
refresh_token=data["refresh_token"],
|
|
45
|
+
expiry=data["expiry"],
|
|
46
|
+
scopes=data.get("scopes", []),
|
|
47
|
+
user_email=data.get("user_email"),
|
|
48
|
+
created_at=data.get("created_at"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def created_at_datetime(self) -> datetime | None:
|
|
53
|
+
"""Parse created_at string to datetime, or None if not set."""
|
|
54
|
+
if self.created_at is None:
|
|
55
|
+
return None
|
|
56
|
+
return datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def expiry_datetime(self) -> datetime:
|
|
60
|
+
"""Parse expiry string to datetime."""
|
|
61
|
+
return datetime.fromisoformat(self.expiry.replace("Z", "+00:00"))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_expired(self) -> bool:
|
|
65
|
+
"""Check if access token is expired."""
|
|
66
|
+
from datetime import timezone
|
|
67
|
+
|
|
68
|
+
return datetime.now(timezone.utc) >= self.expiry_datetime
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MemoryStorage:
|
|
72
|
+
"""In-memory token storage for testing and temporary use."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._tokens: TokenData | None = None
|
|
76
|
+
|
|
77
|
+
def save(self, tokens: TokenData) -> None:
|
|
78
|
+
"""Save tokens to memory."""
|
|
79
|
+
self._tokens = tokens
|
|
80
|
+
|
|
81
|
+
def load(self) -> TokenData | None:
|
|
82
|
+
"""Load tokens from memory."""
|
|
83
|
+
return self._tokens
|
|
84
|
+
|
|
85
|
+
def clear(self) -> None:
|
|
86
|
+
"""Clear stored tokens."""
|
|
87
|
+
self._tokens = None
|
|
88
|
+
|
|
89
|
+
def exists(self) -> bool:
|
|
90
|
+
"""Check if tokens exist in storage."""
|
|
91
|
+
return self._tokens is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FileStorage:
|
|
95
|
+
"""File-based token storage with secure permissions."""
|
|
96
|
+
|
|
97
|
+
# Secure file permissions: owner read/write only (0600)
|
|
98
|
+
SECURE_PERMISSIONS = stat.S_IRUSR | stat.S_IWUSR
|
|
99
|
+
|
|
100
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
101
|
+
"""Initialize file storage.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Path to token file. If None, uses platformdirs default location.
|
|
105
|
+
"""
|
|
106
|
+
if path is None:
|
|
107
|
+
config_dir = platformdirs.user_config_dir(APP_NAME)
|
|
108
|
+
self.path = Path(config_dir) / "tokens.json"
|
|
109
|
+
else:
|
|
110
|
+
self.path = Path(path)
|
|
111
|
+
|
|
112
|
+
def save(self, tokens: TokenData) -> None:
|
|
113
|
+
"""Save tokens to file with secure permissions.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
tokens: TokenData to save.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
StorageError: If file cannot be written.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
# Ensure parent directory exists
|
|
123
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
# Write tokens to file
|
|
126
|
+
with open(self.path, "w") as f:
|
|
127
|
+
json.dump(tokens.to_dict(), f, indent=2)
|
|
128
|
+
|
|
129
|
+
# Set secure permissions (owner read/write only)
|
|
130
|
+
os.chmod(self.path, self.SECURE_PERMISSIONS)
|
|
131
|
+
|
|
132
|
+
except OSError as e:
|
|
133
|
+
raise StorageError(f"Failed to save tokens to {self.path}: {e}") from e
|
|
134
|
+
|
|
135
|
+
def load(self) -> TokenData | None:
|
|
136
|
+
"""Load tokens from file.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
TokenData if file exists and is valid, None otherwise.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
StorageError: If file exists but cannot be read or parsed.
|
|
143
|
+
|
|
144
|
+
Warns:
|
|
145
|
+
InsecureFilePermissionsWarning: If file has insecure permissions.
|
|
146
|
+
"""
|
|
147
|
+
if not self.path.exists():
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Check file permissions
|
|
151
|
+
self._check_permissions()
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(self.path) as f:
|
|
155
|
+
data = json.load(f)
|
|
156
|
+
return TokenData.from_dict(data)
|
|
157
|
+
|
|
158
|
+
except json.JSONDecodeError as e:
|
|
159
|
+
raise StorageError(f"Invalid JSON in token file {self.path}: {e}") from e
|
|
160
|
+
except KeyError as e:
|
|
161
|
+
raise StorageError(f"Missing required field in token file: {e}") from e
|
|
162
|
+
except OSError as e:
|
|
163
|
+
raise StorageError(f"Failed to read token file {self.path}: {e}") from e
|
|
164
|
+
|
|
165
|
+
def clear(self) -> None:
|
|
166
|
+
"""Delete the token file."""
|
|
167
|
+
if self.path.exists():
|
|
168
|
+
try:
|
|
169
|
+
self.path.unlink()
|
|
170
|
+
except OSError as e:
|
|
171
|
+
raise StorageError(f"Failed to delete token file {self.path}: {e}") from e
|
|
172
|
+
|
|
173
|
+
def exists(self) -> bool:
|
|
174
|
+
"""Check if token file exists."""
|
|
175
|
+
return self.path.exists()
|
|
176
|
+
|
|
177
|
+
def _check_permissions(self) -> None:
|
|
178
|
+
"""Check if file has secure permissions, warn if not."""
|
|
179
|
+
if not self.path.exists():
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
current_mode = self.path.stat().st_mode & 0o777
|
|
184
|
+
if current_mode != (self.SECURE_PERMISSIONS & 0o777):
|
|
185
|
+
warnings.warn(
|
|
186
|
+
f"Token file {self.path} has insecure permissions "
|
|
187
|
+
f"(mode {oct(current_mode)}). "
|
|
188
|
+
f"Recommended: {oct(self.SECURE_PERMISSIONS & 0o777)} (owner read/write only). "
|
|
189
|
+
f"Run: chmod 600 {self.path}",
|
|
190
|
+
InsecureFilePermissionsWarning,
|
|
191
|
+
stacklevel=3,
|
|
192
|
+
)
|
|
193
|
+
except OSError:
|
|
194
|
+
# Can't check permissions, skip warning
|
|
195
|
+
pass
|