Skip to content

Commit eddbe30

Browse files
authored
Feature/dcf - Device Code Flow Support (#533)
* Add DeviceFlow* response TypedDicts * Add device code enums * Add Device Flow methods to OAuth * Add DeviceCodeFlowException * Add DCF methods, expose http and remove deprecated asyncio method. * Add start_dcf docs * Add DCF automatic refresh logic * Disallow client_secret when using DCF * Remove unused nested_key param and attr in ManagedHTTPClient * Ensure we don't re-add already loaded tokens * Remove remaining debug prints * Update docs for login_dcf * Allow Bot to be used with DCF * Set owner and user on start for DCF. * Raise NotImplementedError when trying to use DCF with an AutoClient * Add Bot DCF example. * Run ruff * Export DeviceCodeRejection Enum * Add DeviceCodeRejection docs * Add DeviceCodeFlowException docs * Update exception docs * Add dome docs to OAuth HTTP class * Update changelog
1 parent 1c7d1b1 commit eddbe30

File tree

13 files changed

+546
-17
lines changed

13 files changed

+546
-17
lines changed

docs/getting-started/changelog.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ Changelog
1313
- Added - :class:`~twitchio.SuspiciousChatUser` model.
1414
- Added - :func:`~twitchio.PartialUser.add_suspicious_chat_user` to :class:`~twitchio.PartialUser`.
1515
- Added - :func:`~twitchio.PartialUser.remove_suspicious_chat_user` to :class:`~twitchio.PartialUser`.
16+
- Added - :exc:`~twitchio.DeviceCodeFlowException`
17+
- Added - :class:`~twitchio.DeviceCodeRejection`
18+
19+
- Changes
20+
- Some of the internal token management has been adjusted to support applications using DCF.
21+
22+
- twitchio.Client
23+
- Additions
24+
- Added - :meth:`twitchio.Client.login_dcf`
25+
- Added - :meth:`twitchio.Client.start_dcf`
26+
- Added - :attr:`twitchio.Client.http`
27+
28+
- Changes
29+
- The ``client_secret`` passed to :class:`~twitchio.Client` is now optional for DCF support.
30+
- Some methods using deprecated ``asyncio`` methods were updated to use ``inspect``.
31+
32+
- twitchio.ext.commands.Bot
33+
- Changes
34+
- The ``bot_id`` passed to :class:`~twitchio.ext.commands.Bot` is now optional for DCF support.
35+
1636

1737
3.2.1
1838
======

docs/references/enums_etc.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ Enums and Payloads
1313
.. autoclass:: twitchio.eventsub.TransportMethod()
1414
:members:
1515

16+
.. attributetable:: twitchio.DeviceCodeRejection
17+
18+
.. autoclass:: twitchio.DeviceCodeRejection()
19+
:members:
20+
1621

1722
Websocket Subscription Data
1823
============================

docs/references/exceptions.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Exceptions
99
.. autoclass:: twitchio.HTTPException()
1010
:members:
1111

12+
.. autoclass:: twitchio.DeviceCodeFlowException()
13+
:members:
14+
1215
.. autoclass:: twitchio.InvalidTokenException()
1316
:members:
1417

@@ -27,5 +30,6 @@ Exception Hierarchy
2730
- :exc:`TwitchioException`
2831
- :exc:`HTTPException`
2932
- :exc:`InvalidTokenException`
33+
- :exc:`DeviceCodeFlowException`
3034
- :exc:`MessageRejectedError`
3135
- :exc:`MissingConduit`

examples/device_code_flow/bot.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
A basic example of using DCF (Device Code Flow) with a commands.Bot and an eventsub subscription
3+
to the authorized users chat, to run commands.
4+
5+
!note: DCF should only be used when you cannot safely store a client_secert: E.g. a users phone, tv, etc...
6+
7+
Your application should be set to "Public" in the Twitch Developer Console.
8+
"""
9+
import asyncio
10+
import logging
11+
12+
import twitchio
13+
from twitchio import eventsub
14+
from twitchio.ext import commands
15+
16+
17+
LOGGER: logging.Logger = logging.getLogger(__name__)
18+
CLIENT_ID = "..."
19+
20+
SCOPES = twitchio.Scopes()
21+
SCOPES.user_read_chat = True
22+
SCOPES.user_write_chat = True
23+
24+
25+
class Bot(commands.Bot):
26+
def __init__(self) -> None:
27+
super().__init__(client_id=CLIENT_ID, scopes=SCOPES, prefix="!")
28+
29+
async def setup_hook(self) -> None:
30+
await self.add_component(MyComponent(self))
31+
32+
async def event_ready(self) -> None:
33+
# Usually we would do this in the setup_hook; however DCF deviates from our traditional flow slightly...
34+
# Since we have to wait for the user to authorize, it's safer to subscribe in event_ready...
35+
chat = eventsub.ChatMessageSubscription(broadcaster_user_id=self.bot_id, user_id=self.bot_id)
36+
await self.subscribe_websocket(chat, as_bot=True)
37+
38+
async def event_message(self, payload: twitchio.ChatMessage) -> None:
39+
await self.process_commands(payload)
40+
41+
42+
class MyComponent(commands.Component):
43+
def __init__(self, bot: Bot) -> None:
44+
self.bot = bot
45+
46+
@commands.command()
47+
async def hi(self, ctx: commands.Context[Bot]) -> None:
48+
await ctx.send(f"Hello {ctx.chatter.mention}!")
49+
50+
51+
def main() -> None:
52+
twitchio.utils.setup_logging()
53+
54+
async def runner() -> None:
55+
async with Bot() as bot:
56+
resp = (await bot.login_dcf()) or {}
57+
device_code = resp.get("device_code")
58+
interval = resp.get("interval", 5)
59+
60+
# Print URI to visit to authenticate
61+
print(resp.get("verification_uri", ""))
62+
63+
await bot.start_dcf(device_code=device_code, interval=interval)
64+
65+
try:
66+
asyncio.run(runner())
67+
except KeyboardInterrupt:
68+
LOGGER.warning("Shutting down due to KeyboardInterrupt.")
69+
70+
71+
if __name__ == "__main__":
72+
main()

twitchio/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .assets import Asset as Asset
3939
from .authentication import Scopes as Scopes
4040
from .client import *
41+
from .enums import *
4142
from .exceptions import *
4243
from .http import HTTPAsyncIterator as HTTPAsyncIterator, Route as Route
4344
from .models import *

twitchio/authentication/oauth.py

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424

2525
from __future__ import annotations
2626

27+
import asyncio
2728
import secrets
2829
import urllib.parse
2930
from typing import TYPE_CHECKING, ClassVar
3031

32+
import twitchio
33+
34+
from ..enums import DeviceCodeRejection
3135
from ..http import HTTPClient, Route
3236
from ..utils import MISSING
3337
from .payloads import *
@@ -39,6 +43,8 @@
3943
from ..types_.responses import (
4044
AuthorizationURLResponse,
4145
ClientCredentialsResponse,
46+
DeviceCodeFlowResponse,
47+
DeviceCodeTokenResponse,
4248
RefreshTokenResponse,
4349
UserTokenResponse,
4450
ValidateTokenResponse,
@@ -53,7 +59,7 @@ def __init__(
5359
self,
5460
*,
5561
client_id: str,
56-
client_secret: str,
62+
client_secret: str | None = None,
5763
redirect_uri: str | None = None,
5864
scopes: Scopes | None = None,
5965
session: aiohttp.ClientSession = MISSING,
@@ -66,6 +72,27 @@ def __init__(
6672
self.scopes = scopes
6773

6874
async def validate_token(self, token: str, /) -> ValidateTokenPayload:
75+
"""|coro|
76+
77+
Method which validates the provided token.
78+
79+
Parameters
80+
----------
81+
token: :class:`str`
82+
The token to attempt to validate.
83+
84+
Returns
85+
-------
86+
ValidateTokenPayload
87+
The payload received from Twitch if no HTTPException was raised.
88+
89+
Raises
90+
------
91+
HTTPException
92+
An error occurred during a request to Twitch.
93+
HTTPException
94+
Bad or invalid token provided.
95+
"""
6996
token = token.removeprefix("Bearer ").removeprefix("OAuth ")
7097

7198
headers: dict[str, str] = {"Authorization": f"OAuth {token}"}
@@ -108,6 +135,20 @@ async def user_access_token(self, code: str, /, *, redirect_uri: str | None = No
108135
return UserTokenPayload(data)
109136

110137
async def revoke_token(self, token: str, /) -> None:
138+
"""|coro|
139+
140+
Method to revoke the authorization of a provided token.
141+
142+
Parameters
143+
----------
144+
token: :class:`str`
145+
The token to revoke authorization from. The token will be invalid and cannot be used after revocation.
146+
147+
Raises
148+
------
149+
HTTPException
150+
An error occurred during a request to Twitch.
151+
"""
111152
params = self._create_params({"token": token})
112153

113154
route: Route = Route("POST", "/oauth2/revoke", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
@@ -121,6 +162,57 @@ async def client_credentials_token(self) -> ClientCredentialsPayload:
121162

122163
return ClientCredentialsPayload(data)
123164

165+
async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse:
166+
scopes = scopes or self.scopes
167+
if not scopes:
168+
raise ValueError('"scopes" is a required parameter or attribute which is missing.')
169+
170+
params = self._create_params({"scopes": scopes.urlsafe()}, device_code=True)
171+
route: Route = Route("POST", "/oauth2/device", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params)
172+
173+
return await self.request_json(route)
174+
175+
async def device_code_authorization(
176+
self,
177+
*,
178+
scopes: Scopes | None = None,
179+
device_code: str,
180+
interval: int = 5,
181+
) -> DeviceCodeTokenResponse:
182+
scopes = scopes or self.scopes
183+
if not scopes:
184+
raise ValueError('"scopes" is a required parameter or attribute which is missing.')
185+
186+
params = self._create_params(
187+
{
188+
"scopes": scopes.urlsafe(),
189+
"device_code": device_code,
190+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
191+
},
192+
device_code=True,
193+
)
194+
195+
route: Route = Route("POST", "/oauth2/token", use_id=True, params=params)
196+
197+
while True:
198+
try:
199+
resp = await self.request_json(route)
200+
except twitchio.HTTPException as e:
201+
if e.status != 400:
202+
msg = "Unknown error during Device Code Authorization."
203+
raise twitchio.DeviceCodeFlowException(msg, original=e) from e
204+
205+
message = e.extra.get("message", "").lower()
206+
207+
if message != "authorization_pending":
208+
msg = f"An error occurred during Device Code Authorization: {message.upper()}."
209+
raise twitchio.DeviceCodeFlowException(original=e, reason=DeviceCodeRejection(message))
210+
211+
await asyncio.sleep(interval)
212+
continue
213+
214+
return resp
215+
124216
def get_authorization_url(
125217
self,
126218
*,
@@ -163,10 +255,11 @@ def get_authorization_url(
163255
payload: AuthorizationURLPayload = AuthorizationURLPayload(data)
164256
return payload
165257

166-
def _create_params(self, extra_params: dict[str, str]) -> dict[str, str]:
167-
params = {
168-
"client_id": self.client_id,
169-
"client_secret": self.client_secret,
170-
}
258+
def _create_params(self, extra_params: dict[str, str], *, device_code: bool = False) -> dict[str, str]:
259+
params = {"client_id": self.client_id}
260+
261+
if not device_code and self.client_secret:
262+
params["client_secret"] = self.client_secret
263+
171264
params.update(extra_params)
172265
return params

twitchio/authentication/tokens.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,10 @@ def __init__(
6161
self,
6262
*,
6363
client_id: str,
64-
client_secret: str,
64+
client_secret: str | None = None,
6565
redirect_uri: str | None = None,
6666
scopes: Scopes | None = None,
6767
session: aiohttp.ClientSession = MISSING,
68-
nested_key: str | None = None,
6968
client: Client | None = None,
7069
) -> None:
7170
super().__init__(
@@ -85,7 +84,6 @@ def __init__(
8584

8685
self._tokens: TokenMapping = {}
8786
self._app_token: str | None = None
88-
self._nested_key: str | None = None
8987

9088
self._token_lock: asyncio.Lock = asyncio.Lock()
9189
self._has_loaded: bool = False
@@ -213,13 +211,19 @@ async def request(self, route: Route) -> RawResponse | str | None:
213211
if e.extra.get("message", "").lower() not in ("invalid access token", "invalid oauth token"):
214212
raise e
215213

216-
if isinstance(old, str):
214+
if isinstance(old, str) and self.client_secret:
217215
payload: ClientCredentialsPayload = await self.client_credentials_token()
218216
self._app_token = payload.access_token
219217
route.update_headers({"Authorization": f"Bearer {payload.access_token}"})
220218

221219
return await self.request(route)
222220

221+
if isinstance(old, str):
222+
# Will be a DCF token...
223+
# We only expect and will use a single token when DCF is used; the user shouldn't be loading multiples
224+
vals = list(self._tokens.values())
225+
old = vals[0]
226+
223227
logger.debug('Token for "%s" was invalid or expired. Attempting to refresh token.', old["user_id"])
224228
refresh: RefreshTokenPayload = await self.__isolated.refresh_token(old["refresh"])
225229
logger.debug('Token for "%s" was successfully refreshed.', old["user_id"])

0 commit comments

Comments
 (0)