Manuellen OAuth-Token-Refresh im Endpunkt-Status-Fenster ergaenzen

Ein Button "Zugriffstoken erneuern" tauscht das Refresh-Token aus
.credentials.json aktiv gegen ein neues Zugriffstoken und schreibt es in
dieselbe Datei zurueck, die auch Claude Code verwendet -- statt nur auf
den naechsten Start von Claude Code zu warten. Nur auf expliziten Klick,
nie automatisch im Hintergrund; Token wird dabei nie geloggt.
This commit is contained in:
Winkler, Stefan
2026-08-20 19:30:01 +02:00
parent 628a269a14
commit e413a36506
11 changed files with 286 additions and 8 deletions
+2
View File
@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('errorsBridge', {
clear: () => ipcRenderer.send('errors:clear'),
/** Schließt das Fenster. */
close: () => ipcRenderer.send('errors:close'),
/** Stößt den manuellen OAuth-Token-Refresh an. @returns {Promise<{ok: boolean, error?: string}>} */
refreshToken: () => ipcRenderer.invoke('oauth:refresh'),
});
+25
View File
@@ -10,6 +10,8 @@ const $ = (id) => document.getElementById(id);
const el = {
close: $('btn-close'),
clear: $('btn-clear'),
refresh: $('btn-refresh-token'),
feedback: $('feedback'),
statusLocal: $('status-local'),
statusOauth: $('status-oauth'),
logList: $('log-list'),
@@ -21,8 +23,21 @@ const SOURCE_LABEL = {
local: 'Lokal',
oauth: 'Server',
app: 'App',
refresh: 'Token',
};
let feedbackTimer = null;
/** Portiert aus settings/app.js say() für konsistentes Feedback-Verhalten. */
function say(text, kind = 'ok') {
el.feedback.hidden = false;
el.feedback.textContent = text;
el.feedback.dataset.kind = kind;
clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
el.feedback.hidden = true;
}, 4000);
}
function fmtTime(ts) {
const d = new Date(ts);
const now = new Date();
@@ -72,6 +87,16 @@ function render(s) {
el.close.addEventListener('click', () => window.errorsBridge.close());
el.clear.addEventListener('click', () => window.errorsBridge.clear());
el.refresh.addEventListener('click', async () => {
el.refresh.disabled = true;
say('Zugriffstoken wird erneuert …');
try {
const result = await window.errorsBridge.refreshToken();
say(result.ok ? 'Zugriffstoken erneuert.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
} finally {
el.refresh.disabled = false;
}
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') window.errorsBridge.close();
});
+2
View File
@@ -33,7 +33,9 @@
<footer class="actions">
<button class="btn ghost" id="btn-clear">Log löschen</button>
<button class="btn ghost" id="btn-refresh-token">Zugriffstoken erneuern</button>
<span class="spacer"></span>
<span class="feedback" id="feedback" hidden></span>
<span class="count" id="log-count"></span>
</footer>
+15
View File
@@ -224,6 +224,10 @@ dd[data-severity="critical"] .status-label {
color: var(--accent);
}
.log-source[data-source="refresh"] {
color: var(--good);
}
.log-message {
color: var(--text-secondary);
line-height: 1.4;
@@ -245,6 +249,17 @@ dd[data-severity="critical"] .status-label {
padding: 11px 16px;
border-top: 1px solid var(--hairline);
flex: 0 0 auto;
flex-wrap: wrap;
row-gap: 6px;
}
.feedback {
font-size: 11.5px;
color: var(--good);
}
.feedback[data-kind="error"] {
color: var(--critical);
}
.btn {
+30
View File
@@ -15,6 +15,7 @@ const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
const { UsageAggregator, MINUTE_MS } = require('./blocks');
const { LimitModel, weekAnchor, severity } = require('./limits');
const { OAuthUsage } = require('./oauth-usage');
const { OAuthRefresh } = require('./oauth-refresh');
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
const DEBOUNCE_MS = 300;
@@ -63,6 +64,10 @@ class Collector extends EventEmitter {
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
});
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
// Bewusst unabhängig von der Konfiguration und nicht in applyConfig()
// neu erzeugt — Cooldown/lastAttempt sollen Einstellungsänderungen
// überstehen, genau wie this.errors.
this.oauthRefresh = new OAuthRefresh(store);
this.subscription = { subscriptionType: null, rateLimitTier: null };
this.limits = null;
@@ -184,6 +189,31 @@ class Collector extends EventEmitter {
this._recompute();
}
/**
* Manueller Token-Refresh — ausgelöst über den Knopf im
* Endpunkt-Status-Fenster. Schreibt bei Erfolg direkt in
* ~/.claude/.credentials.json (siehe oauth-refresh.js) und setzt danach die
* Serverabfrage zurück, damit sie das neue Token sofort nutzt.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async refreshOauthToken() {
try {
const { expiresAt } = await this.oauthRefresh.refresh();
const until = new Date(expiresAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
// Zeitstempel im Text macht jede Erfolgsmeldung eindeutig — _note()
// dedupliziert sonst nach exaktem Nachrichtentext, ein zweiter
// erfolgreicher Refresh würde sonst stumm verschluckt.
this._note(`Zugriffstoken manuell erneuert (gültig bis ${until})`, 'refresh');
this.oauth.resetAfterTokenRefresh();
await this._recompute();
return { ok: true };
} catch (err) {
this._note(`Token-Erneuerung fehlgeschlagen: ${err.message}`, 'refresh');
await this._recompute();
return { ok: false, error: err.message };
}
}
async _poll() {
if (this._polling) {
this._pollAgain = true;
+4
View File
@@ -406,6 +406,10 @@ function registerIpc() {
ipcMain.on('open-error-log', openErrorLog);
ipcMain.on('errors:clear', () => collector && collector.clearErrors());
ipcMain.on('errors:close', () => errorsWin && errorsWin.close());
ipcMain.handle('oauth:refresh', () => {
if (!collector) return { ok: false, error: 'Anwendung startet noch' };
return collector.refreshOauthToken();
});
ipcMain.handle('settings:load', () => ({
config,
+162
View File
@@ -0,0 +1,162 @@
'use strict';
/**
* Manueller OAuth-Token-Refresh, ausgelöst über den Knopf im
* Endpunkt-Status-Fenster (src/errors/).
*
* Anders als oauth-usage.js (reine Leseabfrage) schreibt dieses Modul in
* ~/.claude/.credentials.json zurück — dieselbe Datei, die auch die
* Claude-Code-CLI verwendet. Ein neues Token landet dort, sodass Claude Code
* automatisch mitprofitiert, ohne eigenen Neustart.
*
* Auslöser ist ausschließlich ein bewusster Klick — kein automatischer,
* stiller Refresh im Hintergrund.
*
* WICHTIG — nicht verifizierte Annahmen: Endpunkt, Client-ID und der exakte
* Feldname des Refresh-Tokens in der Credentials-Datei stammen aus
* allgemeinem, öffentlich bekanntem Wissen über den OAuth-Client der
* Claude-Code-CLI, nicht aus einer im Projekt verifizierten Quelle. Bricht
* Anthropic das Schema, meldet dieses Modul einen klaren Fehler statt
* stillschweigend falsche Daten zu schreiben.
*
* Umgang mit dem Token: nie geloggt (weder das alte noch das neue), nur
* Erfolg/Fehlschlag als Text im Ereignisprotokoll.
*/
const fsp = require('node:fs/promises');
const { CREDENTIALS_FILE } = require('./jsonl');
/** Vermuteter Endpunkt — nicht dokumentiert, siehe Kopfkommentar. */
const TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
/** Vermutete öffentliche Client-ID der Claude-Code-CLI. */
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REQUEST_TIMEOUT_MS = 10000;
/** Sperrt Mehrfachklicks — ein manueller Refresh braucht keine höhere Frequenz. */
const MIN_INTERVAL_MS = 30 * 1000;
class OAuthRefresh {
/** @param {import('./store').Store} store */
constructor(store = null) {
this.store = store;
const persisted = (store && store.data && store.data.oauthRefresh) || {};
this.lastAttempt = persisted.lastAttempt || 0;
this.inFlight = false;
}
_persist() {
if (!this.store) return;
this.store.data.oauthRefresh = { lastAttempt: this.lastAttempt };
this.store.save();
}
/**
* Tauscht das Refresh-Token gegen ein neues Zugriffstoken und schreibt das
* Ergebnis zurück in .credentials.json. Wirft bei jedem Fehler eine
* verständliche deutsche Fehlermeldung — nie Header, nie Token.
* @returns {Promise<{expiresAt: number}>}
*/
async refresh(now = Date.now()) {
if (this.inFlight) throw new Error('Erneuerung läuft bereits');
if (now - this.lastAttempt < MIN_INTERVAL_MS) {
const waitS = Math.ceil((MIN_INTERVAL_MS - (now - this.lastAttempt)) / 1000);
throw new Error(`Bitte kurz warten (${waitS}s) — der letzte Versuch war gerade erst`);
}
this.inFlight = true;
this.lastAttempt = now;
this._persist();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const { raw, oauth, stat } = await this._readCredentials();
// Feldname nicht verifiziert — beide plausiblen Schreibweisen probieren.
const refreshToken = oauth.refreshToken || oauth.refresh_token;
if (!refreshToken) {
throw new Error('Kein Refresh-Token gefunden — bitte „claude login" erneut ausführen');
}
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!res.ok) {
// Body nie ungefiltert loggen/anzeigen — könnte token-ähnliche Daten enthalten.
if (res.status === 400 || res.status === 401) {
throw new Error('Anmeldung abgelehnt (invalid_grant) — bitte „claude login" erneut ausführen');
}
throw new Error(`Token-Endpunkt: HTTP ${res.status}`);
}
const json = await res.json();
const accessToken = json.access_token || json.accessToken;
const newRefreshToken = json.refresh_token || json.refreshToken || refreshToken;
const expiresAt =
json.expires_at ||
json.expiresAt ||
(Number.isFinite(json.expires_in) ? now + json.expires_in * 1000 : null);
if (!accessToken || !expiresAt) {
throw new Error('Unerwartete Antwort vom Token-Endpunkt — Schema weicht ab');
}
await this._writeCredentials(raw, stat, { accessToken, refreshToken: newRefreshToken, expiresAt });
return { expiresAt };
} catch (err) {
if (err.name === 'AbortError') throw new Error('Zeitüberschreitung beim Token-Endpunkt');
throw err;
} finally {
clearTimeout(timer);
this.inFlight = false;
}
}
async _readCredentials() {
let raw;
try {
raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') throw new Error('Anmeldedatei nicht gefunden — bitte „claude login" ausführen');
throw new Error(`Anmeldedatei nicht lesbar: ${err.message}`);
}
const stat = await fsp.stat(CREDENTIALS_FILE);
let data;
try {
data = JSON.parse(raw);
} catch {
throw new Error('.credentials.json ist beschädigt (kein gültiges JSON)');
}
return { raw, oauth: data.claudeAiOauth || {}, stat };
}
/**
* Schreibt nur accessToken/refreshToken/expiresAt innerhalb von
* claudeAiOauth — alle anderen, auch unbekannte Felder bleiben unangetastet.
* Atomar über temp file + rename, damit Claude Code nie einen halb
* geschriebenen Zustand sieht.
*/
async _writeCredentials(raw, stat, patch) {
const data = JSON.parse(raw);
data.claudeAiOauth = { ...(data.claudeAiOauth || {}), ...patch };
const tmp = `${CREDENTIALS_FILE}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
try {
// Rechte der Originaldatei übernehmen (unter Linux typischerweise 600) —
// sonst fällt die neue Datei auf die Umask-Vorgabe zurück.
await fsp.chmod(tmp, stat.mode);
} catch {
// Unter Windows meist wirkungslos, kein Abbruchgrund.
}
await fsp.rename(tmp, CREDENTIALS_FILE);
}
}
module.exports = { OAuthRefresh, TOKEN_ENDPOINT, CLIENT_ID, MIN_INTERVAL_MS };
+23 -3
View File
@@ -20,8 +20,13 @@
*
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
* gesendet. Diese Stufe erneuert das Token nicht selbst — bei Ablauf
* pausiert sie, bis ein neues vorliegt. Ein manueller Refresh ist möglich:
* über den Knopf "Zugriffstoken erneuern" im Endpunkt-Status-Fenster
* (siehe oauth-refresh.js), ausschließlich auf Klick, nie automatisch im
* Hintergrund. Nach einem erfolgreichen manuellen Refresh ruft der Collector
* resetAfterTokenRefresh() auf, damit diese Stufe sofort neu abfragt statt
* einen laufenden Cooldown abzuwarten.
*/
const fsp = require('node:fs/promises');
@@ -96,13 +101,28 @@ class OAuthUsage {
}
}
/**
* Setzt Fehlerzähler und Pause zurück, nachdem oauth-refresh.js extern ein
* neues Zugriffstoken geschrieben hat — die nächste Abfrage soll das sofort
* nutzen, statt einen laufenden Cooldown/MIN_INTERVAL_MS abzuwarten.
*/
resetAfterTokenRefresh() {
this.failures = 0;
this.pausedUntil = 0;
this.lastFetch = 0;
this.lastError = null;
this._persist();
}
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
async _token() {
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
const oauth = JSON.parse(raw).claudeAiOauth;
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start');
throw new Error(
'Zugriffstoken abgelaufen — im Endpunkt-Status-Fenster erneuern oder Claude Code neu starten',
);
}
return oauth.accessToken;
}
+3
View File
@@ -23,6 +23,8 @@ const DEFAULT_STATE = {
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
window: { x: null, y: null, expanded: false },
oauth: { lastFetch: 0, pausedUntil: 0 },
// Sperrt Mehrfachklicks auf "Zugriffstoken erneuern" über Neustarts hinweg.
oauthRefresh: { lastAttempt: 0 },
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
errors: [],
@@ -51,6 +53,7 @@ class Store {
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
oauthRefresh: { ...DEFAULT_STATE.oauthRefresh, ...(parsed.oauthRefresh || {}) },
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
};
} catch (err) {