323 lines
10 KiB
Python
323 lines
10 KiB
Python
# -*- 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)
|