Files
ARIA-AGENT/aria-brain/oauth.py
T
duffyduck 13e87fb083 feat(oauth): ARIA kann Provider selbst registrieren + Custom-Provider in Diagnostic & App
ARIA hat jetzt das META-Tool oauth_register_provider. Wenn Stefan einen
Service nutzen will, der nicht in den (auf Spotify reduzierten) Defaults
ist, kann sie auth_url/token_url/scopes/client_auth selbst eintragen —
ARIA kennt typische OAuth-Endpunkte (Dropbox, Discord, Notion, Slack,
Zoom, Trello, LinkedIn, Reddit, Twitch) aus ihrem Training. Sie traegt
NUR die URLs ein, client_id/secret bleiben Stefans Job (Diagnostic /
App-UI) — bewusste Trennung damit Credentials nicht im Chat-Verlauf
landen.

DEFAULT_PROVIDERS auf Spotify reduziert — Rest war aktuell ungenutzt
und macht den Code unnoetig "groß". ARIA registriert on-demand.

Diagnostic-UI:
- Custom-Provider zeigen auth_url/token_url/scopes als sichtbare Felder
- Defaults verstecken die Felder hinter "Default-URLs ueberschreiben
  (advanced)" damit man die Spotify-URLs nicht versehentlich loescht
- "+ Custom OAuth-Provider hinzufuegen" Button mit Prompts fuer
  Name/URLs/Scopes
- 🗑-Icon bei Custom-Services (Service komplett entfernen)

App-UI (neu fuer unterwegs):
- Settings → Sektion 🔑 "OAuth-Apps" zwischen Skills und Protokoll
- OAuthBrowser-Komponente analog zu Trigger/Skill-Browser:
  Liste mit Status, Tap → Edit-Modal mit client_id/secret +
  Advanced-Toggle fuer URLs. "Autorisieren ↗" oeffnet System-Browser
  via Linking.openURL, redirected zur RVS-Callback-Page,
  Status-Refresh nach 8s.
- "+ Custom"-Button → Full-Screen-Modal fuer Service-Anlage.
- brainApi um listOAuthServices/getOAuthApps/saveOAuthApp/
  deleteOAuthApp/authorizeOAuth/revokeOAuth erweitert.

Workflow ist jetzt: "verbinde mich mit Dropbox" → ARIA registriert
Provider → "trag client_id/secret in Settings ein" → Stefan macht das
in App oder Diagnostic → "Autorisieren ↗" → fertig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:16:31 +02:00

442 lines
18 KiB
Python

"""
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). Aktuell nur Spotify als out-of-the-box Service — fuer alles
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
# danach nur client_id + client_secret in Diagnostic / App 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
},
}
# 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 register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None,
client_auth: str = "body",
extra_auth_params: Optional[dict] = None,
accept_header: Optional[str] = None) -> dict:
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen.
Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
aber vorhandene client_id/client_secret bleiben unberuehrt.
"""
svc = (service or "").strip()
if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
raise ValueError(f"Ungueltiger service-Name: {service!r}")
if not auth_url.startswith(("http://", "https://")):
raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
if not token_url.startswith(("http://", "https://")):
raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
if client_auth not in ("body", "basic"):
raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")
apps = _load_json(APPS_FILE)
entry = apps.get(svc) or {}
entry["auth_url"] = auth_url.strip()
entry["token_url"] = token_url.strip()
if scopes is not None:
entry["scopes"] = list(scopes)
entry["client_auth"] = client_auth
if extra_auth_params is not None:
entry["extra_auth_params"] = extra_auth_params
if accept_header is not None:
entry["accept_header"] = accept_header
apps[svc] = entry
_save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
svc, auth_url, token_url, len(entry.get("scopes") or []))
return entry
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