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
+183 -1
View File
@@ -680,6 +680,34 @@
</div>
</div>
<!-- OAuth Apps -->
<div class="settings-section">
<h2>OAuth-Apps (Spotify, Google, GitHub, Strava, Microsoft, ...)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Trag pro Service `client_id` + `client_secret` ein (aus dem Developer-Dashboard
des Providers). RVS stellt die Callback-URL bereit — die musst Du EINMAL pro
Service im Provider-Dashboard als gueltige Redirect-URI eintragen.
Danach kann ARIA per `oauth_authorize`-Tool eine Auth-URL bauen; Stefan klickt,
autorisiert, ARIA bekommt den Token automatisch.
</div>
<div style="font-size:11px;color:#666680;margin-bottom:8px;" id="oauth-callback-hint">
Lade Callback-URL...
</div>
<div class="card" style="max-width:780px;">
<div id="oauth-services-list" style="display:flex;flex-direction:column;gap:8px;">
<div style="color:#555570;font-style:italic;">Lade Services...</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="loadOAuthServices()" style="padding:6px 14px;font-size:12px;">
↻ Neu laden
</button>
<div style="color:#666680;font-size:10px;">
client_secret wird verschlüsselt persistiert (file-mode 0600). Nicht in git, nicht im Repo.
</div>
</div>
</div>
</div>
<!-- Whisper (STT) -->
<div class="settings-section">
<h2>Whisper (Spracherkennung)</h2>
@@ -3142,11 +3170,12 @@
const oc = b.getAttribute('onclick') || '';
if (oc.includes(`'${tab}'`)) b.classList.add('active');
});
// Einstellungen: Config + QR laden
// Einstellungen: Config + QR + OAuth-Apps laden
if (tab === 'settings') {
send({ action: 'get_voice_config' });
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
} else if (tab === 'brain') {
loadBrainStatus();
loadBrainMemoryList();
@@ -3804,6 +3833,159 @@
}
}
// ── OAuth-Apps UI ─────────────────────────────────────────
//
// Stefan traegt pro Service client_id + client_secret ein. RVS hat eine
// feste Callback-URL die Stefan im Provider-Dashboard registrieren muss.
// Status pro Service: configured / authenticated / expires_in.
function _ofmt(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _oExpiryText(secs) {
if (secs == null) return '';
if (secs <= 0) return 'abgelaufen (refresh beim naechsten Call)';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.round(secs/60)} min`;
if (secs < 86400) return `${Math.round(secs/3600)} h`;
return `${Math.round(secs/86400)} Tage`;
}
async function loadOAuthServices() {
const listEl = document.getElementById('oauth-services-list');
const hintEl = document.getElementById('oauth-callback-hint');
if (!listEl) return;
listEl.innerHTML = '<div style="color:#555570;font-style:italic;">Lade Services...</div>';
try {
const [svcRes, appsRes, rcRes] = await Promise.all([
fetch('/api/brain/oauth/services'),
fetch('/api/brain/oauth/apps'),
fetch('/api/runtime-config'),
]);
const svc = await svcRes.json();
const apps = await appsRes.json();
const rc = await rcRes.json();
const host = rc.RVS_HOST || '';
const port = rc.RVS_PORT || '443';
const tls = String(rc.RVS_TLS) !== 'false';
const scheme = tls ? 'https' : 'http';
const portPart = ((tls && port === '443') || (!tls && port === '80')) ? '' : ':' + port;
const cbBase = host ? `${scheme}://${host}${portPart}/oauth/callback/` : '<RVS_HOST nicht gesetzt>';
if (hintEl) {
hintEl.innerHTML = host
? `<b>Callback-URL pro Service</b> (im Provider-Dashboard eintragen): <code style="color:#0096FF;">${_ofmt(cbBase)}&lt;service&gt;</code>`
: `⚠ RVS_HOST nicht gesetzt — OAuth-Callbacks koennen nicht funktionieren. Setze RVS_HOST in der .env auf den oeffentlich erreichbaren Hostname.`;
}
const services = svc.services || [];
const appDetails = apps.apps || {};
const knownDefaults = apps.defaults || [];
// Zusammenfuehren: jeder Service der entweder in services oder Defaults vorkommt
const allServices = Array.from(new Set([
...services.map(s => s.service),
...knownDefaults,
])).sort();
listEl.innerHTML = '';
for (const svcName of allServices) {
const s = services.find(x => x.service === svcName) || { service: svcName, configured: false, authenticated: false };
const app = appDetails[svcName] || {};
const card = document.createElement('div');
const statusColor = s.authenticated ? '#34C759' : (s.configured ? '#FFD60A' : '#666680');
const statusText = s.authenticated
? `✅ verbunden${s.expiresInSec != null ? ` · Token noch ${_oExpiryText(s.expiresInSec)} gueltig` : ''}${s.hasRefresh ? ' · refresh ok' : ' · KEIN refresh_token'}`
: (s.configured ? '🟡 konfiguriert, nicht autorisiert' : '⚫ noch nicht konfiguriert');
const isCustom = !knownDefaults.includes(svcName);
const customMark = isCustom ? ' <span style="color:#8888AA;font-size:10px;">(custom)</span>' : '';
card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;';
card.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong style="color:#FFF;text-transform:capitalize;">${_ofmt(svcName)}</strong>${customMark}
<span style="color:${statusColor};font-size:12px;flex:1;">${statusText}</span>
${s.authenticated ? `<button class="btn secondary" onclick="revokeOAuth('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;" title="Token loeschen">Abmelden</button>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<label style="color:#8888AA;font-size:11px;">client_id:</label>
<input type="text" id="oauth-cid-${_ofmt(svcName)}" value="${_ofmt(app.client_id || '')}" placeholder="aus dem Provider-Dashboard"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
<label style="color:#8888AA;font-size:11px;">client_secret: ${app.has_client_secret ? '<span style="color:#34C759;">(gespeichert — leer lassen zum Behalten)</span>' : '<span style="color:#FF6B6B;">(fehlt)</span>'}</label>
<div style="display:flex;gap:4px;">
<input type="password" id="oauth-sec-${_ofmt(svcName)}" placeholder="${app.has_client_secret ? 'leer lassen oder neuen eingeben' : 'aus dem Provider-Dashboard'}"
style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
<button type="button" class="btn secondary" onclick="toggleSecret('oauth-sec-${_ofmt(svcName)}', this)" style="padding:2px 8px;font-size:10px;">👁</button>
</div>
<div style="display:flex;gap:6px;margin-top:4px;">
<button class="btn primary" onclick="saveOAuthApp('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;">Speichern</button>
<button class="btn secondary" onclick="authorizeOAuth('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;" ${!s.configured ? 'disabled title="Erst client_id+secret eintragen"' : ''}>
Autorisieren ↗
</button>
</div>
</div>
`;
listEl.appendChild(card);
}
if (allServices.length === 0) {
listEl.innerHTML = '<div style="color:#555570;">Keine Services bekannt.</div>';
}
} catch (e) {
listEl.innerHTML = `<div style="color:#FF6B6B;">Fehler beim Laden: ${_ofmt(e.message)}</div>`;
}
}
async function saveOAuthApp(service) {
const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || '';
const sec = document.getElementById('oauth-sec-' + service)?.value || '';
if (!cid) {
alert('client_id darf nicht leer sein.');
return;
}
try {
const r = await fetch('/api/brain/oauth/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, client_id: cid, client_secret: sec }),
});
if (!r.ok) {
const t = await r.text();
alert('Speichern fehlgeschlagen: ' + t);
return;
}
loadOAuthServices();
} catch (e) {
alert('Speichern fehlgeschlagen: ' + e.message);
}
}
async function authorizeOAuth(service) {
try {
const r = await fetch('/api/brain/oauth/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
});
if (!r.ok) {
const t = await r.text();
alert('Authorize fehlgeschlagen: ' + t);
return;
}
const data = await r.json();
// Authorize-URL in neuem Tab oeffnen — Stefan kann dann beim Provider zustimmen
window.open(data.url, '_blank', 'noopener,noreferrer');
// Status nach ein paar Sekunden refreshen — Provider redirect → RVS → Brain
setTimeout(loadOAuthServices, 8000);
} catch (e) {
alert('Authorize fehlgeschlagen: ' + e.message);
}
}
async function revokeOAuth(service) {
if (!confirm(`Token fuer ${service} wirklich loeschen? ARIA muss danach neu autorisiert werden.`)) return;
try {
const r = await fetch('/api/brain/oauth/' + service + '/revoke', { method: 'POST' });
if (!r.ok) {
const t = await r.text();
alert('Revoke fehlgeschlagen: ' + t);
return;
}
loadOAuthServices();
} catch (e) {
alert('Revoke fehlgeschlagen: ' + e.message);
}
}
async function distillNow() {
if (!confirm('Destillat manuell auslösen?\n\nDie ältesten Turns werden zu fact-Memories verdichtet — kostet einen Claude-Call.')) return;
try {