feat(oauth): ARIA bekommt Zugangstokens nicht mehr zu sehen
oauth_get_token hat den access_token im Klartext als Tool-Ergebnis
zurueckgegeben. Tool-Ergebnisse sind Teil der Unterhaltung — der Token ging
damit an Anthropic und lag je nach Konto-Einstellung 30 Tage oder 5 Jahre dort.
Neu: das Brain fuehrt den API-Aufruf SELBST aus.
- `oauth_call(service, url, method, body)` haengt die Authorization an,
schickt den Request und gibt nur die Antwort zurueck. Der Token verlaesst
den Brain-Prozess nicht.
- `oauth_get_token` sagt nur noch, OB authentifiziert ist und wie lange noch.
Refresh laeuft weiter automatisch.
- Skills bleiben unveraendert: die holen den Token serverseitig ueber
{BRAIN_INTERNAL_URL}/oauth/<service>/token — der laeuft nie durch ein
Gespraech.
Host-Sperre als Sicherheitskern: die URL kommt vom Modell, also koennte ein
Aufruf den Token sonst an einen beliebigen Server schicken. Erlaubt sind nur
Hosts, die zum Provider gehoeren (aus auth_url/token_url, plus optionale
api_hosts). Verglichen wird auf den letzten zwei Labels, NICHT per endswith —
"boesespotify.com" endet sonst auch auf "spotify.com". Dazu: nur https (sonst
ginge der Token im Klartext ueber die Leitung) und nur die ueblichen Methoden.
19 Pruefungen, darunter die Angriffsvarianten: boesespotify.com,
spotify.com.angreifer.de, accounts.spotify.com.evil.io, spotify.co, leerer
Host — alle blockiert; api.spotify.com und abschliessender Punkt erlaubt.
System-Prompt und seed_rules nachgezogen, sonst erklaert ARIA sich das alte
Verhalten selbst. Beide sagen jetzt zusaetzlich ausdruecklich: Secrets gehoeren
nie in den Chat, und ein Secret nie in einer Antwort wiederholen. Genau so ist
hier naemlich ein client_secret abgeflossen — nicht ueber ein Tool, sondern
weil es eingetippt und dann zurueckzitiert wurde.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+73
-12
@@ -820,13 +820,13 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "oauth_get_token",
|
"name": "oauth_get_token",
|
||||||
"description": (
|
"description": (
|
||||||
"Liefert das aktuelle access_token fuer einen Service. "
|
"Prueft, ob ein Service authentifiziert ist, und wie lange der "
|
||||||
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
|
"Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
|
||||||
"und der Provider einen refresh_token mitgegeben hat.\n\n"
|
"GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
|
||||||
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
|
"fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
|
||||||
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
|
"Authorization selbst an. In einem Skill holst Du ihn zur "
|
||||||
"z.B. `Authorization: Bearer <token>`.\n\n"
|
"Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
|
||||||
"Wirft wenn Service noch nicht authentifiziert ist oder der "
|
"Wirft, wenn der Service noch nicht authentifiziert ist oder der "
|
||||||
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
|
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -838,6 +838,35 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "oauth_call",
|
||||||
|
"description": (
|
||||||
|
"Ruft eine Provider-API MIT dem hinterlegten Zugangstoken auf, "
|
||||||
|
"ohne dass Du den Token siehst. Das Brain haengt den "
|
||||||
|
"Authorization-Header an, fuehrt den Request aus und gibt Dir nur "
|
||||||
|
"die Antwort.\n\n"
|
||||||
|
"Nimm das fuer einzelne Aufrufe (z.B. Spotify-Wiedergabe steuern, "
|
||||||
|
"GitHub-Issue anlegen). Fuer etwas, das Du oefter brauchst, bau "
|
||||||
|
"lieber einen Skill.\n\n"
|
||||||
|
"Nur https, und nur Hosts, die zum Service gehoeren — eine fremde "
|
||||||
|
"Domain wird abgelehnt (sonst koennte der Token irgendwohin "
|
||||||
|
"geschickt werden). Antwort wird bei 200 KB gekuerzt."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {"type": "string", "description": "z.B. spotify, github."},
|
||||||
|
"url": {"type": "string", "description": "Vollstaendige https-URL der API."},
|
||||||
|
"method": {"type": "string", "description": "GET (Default), POST, PUT, PATCH, DELETE."},
|
||||||
|
"body": {"type": "string", "description": "Request-Body, meist JSON als String."},
|
||||||
|
"content_type": {"type": "string", "description": "Default application/json."},
|
||||||
|
},
|
||||||
|
"required": ["service", "url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -3723,15 +3752,47 @@ class Agent:
|
|||||||
record = oauth_mod.get_token(svc)
|
record = oauth_mod.get_token(svc)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"FEHLER: {exc}"
|
return f"FEHLER: {exc}"
|
||||||
tok = record.get("access_token", "")
|
|
||||||
ttype = record.get("token_type", "Bearer")
|
|
||||||
exp = record.get("expires_at", 0)
|
exp = record.get("expires_at", 0)
|
||||||
remain = max(0, int(exp) - int(__import__("time").time()))
|
remain = max(0, int(exp) - int(__import__("time").time()))
|
||||||
|
hosts = ", ".join(oauth_mod.allowed_hosts(svc)) or "(keine hinterlegt)"
|
||||||
|
# Der Token wird hier BEWUSST nicht mehr herausgegeben. Frueher
|
||||||
|
# stand er im Klartext in dieser Tool-Antwort und damit im
|
||||||
|
# Gespraechsprotokoll — also auch bei Anthropic. Fuer API-Aufrufe
|
||||||
|
# gibt es oauth_call: das Brain haengt den Header selbst an.
|
||||||
return (
|
return (
|
||||||
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
|
f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
|
||||||
f"access_token: {tok}\n"
|
f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
|
||||||
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
|
f"- Einzelne API-Aufrufe: `oauth_call(service='{svc}', url=...)` — "
|
||||||
|
f"das Brain haengt die Authorization an.\n"
|
||||||
|
f"- In einem Skill: GET {{BRAIN_INTERNAL_URL}}/oauth/{svc}/token "
|
||||||
|
f"zur Laufzeit holen, nie hartkodieren.\n"
|
||||||
|
f"Erlaubte Hosts: {hosts}"
|
||||||
)
|
)
|
||||||
|
if name == "oauth_call":
|
||||||
|
svc = (arguments.get("service") or "").strip()
|
||||||
|
url = (arguments.get("url") or "").strip()
|
||||||
|
if not svc or not url:
|
||||||
|
return "FEHLER: service und url sind Pflicht."
|
||||||
|
body = arguments.get("body")
|
||||||
|
if body is not None and not isinstance(body, str):
|
||||||
|
body = json.dumps(body, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
status, text = oauth_mod.call(
|
||||||
|
svc, url,
|
||||||
|
method=(arguments.get("method") or "GET"),
|
||||||
|
body=body,
|
||||||
|
content_type=(arguments.get("content_type") or "application/json"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("oauth_call fehlgeschlagen")
|
||||||
|
return f"FEHLER: Aufruf fehlgeschlagen: {exc}"
|
||||||
|
ok = 200 <= status < 300
|
||||||
|
head = f"HTTP {status}" + ("" if ok else " (Fehler)")
|
||||||
|
return f"{head}\n{text}" if text else head
|
||||||
if name == "oauth_revoke":
|
if name == "oauth_revoke":
|
||||||
svc = (arguments.get("service") or "").strip()
|
svc = (arguments.get("service") or "").strip()
|
||||||
if not svc:
|
if not svc:
|
||||||
|
|||||||
+111
-1
@@ -28,6 +28,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str,
|
|||||||
scopes: Optional[list[str]] = None,
|
scopes: Optional[list[str]] = None,
|
||||||
client_auth: str = "body",
|
client_auth: str = "body",
|
||||||
extra_auth_params: Optional[dict] = None,
|
extra_auth_params: Optional[dict] = None,
|
||||||
accept_header: Optional[str] = None) -> dict:
|
accept_header: Optional[str] = None,
|
||||||
|
api_hosts: Optional[list[str]] = None) -> dict:
|
||||||
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
|
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
|
||||||
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
|
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
|
||||||
vom Brain-Tool `oauth_register_provider` gerufen.
|
vom Brain-Tool `oauth_register_provider` gerufen.
|
||||||
@@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str,
|
|||||||
entry["extra_auth_params"] = extra_auth_params
|
entry["extra_auth_params"] = extra_auth_params
|
||||||
if accept_header is not None:
|
if accept_header is not None:
|
||||||
entry["accept_header"] = accept_header
|
entry["accept_header"] = accept_header
|
||||||
|
if api_hosts is not None:
|
||||||
|
# Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf.
|
||||||
|
# Noetig, wenn die API auf einer anderen Domain liegt als der Login
|
||||||
|
# (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber
|
||||||
|
# manche Provider nutzen voellig andere Namen).
|
||||||
|
entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()]
|
||||||
apps[svc] = entry
|
apps[svc] = entry
|
||||||
_save_json(APPS_FILE, apps)
|
_save_json(APPS_FILE, apps)
|
||||||
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
||||||
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_hosts(service: str) -> list[str]:
|
||||||
|
"""Hosts, an die ein Token dieses Services geschickt werden darf.
|
||||||
|
|
||||||
|
SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den
|
||||||
|
Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt
|
||||||
|
schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst
|
||||||
|
gehoert: die Hosts aus auth_url/token_url plus optional eingetragene
|
||||||
|
api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit
|
||||||
|
(accounts.spotify.com -> api.spotify.com), fremde Domains nie.
|
||||||
|
"""
|
||||||
|
cfg = _provider_config(service)
|
||||||
|
hosts: set[str] = set()
|
||||||
|
for key in ("auth_url", "token_url"):
|
||||||
|
u = cfg.get(key) or ""
|
||||||
|
try:
|
||||||
|
h = urllib.parse.urlparse(u).hostname or ""
|
||||||
|
except Exception:
|
||||||
|
h = ""
|
||||||
|
if h:
|
||||||
|
hosts.add(h.lower())
|
||||||
|
for h in (cfg.get("api_hosts") or []):
|
||||||
|
h = str(h).strip().lower()
|
||||||
|
if h:
|
||||||
|
hosts.add(h)
|
||||||
|
return sorted(hosts)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_allowed(host: str, allowed: list[str]) -> bool:
|
||||||
|
"""True, wenn host zu einem der erlaubten Hosts gehoert.
|
||||||
|
|
||||||
|
Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner
|
||||||
|
endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf
|
||||||
|
'spotify.com'.
|
||||||
|
"""
|
||||||
|
h = (host or "").lower().strip(".")
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def base(x: str) -> str:
|
||||||
|
parts = x.lower().strip(".").split(".")
|
||||||
|
return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower()
|
||||||
|
|
||||||
|
hb = base(h)
|
||||||
|
return any(h == a or hb == base(a) for a in allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def call(service: str, url: str, method: str = "GET",
|
||||||
|
body: Optional[str] = None, content_type: str = "application/json",
|
||||||
|
timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]:
|
||||||
|
"""Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben.
|
||||||
|
|
||||||
|
Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token
|
||||||
|
verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem
|
||||||
|
Gespraechsprotokoll landen.
|
||||||
|
|
||||||
|
Returns (status_code, text). Wirft ValueError bei unerlaubter URL.
|
||||||
|
"""
|
||||||
|
svc = (service or "").strip()
|
||||||
|
if not svc:
|
||||||
|
raise ValueError("service ist Pflicht")
|
||||||
|
u = (url or "").strip()
|
||||||
|
if not u.startswith("https://"):
|
||||||
|
# Kein http:// — der Token ginge im Klartext ueber die Leitung.
|
||||||
|
raise ValueError("url muss mit https:// beginnen")
|
||||||
|
host = urllib.parse.urlparse(u).hostname or ""
|
||||||
|
allowed = allowed_hosts(svc)
|
||||||
|
if not allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.")
|
||||||
|
if not _host_allowed(host, allowed):
|
||||||
|
raise ValueError(
|
||||||
|
f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}")
|
||||||
|
|
||||||
|
m = (method or "GET").upper()
|
||||||
|
if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
raise ValueError(f"Methode nicht erlaubt: {method}")
|
||||||
|
|
||||||
|
record = get_token(svc) # refresht bei Bedarf
|
||||||
|
token = record.get("access_token", "")
|
||||||
|
ttype = record.get("token_type") or "Bearer"
|
||||||
|
|
||||||
|
data = body.encode("utf-8") if body else None
|
||||||
|
req = urllib.request.Request(u, data=data, method=m)
|
||||||
|
req.add_header("Authorization", f"{ttype} {token}")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
if data is not None:
|
||||||
|
req.add_header("Content-Type", content_type)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read(max_bytes + 1)
|
||||||
|
status = resp.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b""
|
||||||
|
status = exc.code
|
||||||
|
truncated = len(raw) > max_bytes
|
||||||
|
text = raw[:max_bytes].decode("utf-8", "replace")
|
||||||
|
if truncated:
|
||||||
|
text += "\n…(gekuerzt)"
|
||||||
|
logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw))
|
||||||
|
return status, text
|
||||||
|
|
||||||
|
|
||||||
def list_services() -> list[dict]:
|
def list_services() -> list[dict]:
|
||||||
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
||||||
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
||||||
|
|||||||
+15
-1
@@ -457,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None,
|
|||||||
"",
|
"",
|
||||||
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
|
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
|
||||||
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
|
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
|
||||||
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
|
"1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.",
|
||||||
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
||||||
|
"3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt "
|
||||||
|
"die Authorization selbst an.",
|
||||||
|
"",
|
||||||
|
"**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** "
|
||||||
|
"Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag "
|
||||||
|
"also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs"
|
||||||
|
"variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill "
|
||||||
|
"— der holt sich den Token zur Laufzeit ueber "
|
||||||
|
"`{BRAIN_INTERNAL_URL}/oauth/<service>/token`, serverseitig.",
|
||||||
|
"",
|
||||||
|
"**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen "
|
||||||
|
"API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. "
|
||||||
|
"Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. "
|
||||||
|
"Und wiederhole ein Secret niemals in Deiner Antwort.",
|
||||||
"",
|
"",
|
||||||
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
||||||
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
||||||
|
|||||||
@@ -185,11 +185,21 @@ SEED_RULES: List[dict] = [
|
|||||||
"category": "skills",
|
"category": "skills",
|
||||||
"content": (
|
"content": (
|
||||||
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
|
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
|
||||||
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
|
"client_secret direkt in den Skill-Code — und schreibe sie auch "
|
||||||
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
|
"NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
|
||||||
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
|
"uebertragen und gespeichert.\n"
|
||||||
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
|
"\n"
|
||||||
"manuell neu einloggen, das nervt."
|
"Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n"
|
||||||
|
"- Im Gespraech: `oauth_call('<service>', url, ...)` — das Brain "
|
||||||
|
"haengt die Authorization an, Du siehst den Token nie.\n"
|
||||||
|
"- Im Skill-Code: den Token zur Laufzeit holen ueber "
|
||||||
|
"GET {BRAIN_INTERNAL_URL}/oauth/<service>/token. Der Aufruf macht "
|
||||||
|
"Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu "
|
||||||
|
"einloggen, das nervt.\n"
|
||||||
|
"- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, "
|
||||||
|
"API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht "
|
||||||
|
"in den Code und nicht in den Chat. Der Skill bekommt sie zur "
|
||||||
|
"Laufzeit als CFG_<NAME>-Umgebungsvariable."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -235,9 +245,11 @@ SEED_RULES: List[dict] = [
|
|||||||
"content": (
|
"content": (
|
||||||
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
|
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
|
||||||
"'token expired' zurueckgibt: RUFE ZUERST "
|
"'token expired' zurueckgibt: RUFE ZUERST "
|
||||||
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
|
"`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
|
||||||
"gueltigen Token oder refresht ihn automatisch ueber den "
|
"gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
|
||||||
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
|
"ist (den Token selbst bekommst Du bewusst nicht — "
|
||||||
|
"`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: "
|
||||||
|
"danach denselben Aufruf einfach nochmal.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
|
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
|
||||||
"('refresh failed', 'no refresh_token', 'service nicht "
|
"('refresh failed', 'no refresh_token', 'service nicht "
|
||||||
|
|||||||
Reference in New Issue
Block a user