diff options
| author | 2026-07-30 16:02:53 +0200 | |
|---|---|---|
| committer | 2026-07-30 16:02:53 +0200 | |
| commit | 06e71210f33d7b7701f93ff44b336b41e9e868ab (patch) | |
| tree | f2e8a249e1bc5a1c4c003f3be7d02181403f7ecd | |
| parent | 95600cf6da1c6fbd5ee9f1a5de57b17503d4fe9c (diff) | |
| download | client-v3.tar.gz client-v3.tar.bz2 client-v3.zip | |
Authv3
| -rw-r--r-- | ttun/__main__.py | 5 | ||||
| -rw-r--r-- | ttun/client.py | 145 | ||||
| -rw-r--r-- | ttun/settings.py | 5 | ||||
| -rw-r--r-- | ttun/token_storage.py | 42 |
4 files changed, 192 insertions, 5 deletions
diff --git a/ttun/__main__.py b/ttun/__main__.py index 4b108f5..38b9d8d 100644 --- a/ttun/__main__.py +++ b/ttun/__main__.py | |||
| @@ -34,9 +34,11 @@ def main(): | |||
| 34 | parser.add_argument("port", help="The local port to expose") | 34 | parser.add_argument("port", help="The local port to expose") |
| 35 | parser.add_argument( | 35 | parser.add_argument( |
| 36 | "--server", | 36 | "--server", |
| 37 | default=f'{"wss" if SERVER_USING_SSL else "ws"}://{SERVER_HOSTNAME}', | 37 | # default=f'{"wss" if SERVER_USING_SSL else "ws"}://{SERVER_HOSTNAME}', |
| 38 | default=os.environ.get('TTUN_SERVER', SERVER_HOSTNAME), | ||
| 38 | help="The hostname of the ttun server", | 39 | help="The hostname of the ttun server", |
| 39 | ) | 40 | ) |
| 41 | parser.add_argument('--server-using-ssl', default=SERVER_USING_SSL, action='store_true') | ||
| 40 | parser.add_argument( | 42 | parser.add_argument( |
| 41 | "-s", | 43 | "-s", |
| 42 | "--subdomain", | 44 | "--subdomain", |
| @@ -71,6 +73,7 @@ def main(): | |||
| 71 | port=args.port, | 73 | port=args.port, |
| 72 | subdomains=args.subdomain, | 74 | subdomains=args.subdomain, |
| 73 | server=args.server, | 75 | server=args.server, |
| 76 | using_ssl=args.server_using_ssl, | ||
| 74 | to=args.to, | 77 | to=args.to, |
| 75 | https=args.https, | 78 | https=args.https, |
| 76 | headers=args.header, | 79 | headers=args.header, |
diff --git a/ttun/client.py b/ttun/client.py index 63bf258..1e45230 100644 --- a/ttun/client.py +++ b/ttun/client.py | |||
| @@ -1,11 +1,16 @@ | |||
| 1 | import asyncio | 1 | import asyncio |
| 2 | import hashlib | ||
| 2 | import json | 3 | import json |
| 3 | import logging | 4 | import logging |
| 4 | import os | 5 | import os |
| 6 | import secrets | ||
| 5 | import sys | 7 | import sys |
| 8 | import webbrowser | ||
| 9 | from asyncio import Future | ||
| 6 | from asyncio import get_running_loop | 10 | from asyncio import get_running_loop |
| 7 | from base64 import b64decode | 11 | from base64 import b64decode |
| 8 | from base64 import b64encode | 12 | from base64 import b64encode |
| 13 | from base64 import urlsafe_b64encode | ||
| 9 | from datetime import datetime | 14 | from datetime import datetime |
| 10 | from time import perf_counter | 15 | from time import perf_counter |
| 11 | from typing import Awaitable | 16 | from typing import Awaitable |
| @@ -14,6 +19,7 @@ from typing import Coroutine | |||
| 14 | from typing import List | 19 | from typing import List |
| 15 | from typing import Optional | 20 | from typing import Optional |
| 16 | from typing import Tuple | 21 | from typing import Tuple |
| 22 | from urllib.parse import urlencode | ||
| 17 | from uuid import uuid4 | 23 | from uuid import uuid4 |
| 18 | 24 | ||
| 19 | import websockets | 25 | import websockets |
| @@ -21,10 +27,13 @@ from aiohttp import ClientConnectionError | |||
| 21 | from aiohttp import ClientError | 27 | from aiohttp import ClientError |
| 22 | from aiohttp import ClientSession | 28 | from aiohttp import ClientSession |
| 23 | from aiohttp import DummyCookieJar | 29 | from aiohttp import DummyCookieJar |
| 30 | from aiohttp import web | ||
| 24 | from websockets.asyncio.client import ClientConnection | 31 | from websockets.asyncio.client import ClientConnection |
| 25 | from websockets.exceptions import ConnectionClosed | 32 | from websockets.exceptions import ConnectionClosed |
| 33 | from websockets.exceptions import InvalidStatus | ||
| 26 | 34 | ||
| 27 | from ttun import __version__ | 35 | from ttun import __version__ |
| 36 | from ttun import token_storage | ||
| 28 | from ttun.pubsub import PubSub | 37 | from ttun.pubsub import PubSub |
| 29 | from ttun.types import Config | 38 | from ttun.types import Config |
| 30 | from ttun.types import HttpMessage | 39 | from ttun.types import HttpMessage |
| @@ -46,13 +55,15 @@ class Client: | |||
| 46 | self, | 55 | self, |
| 47 | port: int, | 56 | port: int, |
| 48 | server: str, | 57 | server: str, |
| 58 | using_ssl: bool = True, | ||
| 49 | subdomains: List[str] = None, | 59 | subdomains: List[str] = None, |
| 50 | to: str = "127.0.0.1", | 60 | to: str = "127.0.0.1", |
| 51 | https: bool = False, | 61 | https: bool = False, |
| 52 | headers: List[Tuple[str, str]] = None, | 62 | headers: List[Tuple[str, str]] = None, |
| 53 | ): | 63 | ): |
| 54 | self.version = __version__ | 64 | self.version = __version__ |
| 55 | self.server = server | 65 | self.http_origin = f'{"https://" if using_ssl else "http://"}{server}' |
| 66 | self.ws_origin= f'{"wss://" if using_ssl else "ws://"}{server}' | ||
| 56 | self.subdomains = subdomains | 67 | self.subdomains = subdomains |
| 57 | 68 | ||
| 58 | self._config: dict = None | 69 | self._config: dict = None |
| @@ -63,6 +74,9 @@ class Client: | |||
| 63 | 74 | ||
| 64 | self.headers = [] if headers is None else headers | 75 | self.headers = [] if headers is None else headers |
| 65 | 76 | ||
| 77 | token = token_storage.load_token(self.http_origin) | ||
| 78 | self.token_type, self.token = token if token else (None, None) | ||
| 79 | |||
| 66 | self.websocket_connections = {} | 80 | self.websocket_connections = {} |
| 67 | 81 | ||
| 68 | async def send(self, data: dict): | 82 | async def send(self, data: dict): |
| @@ -72,6 +86,7 @@ class Client: | |||
| 72 | data = json.loads(await self.connection.recv()) | 86 | data = json.loads(await self.connection.recv()) |
| 73 | return data | 87 | return data |
| 74 | 88 | ||
| 89 | |||
| 75 | @staticmethod | 90 | @staticmethod |
| 76 | def loop(sleep: int = None): | 91 | def loop(sleep: int = None): |
| 77 | async def wrapper(callback: Callable[[], Coroutine]): | 92 | async def wrapper(callback: Callable[[], Coroutine]): |
| @@ -87,8 +102,134 @@ class Client: | |||
| 87 | 102 | ||
| 88 | return wrapper | 103 | return wrapper |
| 89 | 104 | ||
| 105 | |||
| 106 | async def authenticate(self) -> None: | ||
| 107 | async with ClientSession() as session: | ||
| 108 | async with session.get(f"{self.http_origin}/oauth/config/") as response: | ||
| 109 | config = await response.json() | ||
| 110 | |||
| 111 | code_verifier = secrets.token_urlsafe(64) | ||
| 112 | code_challenge = ( | ||
| 113 | urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) | ||
| 114 | .rstrip(b"=") | ||
| 115 | .decode() | ||
| 116 | ) | ||
| 117 | state = secrets.token_urlsafe(32) | ||
| 118 | |||
| 119 | runner, port, code_future = await self._start_callback_server(state) | ||
| 120 | |||
| 121 | try: | ||
| 122 | # redirect_uri must exactly match a redirect_uri registered on the | ||
| 123 | # server for this client_id; a loopback address will be rejected | ||
| 124 | # until the server accepts CLI-issued loopback redirect URIs | ||
| 125 | redirect_uri = f"http://127.0.0.1:{port}/callback" | ||
| 126 | authorize_url = f"{self.http_origin}/oauth/authorize/?" + urlencode( | ||
| 127 | { | ||
| 128 | "client_id": config["client_id"], | ||
| 129 | "redirect_uri": redirect_uri, | ||
| 130 | "response_type": "code", | ||
| 131 | "state": state, | ||
| 132 | "code_challenge": code_challenge, | ||
| 133 | "code_challenge_method": "S256", | ||
| 134 | } | ||
| 135 | ) | ||
| 136 | |||
| 137 | print(f"Opening browser for authentication:\n{authorize_url}") | ||
| 138 | webbrowser.open(authorize_url) | ||
| 139 | |||
| 140 | try: | ||
| 141 | code = await asyncio.wait_for(code_future, timeout=300) | ||
| 142 | except asyncio.TimeoutError: | ||
| 143 | print("Authentication timed out. Please try again.") | ||
| 144 | raise | ||
| 145 | except RuntimeError as e: | ||
| 146 | print(f"Authentication failed: {e}") | ||
| 147 | raise | ||
| 148 | finally: | ||
| 149 | await runner.cleanup() | ||
| 150 | |||
| 151 | await self._exchange_code(code, redirect_uri, config["client_id"], code_verifier) | ||
| 152 | |||
| 153 | async def _start_callback_server( | ||
| 154 | self, expected_state: str | ||
| 155 | ) -> Tuple[web.AppRunner, int, "Future[str]"]: | ||
| 156 | code_future: Future[str] = get_running_loop().create_future() | ||
| 157 | |||
| 158 | async def callback(request: web.Request) -> web.Response: | ||
| 159 | if not code_future.done(): | ||
| 160 | self._resolve_callback(request, expected_state, code_future) | ||
| 161 | return web.Response( | ||
| 162 | text=( | ||
| 163 | "<p>Authentication complete. You may close this window.</p>" | ||
| 164 | ), | ||
| 165 | content_type="text/html", | ||
| 166 | ) | ||
| 167 | |||
| 168 | app = web.Application() | ||
| 169 | app.add_routes([web.get("/callback", callback)]) | ||
| 170 | |||
| 171 | runner = web.AppRunner(app) | ||
| 172 | await runner.setup() | ||
| 173 | site = web.TCPSite(runner, "127.0.0.1", 0) | ||
| 174 | await site.start() | ||
| 175 | port = runner.addresses[0][1] | ||
| 176 | |||
| 177 | return runner, port, code_future | ||
| 178 | |||
| 179 | @staticmethod | ||
| 180 | def _resolve_callback( | ||
| 181 | request: web.Request, expected_state: str, code_future: "Future[str]" | ||
| 182 | ) -> None: | ||
| 183 | if request.query.get("state") != expected_state: | ||
| 184 | code_future.set_exception(RuntimeError("OAuth state mismatch")) | ||
| 185 | elif "error" in request.query: | ||
| 186 | code_future.set_exception( | ||
| 187 | RuntimeError(f"Authorization denied: {request.query['error']}") | ||
| 188 | ) | ||
| 189 | elif "code" in request.query: | ||
| 190 | code_future.set_result(request.query["code"]) | ||
| 191 | else: | ||
| 192 | code_future.set_exception(RuntimeError("Callback missing code and error")) | ||
| 193 | |||
| 194 | async def _exchange_code( | ||
| 195 | self, code: str, redirect_uri: str, client_id: str, code_verifier: str | ||
| 196 | ) -> None: | ||
| 197 | async with ClientSession() as session: | ||
| 198 | async with session.post( | ||
| 199 | f"{self.http_origin}/oauth/token/", | ||
| 200 | data={ | ||
| 201 | "grant_type": "authorization_code", | ||
| 202 | "code": code, | ||
| 203 | "redirect_uri": redirect_uri, | ||
| 204 | "client_id": client_id, | ||
| 205 | "code_verifier": code_verifier, | ||
| 206 | }, | ||
| 207 | ) as response: | ||
| 208 | token = await response.json() | ||
| 209 | |||
| 210 | self.token_type = token["token_type"] | ||
| 211 | self.token = token["access_token"] | ||
| 212 | token_storage.save_token(self.http_origin, self.token_type, self.token) | ||
| 213 | |||
| 214 | @staticmethod | ||
| 215 | def handle_authentication(func): | ||
| 216 | async def wrapper(self, *args, **kwargs): | ||
| 217 | try: | ||
| 218 | await func(self, *args, **kwargs) | ||
| 219 | except InvalidStatus: | ||
| 220 | await self.authenticate() | ||
| 221 | await func(self, *args, **kwargs) | ||
