from __future__ import annotations 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 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') 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.') 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) def wait_for_code(self, timeout: float) -> str: self.thread.start() self.thread.join(timeout) self.httpd.shutdown() if self.error: raise RuntimeError(self.error) if 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) authorize_url = _make_authorize_url(args.base_url, server.url, state, args.scope) print(f'Opening browser for NanoShare login: {authorize_url}') if not args.no_browser: webbrowser.open(authorize_url) else: print(authorize_url) 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