Files
simple-nanoshare/cli/nanoshare_client/auth.py
T
daniel156161 73fdeacbfe
Build and Push Docker Container / build-and-push (push) Successful in 2m25s
feat!: adopt Python 3.14 and 3.15 language features
- Require Python 3.15 for the server and Python 3.14 for the CLI.
- Match the server requirement to quart_common's Python 3.15 features.
- Replace future annotations with native deferred evaluation.
- Defer CLI command dependencies and QR imports on Python 3.15.
- Enable argparse suggestions for mistyped CLI commands.
- Remove the obsolete tomllib import fallback.
- Update my_helpers and quart_common submodule references.
- Lock test dependencies and exclude them from Docker installations.
- Add regression coverage for lazy imports and deferred annotations.
- Document runtime requirements and standalone CLI validation.
- Validate 276 tests on Python 3.15 and 18 CLI tests on Python 3.14.

BREAKING CHANGE: The server now requires Python 3.15+ and the standalone CLI requires Python 3.14+.
2026-10-26 14:47:29 +01:00

111 lines
3.7 KiB
Python

import secrets
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
import httpx
from .config import DEFAULT_CONFIG, write_config
def _make_authorize_url(base_url: str, callback_url: str, state: str, scope: str) -> str:
url_parts = urlsplit(f'{base_url.rstrip("/")}/auth/cli/login')
query = dict(parse_qs(url_parts.query))
flat_query = {key: value[-1] if isinstance(value, list) else value for key, value in query.items()}
flat_query.update({'redirect_uri': callback_url, 'state': state, 'scope': scope})
return urlunsplit(url_parts._replace(query=urlencode(flat_query)))
class _CallbackServer:
def __init__(self, host: str, port: int, expected_state: str):
self.code: str | None = None
self.error: str | None = None
self.expected_state = expected_state
self.done = threading.Event()
outer = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return
def do_GET(self):
params = parse_qs(urlsplit(self.path).query)
state = params.get('state', [''])[0]
if state != outer.expected_state:
outer.error = 'state mismatch'
self.send_response(400)
self.end_headers()
self.wfile.write(b'NanoShare CLI login failed: state mismatch')
outer.done.set()
return
outer.code = params.get('code', [''])[0]
outer.error = params.get('error', [''])[0] or None
self.send_response(200 if outer.code else 400)
self.end_headers()
if outer.code:
self.wfile.write(b'NanoShare CLI login complete. You can close this browser tab.')
else:
self.wfile.write(b'NanoShare CLI login failed.')
outer.done.set()
self.httpd = ThreadingHTTPServer((host, port), Handler)
self.url = f'http://{host}:{self.httpd.server_port}/callback'
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.started = False
def start(self) -> None:
if not self.started:
self.thread.start()
self.started = True
def wait_for_code(self, timeout: float) -> str:
self.start()
received_callback = self.done.wait(timeout)
self.httpd.shutdown()
self.thread.join(5)
if self.error:
raise RuntimeError(self.error)
if not received_callback or not self.code:
raise TimeoutError('login timed out')
return self.code
def login(args) -> int:
state = secrets.token_urlsafe(24)
server = _CallbackServer(args.callback_host, args.callback_port, state)
server.start()
authorize_url = _make_authorize_url(args.base_url, server.url, state, args.scope)
print(f'Opening browser for NanoShare login: {authorize_url}', flush=True)
if not args.no_browser:
webbrowser.open(authorize_url)
else:
print(authorize_url, flush=True)
try:
code = server.wait_for_code(args.login_timeout)
response = httpx.post(
f'{args.base_url.rstrip("/")}/api/cli/token',
json={'code': code, 'state': state},
timeout=args.timeout,
headers={'Accept': 'application/json'},
)
response.raise_for_status()
data = response.json()
refresh_token = data.get('refresh_token')
if not refresh_token:
raise RuntimeError('server returned no refresh_token')
config_path = args.config or DEFAULT_CONFIG
write_config(
config_path,
node=args.node,
url=args.base_url,
refresh_token=refresh_token,
token_url=f'{args.base_url.rstrip("/")}/token/refresh',
source=args.source,
)
except Exception as exc:
print(f'login failed: {exc}')
return 1
print(f'Logged in. Config written to {config_path}')
return 0