newapi-python 0.1.0__py3-none-any.whl → 0.3.0__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.
newapi/__init__.py CHANGED
@@ -72,4 +72,4 @@ __all__ = [
72
72
  "ValidationError",
73
73
  ]
74
74
 
75
- __version__ = "0.1.0"
75
+ __version__ = "0.3.0"
newapi/_client.py CHANGED
@@ -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,36 @@ 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 access_token and user_id is None:
222
+ raise ConfigurationError(
223
+ "user_id is required when authenticating with an access_token; "
224
+ "new-api instances reject such requests with 401 "
225
+ "'Unauthorized, New-Api-User header not provided'"
226
+ )
227
+ if not session_token or user_id is not None:
228
+ return user_id
229
+ try:
230
+ fields = decode_session_blob(session_token)
231
+ except ValueError as error:
232
+ raise ConfigurationError(
233
+ "session_token is not a decodable browser session cookie; "
234
+ "pass user_id alongside it for instances with opaque cookies"
235
+ ) from error
236
+ embedded_id = fields.get("id")
237
+ if not isinstance(embedded_id, int) or isinstance(embedded_id, bool):
238
+ raise ConfigurationError(
239
+ "session_token does not embed a user id; pass user_id explicitly"
240
+ )
241
+ return embedded_id
242
+
199
243
  def login(
200
244
  self,
201
245
  username: str,
@@ -255,6 +299,13 @@ class NewAPI:
255
299
  return user
256
300
 
257
301
  def refresh(self) -> SessionTokens:
302
+ if self._tokens.session_token and not self._tokens.refresh_token:
303
+ raise AuthenticationError(
304
+ "Session-cookie authentication has no refresh flow; "
305
+ "extract a fresh browser session cookie instead",
306
+ status_code=401,
307
+ code="SESSION_COOKIE_NO_REFRESH",
308
+ )
258
309
  with self._refresh_lock:
259
310
  refresh_token = self._tokens.refresh_token
260
311
  if not refresh_token:
@@ -284,8 +335,21 @@ class NewAPI:
284
335
  raise
285
336
 
286
337
  def logout(self) -> None:
338
+ if self._tokens.session_token and not self._tokens.access_token:
339
+ try:
340
+ self._request(
341
+ "POST",
342
+ "user/logout",
343
+ authenticated=True,
344
+ allow_refresh=False,
345
+ )
346
+ finally:
347
+ self.clear_auth()
348
+ return
287
349
  refresh_token = self._tokens.refresh_token
288
350
  headers: dict[str, str] = {"Origin": self._origin()}
351
+ if self._tokens.user_id is not None:
352
+ headers[_USER_ID_HEADER] = str(self._tokens.user_id)
289
353
  if self._tokens.access_token:
290
354
  headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
291
355
  try:
@@ -418,6 +482,8 @@ class NewAPI:
418
482
  authenticated=authenticated,
419
483
  refresh_cookie=refresh_cookie,
420
484
  )
485
+ if authenticated and self._tokens.session_token:
486
+ self._harvest_session_cookie(response)
421
487
  if (
422
488
  response.status_code == 401
423
489
  and authenticated
@@ -450,12 +516,17 @@ class NewAPI:
450
516
  request_headers = {
451
517
  "Accept": "application/json",
452
518
  "Accept-Language": self.language,
453
- "User-Agent": "newapi-python/0.1.0",
519
+ "User-Agent": "newapi-python/0.3.0",
454
520
  }
455
- if authenticated and self._tokens.access_token:
456
- request_headers["Authorization"] = (
457
- f"{self._tokens.token_type} {self._tokens.access_token}"
458
- )
521
+ if authenticated:
522
+ if self._tokens.user_id is not None:
523
+ request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
524
+ if self._tokens.session_token:
525
+ request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
526
+ elif self._tokens.access_token:
527
+ request_headers["Authorization"] = (
528
+ f"{self._tokens.token_type} {self._tokens.access_token}"
529
+ )
459
530
  if refresh_cookie and not self._session_has_refresh_cookie():
460
531
  request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
461
532
  if headers:
@@ -555,6 +626,15 @@ class NewAPI:
555
626
  return stored
556
627
  return None
557
628
 
629
+ def _harvest_session_cookie(self, response: Any) -> None:
630
+ for value in _set_cookie_values(response):
631
+ match = self._session_cookie_pattern.search(value)
632
+ if match:
633
+ rotated = match.group(1)
634
+ if rotated and rotated != self._tokens.session_token:
635
+ self._tokens = replace(self._tokens, session_token=rotated)
636
+ return
637
+
558
638
  def _session_has_refresh_cookie(self) -> bool:
559
639
  jar = getattr(self._http, "cookies", None)
560
640
  if jar is None:
@@ -606,12 +686,19 @@ class NewAPI:
606
686
  if isinstance(session, Mapping):
607
687
  raw_session_id = session.get("sid")
608
688
  session_id = raw_session_id if isinstance(raw_session_id, str) else None
689
+ user_id = self._tokens.user_id
690
+ user_data = auth.get("user")
691
+ if isinstance(user_data, Mapping):
692
+ raw_user_id = user_data.get("id")
693
+ if isinstance(raw_user_id, int) and not isinstance(raw_user_id, bool):
694
+ user_id = raw_user_id
609
695
  return SessionTokens(
610
696
  access_token=str(auth["access_token"]),
611
697
  refresh_token=refresh_token,
612
698
  expires_at=expires_at,
613
699
  token_type=str(token_type) if token_type else "Bearer",
614
700
  session_id=session_id,
701
+ user_id=user_id,
615
702
  )
616
703
 
617
704
  def _resolve_quota_per_unit(self) -> float:
newapi/_models.py CHANGED
@@ -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()))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newapi-python
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -55,10 +55,13 @@ client = NewAPI(
55
55
  "https://newapi.example.com",
56
56
  access_token=os.environ["NEWAPI_ACCESS_TOKEN"],
57
57
  refresh_token=os.environ.get("NEWAPI_REFRESH_TOKEN"),
58
+ user_id=int(os.environ["NEWAPI_USER_ID"]),
58
59
  )
59
60
  ```
60
61
 
61
- Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known. Users can also generate a long-lived system access token from the dashboard and pass it as `access_token` alone; such tokens cannot refresh browser sessions.
62
+ Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
63
+
64
+ Users can also generate a long-lived system access token from the dashboard and pass it as `access_token`. Access-token authentication additionally requires `user_id`: new-api instances verify the numeric `New-Api-User` header on every authenticated request and reject requests without it with `401 Unauthorized, New-Api-User header not provided`. The client sends the header automatically from `user_id`, and `login()` and `refresh()` capture the id from the instance's responses. Such tokens cannot refresh browser sessions on their own.
62
65
 
63
66
  The default browser fingerprint is Chrome. Choose another `curl_cffi` fingerprint or configure proxies by supplying your own `curl_cffi.requests.Session`:
64
67
 
@@ -69,6 +72,23 @@ session = requests.Session(impersonate="safari")
69
72
  client = NewAPI("https://newapi.example.com", session=session)
70
73
  ```
71
74
 
75
+ ## Authenticate with a browser session cookie
76
+
77
+ 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:
78
+
79
+ ```python
80
+ import os
81
+
82
+ from newapi import NewAPI
83
+
84
+ client = NewAPI(
85
+ "https://oneapi.example.com",
86
+ session_token=os.environ["NEWAPI_SESSION_TOKEN"],
87
+ )
88
+ ```
89
+
90
+ 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.
91
+
72
92
  ## Log in with username and password
73
93
 
74
94
  ```python
@@ -0,0 +1,12 @@
1
+ newapi/__init__.py,sha256=_BZDS3mvNV8s4ckB4x4duVPa5QxruHLHxCeuS5gYc5I,1310
2
+ newapi/_client.py,sha256=ci7AU9R0_IA_xx-Ev01xjv8NMdxgPoJr2dgrMu85zaI,25791
3
+ newapi/_crypto.py,sha256=ocWpBUsQoZlg_dk1Q_vLVKLGEc6I80RpgYlHmLQXZ-I,3902
4
+ newapi/_exceptions.py,sha256=YlZVzYf6VUKSN-fVVvFHfunoNJg6LLcuR2IdHNKzaCg,2049
5
+ newapi/_models.py,sha256=FWUDMqjroiaZLZdj8v_CKcANB6XZZMULrXWdu5mrxE0,6379
6
+ newapi/_resources.py,sha256=IQ1B3QoufC4p8KpXg6NlU9nmUQgTYmncSIYPLoLKYUc,28476
7
+ newapi/_session_blob.py,sha256=GIv4k2H0YrPghNGRg8iY2LZ8kgkFLrlMCpvUtIy35kw,3547
8
+ newapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ newapi_python-0.3.0.dist-info/METADATA,sha256=SFbWbiKQppLpy6HXcJ5uTuVVn-KGoV36wDLyjroxlro,11367
10
+ newapi_python-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ newapi_python-0.3.0.dist-info/licenses/LICENSE,sha256=SIZvsDcR9_4_Q4IMiJNqUwZWvdAmwjRHlXIQkXFwCwE,1067
12
+ newapi_python-0.3.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- newapi/__init__.py,sha256=RxmHlpJQCJ-vQhHsKT06x9ppedYms__-hx14SVf6m3A,1310
2
- newapi/_client.py,sha256=GFrcoTTXtE660jDk45JtVWLLjMfJkLfdl390IuZMul8,21925
3
- newapi/_crypto.py,sha256=ocWpBUsQoZlg_dk1Q_vLVKLGEc6I80RpgYlHmLQXZ-I,3902
4
- newapi/_exceptions.py,sha256=YlZVzYf6VUKSN-fVVvFHfunoNJg6LLcuR2IdHNKzaCg,2049
5
- newapi/_models.py,sha256=8hI47h4o5F5D3CuBEl491UvjEcC1qxCZgflEsuwFfx0,6262
6
- newapi/_resources.py,sha256=IQ1B3QoufC4p8KpXg6NlU9nmUQgTYmncSIYPLoLKYUc,28476
7
- newapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- newapi_python-0.1.0.dist-info/METADATA,sha256=DlRfbe3_knGRi2PppLNI5sGcsZ4Y0C_purpo7N2Zv7E,9776
9
- newapi_python-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
- newapi_python-0.1.0.dist-info/licenses/LICENSE,sha256=SIZvsDcR9_4_Q4IMiJNqUwZWvdAmwjRHlXIQkXFwCwE,1067
11
- newapi_python-0.1.0.dist-info/RECORD,,