tescmd 0.1.2__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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/auth/oauth.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""OAuth2 PKCE helpers, partner registration, and interactive login flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from tescmd.api.client import REGION_BASE_URLS
|
|
17
|
+
from tescmd.api.errors import AuthError
|
|
18
|
+
from tescmd.auth.server import OAuthCallbackServer
|
|
19
|
+
from tescmd.models.auth import (
|
|
20
|
+
AUTHORIZE_URL,
|
|
21
|
+
PARTNER_SCOPES,
|
|
22
|
+
TOKEN_URL,
|
|
23
|
+
TokenData,
|
|
24
|
+
decode_jwt_scopes,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from tescmd.auth.token_store import TokenStore
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# PKCE helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _generate_code_verifier() -> str:
|
|
38
|
+
"""Return a 128-character base64url code verifier (no padding)."""
|
|
39
|
+
return secrets.token_urlsafe(96)[:128]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _generate_code_challenge(verifier: str) -> str:
|
|
43
|
+
"""Compute S256 code challenge for the given *verifier*."""
|
|
44
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
45
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_auth_url(
|
|
49
|
+
client_id: str,
|
|
50
|
+
redirect_uri: str,
|
|
51
|
+
scopes: list[str],
|
|
52
|
+
code_challenge: str,
|
|
53
|
+
state: str,
|
|
54
|
+
*,
|
|
55
|
+
force_consent: bool = False,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Build the full Tesla authorization URL.
|
|
58
|
+
|
|
59
|
+
When *force_consent* is ``True`` the ``prompt_missing_scopes=true``
|
|
60
|
+
parameter is added so Tesla prompts the user to approve any scopes
|
|
61
|
+
that were not granted in a previous consent. This is Tesla's
|
|
62
|
+
proprietary parameter (not the standard ``prompt=consent``).
|
|
63
|
+
"""
|
|
64
|
+
params: dict[str, str] = {
|
|
65
|
+
"response_type": "code",
|
|
66
|
+
"client_id": client_id,
|
|
67
|
+
"redirect_uri": redirect_uri,
|
|
68
|
+
"scope": " ".join(scopes),
|
|
69
|
+
"state": state,
|
|
70
|
+
"code_challenge": code_challenge,
|
|
71
|
+
"code_challenge_method": "S256",
|
|
72
|
+
}
|
|
73
|
+
if force_consent:
|
|
74
|
+
params["prompt_missing_scopes"] = "true"
|
|
75
|
+
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Token exchange / refresh
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def exchange_code(
|
|
84
|
+
code: str,
|
|
85
|
+
code_verifier: str,
|
|
86
|
+
client_id: str,
|
|
87
|
+
client_secret: str | None = None,
|
|
88
|
+
redirect_uri: str = "http://localhost:8085/callback",
|
|
89
|
+
) -> TokenData:
|
|
90
|
+
"""Exchange an authorization code for tokens."""
|
|
91
|
+
payload: dict[str, Any] = {
|
|
92
|
+
"grant_type": "authorization_code",
|
|
93
|
+
"code": code,
|
|
94
|
+
"code_verifier": code_verifier,
|
|
95
|
+
"client_id": client_id,
|
|
96
|
+
"redirect_uri": redirect_uri,
|
|
97
|
+
}
|
|
98
|
+
if client_secret is not None:
|
|
99
|
+
payload["client_secret"] = client_secret
|
|
100
|
+
|
|
101
|
+
async with httpx.AsyncClient() as client:
|
|
102
|
+
resp = await client.post(TOKEN_URL, data=payload)
|
|
103
|
+
if resp.status_code != 200:
|
|
104
|
+
raise AuthError(f"Token exchange failed: {resp.text}", status_code=resp.status_code)
|
|
105
|
+
return TokenData.model_validate(resp.json())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def refresh_access_token(
|
|
109
|
+
refresh_token: str,
|
|
110
|
+
client_id: str,
|
|
111
|
+
client_secret: str | None = None,
|
|
112
|
+
) -> TokenData:
|
|
113
|
+
"""Use a refresh token to obtain new tokens."""
|
|
114
|
+
payload: dict[str, Any] = {
|
|
115
|
+
"grant_type": "refresh_token",
|
|
116
|
+
"refresh_token": refresh_token,
|
|
117
|
+
"client_id": client_id,
|
|
118
|
+
}
|
|
119
|
+
if client_secret is not None:
|
|
120
|
+
payload["client_secret"] = client_secret
|
|
121
|
+
|
|
122
|
+
async with httpx.AsyncClient() as client:
|
|
123
|
+
resp = await client.post(TOKEN_URL, data=payload)
|
|
124
|
+
if resp.status_code != 200:
|
|
125
|
+
raise AuthError(f"Token refresh failed: {resp.text}", status_code=resp.status_code)
|
|
126
|
+
return TokenData.model_validate(resp.json())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Partner registration (one-time per region)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def get_partner_token(
|
|
135
|
+
client_id: str,
|
|
136
|
+
client_secret: str,
|
|
137
|
+
region: str = "na",
|
|
138
|
+
) -> tuple[str, list[str]]:
|
|
139
|
+
"""Obtain a partner token via *client_credentials* grant.
|
|
140
|
+
|
|
141
|
+
The ``audience`` parameter tells Tesla which regional endpoint the
|
|
142
|
+
token is for.
|
|
143
|
+
|
|
144
|
+
Returns a ``(token, granted_scopes)`` tuple. *granted_scopes* are
|
|
145
|
+
decoded from the JWT ``scp`` claim so callers can verify that the
|
|
146
|
+
partner registration will cover all requested scopes.
|
|
147
|
+
"""
|
|
148
|
+
audience = REGION_BASE_URLS.get(region)
|
|
149
|
+
if audience is None:
|
|
150
|
+
msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
|
|
151
|
+
raise AuthError(msg)
|
|
152
|
+
|
|
153
|
+
payload = {
|
|
154
|
+
"grant_type": "client_credentials",
|
|
155
|
+
"client_id": client_id,
|
|
156
|
+
"client_secret": client_secret,
|
|
157
|
+
"scope": " ".join(PARTNER_SCOPES),
|
|
158
|
+
"audience": audience,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async with httpx.AsyncClient() as client:
|
|
162
|
+
resp = await client.post(TOKEN_URL, data=payload)
|
|
163
|
+
if resp.status_code != 200:
|
|
164
|
+
raise AuthError(
|
|
165
|
+
f"Partner token request failed: {resp.text}",
|
|
166
|
+
status_code=resp.status_code,
|
|
167
|
+
)
|
|
168
|
+
data: dict[str, Any] = resp.json()
|
|
169
|
+
token: str = data["access_token"]
|
|
170
|
+
|
|
171
|
+
granted = decode_jwt_scopes(token) or []
|
|
172
|
+
logger.debug("Partner token scopes: requested=%s, granted=%s", PARTNER_SCOPES, granted)
|
|
173
|
+
|
|
174
|
+
return token, granted
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def register_partner_account(
|
|
178
|
+
client_id: str,
|
|
179
|
+
client_secret: str,
|
|
180
|
+
domain: str = "localhost",
|
|
181
|
+
region: str = "na",
|
|
182
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
183
|
+
"""Register the application with the Tesla Fleet API for *region*.
|
|
184
|
+
|
|
185
|
+
This must be called once per region before the Fleet API will accept
|
|
186
|
+
requests. It is safe to call more than once (idempotent).
|
|
187
|
+
|
|
188
|
+
Returns a ``(response_body, partner_scopes)`` tuple.
|
|
189
|
+
*partner_scopes* are the scopes actually granted in the partner
|
|
190
|
+
token, which determines the ceiling for user token scopes.
|
|
191
|
+
"""
|
|
192
|
+
base_url = REGION_BASE_URLS.get(region)
|
|
193
|
+
if base_url is None:
|
|
194
|
+
msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
|
|
195
|
+
raise AuthError(msg)
|
|
196
|
+
|
|
197
|
+
partner_token, granted_scopes = await get_partner_token(client_id, client_secret, region)
|
|
198
|
+
|
|
199
|
+
async with httpx.AsyncClient() as client:
|
|
200
|
+
resp = await client.post(
|
|
201
|
+
f"{base_url}/api/1/partner_accounts",
|
|
202
|
+
json={"domain": domain},
|
|
203
|
+
headers={"Authorization": f"Bearer {partner_token}"},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if resp.status_code >= 400:
|
|
207
|
+
raise AuthError(
|
|
208
|
+
f"Partner registration failed (HTTP {resp.status_code}): {resp.text}",
|
|
209
|
+
status_code=resp.status_code,
|
|
210
|
+
)
|
|
211
|
+
result: dict[str, Any] = resp.json()
|
|
212
|
+
return result, granted_scopes
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Scope extraction
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _extract_granted_scopes(token: TokenData, *, requested: list[str]) -> list[str]:
|
|
221
|
+
"""Return the scopes actually granted in the token response.
|
|
222
|
+
|
|
223
|
+
Checks three sources in priority order:
|
|
224
|
+
1. ``token.scope`` — the ``scope`` field from the OAuth token response
|
|
225
|
+
2. JWT ``scp`` claim — decoded from the access token payload
|
|
226
|
+
3. *requested* — falls back to whatever we asked for
|
|
227
|
+
"""
|
|
228
|
+
if token.scope:
|
|
229
|
+
return token.scope.split()
|
|
230
|
+
|
|
231
|
+
jwt_scopes = decode_jwt_scopes(token.access_token)
|
|
232
|
+
if jwt_scopes is not None:
|
|
233
|
+
return jwt_scopes
|
|
234
|
+
|
|
235
|
+
return requested
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Full interactive login flow
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def login_flow(
|
|
244
|
+
client_id: str,
|
|
245
|
+
client_secret: str | None,
|
|
246
|
+
redirect_uri: str,
|
|
247
|
+
scopes: list[str],
|
|
248
|
+
port: int,
|
|
249
|
+
token_store: TokenStore,
|
|
250
|
+
region: str = "na",
|
|
251
|
+
*,
|
|
252
|
+
force_consent: bool = False,
|
|
253
|
+
) -> TokenData:
|
|
254
|
+
"""Run the full OAuth2 PKCE login flow interactively.
|
|
255
|
+
|
|
256
|
+
1. Generate PKCE pair
|
|
257
|
+
2. Start local callback server
|
|
258
|
+
3. Open browser to authorization URL
|
|
259
|
+
4. Wait for redirect with auth code
|
|
260
|
+
5. Exchange code for tokens
|
|
261
|
+
6. Persist to *token_store*
|
|
262
|
+
|
|
263
|
+
When *force_consent* is ``True`` the authorization URL includes
|
|
264
|
+
``prompt_missing_scopes=true`` so Tesla prompts for any new scopes.
|
|
265
|
+
"""
|
|
266
|
+
verifier = _generate_code_verifier()
|
|
267
|
+
challenge = _generate_code_challenge(verifier)
|
|
268
|
+
state = secrets.token_urlsafe(32)
|
|
269
|
+
|
|
270
|
+
server = OAuthCallbackServer(port=port)
|
|
271
|
+
server.start()
|
|
272
|
+
try:
|
|
273
|
+
url = build_auth_url(
|
|
274
|
+
client_id,
|
|
275
|
+
redirect_uri,
|
|
276
|
+
scopes,
|
|
277
|
+
challenge,
|
|
278
|
+
state,
|
|
279
|
+
force_consent=force_consent,
|
|
280
|
+
)
|
|
281
|
+
webbrowser.open(url)
|
|
282
|
+
|
|
283
|
+
code, callback_state = server.wait_for_callback(timeout=120)
|
|
284
|
+
finally:
|
|
285
|
+
server.stop()
|
|
286
|
+
|
|
287
|
+
if code is None:
|
|
288
|
+
raise AuthError("OAuth callback timed out or was cancelled")
|
|
289
|
+
|
|
290
|
+
if callback_state != state:
|
|
291
|
+
raise AuthError("OAuth state mismatch — possible CSRF attack")
|
|
292
|
+
|
|
293
|
+
token = await exchange_code(
|
|
294
|
+
code=code,
|
|
295
|
+
code_verifier=verifier,
|
|
296
|
+
client_id=client_id,
|
|
297
|
+
client_secret=client_secret,
|
|
298
|
+
redirect_uri=redirect_uri,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Determine actually granted scopes — prefer the token response's scope
|
|
302
|
+
# field, then JWT payload, then fall back to what we requested.
|
|
303
|
+
granted = _extract_granted_scopes(token, requested=scopes)
|
|
304
|
+
|
|
305
|
+
token_store.save(
|
|
306
|
+
access_token=token.access_token,
|
|
307
|
+
refresh_token=token.refresh_token or "",
|
|
308
|
+
expires_at=time.time() + token.expires_in,
|
|
309
|
+
scopes=granted,
|
|
310
|
+
region=region,
|
|
311
|
+
)
|
|
312
|
+
return token
|
tescmd/auth/server.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Lightweight local HTTP server for the OAuth2 redirect callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# HTML templates
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
_SUCCESS_HTML = """\
|
|
15
|
+
<!DOCTYPE html>
|
|
16
|
+
<html><head><title>tescmd</title></head>
|
|
17
|
+
<body style="font-family:sans-serif;text-align:center;margin-top:80px">
|
|
18
|
+
<h1>Authentication Successful</h1>
|
|
19
|
+
<p>You can close this window and return to the terminal.</p>
|
|
20
|
+
</body></html>
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_FAILURE_HTML = """\
|
|
24
|
+
<!DOCTYPE html>
|
|
25
|
+
<html><head><title>tescmd</title></head>
|
|
26
|
+
<body style="font-family:sans-serif;text-align:center;margin-top:80px">
|
|
27
|
+
<h1>Authentication Failed</h1>
|
|
28
|
+
<p>{error}</p>
|
|
29
|
+
</body></html>
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Request handler
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
39
|
+
"""Handle a single GET from the OAuth redirect."""
|
|
40
|
+
|
|
41
|
+
server: OAuthCallbackServer
|
|
42
|
+
|
|
43
|
+
def do_GET(self) -> None:
|
|
44
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
45
|
+
|
|
46
|
+
error = qs.get("error", [None])[0]
|
|
47
|
+
if error is not None:
|
|
48
|
+
self._respond(400, _FAILURE_HTML.format(error=error))
|
|
49
|
+
self.server.callback_result = (None, None, error)
|
|
50
|
+
self.server.callback_event.set()
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
code = qs.get("code", [None])[0]
|
|
54
|
+
state = qs.get("state", [None])[0]
|
|
55
|
+
self._respond(200, _SUCCESS_HTML)
|
|
56
|
+
self.server.callback_result = (code, state, None)
|
|
57
|
+
self.server.callback_event.set()
|
|
58
|
+
|
|
59
|
+
def _respond(self, status: int, body: str) -> None:
|
|
60
|
+
self.send_response(status)
|
|
61
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(body.encode())
|
|
64
|
+
|
|
65
|
+
# Silence default request logging.
|
|
66
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Server
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OAuthCallbackServer(HTTPServer):
|
|
76
|
+
"""HTTPServer that waits for a single OAuth redirect callback."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, port: int = 8085) -> None:
|
|
79
|
+
super().__init__(("127.0.0.1", port), _CallbackHandler)
|
|
80
|
+
self.callback_event = threading.Event()
|
|
81
|
+
self.callback_result: tuple[str | None, str | None, str | None] = (
|
|
82
|
+
None,
|
|
83
|
+
None,
|
|
84
|
+
None,
|
|
85
|
+
)
|
|
86
|
+
self._thread: threading.Thread | None = None
|
|
87
|
+
|
|
88
|
+
def start(self) -> None:
|
|
89
|
+
"""Start serving in a daemon thread."""
|
|
90
|
+
self._thread = threading.Thread(target=self.serve_forever, daemon=True)
|
|
91
|
+
self._thread.start()
|
|
92
|
+
|
|
93
|
+
def wait_for_callback(self, timeout: float = 120) -> tuple[str | None, str | None]:
|
|
94
|
+
"""Block until the callback is received or *timeout* elapses.
|
|
95
|
+
|
|
96
|
+
Returns ``(code, state)`` on success; ``(None, None)`` on timeout or error.
|
|
97
|
+
"""
|
|
98
|
+
received = self.callback_event.wait(timeout=timeout)
|
|
99
|
+
if not received:
|
|
100
|
+
return (None, None)
|
|
101
|
+
code, state, _error = self.callback_result
|
|
102
|
+
return (code, state)
|
|
103
|
+
|
|
104
|
+
def stop(self) -> None:
|
|
105
|
+
"""Shut down the server and join the daemon thread."""
|
|
106
|
+
self.shutdown()
|
|
107
|
+
if self._thread is not None:
|
|
108
|
+
self._thread.join(timeout=5)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Token persistence with keyring and file-based backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import warnings
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
SERVICE_NAME = "tescmd"
|
|
14
|
+
|
|
15
|
+
# Module-level flag so the file-fallback warning fires at most once.
|
|
16
|
+
_warned = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Backend protocol
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _TokenBackend(Protocol):
|
|
25
|
+
"""Minimal interface for reading/writing credential entries."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def backend_name(self) -> str: ...
|
|
29
|
+
def get(self, key: str) -> str | None: ...
|
|
30
|
+
def set(self, key: str, value: str) -> None: ...
|
|
31
|
+
def delete(self, key: str) -> None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Keyring backend (delegates to the OS keyring)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _KeyringBackend:
|
|
40
|
+
"""Wraps the ``keyring`` library."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def backend_name(self) -> str:
|
|
44
|
+
return "keyring"
|
|
45
|
+
|
|
46
|
+
def get(self, key: str) -> str | None:
|
|
47
|
+
import keyring as _kr
|
|
48
|
+
|
|
49
|
+
return _kr.get_password(SERVICE_NAME, key)
|
|
50
|
+
|
|
51
|
+
def set(self, key: str, value: str) -> None:
|
|
52
|
+
import keyring as _kr
|
|
53
|
+
|
|
54
|
+
_kr.set_password(SERVICE_NAME, key, value)
|
|
55
|
+
|
|
56
|
+
def delete(self, key: str) -> None:
|
|
57
|
+
import keyring as _kr
|
|
58
|
+
from keyring.errors import PasswordDeleteError
|
|
59
|
+
|
|
60
|
+
with contextlib.suppress(PasswordDeleteError):
|
|
61
|
+
_kr.delete_password(SERVICE_NAME, key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# File backend (JSON file with atomic writes)
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _FileBackend:
|
|
70
|
+
"""Stores credentials in a JSON file at *path*.
|
|
71
|
+
|
|
72
|
+
The file is written atomically (write-to-temp + rename) and
|
|
73
|
+
permissions are restricted to the current user.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, path: Path) -> None:
|
|
77
|
+
self._path = path
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def backend_name(self) -> str:
|
|
81
|
+
return f"file ({self._path})"
|
|
82
|
+
|
|
83
|
+
# -- helpers -------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _read_store(self) -> dict[str, str]:
|
|
86
|
+
if not self._path.exists():
|
|
87
|
+
return {}
|
|
88
|
+
try:
|
|
89
|
+
data: dict[str, str] = json.loads(self._path.read_text("utf-8"))
|
|
90
|
+
return data
|
|
91
|
+
except (json.JSONDecodeError, OSError):
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
def _write_store(self, data: dict[str, str]) -> None:
|
|
95
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
from tescmd._internal.permissions import secure_file
|
|
98
|
+
|
|
99
|
+
# Atomic write: temp file in the same directory → rename
|
|
100
|
+
fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), suffix=".tmp", prefix=".tokens_")
|
|
101
|
+
try:
|
|
102
|
+
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
|
|
103
|
+
os.close(fd)
|
|
104
|
+
fd = -1 # mark as closed
|
|
105
|
+
Path(tmp).replace(self._path)
|
|
106
|
+
except BaseException:
|
|
107
|
+
if fd != -1:
|
|
108
|
+
os.close(fd)
|
|
109
|
+
with contextlib.suppress(OSError):
|
|
110
|
+
os.unlink(tmp)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
secure_file(self._path)
|
|
114
|
+
|
|
115
|
+
# -- interface -----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def get(self, key: str) -> str | None:
|
|
118
|
+
return self._read_store().get(key)
|
|
119
|
+
|
|
120
|
+
def set(self, key: str, value: str) -> None:
|
|
121
|
+
store = self._read_store()
|
|
122
|
+
store[key] = value
|
|
123
|
+
self._write_store(store)
|
|
124
|
+
|
|
125
|
+
def delete(self, key: str) -> None:
|
|
126
|
+
store = self._read_store()
|
|
127
|
+
store.pop(key, None)
|
|
128
|
+
self._write_store(store)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Backend resolution
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve_backend(
|
|
137
|
+
token_file: str | None = None,
|
|
138
|
+
config_dir: str | None = None,
|
|
139
|
+
) -> _TokenBackend:
|
|
140
|
+
"""Choose the best available backend.
|
|
141
|
+
|
|
142
|
+
1. Explicit *token_file* → file backend (no keyring probe).
|
|
143
|
+
2. Keyring probe succeeds → keyring backend.
|
|
144
|
+
3. Keyring probe fails → file backend at ``{config_dir}/tokens.json``
|
|
145
|
+
with a one-time warning.
|
|
146
|
+
"""
|
|
147
|
+
# 1. Explicit token file — skip keyring entirely
|
|
148
|
+
if token_file:
|
|
149
|
+
return _FileBackend(Path(token_file).expanduser())
|
|
150
|
+
|
|
151
|
+
# 2. Probe keyring
|
|
152
|
+
try:
|
|
153
|
+
import keyring as _kr
|
|
154
|
+
|
|
155
|
+
_kr.get_password(SERVICE_NAME, "__probe__")
|
|
156
|
+
return _KeyringBackend()
|
|
157
|
+
except Exception:
|
|
158
|
+
# NoKeyringError, RuntimeError, or any other keyring failure
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# 3. Fall back to file
|
|
162
|
+
global _warned
|
|
163
|
+
resolved_dir = Path(config_dir or "~/.config/tescmd").expanduser()
|
|
164
|
+
fallback_path = resolved_dir / "tokens.json"
|
|
165
|
+
if not _warned:
|
|
166
|
+
_warned = True
|
|
167
|
+
warnings.warn(
|
|
168
|
+
f"OS keyring unavailable — storing tokens in {fallback_path} "
|
|
169
|
+
"(plaintext with restricted permissions). "
|
|
170
|
+
"Set TESLA_TOKEN_FILE to choose a different path.",
|
|
171
|
+
UserWarning,
|
|
172
|
+
stacklevel=3,
|
|
173
|
+
)
|
|
174
|
+
return _FileBackend(fallback_path)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Public API (unchanged surface)
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TokenStore:
|
|
183
|
+
"""Read / write OAuth tokens via the OS keyring or a file fallback."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
profile: str = "default",
|
|
188
|
+
*,
|
|
189
|
+
token_file: str | None = None,
|
|
190
|
+
config_dir: str | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._profile = profile
|
|
193
|
+
self._backend: _TokenBackend = _resolve_backend(token_file, config_dir)
|
|
194
|
+
|
|
195
|
+
# -- key helpers ---------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _key(self, name: str) -> str:
|
|
198
|
+
return f"{self._profile}/{name}"
|
|
199
|
+
|
|
200
|
+
# -- diagnostics ---------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def backend_name(self) -> str:
|
|
204
|
+
"""Return a human-readable description of the active backend."""
|
|
205
|
+
return self._backend.backend_name
|
|
206
|
+
|
|
207
|
+
# -- properties ----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def access_token(self) -> str | None:
|
|
211
|
+
"""Return the stored access token, or *None*."""
|
|
212
|
+
return self._backend.get(self._key("access_token"))
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def refresh_token(self) -> str | None:
|
|
216
|
+
"""Return the stored refresh token, or *None*."""
|
|
217
|
+
return self._backend.get(self._key("refresh_token"))
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def has_token(self) -> bool:
|
|
221
|
+
"""Return *True* if an access token is stored."""
|
|
222
|
+
return self.access_token is not None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def metadata(self) -> dict[str, Any] | None:
|
|
226
|
+
"""Return the parsed metadata dict, or *None*."""
|
|
227
|
+
raw = self._backend.get(self._key("metadata"))
|
|
228
|
+
if raw is None:
|
|
229
|
+
return None
|
|
230
|
+
result: dict[str, Any] = json.loads(raw)
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
# -- mutators ------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def save(
|
|
236
|
+
self,
|
|
237
|
+
access_token: str,
|
|
238
|
+
refresh_token: str,
|
|
239
|
+
expires_at: float,
|
|
240
|
+
scopes: list[str],
|
|
241
|
+
region: str,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Persist all three entries."""
|
|
244
|
+
self._backend.set(self._key("access_token"), access_token)
|
|
245
|
+
self._backend.set(self._key("refresh_token"), refresh_token)
|
|
246
|
+
meta = json.dumps({"expires_at": expires_at, "scopes": scopes, "region": region})
|
|
247
|
+
self._backend.set(self._key("metadata"), meta)
|
|
248
|
+
|
|
249
|
+
def clear(self) -> None:
|
|
250
|
+
"""Delete all stored credentials."""
|
|
251
|
+
for name in ("access_token", "refresh_token", "metadata"):
|
|
252
|
+
self._backend.delete(self._key(name))
|
|
253
|
+
|
|
254
|
+
# -- import / export -----------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def export_dict(self) -> dict[str, Any]:
|
|
257
|
+
"""Return a plain dict of all stored values (for ``auth export``)."""
|
|
258
|
+
return {
|
|
259
|
+
"access_token": self.access_token,
|
|
260
|
+
"refresh_token": self.refresh_token,
|
|
261
|
+
"metadata": self.metadata,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def import_dict(self, data: dict[str, Any]) -> None:
|
|
265
|
+
"""Restore tokens from a previously exported dict."""
|
|
266
|
+
meta: dict[str, Any] = data.get("metadata") or {}
|
|
267
|
+
self.save(
|
|
268
|
+
access_token=data["access_token"],
|
|
269
|
+
refresh_token=data["refresh_token"],
|
|
270
|
+
expires_at=meta.get("expires_at", 0.0),
|
|
271
|
+
scopes=meta.get("scopes", []),
|
|
272
|
+
region=meta.get("region", "na"),
|
|
273
|
+
)
|
tescmd/ble/__init__.py
ADDED
|
File without changes
|
tescmd/cache/__init__.py
ADDED