From a236c4620b1e6c6fd9c88f3d401b4648f9403932 Mon Sep 17 00:00:00 2001 From: voodoohop Date: Sat, 18 Jul 2026 09:47:23 +0200 Subject: [PATCH] fix: use OAuth device flow for BYOP --- .gitignore | 1 + README.md | 4 +- commands/pollinations-setup.md | 4 +- servers/pollinations_server.py | 172 +++++++++++---------------------- 4 files changed, 61 insertions(+), 120 deletions(-) diff --git a/.gitignore b/.gitignore index c79564c..d2a4aef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ bayesian* __pycache__/ .env config/user.json +.claude/pollinations-user.json social-preview* repository-open-graph* diff --git a/README.md b/README.md index 5f24523..1ef272d 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ This plugin uses Pollinations' [Bring Your Own Pollen](https://github.com/pollin 1. User runs `/pollinations-setup` 2. Browser opens to Pollinations login -3. User authorizes the app -4. API key is captured automatically via localhost redirect +3. User approves the displayed device authorization +4. The plugin polls Pollinations until authorization completes 5. Key is saved locally — never transmitted anywhere except Pollinations API endpoints 6. User's own pollen credits fund all requests diff --git a/commands/pollinations-setup.md b/commands/pollinations-setup.md index ecac266..89576d7 100644 --- a/commands/pollinations-setup.md +++ b/commands/pollinations-setup.md @@ -2,13 +2,13 @@ Connect your Pollinations account to Claude Code using BYOP (Bring Your Own Pollen). -This opens your browser to log in at Pollinations. Once authorized, your API key is saved locally and all generation tools become available. +This opens Pollinations' device authorization page in your browser. Approve the displayed code to save your delegated API key locally and enable the generation tools. Your pollen credits fund usage — the plugin costs nothing to run. ## Usage -Just type `/pollinations-setup` and follow the browser prompt. +Just type `/pollinations-setup` and approve the device authorization in your browser. If you already have an API key, you can pass it directly: ``` diff --git a/servers/pollinations_server.py b/servers/pollinations_server.py index e56447f..93a1ef5 100644 --- a/servers/pollinations_server.py +++ b/servers/pollinations_server.py @@ -5,8 +5,7 @@ Developer pays nothing. User's pollen credits fund requests. Base URL: https://gen.pollinations.ai -Image URL: https://image.pollinations.ai -Auth: Bearer token from BYOP OAuth flow +Auth: Bearer token from BYOP OAuth device flow """ import json import os @@ -36,7 +35,8 @@ USER_CONFIG_FILE = PROJECT_DIR / ".claude" / "pollinations-user.json" IMAGE_URL = "https://gen.pollinations.ai/image" # GET /image/{prompt} for images and video BASE_URL = "https://gen.pollinations.ai" -BYOP_AUTH_URL = "https://enter.pollinations.ai/authorize" +DEVICE_CODE_URL = "https://enter.pollinations.ai/api/device/code" +DEVICE_TOKEN_URL = "https://enter.pollinations.ai/api/device/token" API_KEY = "" # Key comes from user.json via BYOP OAuth, not env vars # Developer app key — registered at enter.pollinations.ai as ai-ministries. @@ -75,6 +75,7 @@ def _save_user_config(data): existing.update(data) USER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) USER_CONFIG_FILE.write_text(json.dumps(existing, indent=2), encoding="utf-8") + USER_CONFIG_FILE.chmod(0o600) def _load_styles(): @@ -151,7 +152,7 @@ def _require_key(config=None): @mcp.tool() def pollinations_setup(api_key: str = "") -> str: - """Connect to Pollinations via BYOP. Opens browser for login, catches the key automatically. If user already has a key they can pass it directly. + """Connect to Pollinations via BYOP device authorization. If the user already has a key they can pass it directly. Args: api_key: Optional — pass a key directly if user has one. Otherwise leave empty to start BYOP login flow. @@ -160,120 +161,59 @@ def pollinations_setup(api_key: str = "") -> str: _save_user_config({"api_key": api_key}) return f"Connected. Key saved. Ready to generate." - # BYOP OAuth flow — spin up localhost server, open browser, catch redirect - import threading - import http.server + # OAuth device flow is designed for CLIs and MCP servers. It avoids putting + # access tokens in URL fragments or running a local callback server. import webbrowser - - captured_key = [None] - server_ready = threading.Event() - - class CallbackHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - # Serve a page that extracts the key from the URL fragment - # Fragment (#api_key=...) never hits the server, so we use JS to POST it back - if self.path.startswith("/capture"): - # JS posted the key to us - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length).decode("utf-8") if length else "" - try: - data = json.loads(body) - captured_key[0] = data.get("api_key", "") - except Exception: - pass - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(b"

Connected! You can close this tab.

") - return - - if self.path.startswith("/options") or self.path == "/capture": - self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.end_headers() - return - - # Main callback page — extracts fragment and POSTs key back to /capture - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - page = """ -

Connecting to Pollinations...

- -""" - self.wfile.write(page.encode("utf-8")) - - def do_POST(self): - self.do_GET() - - def do_OPTIONS(self): - self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.end_headers() - - def log_message(self, format, *args): - pass # silence logs - - # Find a free port - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - sock.close() - - redirect_url = f"http://localhost:{port}" - - # Build auth URL app_key = APP_KEY or _load_config().get("app_key", "") - params = {"redirect_url": redirect_url} - if app_key: - params["app_key"] = app_key - auth_url = f"{BYOP_AUTH_URL}?{urllib.parse.urlencode(params)}" - - # Start server in background - server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler) - server.timeout = 120 - - def serve(): - server_ready.set() - for _ in range(60): # wait up to 2 min - server.handle_request() - if captured_key[0]: - break - - t = threading.Thread(target=serve, daemon=True) - t.start() - server_ready.wait() - - # Open browser - webbrowser.open(auth_url) - - # Wait for key (up to 2 min) - t.join(timeout=120) - - try: - server.server_close() - except Exception: - pass - - if captured_key[0]: - _save_user_config({"api_key": captured_key[0]}) - return f"Connected! Key saved. Ready to generate." - else: - return "Timed out waiting for login. Run /pollinations-setup to try again." + if not app_key: + return "No Pollinations app key configured. Set POLLINATIONS_APP_KEY and try again." + + status, device = _http_json( + DEVICE_CODE_URL, + method="POST", + data={"client_id": app_key}, + headers={"Content-Type": "application/json"}, + ) + if status != 200 or not device.get("device_code"): + return f"Could not start Pollinations login (HTTP {status}): {device.get('error', 'unknown error')}" + + verification_uri = device.get("verification_uri", "/device") + if verification_uri.startswith("/"): + verification_uri = f"https://enter.pollinations.ai{verification_uri}" + verification_url = device.get("verification_uri_complete") or verification_uri + user_code = device.get("user_code", "") + if not device.get("verification_uri_complete") and user_code: + separator = "&" if "?" in verification_url else "?" + verification_url = f"{verification_url}{separator}user_code={urllib.parse.quote(user_code)}" + + webbrowser.open(verification_url) + + interval = max(int(device.get("interval", 5)), 1) + expires_at = time.monotonic() + int(device.get("expires_in", 600)) + while time.monotonic() < expires_at: + time.sleep(interval) + status, token = _http_json( + DEVICE_TOKEN_URL, + method="POST", + data={ + "device_code": device["device_code"], + "client_id": app_key, + }, + headers={"Content-Type": "application/json"}, + ) + if status == 200 and token.get("access_token"): + _save_user_config({"api_key": token["access_token"]}) + return "Connected! Key saved. Ready to generate." + + error = token.get("error") + if error == "authorization_pending": + continue + if error == "slow_down": + interval += 5 + continue + return f"Pollinations login failed: {error or f'HTTP {status}'}" + + return "Pollinations login expired. Run /pollinations-setup to try again." @mcp.tool()