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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newapi-python
3
- Version: 0.1.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "newapi-python"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python client for the user-facing dashboard API of new-api instances"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -72,4 +72,4 @@ __all__ = [
72
72
  "ValidationError",
73
73
  ]
74
74
 
75
- __version__ = "0.1.0"
75
+ __version__ = "0.2.0"
@@ -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.1.0",
511
+ "User-Agent": "newapi-python/0.2.0",
454
512
  }
455
- if authenticated and self._tokens.access_token:
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