From 44d6d7749448cb1098c229a795dbdb11b9dd0cd4 Mon Sep 17 00:00:00 2001 From: Interitio Date: Mon, 23 Sep 2024 15:56:18 +1000 Subject: [PATCH] feat(twitch): Add authentication server. --- src/twitch/authclient.py | 50 ++++++++++++++++++++++ src/twitch/authserver.py | 91 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 src/twitch/authclient.py diff --git a/src/twitch/authclient.py b/src/twitch/authclient.py new file mode 100644 index 00000000..509b080c --- /dev/null +++ b/src/twitch/authclient.py @@ -0,0 +1,50 @@ +""" +Testing client for the twitch AuthServer. +""" +import sys +import os + +sys.path.insert(0, os.path.join(os.getcwd())) +sys.path.insert(0, os.path.join(os.getcwd(), "src")) + +import asyncio +import aiohttp +from twitchAPI.twitch import Twitch +from twitchAPI.oauth import UserAuthenticator +from twitchAPI.type import AuthScope + +from meta.config import conf + + +URI = "http://localhost:3000/twiauth/confirm" +TARGET_SCOPE = [AuthScope.CHAT_EDIT, AuthScope.CHAT_READ] + +async def main(): + # Load in client id and secret + twitch = await Twitch(conf.twitch['app_id'], conf.twitch['app_secret']) + auth = UserAuthenticator(twitch, TARGET_SCOPE, url=URI) + url = auth.return_auth_url() + + # Post url to user + print(url) + + # Send listen request to server + # Wait for listen request + async with aiohttp.ClientSession() as session: + async with session.ws_connect('http://localhost:3000/twiauth/listen') as ws: + await ws.send_json({'state': auth.state}) + result = await ws.receive_json() + + # Hopefully get back code, print the response + print(f"Recieved: {result}") + + # Authorise with code and client details + tokens = await auth.authenticate(user_token=result['code']) + if tokens: + token, refresh = tokens + await twitch.set_user_authentication(token, TARGET_SCOPE, refresh) + print(f"Authorised!") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/src/twitch/authserver.py b/src/twitch/authserver.py index e87c433c..b26c5953 100644 --- a/src/twitch/authserver.py +++ b/src/twitch/authserver.py @@ -1,7 +1,86 @@ -""" -We want to open an aiohttp server and listen on a configured port. -When we get a request, we validate it to be 'of twitch form', -parse out the error or access token, state, etc, and then pass that information on. +import logging +import uuid +import asyncio +from contextvars import ContextVar -Passing on maybe done through webhook server? -""" +import aiohttp +from aiohttp import web + +logger = logging.getLogger(__name__) +reqid: ContextVar[str] = ContextVar('reqid', default='ROOT') + + +class AuthServer: + def __init__(self): + self.listeners = {} + + async def handle_twitch_callback(self, request: web.Request) -> web.StreamResponse: + args = request.query + if 'state' not in args: + raise web.HTTPBadRequest(text="No state provided.") + if args['state'] not in self.listeners: + raise web.HTTPBadRequest(text="Invalid state.") + self.listeners[args['state']].set_result(dict(args)) + return web.Response(text="Authorisation complete! You may now close this page and return to the application.") + + async def handle_listen_request(self, request: web.Request) -> web.StreamResponse: + _reqid = str(uuid.uuid1()) + reqid.set(_reqid) + + logger.debug(f"[reqid: {_reqid}] Received websocket listen connection: {request!r}") + + ws = web.WebSocketResponse() + await ws.prepare(request) + + # Get the listen request data + try: + listen_req = await ws.receive_json(timeout=60) + logger.info(f"[reqid: {_reqid}] Received websocket listen request: {request}") + if 'state' not in listen_req: + logger.error(f"[reqid: {_reqid}] Websocket listen request is missing state, cancelling.") + raise web.HTTPBadRequest(text="Listen request must include state string.") + elif listen_req['state'] in self.listeners: + logger.error(f"[reqid: {_reqid}] Websocket listen request with duplicate state, cancelling.") + raise web.HTTPBadRequest(text="Invalid state string.") + except ValueError: + logger.exception(f"[reqid: {_reqid}] Listen request could not be parsed to JSON.") + raise web.HTTPBadRequest(text="Request must be a JSON formatted string.") + except TypeError: + logger.exception(f"[reqid: {_reqid}] Listen request was binary not JSON.") + raise web.HTTPBadRequest(text="Request must be a JSON formatted string.") + except asyncio.TimeoutError: + logger.info(f"[reqid: {_reqid}] Timed out waiting for listen request data.") + raise web.HTTPRequestTimeout(text="Request must be a JSON formatted string.") + except Exception: + logger.exception(f"[reqid: {_reqid}] Unknown exception.") + raise web.HTTPInternalServerError() + + try: + fut = self.listeners[listen_req['state']] = asyncio.Future() + result = await asyncio.wait_for(fut, timeout=120) + except asyncio.TimeoutError: + logger.info(f"[reqid: {_reqid}] Timed out waiting for auth callback from Twitch, closing.") + raise web.HTTPGatewayTimeout(text="Did not receive an authorisation code from Twitch in time.") + finally: + self.listeners.pop(listen_req['state'], None) + + logger.debug(f"[reqid: {_reqid}] Responding with auth result {result}.") + await ws.send_json(result) + await ws.close() + logger.debug(f"[reqid: {_reqid}] Request completed handling.") + + return ws + +def main(argv): + app = web.Application() + server = AuthServer() + app.router.add_get("/twiauth/confirm", server.handle_twitch_callback) + app.router.add_get("/twiauth/listen", server.handle_listen_request) + + logger.info("App setup and configured. Starting now.") + web.run_app(app, port=int(argv[1]) if len(argv) > 1 else 8080) + + +if __name__ == '__main__': + import sys + main(sys.argv)