chzzk-python 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.
chzzk/auth/oauth.py ADDED
@@ -0,0 +1,452 @@
1
+ """OAuth client for Chzzk authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
7
+ from urllib.parse import urlencode
8
+
9
+ from chzzk.auth.models import (
10
+ AuthorizationCodeRequest,
11
+ RefreshTokenRequest,
12
+ RevokeTokenRequest,
13
+ Token,
14
+ TokenResponse,
15
+ TokenTypeHint,
16
+ )
17
+ from chzzk.exceptions import InvalidStateError, TokenExpiredError
18
+ from chzzk.http import AUTH_INTERLOCK_URL, AUTH_REVOKE_URL, AUTH_TOKEN_URL
19
+ from chzzk.http.client import AsyncHTTPClient, HTTPClient
20
+
21
+ if TYPE_CHECKING:
22
+ pass
23
+
24
+
25
+ @runtime_checkable
26
+ class TokenStorage(Protocol):
27
+ """Protocol for token storage implementations."""
28
+
29
+ def get_token(self) -> Token | None:
30
+ """Retrieve the stored token."""
31
+ ...
32
+
33
+ def save_token(self, token: Token) -> None:
34
+ """Save a token."""
35
+ ...
36
+
37
+ def delete_token(self) -> None:
38
+ """Delete the stored token."""
39
+ ...
40
+
41
+
42
+ class InMemoryTokenStorage:
43
+ """In-memory token storage implementation."""
44
+
45
+ def __init__(self) -> None:
46
+ self._token: Token | None = None
47
+
48
+ def get_token(self) -> Token | None:
49
+ """Retrieve the stored token."""
50
+ return self._token
51
+
52
+ def save_token(self, token: Token) -> None:
53
+ """Save a token."""
54
+ self._token = token
55
+
56
+ def delete_token(self) -> None:
57
+ """Delete the stored token."""
58
+ self._token = None
59
+
60
+
61
+ class ChzzkOAuth:
62
+ """Synchronous OAuth client for Chzzk authentication.
63
+
64
+ This client handles the OAuth 2.0 authorization code flow for Chzzk,
65
+ including token exchange, refresh, and revocation.
66
+
67
+ Example:
68
+ >>> oauth = ChzzkOAuth(
69
+ ... client_id="your-client-id",
70
+ ... client_secret="your-client-secret",
71
+ ... redirect_uri="http://localhost:8080/callback",
72
+ ... )
73
+ >>> auth_url, state = oauth.get_authorization_url()
74
+ >>> # User visits auth_url and gets redirected back with code
75
+ >>> token = oauth.exchange_code(code="auth_code", state=state)
76
+ >>> access_token = token.access_token
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ client_id: str,
82
+ client_secret: str,
83
+ redirect_uri: str,
84
+ *,
85
+ token_storage: TokenStorage | None = None,
86
+ ) -> None:
87
+ """Initialize the OAuth client.
88
+
89
+ Args:
90
+ client_id: Your Chzzk application's client ID.
91
+ client_secret: Your Chzzk application's client secret.
92
+ redirect_uri: The redirect URI registered with your application.
93
+ token_storage: Optional custom token storage. Defaults to InMemoryTokenStorage.
94
+ """
95
+ self.client_id = client_id
96
+ self.client_secret = client_secret
97
+ self.redirect_uri = redirect_uri
98
+ self._storage = token_storage or InMemoryTokenStorage()
99
+ self._http = HTTPClient()
100
+ self._pending_state: str | None = None
101
+
102
+ def get_authorization_url(self, *, state: str | None = None) -> tuple[str, str]:
103
+ """Generate the authorization URL for user authentication.
104
+
105
+ Args:
106
+ state: Optional state parameter. If not provided, a random one is generated.
107
+
108
+ Returns:
109
+ A tuple of (authorization_url, state).
110
+ """
111
+ if state is None:
112
+ state = secrets.token_urlsafe(32)
113
+
114
+ self._pending_state = state
115
+
116
+ params = {
117
+ "clientId": self.client_id,
118
+ "redirectUri": self.redirect_uri,
119
+ "state": state,
120
+ }
121
+
122
+ url = f"{AUTH_INTERLOCK_URL}?{urlencode(params)}"
123
+ return url, state
124
+
125
+ def exchange_code(
126
+ self,
127
+ code: str,
128
+ state: str,
129
+ *,
130
+ validate_state: bool = True,
131
+ ) -> Token:
132
+ """Exchange an authorization code for access and refresh tokens.
133
+
134
+ Args:
135
+ code: The authorization code received from the callback.
136
+ state: The state parameter received from the callback.
137
+ validate_state: Whether to validate the state parameter. Defaults to True.
138
+
139
+ Returns:
140
+ The Token object containing access and refresh tokens.
141
+
142
+ Raises:
143
+ InvalidStateError: If state validation fails.
144
+ """
145
+ if validate_state and self._pending_state and state != self._pending_state:
146
+ raise InvalidStateError(
147
+ f"State mismatch: expected '{self._pending_state}', got '{state}'"
148
+ )
149
+
150
+ request = AuthorizationCodeRequest(
151
+ client_id=self.client_id,
152
+ client_secret=self.client_secret,
153
+ code=code,
154
+ state=state,
155
+ )
156
+
157
+ response_data = self._http.post(
158
+ AUTH_TOKEN_URL,
159
+ json=request.model_dump(by_alias=True),
160
+ )
161
+
162
+ token_response = TokenResponse.model_validate(response_data)
163
+ token = Token.from_response(token_response)
164
+ self._storage.save_token(token)
165
+ self._pending_state = None
166
+
167
+ return token
168
+
169
+ def refresh_token(self, refresh_token: str | None = None) -> Token:
170
+ """Refresh the access token using a refresh token.
171
+
172
+ Args:
173
+ refresh_token: Optional refresh token to use. If not provided,
174
+ uses the stored token's refresh token.
175
+
176
+ Returns:
177
+ The new Token object.
178
+
179
+ Raises:
180
+ TokenExpiredError: If no refresh token is available.
181
+ """
182
+ if refresh_token is None:
183
+ stored_token = self._storage.get_token()
184
+ if stored_token is None:
185
+ raise TokenExpiredError("No token available for refresh")
186
+ refresh_token = stored_token.refresh_token
187
+
188
+ request = RefreshTokenRequest(
189
+ client_id=self.client_id,
190
+ client_secret=self.client_secret,
191
+ refresh_token=refresh_token,
192
+ )
193
+
194
+ response_data = self._http.post(
195
+ AUTH_TOKEN_URL,
196
+ json=request.model_dump(by_alias=True),
197
+ )
198
+
199
+ token_response = TokenResponse.model_validate(response_data)
200
+ token = Token.from_response(token_response)
201
+ self._storage.save_token(token)
202
+
203
+ return token
204
+
205
+ def revoke_token(
206
+ self,
207
+ token: str | None = None,
208
+ token_type_hint: TokenTypeHint = TokenTypeHint.ACCESS_TOKEN,
209
+ ) -> None:
210
+ """Revoke a token.
211
+
212
+ This revokes both access and refresh tokens associated with the same
213
+ authentication (same client_id and user).
214
+
215
+ Args:
216
+ token: Optional token to revoke. If not provided, uses the stored access token.
217
+ token_type_hint: The type of token being revoked.
218
+ """
219
+ if token is None:
220
+ stored_token = self._storage.get_token()
221
+ if stored_token is None:
222
+ return
223
+ token = stored_token.access_token
224
+
225
+ request = RevokeTokenRequest(
226
+ client_id=self.client_id,
227
+ client_secret=self.client_secret,
228
+ token=token,
229
+ token_type_hint=token_type_hint,
230
+ )
231
+
232
+ self._http.post(
233
+ AUTH_REVOKE_URL,
234
+ json=request.model_dump(by_alias=True),
235
+ )
236
+
237
+ self._storage.delete_token()
238
+
239
+ def get_token(self) -> Token | None:
240
+ """Get the stored token.
241
+
242
+ Returns:
243
+ The stored Token object or None if no token is stored.
244
+ """
245
+ return self._storage.get_token()
246
+
247
+ def close(self) -> None:
248
+ """Close the HTTP client."""
249
+ self._http.close()
250
+
251
+ def __enter__(self) -> ChzzkOAuth:
252
+ return self
253
+
254
+ def __exit__(self, *_: object) -> None:
255
+ self.close()
256
+
257
+
258
+ class AsyncChzzkOAuth:
259
+ """Asynchronous OAuth client for Chzzk authentication.
260
+
261
+ This client handles the OAuth 2.0 authorization code flow for Chzzk,
262
+ including token exchange, refresh, and revocation.
263
+
264
+ Example:
265
+ >>> async with AsyncChzzkOAuth(
266
+ ... client_id="your-client-id",
267
+ ... client_secret="your-client-secret",
268
+ ... redirect_uri="http://localhost:8080/callback",
269
+ ... ) as oauth:
270
+ ... auth_url, state = oauth.get_authorization_url()
271
+ ... # User visits auth_url and gets redirected back with code
272
+ ... token = await oauth.exchange_code(code="auth_code", state=state)
273
+ ... access_token = token.access_token
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ client_id: str,
279
+ client_secret: str,
280
+ redirect_uri: str,
281
+ *,
282
+ token_storage: TokenStorage | None = None,
283
+ ) -> None:
284
+ """Initialize the async OAuth client.
285
+
286
+ Args:
287
+ client_id: Your Chzzk application's client ID.
288
+ client_secret: Your Chzzk application's client secret.
289
+ redirect_uri: The redirect URI registered with your application.
290
+ token_storage: Optional custom token storage. Defaults to InMemoryTokenStorage.
291
+ """
292
+ self.client_id = client_id
293
+ self.client_secret = client_secret
294
+ self.redirect_uri = redirect_uri
295
+ self._storage = token_storage or InMemoryTokenStorage()
296
+ self._http = AsyncHTTPClient()
297
+ self._pending_state: str | None = None
298
+
299
+ def get_authorization_url(self, *, state: str | None = None) -> tuple[str, str]:
300
+ """Generate the authorization URL for user authentication.
301
+
302
+ Args:
303
+ state: Optional state parameter. If not provided, a random one is generated.
304
+
305
+ Returns:
306
+ A tuple of (authorization_url, state).
307
+ """
308
+ if state is None:
309
+ state = secrets.token_urlsafe(32)
310
+
311
+ self._pending_state = state
312
+
313
+ params = {
314
+ "clientId": self.client_id,
315
+ "redirectUri": self.redirect_uri,
316
+ "state": state,
317
+ }
318
+
319
+ url = f"{AUTH_INTERLOCK_URL}?{urlencode(params)}"
320
+ return url, state
321
+
322
+ async def exchange_code(
323
+ self,
324
+ code: str,
325
+ state: str,
326
+ *,
327
+ validate_state: bool = True,
328
+ ) -> Token:
329
+ """Exchange an authorization code for access and refresh tokens.
330
+
331
+ Args:
332
+ code: The authorization code received from the callback.
333
+ state: The state parameter received from the callback.
334
+ validate_state: Whether to validate the state parameter. Defaults to True.
335
+
336
+ Returns:
337
+ The Token object containing access and refresh tokens.
338
+
339
+ Raises:
340
+ InvalidStateError: If state validation fails.
341
+ """
342
+ if validate_state and self._pending_state and state != self._pending_state:
343
+ raise InvalidStateError(
344
+ f"State mismatch: expected '{self._pending_state}', got '{state}'"
345
+ )
346
+
347
+ request = AuthorizationCodeRequest(
348
+ client_id=self.client_id,
349
+ client_secret=self.client_secret,
350
+ code=code,
351
+ state=state,
352
+ )
353
+
354
+ response_data = await self._http.post(
355
+ AUTH_TOKEN_URL,
356
+ json=request.model_dump(by_alias=True),
357
+ )
358
+
359
+ token_response = TokenResponse.model_validate(response_data)
360
+ token = Token.from_response(token_response)
361
+ self._storage.save_token(token)
362
+ self._pending_state = None
363
+
364
+ return token
365
+
366
+ async def refresh_token(self, refresh_token: str | None = None) -> Token:
367
+ """Refresh the access token using a refresh token.
368
+
369
+ Args:
370
+ refresh_token: Optional refresh token to use. If not provided,
371
+ uses the stored token's refresh token.
372
+
373
+ Returns:
374
+ The new Token object.
375
+
376
+ Raises:
377
+ TokenExpiredError: If no refresh token is available.
378
+ """
379
+ if refresh_token is None:
380
+ stored_token = self._storage.get_token()
381
+ if stored_token is None:
382
+ raise TokenExpiredError("No token available for refresh")
383
+ refresh_token = stored_token.refresh_token
384
+
385
+ request = RefreshTokenRequest(
386
+ client_id=self.client_id,
387
+ client_secret=self.client_secret,
388
+ refresh_token=refresh_token,
389
+ )
390
+
391
+ response_data = await self._http.post(
392
+ AUTH_TOKEN_URL,
393
+ json=request.model_dump(by_alias=True),
394
+ )
395
+
396
+ token_response = TokenResponse.model_validate(response_data)
397
+ token = Token.from_response(token_response)
398
+ self._storage.save_token(token)
399
+
400
+ return token
401
+
402
+ async def revoke_token(
403
+ self,
404
+ token: str | None = None,
405
+ token_type_hint: TokenTypeHint = TokenTypeHint.ACCESS_TOKEN,
406
+ ) -> None:
407
+ """Revoke a token.
408
+
409
+ This revokes both access and refresh tokens associated with the same
410
+ authentication (same client_id and user).
411
+
412
+ Args:
413
+ token: Optional token to revoke. If not provided, uses the stored access token.
414
+ token_type_hint: The type of token being revoked.
415
+ """
416
+ if token is None:
417
+ stored_token = self._storage.get_token()
418
+ if stored_token is None:
419
+ return
420
+ token = stored_token.access_token
421
+
422
+ request = RevokeTokenRequest(
423
+ client_id=self.client_id,
424
+ client_secret=self.client_secret,
425
+ token=token,
426
+ token_type_hint=token_type_hint,
427
+ )
428
+
429
+ await self._http.post(
430
+ AUTH_REVOKE_URL,
431
+ json=request.model_dump(by_alias=True),
432
+ )
433
+
434
+ self._storage.delete_token()
435
+
436
+ def get_token(self) -> Token | None:
437
+ """Get the stored token.
438
+
439
+ Returns:
440
+ The stored Token object or None if no token is stored.
441
+ """
442
+ return self._storage.get_token()
443
+
444
+ async def close(self) -> None:
445
+ """Close the HTTP client."""
446
+ await self._http.close()
447
+
448
+ async def __aenter__(self) -> AsyncChzzkOAuth:
449
+ return self
450
+
451
+ async def __aexit__(self, *_: object) -> None:
452
+ await self.close()
chzzk/auth/token.py ADDED
@@ -0,0 +1,119 @@
1
+ """Token storage implementations for Chzzk OAuth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from chzzk.auth.models import Token
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ class FileTokenStorage:
17
+ """File-based token storage implementation.
18
+
19
+ Stores tokens as JSON in a local file. Useful for CLI applications
20
+ or simple server applications that need persistent token storage.
21
+
22
+ Example:
23
+ >>> storage = FileTokenStorage(".chzzk_token.json")
24
+ >>> oauth = ChzzkOAuth(
25
+ ... client_id="...",
26
+ ... client_secret="...",
27
+ ... redirect_uri="...",
28
+ ... token_storage=storage,
29
+ ... )
30
+ """
31
+
32
+ def __init__(self, file_path: str | Path) -> None:
33
+ """Initialize file-based token storage.
34
+
35
+ Args:
36
+ file_path: Path to the token storage file.
37
+ """
38
+ self._file_path = Path(file_path)
39
+
40
+ def get_token(self) -> Token | None:
41
+ """Retrieve the stored token from file."""
42
+ if not self._file_path.exists():
43
+ return None
44
+
45
+ try:
46
+ data = json.loads(self._file_path.read_text(encoding="utf-8"))
47
+ return Token.model_validate(data)
48
+ except (json.JSONDecodeError, OSError):
49
+ return None
50
+
51
+ def save_token(self, token: Token) -> None:
52
+ """Save a token to file."""
53
+ self._file_path.parent.mkdir(parents=True, exist_ok=True)
54
+ self._file_path.write_text(
55
+ token.model_dump_json(indent=2),
56
+ encoding="utf-8",
57
+ )
58
+
59
+ def delete_token(self) -> None:
60
+ """Delete the stored token file."""
61
+ if self._file_path.exists():
62
+ self._file_path.unlink()
63
+
64
+
65
+ class CallbackTokenStorage:
66
+ """Callback-based token storage implementation.
67
+
68
+ Allows integration with external storage systems like databases
69
+ or Redis by providing custom callback functions.
70
+
71
+ Example:
72
+ >>> async def load_from_db(user_id: str) -> Token | None:
73
+ ... # Load token from database
74
+ ... pass
75
+ ...
76
+ >>> async def save_to_db(user_id: str, token: Token) -> None:
77
+ ... # Save token to database
78
+ ... pass
79
+ ...
80
+ >>> async def delete_from_db(user_id: str) -> None:
81
+ ... # Delete token from database
82
+ ... pass
83
+ ...
84
+ >>> storage = CallbackTokenStorage(
85
+ ... get_callback=lambda: load_from_db("user123"),
86
+ ... save_callback=lambda t: save_to_db("user123", t),
87
+ ... delete_callback=lambda: delete_from_db("user123"),
88
+ ... )
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ get_callback: Callable[[], Token | None],
95
+ save_callback: Callable[[Token], None],
96
+ delete_callback: Callable[[], None],
97
+ ) -> None:
98
+ """Initialize callback-based token storage.
99
+
100
+ Args:
101
+ get_callback: Function to retrieve the stored token.
102
+ save_callback: Function to save a token.
103
+ delete_callback: Function to delete the stored token.
104
+ """
105
+ self._get_callback = get_callback
106
+ self._save_callback = save_callback
107
+ self._delete_callback = delete_callback
108
+
109
+ def get_token(self) -> Token | None:
110
+ """Retrieve the stored token via callback."""
111
+ return self._get_callback()
112
+
113
+ def save_token(self, token: Token) -> None:
114
+ """Save a token via callback."""
115
+ self._save_callback(token)
116
+
117
+ def delete_token(self) -> None:
118
+ """Delete the stored token via callback."""
119
+ self._delete_callback()