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.
- {lojack_api-0.7.1 → lojack_api-0.7.2}/PKG-INFO +1 -1
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/__init__.py +1 -1
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/auth.py +35 -12
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/PKG-INFO +1 -1
- {lojack_api-0.7.1 → lojack_api-0.7.2}/pyproject.toml +1 -1
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_auth.py +94 -1
- {lojack_api-0.7.1 → lojack_api-0.7.2}/LICENSE +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/README.md +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/api.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/device.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/exceptions.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/models.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/py.typed +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api/transport.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/SOURCES.txt +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/dependency_links.txt +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/requires.txt +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/lojack_api.egg-info/top_level.txt +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/setup.cfg +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_client.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_device.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_exceptions.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_models.py +0 -0
- {lojack_api-0.7.1 → lojack_api-0.7.2}/tests/test_transport.py +0 -0
|
@@ -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 =
|
|
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 -
|
|
221
|
-
|
|
222
|
-
if
|
|
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.
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lojack_api"
|
|
7
|
-
version = "0.7.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|