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/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