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
+116
View File
@@ -36,6 +36,7 @@ import metrics as metrics_mod
import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -849,3 +850,118 @@ async def skills_import(request: Request, overwrite: bool = False):
except ValueError as exc:
raise HTTPException(400, str(exc))
return {"imported": manifest}
# ── OAuth ─────────────────────────────────────────────────────────
@app.get("/oauth/services")
async def oauth_services_list():
"""Liste aller Services mit Status (configured/authenticated/expires)."""
return {"services": oauth_mod.list_services()}
@app.get("/oauth/apps")
async def oauth_apps_get():
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
safe = {}
for service, entry in apps.items():
if not isinstance(entry, dict):
continue
safe[service] = {
"client_id": entry.get("client_id", ""),
"has_client_secret": bool(entry.get("client_secret")),
"scopes": entry.get("scopes"),
"auth_url": entry.get("auth_url"),
"token_url": entry.get("token_url"),
}
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
class OAuthAppIn(BaseModel):
service: str
client_id: str = ""
client_secret: str = ""
scopes: Optional[List[str]] = None
auth_url: Optional[str] = None
token_url: Optional[str] = None
@app.post("/oauth/apps")
async def oauth_apps_set(body: OAuthAppIn):
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
kann fuer reine scope-Aenderungen)."""
service = (body.service or "").strip()
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
entry = apps.get(service) or {}
if body.client_id:
entry["client_id"] = body.client_id.strip()
if body.client_secret:
entry["client_secret"] = body.client_secret.strip()
if body.scopes is not None:
entry["scopes"] = body.scopes
if body.auth_url:
entry["auth_url"] = body.auth_url.strip()
if body.token_url:
entry["token_url"] = body.token_url.strip()
apps[service] = entry
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
return {"ok": True, "service": service}
@app.delete("/oauth/apps/{service}")
async def oauth_apps_delete(service: str):
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
if service in apps:
apps.pop(service)
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
# Token auch wegwerfen
oauth_mod.revoke(service)
return {"ok": True}
@app.post("/oauth/{service}/revoke")
async def oauth_revoke_endpoint(service: str):
return {"ok": oauth_mod.revoke(service)}
class OAuthAuthorizeIn(BaseModel):
service: str
scopes: Optional[List[str]] = None
@app.post("/oauth/authorize")
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
try:
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
except RuntimeError as exc:
raise HTTPException(400, str(exc))
@app.post("/internal/oauth-callback")
async def oauth_callback_internal(request: Request):
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
Macht den state-Match + token-exchange und persistiert."""
try:
body = await request.json()
except Exception as exc:
raise HTTPException(400, f"bad json: {exc}")
service = (body.get("service") or "").strip()
code = (body.get("code") or "").strip()
state = (body.get("state") or "").strip()
err = body.get("error") or None
err_desc = body.get("errorDescription") or None
if not service:
raise HTTPException(400, "service erforderlich")
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
return result