newapi-python 0.1.0__tar.gz → 0.2.0__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.
- {newapi_python-0.1.0 → newapi_python-0.2.0}/PKG-INFO +18 -1
- {newapi_python-0.1.0 → newapi_python-0.2.0}/README.md +17 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/pyproject.toml +1 -1
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/__init__.py +1 -1
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/_client.py +73 -2
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/_models.py +3 -1
- newapi_python-0.2.0/src/newapi/_session_blob.py +109 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/tests/test_client.py +128 -0
- newapi_python-0.2.0/tests/test_session_blob.py +78 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/.gitignore +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/LICENSE +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/_crypto.py +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/_exceptions.py +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/_resources.py +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/src/newapi/py.typed +0 -0
- {newapi_python-0.1.0 → newapi_python-0.2.0}/tests/test_resources.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: newapi-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client for the user-facing dashboard API of new-api instances
|
|
5
5
|
Author: Eight Labs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -69,6 +69,23 @@ session = requests.Session(impersonate="safari")
|
|
|
69
69
|
client = NewAPI("https://newapi.example.com", session=session)
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
## Authenticate with a browser session cookie
|
|
73
|
+
|
|
74
|
+
One-api style deployments without the token-rotation flow authenticate the dashboard through the gorilla `session` cookie plus the numeric `New-Api-User` header that their frontend sends on every request. Pass the cookie value copied from the browser as `session_token`; the client decodes the embedded user id automatically and replays both the cookie and the header:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import os
|
|
78
|
+
|
|
79
|
+
from newapi import NewAPI
|
|
80
|
+
|
|
81
|
+
client = NewAPI(
|
|
82
|
+
"https://oneapi.example.com",
|
|
83
|
+
session_token=os.environ["NEWAPI_SESSION_TOKEN"],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Instances with opaque cookies embed no user id; pass `user_id` explicitly in that case, or to override the embedded one. The cookie name defaults to `session` and can be changed with `session_cookie`. When the instance re-issues the session cookie, the client picks up the rotated value from `Set-Cookie` automatically. Session-cookie mode has no refresh flow: `refresh()` raises `AuthenticationError`, and once the server-side session expires a fresh cookie value must be extracted from the browser. `logout()` calls the fork's legacy `POST /api/user/logout` endpoint in this mode.
|
|
88
|
+
|
|
72
89
|
## Log in with username and password
|
|
73
90
|
|
|
74
91
|
```python
|
|
@@ -39,6 +39,23 @@ session = requests.Session(impersonate="safari")
|
|
|
39
39
|
client = NewAPI("https://newapi.example.com", session=session)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Authenticate with a browser session cookie
|
|
43
|
+
|
|
44
|
+
One-api style deployments without the token-rotation flow authenticate the dashboard through the gorilla `session` cookie plus the numeric `New-Api-User` header that their frontend sends on every request. Pass the cookie value copied from the browser as `session_token`; the client decodes the embedded user id automatically and replays both the cookie and the header:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
from newapi import NewAPI
|
|
50
|
+
|
|
51
|
+
client = NewAPI(
|
|
52
|
+
"https://oneapi.example.com",
|
|
53
|
+
session_token=os.environ["NEWAPI_SESSION_TOKEN"],
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Instances with opaque cookies embed no user id; pass `user_id` explicitly in that case, or to override the embedded one. The cookie name defaults to `session` and can be changed with `session_cookie`. When the instance re-issues the session cookie, the client picks up the rotated value from `Set-Cookie` automatically. Session-cookie mode has no refresh flow: `refresh()` raises `AuthenticationError`, and once the server-side session expires a fresh cookie value must be extracted from the browser. `logout()` calls the fork's legacy `POST /api/user/logout` endpoint in this mode.
|
|
58
|
+
|
|
42
59
|
## Log in with username and password
|
|
43
60
|
|
|
44
61
|
```python
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import re
|
|
4
4
|
import threading
|
|
5
5
|
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import replace
|
|
6
7
|
from typing import Any, cast
|
|
7
8
|
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
8
9
|
|
|
@@ -33,6 +34,7 @@ from ._resources import (
|
|
|
33
34
|
TokensResource,
|
|
34
35
|
TopUpResource,
|
|
35
36
|
)
|
|
37
|
+
from ._session_blob import decode_session_blob
|
|
36
38
|
|
|
37
39
|
_ERROR_TYPES: dict[int, type[APIError]] = {
|
|
38
40
|
400: ValidationError,
|
|
@@ -46,6 +48,8 @@ _ERROR_TYPES: dict[int, type[APIError]] = {
|
|
|
46
48
|
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
47
49
|
_REFRESH_COOKIE = "new_api_refresh"
|
|
48
50
|
_REFRESH_COOKIE_PATTERN = re.compile(rf"(?:^|[;,\s]){_REFRESH_COOKIE}=([^;\s]+)")
|
|
51
|
+
_DEFAULT_SESSION_COOKIE = "session"
|
|
52
|
+
_USER_ID_HEADER = "New-Api-User"
|
|
49
53
|
_DEFAULT_QUOTA_PER_UNIT = 500000.0
|
|
50
54
|
|
|
51
55
|
|
|
@@ -145,6 +149,9 @@ class NewAPI:
|
|
|
145
149
|
refresh_token: str | None = None,
|
|
146
150
|
expires_at: float | None = None,
|
|
147
151
|
session_id: str | None = None,
|
|
152
|
+
session_token: str | None = None,
|
|
153
|
+
user_id: int | None = None,
|
|
154
|
+
session_cookie: str = _DEFAULT_SESSION_COOKIE,
|
|
148
155
|
timeout: float | tuple[float, float] = 30.0,
|
|
149
156
|
language: str = "en",
|
|
150
157
|
allow_insecure: bool = False,
|
|
@@ -153,11 +160,18 @@ class NewAPI:
|
|
|
153
160
|
) -> None:
|
|
154
161
|
self.base_url = _api_base_url(base_url, allow_insecure)
|
|
155
162
|
self.language = language
|
|
163
|
+
self._session_cookie = session_cookie
|
|
164
|
+
self._session_cookie_pattern = re.compile(
|
|
165
|
+
rf"(?:^|[;,\s]){re.escape(session_cookie)}=([^;\s]+)"
|
|
166
|
+
)
|
|
167
|
+
resolved_user_id = self._resolve_session_user_id(access_token, session_token, user_id)
|
|
156
168
|
self._tokens = SessionTokens(
|
|
157
169
|
access_token=access_token,
|
|
158
170
|
refresh_token=refresh_token,
|
|
159
171
|
expires_at=expires_at,
|
|
160
172
|
session_id=session_id,
|
|
173
|
+
session_token=session_token,
|
|
174
|
+
user_id=resolved_user_id,
|
|
161
175
|
)
|
|
162
176
|
self._refresh_lock = threading.RLock()
|
|
163
177
|
self._pending_2fa_token: str | None = None
|
|
@@ -196,6 +210,30 @@ class NewAPI:
|
|
|
196
210
|
def quota_per_unit(self) -> float | None:
|
|
197
211
|
return self._quota_per_unit
|
|
198
212
|
|
|
213
|
+
def _resolve_session_user_id(
|
|
214
|
+
self,
|
|
215
|
+
access_token: str | None,
|
|
216
|
+
session_token: str | None,
|
|
217
|
+
user_id: int | None,
|
|
218
|
+
) -> int | None:
|
|
219
|
+
if access_token and session_token:
|
|
220
|
+
raise ConfigurationError("pass either access_token or session_token, not both")
|
|
221
|
+
if not session_token or user_id is not None:
|
|
222
|
+
return user_id
|
|
223
|
+
try:
|
|
224
|
+
fields = decode_session_blob(session_token)
|
|
225
|
+
except ValueError as error:
|
|
226
|
+
raise ConfigurationError(
|
|
227
|
+
"session_token is not a decodable browser session cookie; "
|
|
228
|
+
"pass user_id alongside it for instances with opaque cookies"
|
|
229
|
+
) from error
|
|
230
|
+
embedded_id = fields.get("id")
|
|
231
|
+
if not isinstance(embedded_id, int) or isinstance(embedded_id, bool):
|
|
232
|
+
raise ConfigurationError(
|
|
233
|
+
"session_token does not embed a user id; pass user_id explicitly"
|
|
234
|
+
)
|
|
235
|
+
return embedded_id
|
|
236
|
+
|
|
199
237
|
def login(
|
|
200
238
|
self,
|
|
201
239
|
username: str,
|
|
@@ -255,6 +293,13 @@ class NewAPI:
|
|
|
255
293
|
return user
|
|
256
294
|
|
|
257
295
|
def refresh(self) -> SessionTokens:
|
|
296
|
+
if self._tokens.session_token and not self._tokens.refresh_token:
|
|
297
|
+
raise AuthenticationError(
|
|
298
|
+
"Session-cookie authentication has no refresh flow; "
|
|
299
|
+
"extract a fresh browser session cookie instead",
|
|
300
|
+
status_code=401,
|
|
301
|
+
code="SESSION_COOKIE_NO_REFRESH",
|
|
302
|
+
)
|
|
258
303
|
with self._refresh_lock:
|
|
259
304
|
refresh_token = self._tokens.refresh_token
|
|
260
305
|
if not refresh_token:
|
|
@@ -284,6 +329,17 @@ class NewAPI:
|
|
|
284
329
|
raise
|
|
285
330
|
|
|
286
331
|
def logout(self) -> None:
|
|
332
|
+
if self._tokens.session_token and not self._tokens.access_token:
|
|
333
|
+
try:
|
|
334
|
+
self._request(
|
|
335
|
+
"POST",
|
|
336
|
+
"user/logout",
|
|
337
|
+
authenticated=True,
|
|
338
|
+
allow_refresh=False,
|
|
339
|
+
)
|
|
340
|
+
finally:
|
|
341
|
+
self.clear_auth()
|
|
342
|
+
return
|
|
287
343
|
refresh_token = self._tokens.refresh_token
|
|
288
344
|
headers: dict[str, str] = {"Origin": self._origin()}
|
|
289
345
|
if self._tokens.access_token:
|
|
@@ -418,6 +474,8 @@ class NewAPI:
|
|
|
418
474
|
authenticated=authenticated,
|
|
419
475
|
refresh_cookie=refresh_cookie,
|
|
420
476
|
)
|
|
477
|
+
if authenticated and self._tokens.session_token:
|
|
478
|
+
self._harvest_session_cookie(response)
|
|
421
479
|
if (
|
|
422
480
|
response.status_code == 401
|
|
423
481
|
and authenticated
|
|
@@ -450,9 +508,13 @@ class NewAPI:
|
|
|
450
508
|
request_headers = {
|
|
451
509
|
"Accept": "application/json",
|
|
452
510
|
"Accept-Language": self.language,
|
|
453
|
-
"User-Agent": "newapi-python/0.
|
|
511
|
+
"User-Agent": "newapi-python/0.2.0",
|
|
454
512
|
}
|
|
455
|
-
if authenticated and self._tokens.
|
|
513
|
+
if authenticated and self._tokens.session_token:
|
|
514
|
+
request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
|
|
515
|
+
if self._tokens.user_id is not None:
|
|
516
|
+
request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
|
|
517
|
+
elif authenticated and self._tokens.access_token:
|
|
456
518
|
request_headers["Authorization"] = (
|
|
457
519
|
f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
458
520
|
)
|
|
@@ -555,6 +617,15 @@ class NewAPI:
|
|
|
555
617
|
return stored
|
|
556
618
|
return None
|
|
557
619
|
|
|
620
|
+
def _harvest_session_cookie(self, response: Any) -> None:
|
|
621
|
+
for value in _set_cookie_values(response):
|
|
622
|
+
match = self._session_cookie_pattern.search(value)
|
|
623
|
+
if match:
|
|
624
|
+
rotated = match.group(1)
|
|
625
|
+
if rotated and rotated != self._tokens.session_token:
|
|
626
|
+
self._tokens = replace(self._tokens, session_token=rotated)
|
|
627
|
+
return
|
|
628
|
+
|
|
558
629
|
def _session_has_refresh_cookie(self) -> bool:
|
|
559
630
|
jar = getattr(self._http, "cookies", None)
|
|
560
631
|
if jar is None:
|
|
@@ -225,10 +225,12 @@ class SessionTokens:
|
|
|
225
225
|
expires_at: float | None = None
|
|
226
226
|
token_type: str = "Bearer"
|
|
227
227
|
session_id: str | None = field(default=None, repr=False)
|
|
228
|
+
session_token: str | None = field(default=None, repr=False)
|
|
229
|
+
user_id: int | None = None
|
|
228
230
|
|
|
229
231
|
@property
|
|
230
232
|
def authenticated(self) -> bool:
|
|
231
|
-
return bool(self.access_token)
|
|
233
|
+
return bool(self.access_token or self.session_token)
|
|
232
234
|
|
|
233
235
|
@property
|
|
234
236
|
def expires_in(self) -> float | None:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Reader:
|
|
8
|
+
def __init__(self, data: bytes) -> None:
|
|
9
|
+
self.data = data
|
|
10
|
+
self.pos = 0
|
|
11
|
+
|
|
12
|
+
def byte(self) -> int:
|
|
13
|
+
if self.pos >= len(self.data):
|
|
14
|
+
raise ValueError("truncated gob data")
|
|
15
|
+
value = self.data[self.pos]
|
|
16
|
+
self.pos += 1
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
def uint(self) -> int:
|
|
20
|
+
first = self.byte()
|
|
21
|
+
if first < 0x80:
|
|
22
|
+
return first
|
|
23
|
+
count = 0x100 - first
|
|
24
|
+
if not 1 <= count <= 8:
|
|
25
|
+
raise ValueError("invalid gob uint length marker")
|
|
26
|
+
raw = self.raw(count)
|
|
27
|
+
return int.from_bytes(raw, "big")
|
|
28
|
+
|
|
29
|
+
def int_value(self) -> int:
|
|
30
|
+
value = self.uint()
|
|
31
|
+
if value & 1:
|
|
32
|
+
return ~(value >> 1)
|
|
33
|
+
return value >> 1
|
|
34
|
+
|
|
35
|
+
def raw(self, count: int) -> bytes:
|
|
36
|
+
if count < 0 or self.pos + count > len(self.data):
|
|
37
|
+
raise ValueError("truncated gob data")
|
|
38
|
+
raw = self.data[self.pos : self.pos + count]
|
|
39
|
+
self.pos += count
|
|
40
|
+
return raw
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_INTERFACE_TYPES = {
|
|
44
|
+
b"\x06": ("string", b"\x0c"),
|
|
45
|
+
b"\x03": ("int", b"\x04"),
|
|
46
|
+
b"\x04": ("bool", b"\x02"),
|
|
47
|
+
}
|
|
48
|
+
_ROOT_TYPE_ID = 64
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _interface_value(reader: _Reader) -> object:
|
|
52
|
+
selector = reader.raw(1)
|
|
53
|
+
if selector not in _INTERFACE_TYPES:
|
|
54
|
+
raise ValueError("unsupported gob interface value type")
|
|
55
|
+
name, tag = _INTERFACE_TYPES[selector]
|
|
56
|
+
if reader.raw(len(name)) != name.encode() or reader.raw(1) != tag:
|
|
57
|
+
raise ValueError("unexpected gob interface type header")
|
|
58
|
+
chunk_length = reader.uint()
|
|
59
|
+
chunk = _Reader(reader.raw(chunk_length))
|
|
60
|
+
if chunk.byte() != 0x00:
|
|
61
|
+
raise ValueError("unexpected gob value chunk lead byte")
|
|
62
|
+
if name == "string":
|
|
63
|
+
value: object = chunk.raw(chunk.uint()).decode()
|
|
64
|
+
elif name == "int":
|
|
65
|
+
value = chunk.int_value()
|
|
66
|
+
else:
|
|
67
|
+
value = bool(chunk.uint())
|
|
68
|
+
if chunk.pos != len(chunk.data):
|
|
69
|
+
raise ValueError("trailing bytes in gob value chunk")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode_gob_map(data: bytes) -> dict[str, object]:
|
|
74
|
+
reader = _Reader(data)
|
|
75
|
+
while reader.pos < len(reader.data):
|
|
76
|
+
message = _Reader(reader.raw(reader.uint()))
|
|
77
|
+
type_id = message.int_value()
|
|
78
|
+
if type_id < 0:
|
|
79
|
+
continue
|
|
80
|
+
if type_id != _ROOT_TYPE_ID:
|
|
81
|
+
raise ValueError(f"unexpected gob root type id {type_id}")
|
|
82
|
+
if message.byte() != 0x00:
|
|
83
|
+
raise ValueError("unexpected gob map lead byte")
|
|
84
|
+
result: dict[str, object] = {}
|
|
85
|
+
for _ in range(message.uint()):
|
|
86
|
+
key = _interface_value(message)
|
|
87
|
+
value = _interface_value(message)
|
|
88
|
+
if not isinstance(key, str):
|
|
89
|
+
raise ValueError("gob session map key is not a string")
|
|
90
|
+
result[key] = value
|
|
91
|
+
if message.pos != len(message.data):
|
|
92
|
+
raise ValueError("trailing bytes in gob value message")
|
|
93
|
+
return result
|
|
94
|
+
raise ValueError("gob stream contains no value message")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _base64_decode(value: str) -> bytes:
|
|
98
|
+
padded = value + "=" * (-len(value) % 4)
|
|
99
|
+
try:
|
|
100
|
+
return base64.urlsafe_b64decode(padded)
|
|
101
|
+
except (binascii.Error, ValueError):
|
|
102
|
+
return base64.b64decode(padded)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def decode_session_blob(token: str) -> dict[str, object]:
|
|
106
|
+
parts = _base64_decode(token.strip()).split(b"|", 2)
|
|
107
|
+
if len(parts) != 3 or not parts[0].isdigit():
|
|
108
|
+
raise ValueError("value is not a gorilla-style session cookie")
|
|
109
|
+
return _decode_gob_map(_base64_decode(parts[1].decode()))
|
|
@@ -10,6 +10,7 @@ import pytest
|
|
|
10
10
|
from cryptography.hazmat.primitives import serialization
|
|
11
11
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
12
12
|
from cryptography.hazmat.primitives.hashes import SHA256
|
|
13
|
+
from test_session_blob import FIXTURE_NO_ID, FIXTURE_USER, wrapped_session_cookie
|
|
13
14
|
|
|
14
15
|
from newapi import (
|
|
15
16
|
APIError,
|
|
@@ -23,6 +24,7 @@ from newapi import (
|
|
|
23
24
|
TwoFactorRequired,
|
|
24
25
|
)
|
|
25
26
|
from newapi._crypto import rsa_oaep_sha256_encrypt
|
|
27
|
+
from newapi._session_blob import decode_session_blob
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class TestSession:
|
|
@@ -457,3 +459,129 @@ def test_balance_uses_status_quota_per_unit() -> None:
|
|
|
457
459
|
assert str(balance.used_amount) == "0.75"
|
|
458
460
|
assert str(balance.aff_amount) == "0.25"
|
|
459
461
|
assert client.quota_per_unit == 1000.0
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_session_cookie_authenticates_with_cookie_and_user_id_header() -> None:
|
|
465
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
466
|
+
|
|
467
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
468
|
+
assert request.url.path.endswith("/user/self")
|
|
469
|
+
assert request.headers.get("cookie") == f"session={token}"
|
|
470
|
+
assert request.headers.get("New-Api-User") == "42"
|
|
471
|
+
assert "Authorization" not in request.headers
|
|
472
|
+
return envelope({"id": 42, "username": "fixture"})
|
|
473
|
+
|
|
474
|
+
client = NewAPI("https://example.test", session_token=token, session=TestSession(handler))
|
|
475
|
+
|
|
476
|
+
user = client.me()
|
|
477
|
+
|
|
478
|
+
assert user.id == 42
|
|
479
|
+
assert client.is_authenticated
|
|
480
|
+
assert client.session.session_token == token
|
|
481
|
+
assert client.session.user_id == 42
|
|
482
|
+
assert client.session.access_token is None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_session_cookie_accepts_explicit_user_id_override() -> None:
|
|
486
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
487
|
+
|
|
488
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
489
|
+
assert request.headers.get("New-Api-User") == "77"
|
|
490
|
+
return envelope({"id": 77})
|
|
491
|
+
|
|
492
|
+
client = NewAPI(
|
|
493
|
+
"https://example.test",
|
|
494
|
+
session_token=token,
|
|
495
|
+
user_id=77,
|
|
496
|
+
session=TestSession(handler),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
assert client.me().id == 77
|
|
500
|
+
assert client.session.user_id == 77
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_session_token_conflicts_with_access_token() -> None:
|
|
504
|
+
with pytest.raises(ConfigurationError):
|
|
505
|
+
NewAPI(
|
|
506
|
+
"https://example.test",
|
|
507
|
+
access_token="access",
|
|
508
|
+
session_token=wrapped_session_cookie(FIXTURE_USER),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_session_token_without_embedded_id_requires_user_id() -> None:
|
|
513
|
+
token = wrapped_session_cookie(FIXTURE_NO_ID)
|
|
514
|
+
|
|
515
|
+
with pytest.raises(ConfigurationError):
|
|
516
|
+
NewAPI("https://example.test", session_token=token)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_undecodable_session_token_requires_user_id() -> None:
|
|
520
|
+
with pytest.raises(ConfigurationError):
|
|
521
|
+
NewAPI("https://example.test", session_token="opaque-cookie-value")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_session_cookie_rotation_is_harvested() -> None:
|
|
525
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
526
|
+
calls: list[str | None] = []
|
|
527
|
+
|
|
528
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
529
|
+
calls.append(request.headers.get("cookie"))
|
|
530
|
+
if len(calls) == 1:
|
|
531
|
+
return httpx.Response(
|
|
532
|
+
200,
|
|
533
|
+
json={"success": True, "message": "", "data": {"id": 42}},
|
|
534
|
+
headers={"set-cookie": "session=session-rotated; Path=/; HttpOnly"},
|
|
535
|
+
)
|
|
536
|
+
return envelope({"id": 42})
|
|
537
|
+
|
|
538
|
+
client = NewAPI("https://example.test", session_token=token, session=TestSession(handler))
|
|
539
|
+
|
|
540
|
+
client.me()
|
|
541
|
+
client.me()
|
|
542
|
+
|
|
543
|
+
assert calls == [f"session={token}", "session=session-rotated"]
|
|
544
|
+
assert client.session.session_token == "session-rotated"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def test_session_cookie_refresh_raises_clear_error() -> None:
|
|
548
|
+
client = NewAPI(
|
|
549
|
+
"https://example.test",
|
|
550
|
+
session_token=wrapped_session_cookie(FIXTURE_USER),
|
|
551
|
+
session=TestSession(lambda request: envelope({})),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
with pytest.raises(AuthenticationError) as caught:
|
|
555
|
+
client.refresh()
|
|
556
|
+
|
|
557
|
+
assert caught.value.code == "SESSION_COOKIE_NO_REFRESH"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_session_cookie_logout_uses_legacy_endpoint_and_clears_auth() -> None:
|
|
561
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
562
|
+
|
|
563
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
564
|
+
assert request.url.path.endswith("/user/logout")
|
|
565
|
+
assert request.method == "POST"
|
|
566
|
+
assert request.headers.get("cookie") == f"session={token}"
|
|
567
|
+
assert request.headers.get("New-Api-User") == "42"
|
|
568
|
+
return envelope(None)
|
|
569
|
+
|
|
570
|
+
client = NewAPI("https://example.test", session_token=token, session=TestSession(handler))
|
|
571
|
+
|
|
572
|
+
client.logout()
|
|
573
|
+
|
|
574
|
+
assert not client.is_authenticated
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_session_cookie_is_redacted_from_representations() -> None:
|
|
578
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
579
|
+
|
|
580
|
+
client = NewAPI(
|
|
581
|
+
"https://example.test",
|
|
582
|
+
session_token=token,
|
|
583
|
+
session=TestSession(lambda request: envelope({})),
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
assert token not in repr(client.session)
|
|
587
|
+
assert decode_session_blob(client.session.session_token)["id"] == 42
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from newapi._session_blob import decode_session_blob
|
|
8
|
+
|
|
9
|
+
FIXTURE_USER = bytes.fromhex(
|
|
10
|
+
"0d7f040102ff8000011001100000ff90ff80000506737472696e670c060004726f6c6503696e74"
|
|
11
|
+
"0402000206737472696e670c08000673746174757303696e740402000206737472696e670c0700"
|
|
12
|
+
"0567726f757006737472696e670c09000764656661756c7406737472696e670c04000269640369"
|
|
13
|
+
"6e740402005406737472696e670c0a0008757365726e616d6506737472696e670c09000766697874"
|
|
14
|
+
"757265"
|
|
15
|
+
)
|
|
16
|
+
FIXTURE_NO_ID = bytes.fromhex(
|
|
17
|
+
"0d7f040102ff800001100110000026ff80000106737472696e670c0a0008757365726e616d6506"
|
|
18
|
+
"737472696e670c0600046e6f6964"
|
|
19
|
+
)
|
|
20
|
+
FIXTURE_MIXED = bytes.fromhex(
|
|
21
|
+
"0d7f040102ff8000011001100000ff83ff80000406737472696e670c070005636f756e7403696e74"
|
|
22
|
+
"040900f94000000000000206737472696e670c040002696403696e740402002106737472696e670c"
|
|
23
|
+
"0600046e6f746506737472696e670c1b0019206ec3a96761746966202620c3bc6ec3af636f646520"
|
|
24
|
+
"e29c9306737472696e670c060004666c616704626f6f6c02020000"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def wrapped_session_cookie(gob: bytes) -> str:
|
|
29
|
+
inner = base64.urlsafe_b64encode(gob).decode().rstrip("=")
|
|
30
|
+
raw = f"1788284860|{inner}|fixture-signature".encode()
|
|
31
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_decodes_typical_session_cookie() -> None:
|
|
35
|
+
token = wrapped_session_cookie(FIXTURE_USER)
|
|
36
|
+
|
|
37
|
+
assert decode_session_blob(token) == {
|
|
38
|
+
"group": "default",
|
|
39
|
+
"id": 42,
|
|
40
|
+
"role": 1,
|
|
41
|
+
"status": 1,
|
|
42
|
+
"username": "fixture",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_decodes_cookie_without_user_id() -> None:
|
|
47
|
+
assert decode_session_blob(wrapped_session_cookie(FIXTURE_NO_ID)) == {"username": "noid"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_decodes_negative_integers_large_integers_booleans_and_unicode() -> None:
|
|
51
|
+
assert decode_session_blob(wrapped_session_cookie(FIXTURE_MIXED)) == {
|
|
52
|
+
"count": 9007199254740993,
|
|
53
|
+
"flag": False,
|
|
54
|
+
"id": -17,
|
|
55
|
+
"note": " négatif & ünïcode ✓",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
"value",
|
|
61
|
+
[
|
|
62
|
+
"",
|
|
63
|
+
"session-cookie-value",
|
|
64
|
+
base64.urlsafe_b64encode(b"no pipes here").decode(),
|
|
65
|
+
base64.urlsafe_b64encode(b"not-a-timestamp|AAAA|sig").decode(),
|
|
66
|
+
base64.urlsafe_b64encode(b"1788284860|!!!not-base64!!!|sig").decode(),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
def test_rejects_non_session_values(value: str) -> None:
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
decode_session_blob(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_rejects_truncated_gob_payload() -> None:
|
|
75
|
+
token = wrapped_session_cookie(FIXTURE_USER[:24])
|
|
76
|
+
|
|
77
|
+
with pytest.raises(ValueError):
|
|
78
|
+
decode_session_blob(token)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|