summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--ttun/__main__.py5
-rw-r--r--ttun/client.py145
-rw-r--r--ttun/settings.py5
-rw-r--r--ttun/token_storage.py42
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 @@
1import asyncio 1import asyncio
2import hashlib
2import json 3import json
3import logging 4import logging
4import os 5import os
6import secrets
5import sys 7import sys
8import webbrowser
9from asyncio import Future
6from asyncio import get_running_loop 10from asyncio import get_running_loop
7from base64 import b64decode 11from base64 import b64decode
8from base64 import b64encode 12from base64 import b64encode
13from base64 import urlsafe_b64encode
9from datetime import datetime 14from datetime import datetime
10from time import perf_counter 15from time import perf_counter
11from typing import Awaitable 16from typing import Awaitable
@@ -14,6 +19,7 @@ from typing import Coroutine
14from typing import List 19from typing import List
15from typing import Optional 20from typing import Optional
16from typing import Tuple 21from typing import Tuple
22from urllib.parse import urlencode
17from uuid import uuid4 23from uuid import uuid4
18 24
19import websockets 25import websockets
@@ -21,10 +27,13 @@ from aiohttp import ClientConnectionError
21from aiohttp import ClientError 27from aiohttp import ClientError
22from aiohttp import ClientSession 28from aiohttp import ClientSession
23from aiohttp import DummyCookieJar 29from aiohttp import DummyCookieJar
30from aiohttp import web
24from websockets.asyncio.client import ClientConnection 31from websockets.asyncio.client import ClientConnection
25from websockets.exceptions import ConnectionClosed 32from websockets.exceptions import ConnectionClosed
33from websockets.exceptions import InvalidStatus
26 34
27from ttun import __version__ 35from ttun import __version__
36from ttun import token_storage
28from ttun.pubsub import PubSub 37from ttun.pubsub import PubSub
29from ttun.types import Config 38from ttun.types import Config
30from ttun.types import HttpMessage 39from 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)
222 return wrapper
223
224
225 @handle_authentication
90 async def connect(self) -> ClientConnection: