""" OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer Spotify, Google, GitHub, Strava, Microsoft etc. Architektur: - Brain haelt einen Pending-Store: state-String → pending Auth-Request (mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS), matched der state und der code wird gegen access_token getauscht. - Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag mit access_token, refresh_token, expires_at, scope). - Provider-Configs: /shared/config/oauth_apps.json — pro Service {client_id, client_secret, auth_url, token_url, scopes, ...}. Wird typischerweise via Diagnostic-UI gefuellt. - Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s bis Ablauf bei get_token() Aufruf. OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy). ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro Service im Provider-Dashboard registrieren. """ from __future__ import annotations import base64 import json import logging import os import secrets import time import urllib.parse import urllib.request from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) CONFIG_DIR = Path("/shared/config") APPS_FILE = CONFIG_DIR / "oauth_apps.json" TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json" # Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config # uebersteuert). Stefan muss nur client_id + client_secret eintragen. DEFAULT_PROVIDERS: dict[str, dict] = { "spotify": { "auth_url": "https://accounts.spotify.com/authorize", "token_url": "https://accounts.spotify.com/api/token", "scopes": ["user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing", "playlist-read-private", "user-library-read"], "client_auth": "basic", # client_id:client_secret als Basic-Auth-Header }, "google": { "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["openid", "email", "profile"], "client_auth": "body", # client_id+secret im Body "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, }, "github": { "auth_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", "scopes": ["read:user"], "client_auth": "body", "accept_header": "application/json", # GitHub returns form-urlencoded otherwise }, "strava": { "auth_url": "https://www.strava.com/oauth/authorize", "token_url": "https://www.strava.com/oauth/token", "scopes": ["read", "activity:read_all"], "client_auth": "body", "extra_auth_params": {"approval_prompt": "auto"}, }, "microsoft": { "auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", "token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", "scopes": ["User.Read", "offline_access"], "client_auth": "body", }, } # Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at} _PENDING: dict[str, dict] = {} PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig # ── Helpers ───────────────────────────────────────────────── def _callback_url(service: str) -> str: """Baut die Redirect-URL die wir bei der Provider-Auth angeben. Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env.""" host = os.environ.get("RVS_HOST", "").strip() if not host: raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich") port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip() tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false" scheme = "https" if tls else "http" # Default-Ports 443/80 nicht in URL anhaengen if (tls and port == "443") or (not tls and port == "80"): return f"{scheme}://{host}/oauth/callback/{service}" return f"{scheme}://{host}:{port}/oauth/callback/{service}" def _load_json(path: Path) -> dict: try: if path.exists(): return json.loads(path.read_text(encoding="utf-8")) except Exception as exc: logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc) return {} def _save_json(path: Path, data: dict) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") tmp.replace(path) # 600 — enthaelt Secrets try: os.chmod(path, 0o600) except OSError: pass except Exception as exc: logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc) def _provider_config(service: str) -> dict: """Mergt Default-Provider-Config mit User-Override aus oauth_apps.json.""" defaults = DEFAULT_PROVIDERS.get(service, {}).copy() apps = _load_json(APPS_FILE) user = (apps.get(service) or {}).copy() # Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list. merged = {**defaults, **user} return merged def _provider_credentials(service: str) -> tuple[str, str]: """Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht konfiguriert — der OAuth-Flow kann ohne nicht starten.""" apps = _load_json(APPS_FILE) entry = apps.get(service) or {} cid = (entry.get("client_id") or "").strip() sec = (entry.get("client_secret") or "").strip() if not cid or not sec: raise RuntimeError( f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > " f"OAuth-Apps client_id + client_secret eintragen." ) return cid, sec def _cleanup_pending() -> None: """Entfernt abgelaufene Pending-Auths.""" now = time.time() for state, info in list(_PENDING.items()): if now - info.get("created_at", 0) > PENDING_TTL_SEC: _PENDING.pop(state, None) # ── Authorize ─────────────────────────────────────────────── def build_authorize_url(service: str, scopes: Optional[list[str]] = None, extra_params: Optional[dict] = None) -> dict: """Baut die Authorize-URL fuer einen Provider. Speichert den state im Pending-Store. Returns {url, state, redirect_uri, service}. Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan, der oeffnet sie im Browser, autorisiert, Provider redirected zur redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched state → exchange. """ _cleanup_pending() cfg = _provider_config(service) if not cfg.get("auth_url") or not cfg.get("token_url"): raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. " f"In oauth_apps.json eintragen oder einen der " f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).") cid, _ = _provider_credentials(service) redirect_uri = _callback_url(service) state = secrets.token_urlsafe(32) use_scopes = scopes if scopes else cfg.get("scopes") or [] params = { "client_id": cid, "response_type": "code", "redirect_uri": redirect_uri, "state": state, } if use_scopes: params["scope"] = " ".join(use_scopes) params.update(cfg.get("extra_auth_params") or {}) if extra_params: params.update(extra_params) url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params) _PENDING[state] = { "service": service, "redirect_uri": redirect_uri, "scopes": use_scopes, "created_at": time.time(), } logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s", service, state[:8] + "...", redirect_uri) return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service} # ── Token-Exchange ────────────────────────────────────────── def _token_request(token_url: str, body_params: dict, cfg: dict, client_id: str, client_secret: str) -> dict: """POST an provider /token endpoint. Returns parsed JSON oder wirft.""" data = urllib.parse.urlencode(body_params).encode("utf-8") headers = {"Content-Type": "application/x-www-form-urlencoded"} if cfg.get("accept_header"): headers["Accept"] = cfg["accept_header"] # Client-Auth: 'basic' (Header) oder 'body' (im Form-Body) if cfg.get("client_auth") == "basic": auth_str = f"{client_id}:{client_secret}" b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii") headers["Authorization"] = f"Basic {b64}" else: # bereits im body_params drin (siehe Caller) pass req = urllib.request.Request(token_url, data=data, method="POST", headers=headers) try: with urllib.request.urlopen(req, timeout=15) as resp: raw = resp.read().decode("utf-8", "ignore") try: return json.loads(raw) except json.JSONDecodeError: # GitHub default ist form-urlencoded — accept_header sollte # JSON anfordern, aber falls's doch mal kommt: parsed = urllib.parse.parse_qs(raw) return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()} except urllib.error.HTTPError as e: body = e.read().decode("utf-8", "ignore")[:500] raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e def handle_callback(service: str, code: str, state: str, error: Optional[str] = None, error_description: Optional[str] = None) -> dict: """Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen Token, speichert. Returns {ok, service, message, ...}. Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen. """ _cleanup_pending() if error: # Provider hat User-Abbruch oder Fehler gemeldet _PENDING.pop(state, None) if state else None logger.warning("[oauth] Provider-Error %s/%s: %s — %s", service, state[:8] + "..." if state else "?", error, error_description) return {"ok": False, "service": service, "error": error, "errorDescription": error_description} pending = _PENDING.pop(state, None) if not pending: logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service) return {"ok": False, "service": service, "error": "invalid_state", "errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."} if pending.get("service") != service: logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s", pending.get("service"), service) return {"ok": False, "service": service, "error": "service_mismatch", "errorDescription": "state gehoert zu einem anderen Service."} if not code: return {"ok": False, "service": service, "error": "no_code"} cfg = _provider_config(service) try: client_id, client_secret = _provider_credentials(service) except RuntimeError as exc: return {"ok": False, "service": service, "error": "no_credentials", "errorDescription": str(exc)} body = { "grant_type": "authorization_code", "code": code, "redirect_uri": pending["redirect_uri"], } if cfg.get("client_auth") != "basic": body["client_id"] = client_id body["client_secret"] = client_secret try: token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret) except Exception as exc: logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service) return {"ok": False, "service": service, "error": "exchange_failed", "errorDescription": str(exc)[:200]} access = token_data.get("access_token") if not access: return {"ok": False, "service": service, "error": "no_access_token", "errorDescription": str(token_data)[:200]} expires_in = int(token_data.get("expires_in") or 3600) refresh = token_data.get("refresh_token") or "" scope = token_data.get("scope") or " ".join(pending.get("scopes") or []) token_type = token_data.get("token_type") or "Bearer" record = { "service": service, "access_token": access, "refresh_token": refresh, "token_type": token_type, "scope": scope, "expires_at": int(time.time()) + expires_in, "obtained_at": int(time.time()), } _persist_token(service, record) logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s", service, expires_in, "ja" if refresh else "nein") return {"ok": True, "service": service, "expiresIn": expires_in, "hasRefresh": bool(refresh), "scope": scope} # ── Token-Storage / Refresh / Revoke ───────────────────────── def _persist_token(service: str, record: dict) -> None: tokens = _load_json(TOKENS_FILE) tokens[service] = record _save_json(TOKENS_FILE, tokens) def _load_token(service: str) -> Optional[dict]: return _load_json(TOKENS_FILE).get(service) def get_token(service: str, refresh_threshold_sec: int = 60) -> dict: """Holt das aktuelle access_token fuer einen Service. Refresht automatisch wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze record-dict — Caller nimmt sich access_token raus. Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer soll dann oauth_authorize anbieten.""" record = _load_token(service) if not record: raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per " f"oauth_authorize authentifizieren.") exp = int(record.get("expires_at") or 0) remaining = exp - int(time.time()) if remaining > refresh_threshold_sec: return record # Refresh noetig refresh_tok = (record.get("refresh_token") or "").strip() if not refresh_tok: raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token " f"vorhanden — bitte neu autorisieren mit oauth_authorize.") cfg = _provider_config(service) client_id, client_secret = _provider_credentials(service) body = { "grant_type": "refresh_token", "refresh_token": refresh_tok, } if cfg.get("client_auth") != "basic": body["client_id"] = client_id body["client_secret"] = client_secret try: new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret) except Exception as exc: raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc new_access = new_data.get("access_token") if not new_access: raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}") expires_in = int(new_data.get("expires_in") or 3600) # refresh_token kann (manche Provider) bei jedem Refresh rotieren new_refresh = (new_data.get("refresh_token") or refresh_tok).strip() record.update({ "access_token": new_access, "refresh_token": new_refresh, "expires_at": int(time.time()) + expires_in, "obtained_at": int(time.time()), }) if new_data.get("scope"): record["scope"] = new_data["scope"] _persist_token(service, record) logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in) return record def revoke(service: str) -> bool: """Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call).""" tokens = _load_json(TOKENS_FILE) if service not in tokens: return False tokens.pop(service, None) _save_json(TOKENS_FILE, tokens) logger.info("[oauth] %s Token geloescht (lokal).", service) return True def list_services() -> list[dict]: """Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist + Ablaufzeit. Wird von Diagnostic genutzt.""" apps = _load_json(APPS_FILE) tokens = _load_json(TOKENS_FILE) out = [] services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys()) now = int(time.time()) for s in sorted(services): app = apps.get(s) or {} tok = tokens.get(s) or {} configured = bool(app.get("client_id") and app.get("client_secret")) out.append({ "service": s, "configured": configured, "authenticated": bool(tok.get("access_token")), "expiresAt": tok.get("expires_at"), "expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None, "hasRefresh": bool(tok.get("refresh_token")), "scope": tok.get("scope", ""), "isDefault": s in DEFAULT_PROVIDERS, }) return out