feat(oauth): generische OAuth2-Pipeline ueber RVS-Callback (Spotify/Google/GitHub/Strava/MS)

Bisher musste Stefan bei OAuth-Flows manuell den Auth-Code aus der
Browser-URL kopieren (redirect_uri war localhost). Jetzt: RVS hat einen
HTTP-Listener auf demselben Port wie der WebSocket, Provider redirecten
nach Auth zu https://{RVS_HOST}/oauth/callback/{service}, RVS broadcastet,
aria-bridge forwarded, Brain matched state + tauscht code gegen Token.
Token-Refresh laeuft automatisch.

- rvs/server.js: hybrid http.createServer + WebSocketServer{noServer}.
  Route GET /oauth/callback/{service}, broadcast oauth_callback an alle
  Raeume, schoene Dark-Mode-HTML-Antwort an den Browser (Auto-Close 4s).
- bridge/aria_bridge.py: empfaengt oauth_callback, POSTet an Brain
  /internal/oauth-callback.
- aria-brain/oauth.py: neuer Manager. Pending-Store mit state+TTL,
  Token-Exchange (Basic-Auth oder Body je nach Provider), persistente
  Speicherung in /shared/config/oauth_tokens.json (mode 0600),
  Token-Refresh wenn <60s Restzeit. Vordefinierte Configs fuer Spotify,
  Google, GitHub, Strava, Microsoft.
- aria-brain/agent.py: META-Tools oauth_authorize / oauth_get_token /
  oauth_revoke.
- aria-brain/prompts.py: System-Prompt-Block zeigt ARIA die feste
  Callback-URL als Quelle der Wahrheit + aktuelle Service-States.
- aria-brain/main.py: HTTP-Endpoints /oauth/services, /oauth/apps,
  /oauth/authorize, /oauth/{service}/revoke, /internal/oauth-callback.
- diagnostic: neue Section "OAuth-Apps". Pro Service Karte mit Status,
  client_id + client_secret (Passwort-Toggle), Speichern + Autorisieren-
  Buttons. Authorize oeffnet Provider-Auth in neuem Tab.
- docker-compose.yml: brain-env um RVS_HOST + RVS_PORT_PUBLIC + RVS_TLS
  ergaenzt (Brain braucht die Werte zum Bau der Callback-URL).
- .env.example: RVS_PORT_PUBLIC + Brain-Timeout-Vars (PROXY_TIMEOUT_SEC
  + Connect/Write/Pool) dokumentiert.
- README.md: OAuth-Pipeline + ARIA-Live-Mirror in Diagnostic-Section,
  OAuth-Apps in Einstellungen-Tab erwaehnt.
- issue.md: OAuth-Pipeline + Brain-Timeout-Fix als erledigt dokumentiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:39:54 +02:00
parent 0887674497
commit acaa9fc3f2
11 changed files with 1150 additions and 10 deletions
+143 -1
View File
@@ -30,6 +30,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -245,6 +246,88 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_authorize",
"description": (
"Startet einen OAuth2-Authorize-Flow fuer einen externen "
"Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
"Returnt eine URL die Stefan im Browser oeffnen muss — er "
"loggt sich beim Provider ein und stimmt den Scopes zu, der "
"Provider redirected zu unserem RVS-Callback, RVS forwarded "
"an Brain, Token wird automatisch gespeichert.\n\n"
"**Nutze das wenn:** Stefan moechte einen Service nutzen "
"(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
"Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
"**Workflow:**\n"
"1. `oauth_authorize(service='spotify')` -> URL\n"
"2. Gib Stefan die URL als anklickbaren Link\n"
"3. Warte bis er sagt dass er autorisiert hat\n"
"4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
"Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
"Service `client_id` + `client_secret` eingetragen. Falls nicht, "
"wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
"er soll das machen, NICHT versuchen die Credentials selbst zu "
"raten oder zu generieren."
),
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
},
"scopes": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, google, ..."},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_revoke",
"description": (
"Loescht das gespeicherte Token fuer einen Service (lokal). "
"Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
"er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
"\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
),
"parameters": {
"type": "object",
"properties": {"service": {"type": "string"}},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
@@ -540,11 +623,24 @@ class Agent:
# 5. System-Prompt + Window-Messages
flux_config = _load_flux_config()
# OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
try:
oauth_services = oauth_mod.list_services()
except Exception as exc:
logger.warning("oauth list_services fehlgeschlagen: %s", exc)
oauth_services = None
oauth_host = os.environ.get("RVS_HOST", "").strip()
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
condition_funcs=condition_funcs,
flux_config=flux_config)
flux_config=flux_config,
oauth_services=oauth_services,
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -730,6 +826,52 @@ class Agent:
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
if name == "oauth_authorize":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht (z.B. 'spotify')."
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
try:
info = oauth_mod.build_authorize_url(svc, scopes=scopes)
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_authorize fehlgeschlagen")
return f"FEHLER: {exc}"
return (
f"OK — Authorize-URL fuer {svc} bereit.\n"
f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
f"{info['url']}\n\n"
f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
f"Redirect-URI eintragen."
)
if name == "oauth_get_token":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
try:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
)
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
ok = oauth_mod.revoke(svc)
return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
if name == "flux_generate":
prompt = (arguments.get("prompt") or "").strip()
if not prompt: