From 39743560aeded95df633440c63a7b9ddaea6f6ae Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:57:59 +1000 Subject: [PATCH 01/23] Add DeviceFlow* response TypedDicts --- twitchio/types_/responses.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/twitchio/types_/responses.py b/twitchio/types_/responses.py index d4b4a968..df644ebe 100644 --- a/twitchio/types_/responses.py +++ b/twitchio/types_/responses.py @@ -138,6 +138,22 @@ class AuthorizationURLResponse(TypedDict): state: str +class DeviceCodeFlowResponse(TypedDict): + device_code: str + expires_in: int + interval: int + user_code: str + verification_uri: str + + +class DeviceCodeTokenResponse(TypedDict): + access_token: str + expires_in: int + refresh_token: str + scope: list[str] + token_type: str + + OAuthResponses: TypeAlias = ( RefreshTokenResponse | ValidateTokenResponse | ClientCredentialsResponse | UserTokenResponse | AuthorizationURLResponse ) From 4ec883b1e91c763e8060eb6b0ba2193b31590329 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:58:23 +1000 Subject: [PATCH 02/23] Add device code enums --- twitchio/enums.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 twitchio/enums.py diff --git a/twitchio/enums.py b/twitchio/enums.py new file mode 100644 index 00000000..04587e33 --- /dev/null +++ b/twitchio/enums.py @@ -0,0 +1,31 @@ +""" +MIT License + +Copyright (c) 2017 - Present PythonistaGuild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import enum + + +class DeviceCodeRejection(enum.Enum): + UNKNOWN = "unknown" + INVALID_REFRESH_TOKEN = "invalid refresh token" + INVALID_DEVICE_CODE = "invalid device code" From 57beb60190e714d134c85dd9e7ae779f8fe3855b Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:59:25 +1000 Subject: [PATCH 03/23] Add Device Flow methods to OAuth --- twitchio/authentication/oauth.py | 70 ++++++++++++++++++++++++++++--- twitchio/authentication/tokens.py | 2 +- twitchio/http.py | 3 +- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/twitchio/authentication/oauth.py b/twitchio/authentication/oauth.py index 247b4c72..4a22708c 100644 --- a/twitchio/authentication/oauth.py +++ b/twitchio/authentication/oauth.py @@ -24,10 +24,14 @@ from __future__ import annotations +import asyncio import secrets import urllib.parse from typing import TYPE_CHECKING, ClassVar +import twitchio + +from ..enums import DeviceCodeRejection from ..http import HTTPClient, Route from ..utils import MISSING from .payloads import * @@ -39,6 +43,8 @@ from ..types_.responses import ( AuthorizationURLResponse, ClientCredentialsResponse, + DeviceCodeFlowResponse, + DeviceCodeTokenResponse, RefreshTokenResponse, UserTokenResponse, ValidateTokenResponse, @@ -53,7 +59,7 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, redirect_uri: str | None = None, scopes: Scopes | None = None, session: aiohttp.ClientSession = MISSING, @@ -121,6 +127,57 @@ async def client_credentials_token(self) -> ClientCredentialsPayload: return ClientCredentialsPayload(data) + async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse: + scopes = scopes or self.scopes + if not scopes: + raise ValueError('"scopes" is a required parameter or attribute which is missing.') + + params = self._create_params({"scopes": scopes.urlsafe()}, device_code=True) + route: Route = Route("POST", "/oauth2/device", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params) + + return await self.request_json(route) + + async def device_code_authorization( + self, + *, + scopes: Scopes | None = None, + device_code: str, + interval: int = 5, + ) -> DeviceCodeTokenResponse: + scopes = scopes or self.scopes + if not scopes: + raise ValueError('"scopes" is a required parameter or attribute which is missing.') + + params = self._create_params( + { + "scopes": scopes.urlsafe(), + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + device_code=True, + ) + + route: Route = Route("POST", "/oauth2/token", use_id=True, params=params) + + while True: + try: + resp = await self.request_json(route) + except twitchio.HTTPException as e: + if e.status != 400: + msg = "Unknown error during Device Code Authorization." + raise twitchio.DeviceCodeFlowException(msg, original=e) from e + + message = e.extra.get("message", "").lower() + + if message != "authorization_pending": + msg = f"An error occurred during Device Code Authorization: {message.upper()}." + raise twitchio.DeviceCodeFlowException(original=e, reason=DeviceCodeRejection(message)) + + await asyncio.sleep(interval) + continue + + return resp + def get_authorization_url( self, *, @@ -163,10 +220,11 @@ def get_authorization_url( payload: AuthorizationURLPayload = AuthorizationURLPayload(data) return payload - def _create_params(self, extra_params: dict[str, str]) -> dict[str, str]: - params = { - "client_id": self.client_id, - "client_secret": self.client_secret, - } + def _create_params(self, extra_params: dict[str, str], *, device_code: bool = False) -> dict[str, str]: + params = {"client_id": self.client_id} + + if not device_code and self.client_secret: + params["client_secret"] = self.client_secret + params.update(extra_params) return params diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py index ef0c60ef..6cabd123 100644 --- a/twitchio/authentication/tokens.py +++ b/twitchio/authentication/tokens.py @@ -61,7 +61,7 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, redirect_uri: str | None = None, scopes: Scopes | None = None, session: aiohttp.ClientSession = MISSING, diff --git a/twitchio/http.py b/twitchio/http.py index e445ff62..64cf61b8 100644 --- a/twitchio/http.py +++ b/twitchio/http.py @@ -36,8 +36,9 @@ import aiohttp +from twitchio.exceptions import HTTPException + from . import __version__ -from .exceptions import HTTPException from .models.analytics import ExtensionAnalytics, GameAnalytics from .models.bits import ExtensionTransaction from .models.channel_points import CustomRewardRedemption From 911d0f6c32a37e4908fc2071e9ed1eab7ccdf614 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:59:41 +1000 Subject: [PATCH 04/23] Add DeviceCodeFlowException --- twitchio/exceptions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/twitchio/exceptions.py b/twitchio/exceptions.py index ba20ef3a..d5fed17d 100644 --- a/twitchio/exceptions.py +++ b/twitchio/exceptions.py @@ -26,8 +26,11 @@ from typing import TYPE_CHECKING, Any +from .enums import DeviceCodeRejection + __all__ = ( + "DeviceCodeFlowException", "HTTPException", "InvalidTokenException", "MessageRejectedError", @@ -84,6 +87,15 @@ def __init__( super().__init__(msg) +class DeviceCodeFlowException(HTTPException): + # TODO: Docs... + """...""" + + def __init__(self, msg: str = "", /, *, original: HTTPException, reason: DeviceCodeRejection | None = None) -> None: + self.reason: DeviceCodeRejection = reason or DeviceCodeRejection.UNKNOWN + super().__init__(msg, route=original.route, status=original.status, extra=original.extra) + + class InvalidTokenException(HTTPException): """Exception raised when an token can not be validated or refreshed. From 9230fa71952ea3670a91c018bbcec95eccd7ca81 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:00:23 +1000 Subject: [PATCH 05/23] Add DCF methods, expose http and remove deprecated asyncio method. --- twitchio/client.py | 121 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/twitchio/client.py b/twitchio/client.py index 11b642b3..7a11f880 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -25,6 +25,7 @@ from __future__ import annotations import asyncio +import inspect import logging import math from collections import defaultdict @@ -68,6 +69,7 @@ from .types_.conduits import ShardUpdateRequest from .types_.eventsub import ShardStatus, SubscriptionCreateTransport, SubscriptionResponse, _SubscriptionData from .types_.options import AutoClientOptions, ClientOptions, WaitPredicateT + from .types_.responses import DeviceCodeFlowResponse from .types_.tokens import TokenMappingData @@ -128,7 +130,7 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, bot_id: str | None = None, **options: Unpack[ClientOptions], ) -> None: @@ -188,6 +190,40 @@ def adapter(self) -> BaseAdapter[Any]: currently running.""" return self._adapter + @property + def http(self) -> ManagedHTTPClient: + """Property exposing the internal :class:`~twitchio.ManagedHTTPClient` used for requests to the Twitch API. + + .. warning:: + + Altering or changing this class during runtime may have unwanted side-effects. It is exposed for developer + easabilty, especially when OAuth or Device Code Flow methods are required. It is not intended to replace the use + of the built-in methods of the :class:`~twitchio.Client` or other models. + """ + return self._http + + async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse: + """|coro| + + .. warning:: + + It's not intended to use DCF when storing a ``client-secret`` is a safe and practical option. + DCF is intended to be used on user devices where storing your ``client-secret`` is not possible. + + .. note:: + + When using tokens generated through DCF, the only ``EventSub`` transport available is traditional websockets. + Using ``Conduits`` and ``Webhooks`` are not available as they require a ``App Token``. + + Method which starts a Twitch Device Code Flow. + + The DCF (Device Code Flow) is used to obtain an access/refresh token pair for use on client-side applications where + storing a ``client-secret`` would be unsafe E.g. a users device (Phone, TV, etc...). + + When using a token + """ + return await self._http.device_code_flow(scopes=scopes) + async def set_adapter(self, adapter: BaseAdapter[Any]) -> None: """|coro| @@ -392,6 +428,87 @@ async def setup_hook(self) -> None: """ ... + async def login_dcf( + self, + *, + load_token: bool = True, + save_token: bool = True, + scopes: Scopes | None = None, + force_flow: bool = False, + ) -> DeviceCodeFlowResponse | None: + if self._login_called: + return + + self._login_called = True + self._save_tokens = save_token + + if not self._http.client_id: + raise RuntimeError('Expected a valid "client_id", instead received: %s', self._http.client_id) + + self._http._app_token = None + + if load_token and not force_flow: + async with self._http._token_lock: + await self.load_tokens() + else: + self._http._has_loaded = True + + await self._setup() + + if not self._http._tokens: + return await self.device_code_flow(scopes=scopes) + + async def start_dcf( + self, + *, + device_code: str | None = None, + interval: int = 5, + timeout: int | None = 90, + scopes: Scopes | None = None, + ) -> None: + if not self._login_called: + raise RuntimeError('Client failed to start: "login_dcf" must be called before "start_dcf".') + + self.__waiter.clear() + + try: + mapping = list(self._http._tokens.values()) + pair = mapping[0] + token = pair["token"] + refresh = pair["refresh"] + except (IndexError, KeyError): + token = "" + refresh = "" + + if device_code: + async with asyncio.timeout(timeout): + resp = await self._http.device_code_authorization(device_code=device_code, interval=interval, scopes=scopes) + + token = resp["access_token"] + refresh = resp["refresh_token"] + + if not token or not refresh: + raise RuntimeError( + "Unable to start Client: No DCF token pair was able to be loaded. Try force running the flow." + ) + + validated = await self.add_token(token=token, refresh=refresh) + self._http._app_token = token + + user = await self.fetch_user(id=validated.user_id) + if not user: + raise RuntimeError("Unable to fetch associated user with DCF token.") + + self._bot_id = user.id + self.dispatch("ready") + self._ready_event.set() + + try: + await self.__waiter.wait() + finally: + self._ready_event.clear() + await self.close() + async def login(self, *, token: str | None = None, load_tokens: bool = True, save_tokens: bool = True) -> None: """|coro| @@ -884,7 +1001,7 @@ def add_listener(self, listener: Callable[..., Coroutine[Any, Any, None]], *, ev if name == "event_": raise ValueError('Listener and event names cannot be named "event_".') - if not asyncio.iscoroutinefunction(listener): + if not inspect.iscoroutinefunction(listener): raise TypeError("Listeners and Events must be coroutines.") self._listeners[name].add(listener) From d6a74d64e1eaa51e7879745f8ec692ca2c0b7553 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:33:06 +1000 Subject: [PATCH 06/23] Add start_dcf docs --- twitchio/client.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/twitchio/client.py b/twitchio/client.py index 7a11f880..f2016178 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -436,6 +436,7 @@ async def login_dcf( scopes: Scopes | None = None, force_flow: bool = False, ) -> DeviceCodeFlowResponse | None: + # TODO: Docs... if self._login_called: return @@ -465,7 +466,70 @@ async def start_dcf( interval: int = 5, timeout: int | None = 90, scopes: Scopes | None = None, + block: bool = True, ) -> None: + """|coro| + + .. warning:: + + DCF is intended to be used when your application cannot safely store a ``client-secret``. E.g. on a users + device (phone, tv, etc...). + + + .. note:: + + The :meth:`.login_dcf` method must be called before this method. :meth:`.login_dcf` provides a response payload + and other useful data that can be used with :meth:`.start_dcf`. + + + Method to start the :class:`~twitchio.Client` with DCF (Device Code Flow). + + Unlike :meth:`.start` this method must be called after :meth:`.login_dcf` and does not directly call this method + itself. + + Unlike :meth:`.start` this method can be used to run the :class:`~twitchio.Client` with or without asynchronous + blocking behaviours. However due to the design of ``DCF`` the :meth:`.login_dcf` method must still be called first. + + When the ``device_code`` parameter is ``None`` (default), this method will not wait for an authorization response + from Twitch. However, a token must be loaded prior to this method being called + (usually automatically in :meth:`.login_dcf`), else a :exc:`RuntimeError` will be raised. + + Parameters + ---------- + device_code: :class:`str` | ``None`` + The device code received as a response from :meth:`.login_dcf`. If :meth:`.login_dcf` loaded a saved token, you + can safely disregard this parameter, unless you are forcing a user to reauthenticate. + interval: :class:`int` + An :class:`int` as seconds, passed to determine how long we should wait before checking the users authentication + status in the DCF. This can be changed however the provided interval in the response from :meth:`.login_dcf` is + usually preferred. Defaults to ``5``. + timeout: :class:`int` | ``None`` + An :class:`int` as seconds before this method will timeout waiting for a user to complete the DCF. Could be + ``None`` to disable timeout. Defaults to ``90``. If a timeout occurs a :exc:`TimeoutError` will be raised. + scopes: :class:`~twitchio.Scopes` | ``None`` + A :class:`~twitchio.Scopes` that will be granted by the user during the DCF. This should be the same scopes passed + to :meth:`.login_dcf`. If scopes are assigned or passed to the :class:`~twitchio.Client` you do not need to pass + scopes here or in :meth:`.login_dcf`. Defaults to ``None`` which means you would need to pass scopes to the client. + block: :class:`bool` + A bool indicating whether to run the :class:`~twitchio.Client` in a asynchronously blocking loop. This is the + default and same behaviour as :meth:`.start`. When set to ``False``, your :class:`~twitchio.Client` will be logged + in and can be used standalone. Defaults to ``True``. + + Raises + ------ + RuntimeError + :meth:`.login_dcf` must be called before this method. + RuntimeError + A token and refresh pair must be loaded prior to calling this method, or you must pass the ``device_code`` parameter. + RuntimeError + A valid :class:`~twitchio.User` could not be fetched with the token received. + DeviceCodeFlowException + ... + HTTPException + ... + TimeoutError + ... + """ if not self._login_called: raise RuntimeError('Client failed to start: "login_dcf" must be called before "start_dcf".') @@ -493,6 +557,9 @@ async def start_dcf( ) validated = await self.add_token(token=token, refresh=refresh) + + # Technically a User Token, however this will allow similar default behaviours since DCF should be bound to a + # ...single user. self._http._app_token = token user = await self.fetch_user(id=validated.user_id) @@ -500,9 +567,14 @@ async def start_dcf( raise RuntimeError("Unable to fetch associated user with DCF token.") self._bot_id = user.id + + # Event Ready will act more similarly to setup_hook with DCF setups since we have to wait for the user to respond self.dispatch("ready") self._ready_event.set() + if not block: + return + try: await self.__waiter.wait() finally: From e63baca4b2a83e44a147d5b8fcc71490cafbb5df Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:49:09 +1000 Subject: [PATCH 07/23] Add DCF automatic refresh logic --- twitchio/authentication/tokens.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py index 6cabd123..d925d465 100644 --- a/twitchio/authentication/tokens.py +++ b/twitchio/authentication/tokens.py @@ -213,13 +213,19 @@ async def request(self, route: Route) -> RawResponse | str | None: if e.extra.get("message", "").lower() not in ("invalid access token", "invalid oauth token"): raise e - if isinstance(old, str): + if isinstance(old, str) and self.client_secret: payload: ClientCredentialsPayload = await self.client_credentials_token() self._app_token = payload.access_token route.update_headers({"Authorization": f"Bearer {payload.access_token}"}) return await self.request(route) + if isinstance(old, str): + # Will be a DCF token... + # We only expect and will use a single token when DCF is used; the user shouldn't be loading multiples + vals = list(self._tokens.values()) + old = vals[0] + logger.debug('Token for "%s" was invalid or expired. Attempting to refresh token.', old["user_id"]) refresh: RefreshTokenPayload = await self.__isolated.refresh_token(old["refresh"]) logger.debug('Token for "%s" was successfully refreshed.', old["user_id"]) From eefec2e9a41aad04c1383ae22733555eb648d313 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:49:30 +1000 Subject: [PATCH 08/23] Disallow client_secret when using DCF --- twitchio/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/twitchio/client.py b/twitchio/client.py index f2016178..f8448843 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -446,6 +446,9 @@ async def login_dcf( if not self._http.client_id: raise RuntimeError('Expected a valid "client_id", instead received: %s', self._http.client_id) + if self._http.client_secret: + raise RuntimeError('A "client_secret" cannot be used with Device Code Flows.') + self._http._app_token = None if load_token and not force_flow: From 00b201bb41090a95320eeebc09377ae348181976 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:24:34 +1000 Subject: [PATCH 09/23] Remove unused nested_key param and attr in ManagedHTTPClient --- twitchio/authentication/tokens.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py index d925d465..5906c7b6 100644 --- a/twitchio/authentication/tokens.py +++ b/twitchio/authentication/tokens.py @@ -65,7 +65,6 @@ def __init__( redirect_uri: str | None = None, scopes: Scopes | None = None, session: aiohttp.ClientSession = MISSING, - nested_key: str | None = None, client: Client | None = None, ) -> None: super().__init__( @@ -85,7 +84,6 @@ def __init__( self._tokens: TokenMapping = {} self._app_token: str | None = None - self._nested_key: str | None = None self._token_lock: asyncio.Lock = asyncio.Lock() self._has_loaded: bool = False @@ -114,7 +112,9 @@ def _dispatch_event(self, user_id: str, payload: RefreshTokenPayload) -> None: async def _attempt_refresh_on_add(self, token: str, refresh: str) -> ValidateTokenPayload: try: resp: RefreshTokenPayload = await self.__isolated.refresh_token(refresh) + print(resp) except HTTPException as e: + print(e) msg: str = f'Token was invalid and cannot be refreshed. Please re-authenticate user with token: "{token}"' raise InvalidTokenException(msg, token=token, refresh=refresh, type_="refresh", original=e) From b41722428d2761de2a618cef995360411d949c6e Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:25:29 +1000 Subject: [PATCH 10/23] Ensure we don't re-add already loaded tokens --- twitchio/client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/twitchio/client.py b/twitchio/client.py index f8448843..1931b8ae 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -543,9 +543,11 @@ async def start_dcf( pair = mapping[0] token = pair["token"] refresh = pair["refresh"] + user_id = pair["user_id"] except (IndexError, KeyError): token = "" refresh = "" + user_id = "" if device_code: async with asyncio.timeout(timeout): @@ -554,18 +556,19 @@ async def start_dcf( token = resp["access_token"] refresh = resp["refresh_token"] + validated = await self.add_token(token=token, refresh=refresh) + user_id = validated.user_id + if not token or not refresh: raise RuntimeError( "Unable to start Client: No DCF token pair was able to be loaded. Try force running the flow." ) - validated = await self.add_token(token=token, refresh=refresh) - # Technically a User Token, however this will allow similar default behaviours since DCF should be bound to a # ...single user. self._http._app_token = token - user = await self.fetch_user(id=validated.user_id) + user = await self.fetch_user(id=user_id) if not user: raise RuntimeError("Unable to fetch associated user with DCF token.") From 1e68f171ebbce5b527aa985c642d3e6cc2c004b5 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:24:28 +1000 Subject: [PATCH 11/23] Remove remaining debug prints --- twitchio/authentication/tokens.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py index 5906c7b6..811d8f6a 100644 --- a/twitchio/authentication/tokens.py +++ b/twitchio/authentication/tokens.py @@ -112,9 +112,7 @@ def _dispatch_event(self, user_id: str, payload: RefreshTokenPayload) -> None: async def _attempt_refresh_on_add(self, token: str, refresh: str) -> ValidateTokenPayload: try: resp: RefreshTokenPayload = await self.__isolated.refresh_token(refresh) - print(resp) except HTTPException as e: - print(e) msg: str = f'Token was invalid and cannot be refreshed. Please re-authenticate user with token: "{token}"' raise InvalidTokenException(msg, token=token, refresh=refresh, type_="refresh", original=e) From 84eb0aa6d8a9dbd21f8de446d796ad574324fd46 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:40:33 +1000 Subject: [PATCH 12/23] Update docs for login_dcf --- twitchio/client.py | 80 ++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/twitchio/client.py b/twitchio/client.py index 1931b8ae..c5eeea37 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -202,28 +202,6 @@ def http(self) -> ManagedHTTPClient: """ return self._http - async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse: - """|coro| - - .. warning:: - - It's not intended to use DCF when storing a ``client-secret`` is a safe and practical option. - DCF is intended to be used on user devices where storing your ``client-secret`` is not possible. - - .. note:: - - When using tokens generated through DCF, the only ``EventSub`` transport available is traditional websockets. - Using ``Conduits`` and ``Webhooks`` are not available as they require a ``App Token``. - - Method which starts a Twitch Device Code Flow. - - The DCF (Device Code Flow) is used to obtain an access/refresh token pair for use on client-side applications where - storing a ``client-secret`` would be unsafe E.g. a users device (Phone, TV, etc...). - - When using a token - """ - return await self._http.device_code_flow(scopes=scopes) - async def set_adapter(self, adapter: BaseAdapter[Any]) -> None: """|coro| @@ -436,7 +414,55 @@ async def login_dcf( scopes: Scopes | None = None, force_flow: bool = False, ) -> DeviceCodeFlowResponse | None: - # TODO: Docs... + """|coro| + + .. warning:: + + DCF is intended to be used when your application cannot safely store a ``client-secret``. E.g. on a users + device (phone, tv, etc...). + + .. note:: + + This method should be called before :meth:`.start_dcf`. + + Method to initiate a DCF (Device Code Flow) and setup the :class:`~twitchio.Client`. + + If a token has been loaded automcatically via this method, a DCF authorization flow will not be initiated. + You can change this behaviour by setting the ``force_flow`` keyword-only argument to ``True``. This will force the + user to re-authenticate via DCF. + + This method works together with :meth:`.start_dcf` to complete the flow and setup the :class:`~twitchio.Client`. + You should call :meth:`.start_dcf` after this method. + + Parameters + ---------- + load_token: bool + Whether to attempt to load an existing saved token. Defaults to ``True``. + save_token: bool + Whether to save any tokens loaded into the :class:`~twitchio.Client` at close. Defaults to ``True``. + scopes: :class:`~twitchio.Scopes` | ``None`` + A :class:`~twitchio.Scopes` object with the required scopes set for the user to authenticate with. If you pass + these to the :class:`~twitchio.Client` constructor you do not need to pass them here. + force_flow: bool + Wtheer to force the user to authenticate, even when an existing token is found and valid. Useful when you + require new scopes or for testing. Defaults to ``False``. + + Returns + ------- + DeviceCodeFlowResponse + A dict with the keys: ``device_code``, ``expires_in``, ``interval``, ``user_code`` and ``verification_uri``. + None + The Device Code Flow was not initiated; E.g. an existing token was loaded and ``force_flow`` was ``False``. + + Raises + ------ + RuntimeError + Invalid ``client_id`` was passed to the :class:`~twitchio.Client`. + RuntimeError + You cannot use a ``client_secret`` with DCF. + HTTPException + An error was raised making a request to Twitch. + """ if self._login_called: return @@ -460,7 +486,7 @@ async def login_dcf( await self._setup() if not self._http._tokens: - return await self.device_code_flow(scopes=scopes) + return await self._http.device_code_flow(scopes=scopes) async def start_dcf( self, @@ -527,11 +553,11 @@ async def start_dcf( RuntimeError A valid :class:`~twitchio.User` could not be fetched with the token received. DeviceCodeFlowException - ... + An exception was raised during a request to Twitch for DCF. Check the device code is valid. HTTPException - ... + An exception was raised during a request to Twitch. TimeoutError - ... + Timed-out waiting for the user to complete the DCF. """ if not self._login_called: raise RuntimeError('Client failed to start: "login_dcf" must be called before "start_dcf".') From fc4ffdfeff653688416c04a10689e364f32ac81a Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:47:15 +1000 Subject: [PATCH 13/23] Allow Bot to be used with DCF --- twitchio/ext/commands/bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/twitchio/ext/commands/bot.py b/twitchio/ext/commands/bot.py index c90d0927..ddaf1b44 100644 --- a/twitchio/ext/commands/bot.py +++ b/twitchio/ext/commands/bot.py @@ -24,8 +24,8 @@ from __future__ import annotations -import asyncio import importlib.util +import inspect import logging import sys import types @@ -159,8 +159,8 @@ def __init__( self, *, client_id: str, - client_secret: str, - bot_id: str, + client_secret: str | None = None, + bot_id: str | None = None, owner_id: str | None = None, prefix: PrefixT, **options: Unpack[BotOptions], @@ -698,7 +698,7 @@ async def load_module(self, name: str, *, package: str | None = None) -> None: del sys.modules[name] raise NoEntryPointError(f'The module "{module}" has no setup coroutine.') from exc - if not asyncio.iscoroutinefunction(entry): + if not inspect.iscoroutinefunction(entry): del sys.modules[name] raise TypeError(f'The module "{module}"\'s setup function is not a coroutine.') From e7e73850acd774b423e1a5ff16f50b24c9f5ec84 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:47:40 +1000 Subject: [PATCH 14/23] Set owner and user on start for DCF. --- twitchio/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/twitchio/client.py b/twitchio/client.py index c5eeea37..1fa25f4c 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -599,6 +599,9 @@ async def start_dcf( raise RuntimeError("Unable to fetch associated user with DCF token.") self._bot_id = user.id + self._owner_id = user.id + self._user = user + self._owner = user # Event Ready will act more similarly to setup_hook with DCF setups since we have to wait for the user to respond self.dispatch("ready") From e2a7c808bca8810f4ed1c7947b48ddbe664a8779 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:28:23 +1000 Subject: [PATCH 15/23] Raise NotImplementedError when trying to use DCF with an AutoClient --- twitchio/client.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/twitchio/client.py b/twitchio/client.py index 1fa25f4c..47edfbeb 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -3970,3 +3970,20 @@ async def subscribe_webhook(self, *args: Any, **kwargs: Any) -> Any: AutoClient does not implement this method. """ raise NotImplementedError("AutoClient does not implement this method.") + + async def start_dcf(self, *args: Any, **kwargs: Any) -> Any: + """ + .. important:: + + AutoClient does not implement this method. + """ + raise NotImplementedError("AutoClient does not implement this method.") + + async def login_dcf(self, *args: Any, **kwargs: Any) -> Any: + """ + .. important:: + + AutoClient does not implement this method. + """ + raise NotImplementedError("AutoClient does not implement this method.") + From e9d55550f42e478c26f0f9d436bf2350ea5d3f33 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:51:30 +1000 Subject: [PATCH 16/23] Add Bot DCF example. --- examples/device_code_flow/bot.py | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 examples/device_code_flow/bot.py diff --git a/examples/device_code_flow/bot.py b/examples/device_code_flow/bot.py new file mode 100644 index 00000000..5bc45a20 --- /dev/null +++ b/examples/device_code_flow/bot.py @@ -0,0 +1,72 @@ +""" +A basic example of using DCF (Device Code Flow) with a commands.Bot and an eventsub subscription +to the authorized users chat, to run commands. + +!note: DCF should only be used when you cannot safely store a client_secert: E.g. a users phone, tv, etc... + +Your application should be set to "Public" in the Twitch Developer Console. +""" +import asyncio +import logging + +import twitchio +from twitchio import eventsub +from twitchio.ext import commands + + +LOGGER: logging.Logger = logging.getLogger(__name__) +CLIENT_ID = "..." + +SCOPES = twitchio.Scopes() +SCOPES.user_read_chat = True +SCOPES.user_write_chat = True + + +class Bot(commands.Bot): + def __init__(self) -> None: + super().__init__(client_id=CLIENT_ID, scopes=SCOPES, prefix="!") + + async def setup_hook(self) -> None: + await self.add_component(MyComponent(self)) + + async def event_ready(self) -> None: + # Usually we would do this in the setup_hook; however DCF deviates from our traditional flow slightly... + # Since we have to wait for the user to authorize, it's safer to subscribe in event_ready... + chat = eventsub.ChatMessageSubscription(broadcaster_user_id=self.bot_id, user_id=self.bot_id) + await self.subscribe_websocket(chat, as_bot=True) + + async def event_message(self, payload: twitchio.ChatMessage) -> None: + await self.process_commands(payload) + + +class MyComponent(commands.Component): + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @commands.command() + async def hi(self, ctx: commands.Context[Bot]) -> None: + await ctx.send(f"Hello {ctx.chatter.mention}!") + + +def main() -> None: + twitchio.utils.setup_logging() + + async def runner() -> None: + async with Bot() as bot: + resp = (await bot.login_dcf()) or {} + device_code = resp.get("device_code") + interval = resp.get("interval", 5) + + # Print URI to visit to authenticate + print(resp.get("verification_uri", "")) + + await bot.start_dcf(device_code=device_code, interval=interval) + + try: + asyncio.run(runner()) + except KeyboardInterrupt: + LOGGER.warning("Shutting down due to KeyboardInterrupt.") + + +if __name__ == "__main__": + main() From e75947b730699d2757a9136673ad9d1608e48771 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:51:46 +1000 Subject: [PATCH 17/23] Run ruff --- twitchio/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/twitchio/client.py b/twitchio/client.py index 47edfbeb..70d344d0 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -3970,7 +3970,7 @@ async def subscribe_webhook(self, *args: Any, **kwargs: Any) -> Any: AutoClient does not implement this method. """ raise NotImplementedError("AutoClient does not implement this method.") - + async def start_dcf(self, *args: Any, **kwargs: Any) -> Any: """ .. important:: @@ -3986,4 +3986,3 @@ async def login_dcf(self, *args: Any, **kwargs: Any) -> Any: AutoClient does not implement this method. """ raise NotImplementedError("AutoClient does not implement this method.") - From 5ec98d6acbb77019f649a4ba4ac47a702a5ac003 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:56:30 +1000 Subject: [PATCH 18/23] Export DeviceCodeRejection Enum --- twitchio/__init__.py | 1 + twitchio/enums.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/twitchio/__init__.py b/twitchio/__init__.py index e8a00544..592ae1d1 100644 --- a/twitchio/__init__.py +++ b/twitchio/__init__.py @@ -38,6 +38,7 @@ from .assets import Asset as Asset from .authentication import Scopes as Scopes from .client import * +from .enums import * from .exceptions import * from .http import HTTPAsyncIterator as HTTPAsyncIterator, Route as Route from .models import * diff --git a/twitchio/enums.py b/twitchio/enums.py index 04587e33..d68f501c 100644 --- a/twitchio/enums.py +++ b/twitchio/enums.py @@ -25,6 +25,9 @@ import enum +__all__ = ("DeviceCodeRejection",) + + class DeviceCodeRejection(enum.Enum): UNKNOWN = "unknown" INVALID_REFRESH_TOKEN = "invalid refresh token" From 3dc0e5f1c7ca0dca934283e2335efae2b2d17157 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:03:54 +1000 Subject: [PATCH 19/23] Add DeviceCodeRejection docs --- docs/references/enums_etc.rst | 5 +++++ twitchio/enums.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/references/enums_etc.rst b/docs/references/enums_etc.rst index d8a0d3dd..f9d67d49 100644 --- a/docs/references/enums_etc.rst +++ b/docs/references/enums_etc.rst @@ -13,6 +13,11 @@ Enums and Payloads .. autoclass:: twitchio.eventsub.TransportMethod() :members: +.. attributetable:: twitchio.DeviceCodeRejection + +.. autoclass:: twitchio.DeviceCodeRejection() + :members: + Websocket Subscription Data ============================ diff --git a/twitchio/enums.py b/twitchio/enums.py index d68f501c..27bb2551 100644 --- a/twitchio/enums.py +++ b/twitchio/enums.py @@ -29,6 +29,17 @@ class DeviceCodeRejection(enum.Enum): + """An enum respresenting the reason a DCF (Device Code Flow) failed. + + Attributes + ---------- + UNKNOWN + The reason is unknown. Twitch likely did not provide one or the exception was a 5xx status code. + INVALID_REFRESH_TOKEN + The refresh used was invalid or expired. DCF refresh tokens can only be used once and last ``30`` days. + INVALID_DEVICE_CODE + The provided device code was not valid or the user has already authenticated with this code. + """ UNKNOWN = "unknown" INVALID_REFRESH_TOKEN = "invalid refresh token" INVALID_DEVICE_CODE = "invalid device code" From 619f6f8abef46e88c30354e68dfedffd36fd6ebf Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:05:21 +1000 Subject: [PATCH 20/23] Add DeviceCodeFlowException docs --- docs/references/exceptions.rst | 4 ++++ twitchio/enums.py | 3 ++- twitchio/exceptions.py | 19 +++++++++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/references/exceptions.rst b/docs/references/exceptions.rst index 98735291..8bf89b5f 100644 --- a/docs/references/exceptions.rst +++ b/docs/references/exceptions.rst @@ -9,6 +9,9 @@ Exceptions .. autoclass:: twitchio.HTTPException() :members: +.. autoclass:: twitchio.DeviceCodeFlowException() + :members: + .. autoclass:: twitchio.InvalidTokenException() :members: @@ -27,5 +30,6 @@ Exception Hierarchy - :exc:`TwitchioException` - :exc:`HTTPException` - :exc:`InvalidTokenException` + - :exc:`DeviceCodeFlowException` - :exc:`MessageRejectedError` - :exc:`MissingConduit` \ No newline at end of file diff --git a/twitchio/enums.py b/twitchio/enums.py index 27bb2551..82e8ee51 100644 --- a/twitchio/enums.py +++ b/twitchio/enums.py @@ -30,7 +30,7 @@ class DeviceCodeRejection(enum.Enum): """An enum respresenting the reason a DCF (Device Code Flow) failed. - + Attributes ---------- UNKNOWN @@ -40,6 +40,7 @@ class DeviceCodeRejection(enum.Enum): INVALID_DEVICE_CODE The provided device code was not valid or the user has already authenticated with this code. """ + UNKNOWN = "unknown" INVALID_REFRESH_TOKEN = "invalid refresh token" INVALID_DEVICE_CODE = "invalid device code" diff --git a/twitchio/exceptions.py b/twitchio/exceptions.py index d5fed17d..fe4b9b27 100644 --- a/twitchio/exceptions.py +++ b/twitchio/exceptions.py @@ -88,8 +88,23 @@ def __init__( class DeviceCodeFlowException(HTTPException): - # TODO: Docs... - """...""" + """Exception raised when an error occurs during a DCF (Device Code Flow). + + This exception inherits from :exc:`~twitchio.HTTPException` and contains additional information. + + Attributes + ---------- + reason: :class:`twitchio.DeviceCodeRejection` + The reason the Device Code Flow failed, as an enum. Could be ``UNKNOWN`` if the reason was not provided by Twitch. + route: :class:`twitchio.Route` | None + An optional :class:`twitchio.Route` supplied to this exception, which contains various information about the + request. + status: int + The HTTP response code received from Twitch. E.g. ``404`` or ``409``. + extra: dict[Literal["message"], str] + A dict with a single key named "message", which may contain additional information from Twitch + about why the request failed. + """ def __init__(self, msg: str = "", /, *, original: HTTPException, reason: DeviceCodeRejection | None = None) -> None: self.reason: DeviceCodeRejection = reason or DeviceCodeRejection.UNKNOWN From 247265d480ba1b6a47afe268d292be97bf0f1a6d Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:06:26 +1000 Subject: [PATCH 21/23] Update exception docs --- twitchio/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twitchio/exceptions.py b/twitchio/exceptions.py index fe4b9b27..dab0bd49 100644 --- a/twitchio/exceptions.py +++ b/twitchio/exceptions.py @@ -99,7 +99,7 @@ class DeviceCodeFlowException(HTTPException): route: :class:`twitchio.Route` | None An optional :class:`twitchio.Route` supplied to this exception, which contains various information about the request. - status: int + status: :class:`int` The HTTP response code received from Twitch. E.g. ``404`` or ``409``. extra: dict[Literal["message"], str] A dict with a single key named "message", which may contain additional information from Twitch From 66f20fb6cc71d5a2e1afb8701920d9e7def41fb0 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:12:09 +1000 Subject: [PATCH 22/23] Add dome docs to OAuth HTTP class --- twitchio/authentication/oauth.py | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/twitchio/authentication/oauth.py b/twitchio/authentication/oauth.py index 4a22708c..dfc4f126 100644 --- a/twitchio/authentication/oauth.py +++ b/twitchio/authentication/oauth.py @@ -72,6 +72,27 @@ def __init__( self.scopes = scopes async def validate_token(self, token: str, /) -> ValidateTokenPayload: + """|coro| + + Method which validates the provided token. + + Parameters + ---------- + token: :class:`str` + The token to attempt to validate. + + Returns + ------- + ValidateTokenPayload + The payload received from Twitch if no HTTPException was raised. + + Raises + ------ + HTTPException + An error occurred during a request to Twitch. + HTTPException + Bad or invalid token provided. + """ token = token.removeprefix("Bearer ").removeprefix("OAuth ") headers: dict[str, str] = {"Authorization": f"OAuth {token}"} @@ -114,6 +135,20 @@ async def user_access_token(self, code: str, /, *, redirect_uri: str | None = No return UserTokenPayload(data) async def revoke_token(self, token: str, /) -> None: + """|coro| + + Method to revoke the authorization of a provided token. + + Parameters + ---------- + token: :class:`str` + The token to revoke authorization from. The token will be invalid and cannot be used after revocation. + + Raises + ------ + HTTPException + An error occurred during a request to Twitch. + """ params = self._create_params({"token": token}) route: Route = Route("POST", "/oauth2/revoke", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params) From 5092ddb9a05b173669191e6c3930a6359a5f0fcf Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:35:54 +1000 Subject: [PATCH 23/23] Update changelog --- docs/getting-started/changelog.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 1b17a04a..6e0d4cea 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -13,6 +13,26 @@ Changelog - Added - :class:`~twitchio.SuspiciousChatUser` model. - Added - :func:`~twitchio.PartialUser.add_suspicious_chat_user` to :class:`~twitchio.PartialUser`. - Added - :func:`~twitchio.PartialUser.remove_suspicious_chat_user` to :class:`~twitchio.PartialUser`. + - Added - :exc:`~twitchio.DeviceCodeFlowException` + - Added - :class:`~twitchio.DeviceCodeRejection` + + - Changes + - Some of the internal token management has been adjusted to support applications using DCF. + +- twitchio.Client + - Additions + - Added - :meth:`twitchio.Client.login_dcf` + - Added - :meth:`twitchio.Client.start_dcf` + - Added - :attr:`twitchio.Client.http` + + - Changes + - The ``client_secret`` passed to :class:`~twitchio.Client` is now optional for DCF support. + - Some methods using deprecated ``asyncio`` methods were updated to use ``inspect``. + +- twitchio.ext.commands.Bot + - Changes + - The ``bot_id`` passed to :class:`~twitchio.ext.commands.Bot` is now optional for DCF support. + 3.2.1 ======