fastapi-twitch 0.1.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.
- fastapi_twitch/__init__.py +58 -0
- fastapi_twitch/_constants.py +14 -0
- fastapi_twitch/core.py +386 -0
- fastapi_twitch/exceptions.py +52 -0
- fastapi_twitch/integrations/__init__.py +0 -0
- fastapi_twitch/integrations/fastapi.py +228 -0
- fastapi_twitch/models.py +95 -0
- fastapi_twitch/py.typed +0 -0
- fastapi_twitch/state.py +108 -0
- fastapi_twitch-0.1.0.dist-info/METADATA +461 -0
- fastapi_twitch-0.1.0.dist-info/RECORD +13 -0
- fastapi_twitch-0.1.0.dist-info/WHEEL +4 -0
- fastapi_twitch-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from ._constants import DEFAULT_SCOPES
|
|
6
|
+
from .core import TwitchOAuthClient
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
InvalidPKCEError,
|
|
10
|
+
InvalidStateError,
|
|
11
|
+
OAuthError,
|
|
12
|
+
ScopeMissingError,
|
|
13
|
+
StateStoreError,
|
|
14
|
+
TwitchAPIError,
|
|
15
|
+
TwitchAuthError,
|
|
16
|
+
)
|
|
17
|
+
from .models import StatePayload, TokenSet, TwitchTokens, TwitchUser
|
|
18
|
+
from .state import RedisStateStore, SessionStateStore, StateStore
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .integrations.fastapi import TwitchAuth
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DEFAULT_SCOPES",
|
|
27
|
+
"ConfigurationError",
|
|
28
|
+
"InvalidPKCEError",
|
|
29
|
+
"InvalidStateError",
|
|
30
|
+
"OAuthError",
|
|
31
|
+
"RedisStateStore",
|
|
32
|
+
"ScopeMissingError",
|
|
33
|
+
"SessionStateStore",
|
|
34
|
+
"StatePayload",
|
|
35
|
+
"StateStore",
|
|
36
|
+
"StateStoreError",
|
|
37
|
+
"TokenSet",
|
|
38
|
+
"TwitchAPIError",
|
|
39
|
+
"TwitchAuth",
|
|
40
|
+
"TwitchAuthError",
|
|
41
|
+
"TwitchOAuthClient",
|
|
42
|
+
"TwitchTokens",
|
|
43
|
+
"TwitchUser",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __getattr__(name: str) -> Any:
|
|
49
|
+
if name == "TwitchAuth":
|
|
50
|
+
try:
|
|
51
|
+
from .integrations.fastapi import TwitchAuth as _TwitchAuth
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"TwitchAuth requires the FastAPI extra: "
|
|
55
|
+
"`pip install 'fastapi-twitch[fastapi]'`"
|
|
56
|
+
) from exc
|
|
57
|
+
return _TwitchAuth
|
|
58
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
DEFAULT_SCOPES: tuple[str, ...] = ("user:read:email",)
|
|
4
|
+
|
|
5
|
+
AUTH_URL = "https://id.twitch.tv/oauth2/authorize"
|
|
6
|
+
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
|
7
|
+
VALIDATE_URL = "https://id.twitch.tv/oauth2/validate"
|
|
8
|
+
REVOKE_URL = "https://id.twitch.tv/oauth2/revoke"
|
|
9
|
+
HELIX_USERS_URL = "https://api.twitch.tv/helix/users"
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT: float = 10.0
|
|
12
|
+
DEFAULT_STATE_TTL_SECONDS: int = 600
|
|
13
|
+
TOKEN_MASK_KEEP: int = 4
|
|
14
|
+
MAX_HELIX_IDS_PER_REQUEST: int = 100
|
fastapi_twitch/core.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._constants import (
|
|
14
|
+
AUTH_URL,
|
|
15
|
+
DEFAULT_TIMEOUT,
|
|
16
|
+
HELIX_USERS_URL,
|
|
17
|
+
MAX_HELIX_IDS_PER_REQUEST,
|
|
18
|
+
REVOKE_URL,
|
|
19
|
+
TOKEN_URL,
|
|
20
|
+
VALIDATE_URL,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import OAuthError, TwitchAPIError
|
|
23
|
+
from .models import TokenSet, TwitchTokens, TwitchUser
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _b64url(data: bytes) -> str:
|
|
27
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TwitchOAuthClient:
|
|
31
|
+
"""Async Twitch OAuth 2.0 + Helix ``/users`` client.
|
|
32
|
+
|
|
33
|
+
Owns an :class:`httpx.AsyncClient` (or uses an injected one) and exposes
|
|
34
|
+
PKCE-aware login, code exchange, refresh, validation, revocation, and
|
|
35
|
+
account-data fetches. No FastAPI imports — safe to use from aiohttp,
|
|
36
|
+
plain asyncio scripts, or Celery tasks.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
client_id: str,
|
|
42
|
+
client_secret: str,
|
|
43
|
+
*,
|
|
44
|
+
http_client: httpx.AsyncClient | None = None,
|
|
45
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not client_id:
|
|
48
|
+
raise ValueError("client_id is required")
|
|
49
|
+
if not client_secret:
|
|
50
|
+
raise ValueError("client_secret is required")
|
|
51
|
+
self._client_id = client_id
|
|
52
|
+
self._client_secret = client_secret
|
|
53
|
+
self._owns_client = http_client is None
|
|
54
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def client_id(self) -> str:
|
|
58
|
+
return self._client_id
|
|
59
|
+
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
if self._owns_client:
|
|
62
|
+
await self._http.aclose()
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> TwitchOAuthClient:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
68
|
+
await self.aclose()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def generate_state() -> str:
|
|
72
|
+
"""Cryptographically-strong state token (32 bytes, URL-safe)."""
|
|
73
|
+
return secrets.token_urlsafe(32)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
77
|
+
"""Return ``(code_verifier, code_challenge)`` for S256 PKCE."""
|
|
78
|
+
verifier = secrets.token_urlsafe(64)
|
|
79
|
+
return verifier, TwitchOAuthClient.challenge_for(verifier)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def challenge_for(code_verifier: str) -> str:
|
|
83
|
+
"""Return the S256 ``code_challenge`` matching a ``code_verifier``."""
|
|
84
|
+
return _b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
|
85
|
+
|
|
86
|
+
def login_url(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
redirect_uri: str,
|
|
90
|
+
state: str,
|
|
91
|
+
code_challenge: str,
|
|
92
|
+
scopes: list[str],
|
|
93
|
+
) -> str:
|
|
94
|
+
if not redirect_uri:
|
|
95
|
+
raise ValueError("redirect_uri is required")
|
|
96
|
+
if not state:
|
|
97
|
+
raise ValueError("state is required")
|
|
98
|
+
if not code_challenge:
|
|
99
|
+
raise ValueError("code_challenge is required")
|
|
100
|
+
params = {
|
|
101
|
+
"client_id": self._client_id,
|
|
102
|
+
"redirect_uri": redirect_uri,
|
|
103
|
+
"response_type": "code",
|
|
104
|
+
"scope": " ".join(scopes),
|
|
105
|
+
"state": state,
|
|
106
|
+
"code_challenge": code_challenge,
|
|
107
|
+
"code_challenge_method": "S256",
|
|
108
|
+
}
|
|
109
|
+
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
110
|
+
|
|
111
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
112
|
+
# RFC 6749 §2.3.1 - client_secret_basic
|
|
113
|
+
token = _b64url(f"{self._client_id}:{self._client_secret}".encode())
|
|
114
|
+
return {
|
|
115
|
+
"Authorization": f"Basic {token}",
|
|
116
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
117
|
+
"Accept": "application/json",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _bearer_headers(self, access_token: str) -> dict[str, str]:
|
|
121
|
+
return {
|
|
122
|
+
"Authorization": f"Bearer {access_token}",
|
|
123
|
+
"Client-Id": self._client_id,
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async def _post_form(self, url: str, data: dict[str, str]) -> dict[str, Any]:
|
|
128
|
+
try:
|
|
129
|
+
response = await self._http.post(url, data=data, headers=self._auth_headers())
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
raise TwitchAPIError(0, str(exc)) from exc
|
|
132
|
+
return self._parse_json(response)
|
|
133
|
+
|
|
134
|
+
async def _post_empty(self, url: str, params: dict[str, str]) -> None:
|
|
135
|
+
try:
|
|
136
|
+
response = await self._http.post(url, params=params, headers=self._auth_headers())
|
|
137
|
+
except httpx.HTTPError as exc:
|
|
138
|
+
raise TwitchAPIError(0, str(exc)) from exc
|
|
139
|
+
if response.status_code >= 400:
|
|
140
|
+
raise TwitchAPIError(response.status_code, response.text)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _parse_json(response: httpx.Response) -> dict[str, Any]:
|
|
144
|
+
if response.status_code == 400:
|
|
145
|
+
# RFC 6749 error envelope
|
|
146
|
+
try:
|
|
147
|
+
payload = response.json()
|
|
148
|
+
except (ValueError, json.JSONDecodeError):
|
|
149
|
+
raise TwitchAPIError(response.status_code, response.text) from None
|
|
150
|
+
raise OAuthError(
|
|
151
|
+
str(payload.get("error", "unknown_error")),
|
|
152
|
+
payload.get("error_description"),
|
|
153
|
+
)
|
|
154
|
+
if response.status_code >= 400:
|
|
155
|
+
raise TwitchAPIError(response.status_code, response.text)
|
|
156
|
+
try:
|
|
157
|
+
payload = response.json()
|
|
158
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
159
|
+
raise TwitchAPIError(response.status_code, response.text) from exc
|
|
160
|
+
if not isinstance(payload, dict):
|
|
161
|
+
raise TwitchAPIError(response.status_code, "non-object response")
|
|
162
|
+
return payload
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _parse_tokens(payload: dict[str, Any]) -> TwitchTokens:
|
|
166
|
+
try:
|
|
167
|
+
return TwitchTokens(
|
|
168
|
+
access_token=payload["access_token"],
|
|
169
|
+
refresh_token=payload["refresh_token"],
|
|
170
|
+
expires_in=int(payload["expires_in"]),
|
|
171
|
+
scope=(
|
|
172
|
+
payload.get("scope", "").split()
|
|
173
|
+
if isinstance(payload.get("scope"), str)
|
|
174
|
+
else list(payload.get("scope", []))
|
|
175
|
+
),
|
|
176
|
+
obtained_at=datetime.now(timezone.utc),
|
|
177
|
+
)
|
|
178
|
+
except KeyError as exc:
|
|
179
|
+
raise OAuthError("invalid_response", f"missing field: {exc.args[0]}") from exc
|
|
180
|
+
|
|
181
|
+
async def exchange_code(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
code: str,
|
|
185
|
+
redirect_uri: str,
|
|
186
|
+
code_verifier: str,
|
|
187
|
+
) -> TwitchTokens:
|
|
188
|
+
"""Trade an authorization code for a token set (PKCE-protected)."""
|
|
189
|
+
if not code:
|
|
190
|
+
raise ValueError("code is required")
|
|
191
|
+
if not redirect_uri:
|
|
192
|
+
raise ValueError("redirect_uri is required")
|
|
193
|
+
if not code_verifier:
|
|
194
|
+
raise ValueError("code_verifier is required")
|
|
195
|
+
payload = await self._post_form(
|
|
196
|
+
TOKEN_URL,
|
|
197
|
+
{
|
|
198
|
+
"client_id": self._client_id,
|
|
199
|
+
"grant_type": "authorization_code",
|
|
200
|
+
"code": code,
|
|
201
|
+
"redirect_uri": redirect_uri,
|
|
202
|
+
"code_verifier": code_verifier,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
return self._parse_tokens(payload)
|
|
206
|
+
|
|
207
|
+
async def refresh_tokens(self, *, refresh_token: str) -> TwitchTokens:
|
|
208
|
+
if not refresh_token:
|
|
209
|
+
raise ValueError("refresh_token is required")
|
|
210
|
+
payload = await self._post_form(
|
|
211
|
+
TOKEN_URL,
|
|
212
|
+
{
|
|
213
|
+
"client_id": self._client_id,
|
|
214
|
+
"grant_type": "refresh_token",
|
|
215
|
+
"refresh_token": refresh_token,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
return self._parse_tokens(payload)
|
|
219
|
+
|
|
220
|
+
async def validate(self, access_token: str) -> TwitchUser:
|
|
221
|
+
"""Call ``GET /oauth2/validate`` to confirm a token + return minimal user."""
|
|
222
|
+
if not access_token:
|
|
223
|
+
raise ValueError("access_token is required")
|
|
224
|
+
try:
|
|
225
|
+
response = await self._http.get(
|
|
226
|
+
VALIDATE_URL,
|
|
227
|
+
headers={"Authorization": f"OAuth {access_token}"},
|
|
228
|
+
)
|
|
229
|
+
except httpx.HTTPError as exc:
|
|
230
|
+
raise TwitchAPIError(0, str(exc)) from exc
|
|
231
|
+
if response.status_code == 401:
|
|
232
|
+
raise TwitchAPIError(401, "token invalid or expired")
|
|
233
|
+
if response.status_code >= 400:
|
|
234
|
+
raise TwitchAPIError(response.status_code, response.text)
|
|
235
|
+
data = self._parse_json(response)
|
|
236
|
+
try:
|
|
237
|
+
return TwitchUser(
|
|
238
|
+
id=str(data["user_id"]),
|
|
239
|
+
login=data["login"],
|
|
240
|
+
scopes=data.get("scopes", []) or [],
|
|
241
|
+
)
|
|
242
|
+
except KeyError as exc:
|
|
243
|
+
raise OAuthError("invalid_response", f"missing field: {exc.args[0]}") from exc
|
|
244
|
+
|
|
245
|
+
async def revoke(self, *, token: str) -> None:
|
|
246
|
+
"""Best-effort token revocation. Twitch always returns 200 even for unknown tokens."""
|
|
247
|
+
if not token:
|
|
248
|
+
raise ValueError("token is required")
|
|
249
|
+
await self._post_empty(REVOKE_URL, {"client_id": self._client_id, "token": token})
|
|
250
|
+
|
|
251
|
+
async def fetch_user(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
access_token: str,
|
|
255
|
+
user_id: str | None = None,
|
|
256
|
+
login: str | None = None,
|
|
257
|
+
) -> TwitchUser:
|
|
258
|
+
"""Fetch one user via Helix ``GET /helix/users``.
|
|
259
|
+
|
|
260
|
+
Without ``user_id``/``login`` returns the current token holder.
|
|
261
|
+
"""
|
|
262
|
+
users = await self.fetch_users(
|
|
263
|
+
access_token=access_token, ids=[user_id] if user_id else None,
|
|
264
|
+
logins=[login] if login else None,
|
|
265
|
+
)
|
|
266
|
+
if not users:
|
|
267
|
+
raise TwitchAPIError(404, "user not found")
|
|
268
|
+
return users[0]
|
|
269
|
+
|
|
270
|
+
async def fetch_users(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
access_token: str,
|
|
274
|
+
ids: list[str] | None = None,
|
|
275
|
+
logins: list[str] | None = None,
|
|
276
|
+
) -> list[TwitchUser]:
|
|
277
|
+
"""Fetch up to 100 users at a time. Splits into batches automatically."""
|
|
278
|
+
if not access_token:
|
|
279
|
+
raise ValueError("access_token is required")
|
|
280
|
+
ids = ids or []
|
|
281
|
+
logins = logins or []
|
|
282
|
+
if not ids and not logins:
|
|
283
|
+
return [await self._fetch_helix_me(access_token)]
|
|
284
|
+
|
|
285
|
+
results: list[TwitchUser] = []
|
|
286
|
+
for chunk in _chunked(ids, MAX_HELIX_IDS_PER_REQUEST):
|
|
287
|
+
results.extend(await self._fetch_helix(access_token, id_=chunk, login=None))
|
|
288
|
+
for chunk in _chunked(logins, MAX_HELIX_IDS_PER_REQUEST):
|
|
289
|
+
results.extend(await self._fetch_helix(access_token, id_=None, login=chunk))
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
async def _fetch_helix_me(self, access_token: str) -> TwitchUser:
|
|
293
|
+
try:
|
|
294
|
+
response = await self._http.get(
|
|
295
|
+
HELIX_USERS_URL, headers=self._bearer_headers(access_token)
|
|
296
|
+
)
|
|
297
|
+
except httpx.HTTPError as exc:
|
|
298
|
+
raise TwitchAPIError(0, str(exc)) from exc
|
|
299
|
+
users = self._parse_helix_users(response)
|
|
300
|
+
if not users:
|
|
301
|
+
raise TwitchAPIError(404, "user not found")
|
|
302
|
+
return users[0]
|
|
303
|
+
|
|
304
|
+
async def _fetch_helix(
|
|
305
|
+
self,
|
|
306
|
+
access_token: str,
|
|
307
|
+
*,
|
|
308
|
+
id_: list[str] | None,
|
|
309
|
+
login: list[str] | None,
|
|
310
|
+
) -> list[TwitchUser]:
|
|
311
|
+
params: dict[str, Any] = {}
|
|
312
|
+
if id_:
|
|
313
|
+
params["id"] = id_
|
|
314
|
+
if login:
|
|
315
|
+
params["login"] = login
|
|
316
|
+
try:
|
|
317
|
+
response = await self._http.get(
|
|
318
|
+
HELIX_USERS_URL, params=params, headers=self._bearer_headers(access_token)
|
|
319
|
+
)
|
|
320
|
+
except httpx.HTTPError as exc:
|
|
321
|
+
raise TwitchAPIError(0, str(exc)) from exc
|
|
322
|
+
return self._parse_helix_users(response)
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _parse_helix_users(response: httpx.Response) -> list[TwitchUser]:
|
|
326
|
+
if response.status_code == 401:
|
|
327
|
+
raise TwitchAPIError(401, "token invalid or expired")
|
|
328
|
+
if response.status_code >= 400:
|
|
329
|
+
raise TwitchAPIError(response.status_code, response.text)
|
|
330
|
+
try:
|
|
331
|
+
payload = response.json()
|
|
332
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
333
|
+
raise TwitchAPIError(response.status_code, response.text) from exc
|
|
334
|
+
data = payload.get("data") or []
|
|
335
|
+
if not data:
|
|
336
|
+
return []
|
|
337
|
+
return [TwitchOAuthClient._parse_helix_user(item) for item in data]
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _parse_helix_user(item: dict[str, Any]) -> TwitchUser:
|
|
341
|
+
created_at_raw = item.get("created_at")
|
|
342
|
+
created_at: datetime | None = None
|
|
343
|
+
if created_at_raw:
|
|
344
|
+
created_at = datetime.fromisoformat(created_at_raw.replace("Z", "+00:00"))
|
|
345
|
+
try:
|
|
346
|
+
return TwitchUser(
|
|
347
|
+
id=str(item["id"]),
|
|
348
|
+
login=item["login"],
|
|
349
|
+
display_name=item.get("display_name"),
|
|
350
|
+
type=item.get("type"),
|
|
351
|
+
broadcaster_type=item.get("broadcaster_type"),
|
|
352
|
+
description=item.get("description"),
|
|
353
|
+
profile_image_url=item.get("profile_image_url"),
|
|
354
|
+
offline_image_url=item.get("offline_image_url"),
|
|
355
|
+
view_count=item.get("view_count"),
|
|
356
|
+
email=item.get("email"),
|
|
357
|
+
created_at=created_at,
|
|
358
|
+
)
|
|
359
|
+
except KeyError as exc:
|
|
360
|
+
raise OAuthError("invalid_response", f"missing field: {exc.args[0]}") from exc
|
|
361
|
+
|
|
362
|
+
async def complete_login(
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
code: str,
|
|
366
|
+
redirect_uri: str,
|
|
367
|
+
code_verifier: str,
|
|
368
|
+
) -> TokenSet:
|
|
369
|
+
"""Exchange code → validate → fetch user. Best-effort Helix fetch."""
|
|
370
|
+
tokens = await self.exchange_code(
|
|
371
|
+
code=code, redirect_uri=redirect_uri, code_verifier=code_verifier
|
|
372
|
+
)
|
|
373
|
+
minimal = await self.validate(tokens.access_token)
|
|
374
|
+
try:
|
|
375
|
+
full = await self.fetch_user(access_token=tokens.access_token)
|
|
376
|
+
user = full.model_copy(update={"scopes": minimal.scopes or full.scopes})
|
|
377
|
+
except TwitchAPIError:
|
|
378
|
+
user = minimal
|
|
379
|
+
return TokenSet(tokens=tokens, user=user, validated_at=datetime.now(timezone.utc))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _chunked(items: list[str], size: int) -> list[list[str]]:
|
|
383
|
+
return [items[i : i + size] for i in range(0, len(items), size)]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
__all__ = ["TwitchOAuthClient"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TwitchAuthError(Exception):
|
|
5
|
+
"""Base exception for all library errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(TwitchAuthError):
|
|
9
|
+
"""Library is misconfigured (e.g. http:// redirect_uri in production)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StateStoreError(TwitchAuthError):
|
|
13
|
+
"""State store backend failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidStateError(TwitchAuthError):
|
|
17
|
+
"""OAuth state mismatch / missing / already used."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidPKCEError(TwitchAuthError):
|
|
21
|
+
"""PKCE code_verifier missing or mismatched (server-side bug)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OAuthError(TwitchAuthError):
|
|
25
|
+
"""Twitch returned an OAuth error response.
|
|
26
|
+
|
|
27
|
+
Maps to RFC 6749 §4.1.2.1 / §5.2 error codes
|
|
28
|
+
(e.g. ``invalid_grant``, ``invalid_client``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, code: str, description: str | None = None) -> None:
|
|
32
|
+
self.code = code
|
|
33
|
+
self.description = description
|
|
34
|
+
super().__init__(f"{code}: {description}" if description else code)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TwitchAPIError(TwitchAuthError):
|
|
38
|
+
"""Non-2xx response from Twitch (transport-level or Helix)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, status_code: int, body: str | None = None) -> None:
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.body = body
|
|
43
|
+
super().__init__(f"Twitch API returned {status_code}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScopeMissingError(TwitchAuthError):
|
|
47
|
+
"""Requested data requires a scope the token does not have."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, required: str, granted: list[str]) -> None:
|
|
50
|
+
self.required = required
|
|
51
|
+
self.granted = granted
|
|
52
|
+
super().__init__(f"scope '{required}' not granted (have: {granted})")
|
|
File without changes
|