Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/getting-started/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
======
Expand Down
5 changes: 5 additions & 0 deletions docs/references/enums_etc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ Enums and Payloads
.. autoclass:: twitchio.eventsub.TransportMethod()
:members:

.. attributetable:: twitchio.DeviceCodeRejection

.. autoclass:: twitchio.DeviceCodeRejection()
:members:


Websocket Subscription Data
============================
Expand Down
4 changes: 4 additions & 0 deletions docs/references/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Exceptions
.. autoclass:: twitchio.HTTPException()
:members:

.. autoclass:: twitchio.DeviceCodeFlowException()
:members:

.. autoclass:: twitchio.InvalidTokenException()
:members:

Expand All @@ -27,5 +30,6 @@ Exception Hierarchy
- :exc:`TwitchioException`
- :exc:`HTTPException`
- :exc:`InvalidTokenException`
- :exc:`DeviceCodeFlowException`
- :exc:`MessageRejectedError`
- :exc:`MissingConduit`
72 changes: 72 additions & 0 deletions examples/device_code_flow/bot.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions twitchio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
105 changes: 99 additions & 6 deletions twitchio/authentication/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand All @@ -39,6 +43,8 @@
from ..types_.responses import (
AuthorizationURLResponse,
ClientCredentialsResponse,
DeviceCodeFlowResponse,
DeviceCodeTokenResponse,
RefreshTokenResponse,
UserTokenResponse,
ValidateTokenResponse,
Expand All @@ -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,
Expand All @@ -66,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}"}
Expand Down Expand Up @@ -108,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)
Expand All @@ -121,6 +162,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,
*,
Expand Down Expand Up @@ -163,10 +255,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
12 changes: 8 additions & 4 deletions twitchio/authentication/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ 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,
nested_key: str | None = None,
client: Client | None = None,
) -> None:
super().__init__(
Expand All @@ -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
Expand Down Expand Up @@ -213,13 +211,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"])
Expand Down
Loading