lojack-api 0.7.1__tar.gz → 0.7.2__tar.gz

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.
Files changed (24) hide show
  1. {lojack_api-0.7.1 → lojack_api-0.7.2}/PKG-INFO +1 -1
  2. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/__init__.py +1 -1
  3. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/auth.py +35 -12
  4. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/PKG-INFO +1 -1
  5. {lojack_api-0.7.1 → lojack_api-0.7.2}/pyproject.toml +1 -1
  6. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_auth.py +94 -1
  7. {lojack_api-0.7.1 → lojack_api-0.7.2}/LICENSE +0 -0
  8. {lojack_api-0.7.1 → lojack_api-0.7.2}/README.md +0 -0
  9. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/api.py +0 -0
  10. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/device.py +0 -0
  11. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/exceptions.py +0 -0
  12. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/models.py +0 -0
  13. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/py.typed +0 -0
  14. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/transport.py +0 -0
  15. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/SOURCES.txt +0 -0
  16. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/dependency_links.txt +0 -0
  17. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/requires.txt +0 -0
  18. {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/top_level.txt +0 -0
  19. {lojack_api-0.7.1 → lojack_api-0.7.2}/setup.cfg +0 -0
  20. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_client.py +0 -0
  21. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_device.py +0 -0
  22. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_exceptions.py +0 -0
  23. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_models.py +0 -0
  24. {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lojack_api
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: An async Python client library for the LoJack API, designed for Home Assistant integrations.
5
5
  Author: Devin Slick
6
6
  License: MIT
@@ -45,7 +45,7 @@ from .models import (
45
45
  )
46
46
  from .transport import AiohttpTransport
47
47
 
48
- __version__ = "0.7.0"
48
+ __version__ = "0.7.2"
49
49
 
50
50
  __all__ = [
51
51
  # Main client
@@ -12,10 +12,11 @@ The Spireon LoJack API uses:
12
12
  from __future__ import annotations
13
13
 
14
14
  import base64
15
+ import json
15
16
  import uuid
16
17
  from dataclasses import dataclass
17
18
  from datetime import datetime, timedelta, timezone
18
- from typing import TYPE_CHECKING, Any
19
+ from typing import TYPE_CHECKING, Any, cast
19
20
 
20
21
  from .exceptions import AuthenticationError
21
22
 
@@ -183,6 +184,19 @@ class AuthManager:
183
184
  user_id=self._user_id,
184
185
  )
185
186
 
187
+ @staticmethod
188
+ def _extract_user_id_from_jwt(token: str) -> str | None:
189
+ """Extract user ID from Spireon JWT ns:u claim (no signature verification needed)."""
190
+ try:
191
+ parts = token.split(".")
192
+ if len(parts) != 3:
193
+ return None
194
+ padding = "=" * (4 - len(parts[1]) % 4)
195
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + padding))
196
+ return cast("str | None", payload.get("ns:u") or payload.get("sub"))
197
+ except Exception:
198
+ return None
199
+
186
200
  async def login(self) -> str:
187
201
  """Authenticate with the identity service using Basic Auth.
188
202
 
@@ -215,21 +229,30 @@ class AuthManager:
215
229
 
216
230
  token: str = str(token_value)
217
231
  self._access_token = token
218
- self._user_id = data.get("userId") or data.get("user_id")
232
+ self._user_id = (
233
+ self._extract_user_id_from_jwt(token)
234
+ or data.get("userId")
235
+ or data.get("user_id")
236
+ )
219
237
 
220
- # Parse expiration - tokens typically expire after some time
221
- expires_in = data.get("expiresIn") or data.get("expires_in")
222
- if expires_in:
238
+ # Parse expiration - prefer ISO 8601 expiresOn, fall back to expiresIn seconds
239
+ expires_on = data.get("expiresOn")
240
+ if expires_on:
223
241
  try:
224
- self._expires_at = datetime.now(timezone.utc) + timedelta(
225
- seconds=int(expires_in)
226
- )
227
- except (ValueError, TypeError):
228
- # Default to 1 hour if not specified
242
+ self._expires_at = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
243
+ except ValueError:
229
244
  self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
230
245
  else:
231
- # Default expiration if not provided
232
- self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
246
+ expires_in = data.get("expiresIn") or data.get("expires_in")
247
+ if expires_in:
248
+ try:
249
+ self._expires_at = datetime.now(timezone.utc) + timedelta(
250
+ seconds=int(expires_in)
251
+ )
252
+ except (ValueError, TypeError):
253
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
254
+ else:
255
+ self._expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
233
256
 
234
257
  return token
235
258
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lojack_api
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: An async Python client library for the LoJack API, designed for Home Assistant integrations.
5
5
  Author: Devin Slick
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lojack_api"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "An async Python client library for the LoJack API, designed for Home Assistant integrations."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Devin Slick" }]
@@ -100,7 +100,7 @@ class TestAuthManager:
100
100
 
101
101
  @pytest.mark.asyncio
102
102
  async def test_login_success(self, auth, transport):
103
- """Test successful login."""
103
+ """Test successful login with legacy userId field."""
104
104
  transport.request.return_value = {
105
105
  "token": "new-token",
106
106
  "expiresIn": 3600,
@@ -114,6 +114,83 @@ class TestAuthManager:
114
114
  assert auth.user_id == "user-123"
115
115
  transport.request.assert_called_once()
116
116
 
117
+ @pytest.mark.asyncio
118
+ async def test_login_user_id_from_jwt(self, auth, transport):
119
+ """Test that user_id is extracted from JWT ns:u claim (real API response shape)."""
120
+ import base64
121
+ import json
122
+
123
+ claims = {"sub": "fnmrihikdnykh7rng", "ns:u": "da05c72d-6a30-466f-8d86-763bd1e8844a"}
124
+ payload = base64.urlsafe_b64encode(
125
+ json.dumps(claims).encode()
126
+ ).decode().rstrip("=")
127
+ jwt_token = f"header.{payload}.signature"
128
+
129
+ transport.request.return_value = {
130
+ "token": jwt_token,
131
+ "scope": "ACCOUNT_USER_SCOPE",
132
+ "expiresOn": "2026-03-27T03:12:54Z",
133
+ "refreshBy": "2026-03-27T03:11:54Z",
134
+ "refreshInSeconds": 86339,
135
+ }
136
+
137
+ token = await auth.login()
138
+
139
+ assert token == jwt_token
140
+ assert auth.user_id == "da05c72d-6a30-466f-8d86-763bd1e8844a"
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_login_user_id_falls_back_to_sub(self, auth, transport):
144
+ """Test that user_id falls back to JWT sub claim when ns:u is absent."""
145
+ import base64
146
+ import json
147
+
148
+ payload = base64.urlsafe_b64encode(
149
+ json.dumps({"sub": "fallback-sub-id"}).encode()
150
+ ).decode().rstrip("=")
151
+ jwt_token = f"header.{payload}.signature"
152
+
153
+ transport.request.return_value = {
154
+ "token": jwt_token,
155
+ "expiresOn": "2026-03-27T03:12:54Z",
156
+ }
157
+
158
+ await auth.login()
159
+
160
+ assert auth.user_id == "fallback-sub-id"
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_login_expires_on_parsed(self, auth, transport):
164
+ """Test that expiresOn ISO 8601 timestamp is parsed correctly."""
165
+ transport.request.return_value = {
166
+ "token": "new-token",
167
+ "expiresOn": "2026-03-27T03:12:54Z",
168
+ }
169
+
170
+ await auth.login()
171
+
172
+ assert auth._expires_at is not None
173
+ assert auth._expires_at.year == 2026
174
+ assert auth._expires_at.month == 3
175
+ assert auth._expires_at.day == 27
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_login_expires_on_invalid_falls_back(self, auth, transport):
179
+ """Test that an invalid expiresOn falls back to 1-hour default."""
180
+ from datetime import datetime, timezone
181
+
182
+ transport.request.return_value = {
183
+ "token": "new-token",
184
+ "expiresOn": "not-a-timestamp",
185
+ }
186
+
187
+ before = datetime.now(timezone.utc)
188
+ await auth.login()
189
+
190
+ assert auth._expires_at is not None
191
+ delta = auth._expires_at - before
192
+ assert 3590 < delta.total_seconds() < 3610 # ~1 hour
193
+
117
194
  @pytest.mark.asyncio
118
195
  async def test_login_missing_credentials(self, transport):
119
196
  """Test login without credentials."""
@@ -361,6 +438,22 @@ class TestGetSpireonHeaders:
361
438
  assert headers["Authorization"] == "Basic base64-credentials"
362
439
 
363
440
 
441
+ class TestExtractUserIdFromJwt:
442
+ """Tests for AuthManager._extract_user_id_from_jwt."""
443
+
444
+ def test_returns_none_for_non_three_part_token(self):
445
+ """Token without three dot-separated parts returns None."""
446
+ from lojack_api.auth import AuthManager
447
+ assert AuthManager._extract_user_id_from_jwt("not.a.valid.jwt.parts") is None
448
+
449
+ def test_returns_none_for_invalid_json_payload(self):
450
+ """Payload that is valid base64 but not JSON triggers except and returns None."""
451
+ import base64
452
+ from lojack_api.auth import AuthManager
453
+ bad_payload = base64.urlsafe_b64encode(b"not-json").decode().rstrip("=")
454
+ assert AuthManager._extract_user_id_from_jwt(f"header.{bad_payload}.sig") is None
455
+
456
+
364
457
  class TestEncodeBasicAuth:
365
458
  """Tests for encode_basic_auth function."""
366
459
 
File without changes
File without changes
File without changes
File without changes