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.
@@ -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