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:
+183
-1
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)}<service></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 {
|
||||
|
||||
Reference in New Issue
Block a user