diff --git a/android/src/components/OAuthBrowser.tsx b/android/src/components/OAuthBrowser.tsx new file mode 100644 index 0000000..a504afa --- /dev/null +++ b/android/src/components/OAuthBrowser.tsx @@ -0,0 +1,614 @@ +/** + * OAuth-Browser β€” Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer + * Credentials. Eingesetzt von SettingsScreen β†’ Sektion "OAuth-Apps". + * + * Pro Service: + * - Status (verbunden / konfiguriert / leer) + * - client_id + client_secret (Passwort-Toggle) + * - Bei Custom-Services: auch auth_url + token_url + scopes editierbar + * - "Autorisieren β†—" oeffnet die Provider-Auth-Seite im System-Browser + * - "Abmelden" + (bei Custom) "πŸ—‘ Service entfernen" + * + * Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes. + * + * Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur + * Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles + * ueber TLS (wss://) β€” der Wert ist nie im Klartext im Netz unterwegs. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + FlatList, + Linking, + Modal, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi'; + +const COL_OK = '#34C759'; +const COL_PENDING = '#FFD60A'; +const COL_OFF = '#666680'; +const COL_ERR = '#FF6B6B'; + +function fmtExpiry(secs: number | null | undefined): string { + if (secs == null) return ''; + if (secs <= 0) return 'abgelaufen'; + 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`; +} + +interface MergedService extends OAuthServiceStatus { + app?: OAuthAppConfig; + isDefault: boolean; +} + +export const OAuthBrowser: React.FC = () => { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [editService, setEditService] = useState(null); + const [showNew, setShowNew] = useState(false); + + const load = useCallback(() => { + setLoading(true); setErr(null); + Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()]) + .then(([statusRes, appsRes]) => { + const apps = appsRes.apps || {}; + const defaults = new Set(appsRes.defaults || []); + const items: MergedService[] = (statusRes.services || []).map(s => ({ + ...s, + app: apps[s.service], + isDefault: defaults.has(s.service), + })); + items.sort((a, b) => { + if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1; + if (a.configured !== b.configured) return a.configured ? -1 : 1; + return a.service.localeCompare(b.service); + }); + setServices(items); + }) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const renderItem = ({ item }: { item: MergedService }) => { + let statusColor: string = COL_OFF; + let statusIcon = '⚫'; + let statusText = 'nicht konfiguriert'; + if (item.authenticated) { + statusColor = COL_OK; statusIcon = 'βœ…'; + statusText = `verbunden${item.expiresInSec != null ? ' Β· noch ' + fmtExpiry(item.expiresInSec) : ''}`; + } else if (item.configured) { + statusColor = COL_PENDING; statusIcon = '🟑'; + statusText = 'konfiguriert, nicht autorisiert'; + } + return ( + setEditService(item)}> + + + {item.service} + {!item.isDefault ? ( + (custom) + ) : null} + + {statusIcon} {statusText} + + + ); + }; + + return ( + + + + Verbinde ARIA mit externen Services (Spotify u.a.). + + + {'↻'} + + setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}> + + Custom + + + + {err ? {err} : null} + + {loading && services.length === 0 ? ( + + ) : ( + s.service} + renderItem={renderItem} + nestedScrollEnabled={true} + ListEmptyComponent={ + + (keine OAuth-Services β€” frag ARIA: "verbinde mich mit X") + + } + contentContainerStyle={{paddingBottom: 20}} + /> + )} + + {editService ? ( + setEditService(null)} + onReload={() => { setEditService(null); load(); }} + /> + ) : null} + + {showNew ? ( + setShowNew(false)} + onCreated={() => { setShowNew(false); load(); }} + /> + ) : null} + + ); +}; + +// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ────────── + +interface EditProps { + service: MergedService; + onClose: () => void; + onReload: () => void; +} + +const OAuthEditModal: React.FC = ({ service: svc, onClose, onReload }) => { + const [clientId, setClientId] = useState(svc.app?.client_id || ''); + const [clientSecret, setClientSecret] = useState(''); + const [showSecret, setShowSecret] = useState(false); + const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || ''); + const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || ''); + const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' ')); + const [saving, setSaving] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + + const save = async () => { + if (!clientId.trim()) { + Alert.alert('Fehler', 'client_id darf nicht leer sein.'); + return; + } + setSaving(true); + const body: any = { + service: svc.service, + client_id: clientId.trim(), + }; + if (clientSecret) body.client_secret = clientSecret; + if (authUrl.trim()) body.auth_url = authUrl.trim(); + if (tokenUrl.trim()) body.token_url = tokenUrl.trim(); + if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean); + try { + await brainApi.saveOAuthApp(body); + onReload(); + } catch (e: any) { + Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)); + } finally { + setSaving(false); + } + }; + + const authorize = async () => { + if (!svc.configured) { + Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.'); + return; + } + try { + const r = await brainApi.authorizeOAuth(svc.service); + // Im System-Browser oeffnen β€” InAppBrowser wuerde z.T. von Providern blockiert + const ok = await Linking.canOpenURL(r.url); + if (!ok) { + Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.'); + return; + } + Linking.openURL(r.url); + Alert.alert( + 'Im Browser anmelden', + `Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen β€” ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`, + [{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }], + ); + } catch (e: any) { + Alert.alert('Authorize fehlgeschlagen', String(e?.message || e)); + } + }; + + const revoke = () => { + Alert.alert( + 'Abmelden?', + `Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Abmelden', + style: 'destructive', + onPress: async () => { + try { await brainApi.revokeOAuth(svc.service); onReload(); } + catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); } + }, + }, + ], + ); + }; + + const removeService = () => { + Alert.alert( + 'Service komplett entfernen?', + `"${svc.service}" wird inkl. client_id/secret und Token geloescht.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', + style: 'destructive', + onPress: async () => { + try { await brainApi.deleteOAuthApp(svc.service); onReload(); } + catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); } + }, + }, + ], + ); + }; + + return ( + + + + {svc.service} + + {'βœ•'} + + + + + {svc.authenticated ? ( + + + βœ… verbunden{svc.expiresInSec != null ? ` Β· Token noch ${fmtExpiry(svc.expiresInSec)}` : ''} + + {svc.hasRefresh ? refresh_token vorhanden β€” auto-renew aktiv + : KEIN refresh_token β€” Token verfaellt komplett} + {svc.scope ? scopes: {svc.scope} : null} + + ) : null} + + client_id + + + + client_secret {svc.app?.has_client_secret ? 'β€” gespeichert (leer = behalten)' : 'β€” fehlt'} + + + + setShowSecret(v => !v)} + > + {showSecret ? 'πŸ™ˆ' : 'πŸ‘'} + + + + {/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan + nicht ausversehen die Spotify-URLs ueberschreibt. */} + {svc.isDefault ? ( + setShowAdvanced(v => !v)} style={{marginTop: 12}}> + + {showAdvanced ? 'β–Ό' : 'β–Ά'} Default-URLs ueberschreiben (advanced) + + + ) : null} + + {(!svc.isDefault || showAdvanced) ? ( + + auth_url + + token_url + + scopes (space-separated) + + + ) : null} + + + + + {saving ? 'speichert...' : 'Speichern'} + + + + + Autorisieren β†— + + + + + {svc.authenticated ? ( + + Abmelden (Token loeschen) + + ) : null} + + {!svc.isDefault ? ( + + πŸ—‘ Service komplett entfernen + + ) : null} + + + + + + ); +}; + +// ── Neuer Custom-Provider ────────────────────────────────────────── + +interface NewProps { + onClose: () => void; + onCreated: () => void; +} + +const OAuthCustomNewModal: React.FC = ({ onClose, onCreated }) => { + const [name, setName] = useState(''); + const [authUrl, setAuthUrl] = useState('https://'); + const [tokenUrl, setTokenUrl] = useState('https://'); + const [scopes, setScopes] = useState(''); + const [creating, setCreating] = useState(false); + + const create = async () => { + const svc = name.trim().toLowerCase(); + if (!/^[a-z0-9_-]+$/.test(svc)) { + Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -'); + return; + } + if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) { + Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.'); + return; + } + setCreating(true); + try { + const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() }; + if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean); + await brainApi.saveOAuthApp(body); + onCreated(); + } catch (e: any) { + Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e)); + } finally { + setCreating(false); + } + }; + + return ( + + + + Custom OAuth-Provider + + {'βœ•'} + + + + + Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret + kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim + Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt. + + + Service-Name (z.B. dropbox, discord) + + + auth_url + + + token_url + + + scopes (space-separated, optional) + + + + + Abbrechen + + + + {creating ? '...' : 'Anlegen'} + + + + + + + ); +}; + +// ── Styles ───────────────────────────────────────────────────────── + +const s = StyleSheet.create({ + toolbar: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 10, + paddingVertical: 8, + backgroundColor: '#0D0D1A', + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + iconBtn: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + backgroundColor: '#1A1A2E', + }, + row: { + paddingVertical: 12, + paddingHorizontal: 14, + backgroundColor: '#0D0D1A', + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + err: { + color: '#FF6B6B', + padding: 12, + fontSize: 12, + }, + modal: { + flex: 1, + backgroundColor: '#0D0D1A', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + modalTitle: { + color: '#E0E0F0', + fontSize: 16, + fontWeight: '700', + flex: 1, + marginRight: 12, + textTransform: 'capitalize', + }, + label: { + color: '#8888AA', + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginTop: 12, + marginBottom: 4, + }, + input: { + backgroundColor: '#1A1A2E', + borderWidth: 1, + borderColor: '#1E1E2E', + borderRadius: 6, + color: '#E0E0F0', + padding: 10, + fontSize: 14, + fontFamily: 'monospace', + }, + metaBox: { + backgroundColor: '#1A1A2E', + borderRadius: 6, + padding: 10, + gap: 4, + }, + meta: { + color: '#8888AA', + fontSize: 12, + }, + btn: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: 'transparent', + }, +}); + +export default OAuthBrowser; diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 1f55812..c876e95 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -57,6 +57,7 @@ import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/back import MemoryBrowser from '../components/MemoryBrowser'; import TriggerBrowser from '../components/TriggerBrowser'; import SkillBrowser from '../components/SkillBrowser'; +import OAuthBrowser from '../components/OAuthBrowser'; import { isVerboseLogging, setVerboseLogging } from '../services/logger'; import { isWakeReadySoundEnabled, @@ -108,6 +109,7 @@ const SETTINGS_SECTIONS = [ { id: 'memory', icon: '🧠', label: 'GedΓ€chtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, lΓΆschen' }, { id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, lΓΆschen' }, { id: 'skills', icon: 'πŸ› οΈ', label: 'Skills', desc: 'Skills ausfΓΌhren, aktivieren, Logs ansehen, lΓΆschen' }, + { id: 'oauth', icon: 'πŸ”‘', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... β€” client_id/secret, autorisieren, abmelden' }, { id: 'protocol', icon: 'πŸ“œ', label: 'Protokoll', desc: 'Privatsphaere, Backup' }, { id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' }, ] as const; @@ -930,7 +932,7 @@ const SettingsScreen: React.FC = () => { // Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat // (Memory, Trigger), den outer Scroll deaktivieren β€” Android-nested- // scrolling laesst sonst nur in eine Richtung scrollen. - scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills'} + scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'} > {currentSection === null && ( @@ -1824,6 +1826,20 @@ const SettingsScreen: React.FC = () => { )} + {/* === OAuth-Apps === */} + {currentSection === 'oauth' && (<> + OAuth-Apps + + Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...). + Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein, + dann "Autorisieren β†—" tippen. Custom-Services kannst Du via "+ Custom" anlegen β€” + ARIA kann das auch selbst per Chat ("verbinde mich mit X"). + + + + + )} + {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index c98e0ae..aa50b4d 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -121,6 +121,27 @@ export interface Memory { attachments?: MemoryAttachment[]; } +/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */ +export interface OAuthServiceStatus { + service: string; + configured: boolean; + authenticated: boolean; + expiresAt?: number | null; + expiresInSec?: number | null; + hasRefresh: boolean; + scope?: string; + isDefault: boolean; +} + +/** OAuth-App-Config (client_id/scopes/URLs) β€” client_secret kommt NIE rausgegeben. */ +export interface OAuthAppConfig { + client_id: string; + has_client_secret: boolean; + scopes?: string[] | null; + auth_url?: string | null; + token_url?: string | null; +} + /** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */ export interface Skill { name: string; @@ -376,6 +397,62 @@ export const brainApi = { getSkillLogs(name: string, limit: number = 20): Promise { return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`); }, + + // ── OAuth ──────────────────────────────────────────────────────── + + /** Liste aller Services mit Auth-Status (configured/authenticated/expires). */ + listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> { + return _send('/oauth/services'); + }, + + /** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */ + getOAuthApps(): Promise<{ apps: Record; defaults: string[] }> { + return _send('/oauth/apps'); + }, + + /** Provider-Config setzen/aktualisieren. Leerer client_secret laesst + * den bestehenden Wert stehen. */ + saveOAuthApp(body: { + service: string; + client_id?: string; + client_secret?: string; + scopes?: string[]; + auth_url?: string; + token_url?: string; + }): Promise<{ ok: boolean; service: string }> { + return _send('/oauth/apps', { + method: 'POST', + body, + timeoutMs: 15000, + }); + }, + + /** Service-Eintrag komplett entfernen (incl. Token). */ + deleteOAuthApp(service: string): Promise<{ ok: boolean }> { + return _send(`/oauth/apps/${encodeURIComponent(service)}`, { + method: 'DELETE', + timeoutMs: 15000, + }); + }, + + /** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */ + authorizeOAuth(service: string, scopes?: string[]): Promise<{ + url: string; state: string; redirect_uri: string; service: string; + }> { + return _send('/oauth/authorize', { + method: 'POST', + body: { service, scopes }, + timeoutMs: 15000, + }); + }, + + /** Token loeschen (lokal β€” kein Provider-Revoke). */ + revokeOAuth(service: string): Promise<{ ok: boolean }> { + return _send(`/oauth/${encodeURIComponent(service)}/revoke`, { + method: 'POST', + timeoutMs: 15000, + }); + }, }; export default brainApi; diff --git a/aria-brain/agent.py b/aria-brain/agent.py index bdf67fc..b807bf1 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -307,6 +307,65 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "oauth_register_provider", + "description": ( + "Registriert einen NEUEN OAuth2-Provider in oauth_apps.json β€” " + "nutze das wenn Stefan einen Service nutzen will, der noch nicht " + "in der Default-Liste (spotify, google, github, strava, microsoft) " + "ist. Du kennst typische OAuth-Endpunkte aus deinem Training " + "(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, " + "Zoom, Trello, ...). Trag NUR die URLs ein β€” client_id / " + "client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder " + "App > Settings > OAuth-Apps).\n\n" + "**Workflow bei neuem Service:**\n" + "1. `oauth_register_provider` mit auth_url + token_url + scopes\n" + "2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in " + "Diagnostic/App > OAuth-Apps deine client_id + client_secret aus " + "dem {name}-Developer-Dashboard ein. Plus die Callback-URL " + "{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n" + "3. Warten bis Stefan fertig ist\n" + "4. `oauth_authorize` rufen\n\n" + "**`client_auth`-Wert:** Die meisten Provider wollen client_id+" + "secret im Body (`body`, default). Spotify und manche andere " + "wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, " + "nimm `body` β€” schlaegt der Token-Request dann mit 401 fehl, " + "switch auf `basic`.\n\n" + "Bei Provider die du wirklich nicht kennst: frag Stefan oder " + "such die Docs raus statt zu raten." + ), + "parameters": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')", + }, + "auth_url": { + "type": "string", + "description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'", + }, + "token_url": { + "type": "string", + "description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'", + }, + "scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "Default-Scopes die der User beim Auth zustimmen muss", + }, + "client_auth": { + "type": "string", + "enum": ["body", "basic"], + "description": "Wie der Provider client_id/secret erwartet (Default 'body')", + }, + }, + "required": ["service", "auth_url", "token_url"], + }, + }, + }, { "type": "function", "function": { @@ -927,6 +986,37 @@ class Agent: else: lines.append(f"- {t['name']} ({t['type']}, {state})") return "\n".join(lines) + if name == "oauth_register_provider": + svc = (arguments.get("service") or "").strip() + auth_url = (arguments.get("auth_url") or "").strip() + token_url = (arguments.get("token_url") or "").strip() + scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None + client_auth = (arguments.get("client_auth") or "body").strip().lower() + if not svc or not auth_url or not token_url: + return "FEHLER: service, auth_url, token_url sind Pflicht." + try: + entry = oauth_mod.register_provider( + svc, auth_url, token_url, scopes=scopes, client_auth=client_auth, + ) + except ValueError as exc: + return f"FEHLER: {exc}" + except Exception as exc: + logger.exception("oauth_register_provider fehlgeschlagen") + return f"FEHLER: {exc}" + cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"/oauth/callback/{svc}" + scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)" + return ( + f"OK β€” Provider '{svc}' registriert.\n" + f" auth_url: {entry['auth_url']}\n" + f" token_url: {entry['token_url']}\n" + f" scopes: {scopes_str}\n" + f" client_auth: {entry['client_auth']}\n\n" + f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > " + f"Settings > OAuth-Apps) deine client_id + client_secret aus " + f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL " + f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n" + f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf." + ) if name == "oauth_authorize": svc = (arguments.get("service") or "").strip() if not svc: diff --git a/aria-brain/oauth.py b/aria-brain/oauth.py index c19ed49..9a935de 100644 --- a/aria-brain/oauth.py +++ b/aria-brain/oauth.py @@ -40,7 +40,10 @@ 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). Stefan muss nur client_id + client_secret eintragen. +# 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", @@ -50,33 +53,6 @@ DEFAULT_PROVIDERS: dict[str, dict] = { "user-library-read"], "client_auth": "basic", # client_id:client_secret als Basic-Auth-Header }, - "google": { - "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", - "token_url": "https://oauth2.googleapis.com/token", - "scopes": ["openid", "email", "profile"], - "client_auth": "body", # client_id+secret im Body - "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, - }, - "github": { - "auth_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "scopes": ["read:user"], - "client_auth": "body", - "accept_header": "application/json", # GitHub returns form-urlencoded otherwise - }, - "strava": { - "auth_url": "https://www.strava.com/oauth/authorize", - "token_url": "https://www.strava.com/oauth/token", - "scopes": ["read", "activity:read_all"], - "client_auth": "body", - "extra_auth_params": {"approval_prompt": "auto"}, - }, - "microsoft": { - "auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - "token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", - "scopes": ["User.Read", "offline_access"], - "client_auth": "body", - }, } # Pending Auth-Requests: state β†’ {service, scopes, redirect_uri, created_at} @@ -149,6 +125,46 @@ def _provider_credentials(service: str) -> tuple[str, str]: 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() diff --git a/diagnostic/index.html b/diagnostic/index.html index 8b9263f..303b317 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -3900,11 +3900,27 @@ const isCustom = !knownDefaults.includes(svcName); const customMark = isCustom ? ' (custom)' : ''; card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;'; + // Custom-Provider zeigen URL/Scope-Felder zum Editieren β€” Defaults + // verstecken die Felder hinter einem "
" damit sie nicht + // ausversehen ueberschrieben werden. + const scopesValue = Array.isArray(app.scopes) ? app.scopes.join(' ') : ''; + const urlFieldsHtml = ` + + + + + + + `; card.innerHTML = `
${_ofmt(svcName)}${customMark} ${statusText} ${s.authenticated ? `` : ''} + ${isCustom ? `` : ''}
@@ -3916,6 +3932,12 @@ style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
+ ${isCustom ? urlFieldsHtml : ` +
+ Default-URLs ΓΌberschreiben (advanced) +
${urlFieldsHtml}
+
+ `}
+ `; + listEl.appendChild(addCard); if (allServices.length === 0) { - listEl.innerHTML = '
Keine Services bekannt.
'; + // (addCard ist trotzdem schon dran) } } catch (e) { listEl.innerHTML = `
Fehler beim Laden: ${_ofmt(e.message)}
`; } } + function openOAuthCustomDialog() { + const name = (prompt('Service-Name (z.B. dropbox, discord) β€” a-z 0-9 _ -:') || '').trim().toLowerCase(); + if (!name || !/^[a-z0-9_-]+$/.test(name)) { + if (name) alert('Ungueltiger Name. Erlaubt: a-z 0-9 _ -'); + return; + } + const authUrl = (prompt(`auth_url fuer ${name}:`, 'https://') || '').trim(); + if (!authUrl) return; + const tokenUrl = (prompt(`token_url fuer ${name}:`, 'https://') || '').trim(); + if (!tokenUrl) return; + const scopesRaw = (prompt(`scopes (space-separated, optional):`, '') || '').trim(); + const scopes = scopesRaw ? scopesRaw.split(/\s+/).filter(Boolean) : undefined; + fetch('/api/brain/oauth/apps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: name, auth_url: authUrl, token_url: tokenUrl, scopes }), + }) + .then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(new Error(t)))) + .then(() => loadOAuthServices()) + .catch(e => alert('Custom-Service anlegen fehlgeschlagen: ' + e.message)); + } + async function deleteOAuthApp(service) { + if (!confirm(`Service "${service}" komplett entfernen? client_id/secret + Token werden geloescht.`)) return; + try { + const r = await fetch('/api/brain/oauth/apps/' + encodeURIComponent(service), { method: 'DELETE' }); + if (!r.ok) { + alert('Loeschen fehlgeschlagen: ' + (await r.text())); + return; + } + loadOAuthServices(); + } catch (e) { + alert('Loeschen fehlgeschlagen: ' + e.message); + } + } async function saveOAuthApp(service) { const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || ''; const sec = document.getElementById('oauth-sec-' + service)?.value || ''; + const authUrl = (document.getElementById('oauth-auth-' + service)?.value || '').trim(); + const tokenUrl = (document.getElementById('oauth-tok-' + service)?.value || '').trim(); + const scopesRaw = (document.getElementById('oauth-scopes-' + service)?.value || '').trim(); if (!cid) { alert('client_id darf nicht leer sein.'); return; } + const body = { service, client_id: cid, client_secret: sec }; + if (authUrl) body.auth_url = authUrl; + if (tokenUrl) body.token_url = tokenUrl; + if (scopesRaw) body.scopes = scopesRaw.split(/\s+/).filter(Boolean); 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 }), + body: JSON.stringify(body), }); if (!r.ok) { const t = await r.text();