# -*- coding: utf-8 -*- """OAuth 2.1 Authorization Code + PKCE.""" import base64 import datetime import hashlib import json import os import secrets from typing import Optional from urllib.parse import urlencode, urlparse from fastapi import HTTPException from . import auth from .db import get_conn from .jwt_utils import ( ACCESS_TOKEN_HOURS, base_url, create_access_token, create_refresh_token, decode_access_token, revoke_refresh_token, scopes_for_user, consume_refresh_token, ) def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool: if method != "S256": return False digest = hashlib.sha256(code_verifier.encode()).digest() computed = base64.urlsafe_b64encode(digest).decode().rstrip("=") return computed == challenge TRUSTED_REDIRECT_URIS = frozenset({ "https://claude.ai/api/mcp/auth_callback", "https://chatgpt.com/connector_platform_oauth_redirect", "https://chat.openai.com/connector_platform_oauth_redirect", # Cursor IDE / Agents (docs.cursor.com/mcp) "https://www.cursor.com/agents/mcp/oauth/callback", "http://localhost:8787/callback", # Legacy Cursor desktop "cursor://anysphere.cursor-mcp/oauth/callback", }) def ensure_default_client() -> None: clients = [ ( os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"), "Loogle MCP Public", [ "https://chatgpt.com/connector_platform_oauth_redirect", "https://chat.openai.com/connector_platform_oauth_redirect", "https://claude.ai/api/mcp/auth_callback", "https://www.cursor.com/agents/mcp/oauth/callback", "http://localhost:8787/callback", "cursor://anysphere.cursor-mcp/oauth/callback", "http://127.0.0.1:*/callback", "http://localhost:*/callback", ], ), ( "cursor", "Cursor IDE", [ "https://www.cursor.com/agents/mcp/oauth/callback", "http://localhost:8787/callback", "cursor://anysphere.cursor-mcp/oauth/callback", ], ), ( "claude-desktop", "Claude Desktop/App", ["https://claude.ai/api/mcp/auth_callback"], ), ( "claude-ai", "Claude.ai", ["https://claude.ai/api/mcp/auth_callback"], ), ] conn = get_conn() for client_id, client_name, redirect_uris in clients: row = conn.execute( "SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,) ).fetchone() if row: conn.execute( "UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?", (client_name, json.dumps(redirect_uris), client_id), ) else: conn.execute( "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", (client_id, client_name, json.dumps(redirect_uris)), ) conn.commit() def _is_trusted_redirect_uri(redirect_uri: str) -> bool: if redirect_uri in TRUSTED_REDIRECT_URIS: return True parsed = urlparse(redirect_uri) if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"): if (parsed.path or "").endswith("/callback"): return True return False def register_client(client_name: str, redirect_uris: list[str]) -> dict: client_id = secrets.token_urlsafe(16) for uri in redirect_uris: if not _is_trusted_redirect_uri(uri): raise HTTPException( status_code=400, detail=f"Redirect URI non consentito: {uri}", ) get_conn().execute( "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", (client_id, client_name, json.dumps(redirect_uris)), ) get_conn().commit() return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris} def _client_redirect_uris(client_id: str) -> list[str]: row = get_conn().execute( "SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,) ).fetchone() if not row: return [] return json.loads(row["redirect_uris"]) def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None: """Registra client OAuth al volo (Claude usa spesso client_id = username).""" conn = get_conn() row = conn.execute( "SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,) ).fetchone() if row: uris = set(json.loads(row["redirect_uris"])) if redirect_uri not in uris: uris.add(redirect_uri) conn.execute( "UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?", (json.dumps(sorted(uris)), client_id), ) conn.commit() return conn.execute( "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", (client_id, f"MCP client {client_id}", json.dumps([redirect_uri])), ) conn.commit() def _redirect_allowed(client_id: str, redirect_uri: str) -> bool: if _is_trusted_redirect_uri(redirect_uri): _ensure_client_for_redirect(client_id, redirect_uri) return True allowed = _client_redirect_uris(client_id) if redirect_uri in allowed: return True parsed = urlparse(redirect_uri) for pattern in allowed: if "*" in pattern: pp = urlparse(pattern.replace("*", "placeholder")) if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]): return True return False def create_auth_code( client_id: str, user_id: int, redirect_uri: str, scope: str, code_challenge: Optional[str], code_challenge_method: Optional[str], ) -> str: code = secrets.token_urlsafe(32) expires = ( datetime.datetime.utcnow() + datetime.timedelta(minutes=10) ).strftime("%Y-%m-%d %H:%M:%S") get_conn().execute( "INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)" " VALUES (?,?,?,?,?,?,?,?)", (code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires), ) get_conn().commit() return code def exchange_code( code: str, client_id: str, redirect_uri: str, code_verifier: Optional[str], ) -> dict: row = get_conn().execute( "SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')", (code,), ).fetchone() if not row: raise HTTPException(400, "Codice non valido o scaduto") row = dict(row) if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri: raise HTTPException(400, "Client o redirect URI non validi") if row.get("code_challenge"): if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"): raise HTTPException(400, "PKCE verification failed") user = auth.get_user_by_id(row["user_id"]) if not user: raise HTTPException(400, "Utente non trovato") get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,)) get_conn().commit() scope = scopes_for_user(user, row["scope"]) access_token, _ = create_access_token(user, scope, client_id) refresh = create_refresh_token(user["id"], scope, client_id) return { "access_token": access_token, "token_type": "Bearer", "expires_in": ACCESS_TOKEN_HOURS * 3600, "refresh_token": refresh, "scope": scope, } def refresh_access_token(refresh_token: str, client_id: str) -> dict: row = consume_refresh_token(refresh_token) if not row or row["client_id"] != client_id: raise HTTPException(400, "Refresh token non valido") user = auth.get_user_by_id(row["user_id"]) if not user: raise HTTPException(400, "Utente non trovato") revoke_refresh_token(refresh_token) scope = row["scope"] access_token, _ = create_access_token(user, scope, client_id) refresh = create_refresh_token(user["id"], scope, client_id) return { "access_token": access_token, "token_type": "Bearer", "expires_in": ACCESS_TOKEN_HOURS * 3600, "refresh_token": refresh, "scope": scope, } def authorization_server_metadata() -> dict: base = base_url() return { "issuer": base, "authorization_endpoint": f"{base}/authorize", "token_endpoint": f"{base}/token", "registration_endpoint": f"{base}/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], "scopes_supported": [ "context:read", "context:write", "knowledge:read", "knowledge:write", "gitea:read", "gitea:write", "home:read", "irrigation:read", "turni:read", "admin", ], } def protected_resource_metadata() -> dict: base = base_url() return { "resource": f"{base}/mcp", "authorization_servers": [base], "scopes_supported": [ "context:read", "context:write", "knowledge:read", "knowledge:write", "gitea:read", "gitea:write", "home:read", "irrigation:read", "turni:read", ], "bearer_methods_supported": ["header"], } def build_authorize_redirect( client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: Optional[str], code_challenge_method: Optional[str], user_id: int, ) -> str: if not _redirect_allowed(client_id, redirect_uri): raise HTTPException(400, "Redirect URI non autorizzato") code = create_auth_code( client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method ) params = {"code": code, "state": state} sep = "&" if "?" in redirect_uri else "?" return f"{redirect_uri}{sep}{urlencode(params)}" def bearer_claims_from_header(authorization: str) -> Optional[dict]: if not authorization.lower().startswith("bearer "): return None token = authorization[7:].strip() return decode_access_token(token)