514 lines
25 KiB
JavaScript
514 lines
25 KiB
JavaScript
"use strict";
|
|
|
|
// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
|
|
// This module is main-process-only; dependencies are injectable for scratch tests.
|
|
const crypto = require("node:crypto");
|
|
const http = require("node:http");
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
|
|
const ISSUER = "https://auth.openai.com";
|
|
const RESOURCE = "https://api.openai.com/v1";
|
|
const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
|
|
const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
|
|
const JWKS = `${ISSUER}/.well-known/jwks.json`;
|
|
const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
|
|
const random = () => crypto.randomBytes(32).toString("base64url");
|
|
const fail = (code) => Object.assign(new Error(code), { code });
|
|
const publicError = (err) => ({
|
|
cancelled: "ChatGPT sign-in cancelled.",
|
|
timeout: "ChatGPT sign-in timed out. Try again.",
|
|
secure_storage: "Secure credential storage is unavailable. ChatGPT sign-in requires the desktop app and an OS secure-storage backend.",
|
|
storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.",
|
|
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
|
|
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
|
disconnected: "This ChatGPT connection is disconnected. Reconnect in Connections.",
|
|
background: "Background use is off for this ChatGPT account. Enable Allow background use in Connections, or explicitly select an API-key model for that workload.",
|
|
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
|
|
expired: "The ChatGPT session expired. Reconnect in Connections.",
|
|
network: "ChatGPT could not be reached. Check your connection and try again.",
|
|
pending: "A ChatGPT sign-in is already pending.",
|
|
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
|
|
}[err?.code] || "ChatGPT sign-in failed. Try again; no API-key fallback was used.");
|
|
|
|
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
|
|
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
|
|
/** Reject the entire encrypted record. Encryption is not schema validation. */
|
|
function validateState(state) {
|
|
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
|
|
const ids = new Set(); const clients = new Set();
|
|
for (const r of state.registrations) {
|
|
if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || typeof r.background !== "boolean" || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage");
|
|
ids.add(r.id); clients.add(r.clientId);
|
|
if (r.tokens !== undefined) {
|
|
const t = r.tokens;
|
|
if (!t || !nonempty(t.idToken) || !Array.isArray(t.scopes) || t.scopes.some((s) => !nonempty(s)) || !Number.isFinite(t.expiresAt) || t.expiresAt < 0 || (t.accessToken !== undefined && (!nonempty(t.accessToken) || t.accessToken.startsWith("sk-"))) || (t.refreshToken !== undefined && !nonempty(t.refreshToken))) throw fail("storage");
|
|
}
|
|
}
|
|
if (state.activeId !== null && !ids.has(state.activeId)) throw fail("storage");
|
|
return state;
|
|
}
|
|
|
|
/** Encrypted envelope only, outside learning data. No plaintext fallback. */
|
|
function encryptedStore(file, safeStorage) {
|
|
function secure() {
|
|
if (!safeStorage.isEncryptionAvailable() || safeStorage.getSelectedStorageBackend?.() === "basic_text") throw fail("secure_storage");
|
|
}
|
|
return {
|
|
secure,
|
|
async read() {
|
|
secure();
|
|
try {
|
|
const envelope = JSON.parse(await fs.readFile(file, "utf8"));
|
|
if (envelope.version !== 1 || typeof envelope.encrypted !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope.encrypted) || !envelope.encrypted) throw fail("storage");
|
|
const state = JSON.parse(safeStorage.decryptString(Buffer.from(envelope.encrypted, "base64")));
|
|
return validateState(state);
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") return null;
|
|
throw fail("storage");
|
|
}
|
|
},
|
|
async write(state) {
|
|
secure();
|
|
validateState(state);
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
let handle;
|
|
try {
|
|
const encrypted = safeStorage.encryptString(JSON.stringify(state)).toString("base64");
|
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
handle = await fs.open(temp, "wx", 0o600);
|
|
await handle.writeFile(JSON.stringify({ version: 1, encrypted }));
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = null;
|
|
await fs.rename(temp, file);
|
|
// Directory fsync is supported on Unix; the file fsync above is mandatory.
|
|
if (process.platform !== "win32") {
|
|
const dir = await fs.open(path.dirname(file), "r");
|
|
try { await dir.sync(); } finally { await dir.close(); }
|
|
}
|
|
} catch {
|
|
throw fail("storage");
|
|
} finally {
|
|
await handle?.close().catch(() => {});
|
|
await fs.unlink(temp).catch(() => {});
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function allowedAuthEndpoint(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.origin === ISSUER && !url.username && !url.password && !url.search && !url.hash;
|
|
} catch { return false; }
|
|
}
|
|
|
|
function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch, onChange = () => {}, timeoutMs = 180000 }) {
|
|
let state;
|
|
let init;
|
|
let queue = Promise.resolve();
|
|
let epoch = 0;
|
|
let revision = 0;
|
|
let authorityRevision = 0;
|
|
let catalogSequence = 0;
|
|
let storageDenied = false;
|
|
let secureAvailable = true;
|
|
const stamp = () => ({ epoch, authorityRevision, revision });
|
|
const backgroundIntent = new Map();
|
|
let pending;
|
|
let catalog = [];
|
|
let catalogError = null;
|
|
let lastError = null;
|
|
let closed = false;
|
|
let paused = false;
|
|
let suspended = false;
|
|
let retryRegistration;
|
|
let transportAbort = new AbortController();
|
|
let backgroundAbort = new AbortController();
|
|
const serial = (fn) => {
|
|
const next = queue.then(fn, fn);
|
|
queue = next.catch(() => {});
|
|
return next;
|
|
};
|
|
const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined;
|
|
const backgroundAllowed = (account) => account.background === true && backgroundIntent.get(account.id)?.enabled !== false;
|
|
const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
|
|
function stop(backgroundOnly = false) {
|
|
if (backgroundOnly) {
|
|
backgroundAbort.abort();
|
|
backgroundAbort = new AbortController();
|
|
notify("background");
|
|
return;
|
|
}
|
|
suspended = true;
|
|
epoch++;
|
|
authorityRevision++;
|
|
catalogSequence++;
|
|
transportAbort.abort();
|
|
transportAbort = new AbortController();
|
|
catalog = [];
|
|
catalogError = null;
|
|
notify("all");
|
|
}
|
|
async function ready() {
|
|
if (closed) throw fail("disconnected");
|
|
if (!init) init = (async () => {
|
|
const loaded = await store.read();
|
|
state = loaded ? validateState(loaded) : null;
|
|
if (!state) {
|
|
const first = { version: 1, hostId: `urn:uuid:${crypto.randomUUID()}`, activeId: null, registrations: [] };
|
|
await store.write(first);
|
|
state = first;
|
|
}
|
|
})();
|
|
try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
|
|
}
|
|
function denyStorage(err) {
|
|
secureAvailable = false;
|
|
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
|
}
|
|
function checkSecure() {
|
|
try { store.secure(); secureAvailable = true; } catch (err) { denyStorage(err); throw err; }
|
|
}
|
|
async function save(next) {
|
|
try { checkSecure(); validateState(next); await store.write(next); checkSecure(); } catch (err) {
|
|
// A rotated token may already have invalidated its predecessor. Fail closed.
|
|
denyStorage(err);
|
|
lastError = publicError(err);
|
|
notify();
|
|
throw err;
|
|
}
|
|
state = next;
|
|
}
|
|
function snapshot() {
|
|
return {
|
|
available: !!state && secureAvailable && !closed && !paused,
|
|
pending: !!pending,
|
|
activeId: state?.activeId ?? null,
|
|
accounts: (state?.registrations || []).map((r) => ({
|
|
id: r.id,
|
|
email: r.email || "ChatGPT account",
|
|
registration: r.clientId,
|
|
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
|
|
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
|
|
background: backgroundAllowed(r),
|
|
})),
|
|
error: lastError,
|
|
catalogError,
|
|
};
|
|
}
|
|
async function status() {
|
|
try { await ready(); } catch (err) { return { ...snapshot(), available: false, error: publicError(err) }; }
|
|
return snapshot();
|
|
}
|
|
async function json(url, options = {}) {
|
|
try {
|
|
const response = await request(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(15000) });
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => ({}));
|
|
const oauthCode = typeof body.error === "string" ? body.error : body.error?.code;
|
|
throw Object.assign(fail(oauthCode === "invalid_grant" || response.status === 401 || response.status === 403 ? "expired" : "network"), { oauthCode });
|
|
}
|
|
return await response.json();
|
|
} catch (err) { throw err.code ? err : fail("network"); }
|
|
}
|
|
async function identity(idToken, clientId, nonce, expectedSubject) {
|
|
try {
|
|
const { jwtVerify, createLocalJWKSet } = await import("jose");
|
|
// Fixed, documented JWKS origin; never follow a discovery-supplied URL.
|
|
const keys = await json(JWKS);
|
|
const { payload } = await jwtVerify(idToken, createLocalJWKSet(keys), {
|
|
issuer: ISSUER, audience: clientId, algorithms: ["RS256"],
|
|
requiredClaims: ["sub", "exp", "iat"], clockTolerance: 5,
|
|
});
|
|
if (typeof payload.sub !== "string" || !payload.sub || (nonce !== undefined && payload.nonce !== nonce) || (expectedSubject && payload.sub !== expectedSubject)) throw fail("identity");
|
|
if ((payload.azp !== undefined && payload.azp !== clientId) || (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId)) throw fail("identity");
|
|
return { subject: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined };
|
|
} catch { throw fail("identity"); }
|
|
}
|
|
function tokenSet(body, previous) {
|
|
const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/) : previous?.scopes;
|
|
if (!scopes || SCOPES.some((scope) => !scopes.includes(scope))) {
|
|
// Lack of entitlement is not lack of revocable material.
|
|
return { idToken: body.id_token ?? previous?.idToken, scopes: scopes ?? [], expiresAt: 0,
|
|
...(nonempty(body.refresh_token) ? { refreshToken: body.refresh_token } : previous?.refreshToken ? { refreshToken: previous.refreshToken } : {}) };
|
|
}
|
|
if (typeof body.access_token !== "string" || !body.access_token || body.access_token.startsWith("sk-") || body.token_type?.toLowerCase() !== "bearer" || !Number.isFinite(body.expires_in) || body.expires_in <= 0) throw fail("identity");
|
|
const refreshToken = body.refresh_token ?? previous?.refreshToken;
|
|
if (!nonempty(refreshToken)) throw fail("identity");
|
|
return { accessToken: body.access_token, refreshToken, idToken: body.id_token ?? previous?.idToken, scopes, expiresAt: Date.now() + body.expires_in * 1000 };
|
|
}
|
|
async function grant(fields, signal) {
|
|
return json(TOKEN, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams({ ...fields, resource: RESOURCE }), signal });
|
|
}
|
|
|
|
async function login(accountId) {
|
|
await ready();
|
|
if (pending) throw fail("pending");
|
|
if (paused) throw fail("disconnected");
|
|
const selected = accountId ? state.registrations.find((r) => r.id === accountId) : retryRegistration;
|
|
if (accountId && !selected) throw fail("identity");
|
|
const attemptEpoch = epoch;
|
|
const verifier = random();
|
|
const nonce = random();
|
|
const expectedState = random();
|
|
const abort = new AbortController();
|
|
let finish;
|
|
const completion = new Promise((resolve) => { finish = resolve; });
|
|
const listener = http.createServer();
|
|
let timer;
|
|
let consumed = false;
|
|
const cleanup = () => { clearTimeout(timer); listener.close(); listener.closeAllConnections(); };
|
|
const cancel = (code = "cancelled") => {
|
|
abort.abort();
|
|
cleanup();
|
|
if (pending?.abort === abort) pending = null;
|
|
lastError = publicError(fail(code));
|
|
finish();
|
|
notify();
|
|
};
|
|
pending = { abort, cancel };
|
|
lastError = null;
|
|
notify();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
listener.once("error", reject);
|
|
listener.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const redirectUri = `http://127.0.0.1:${listener.address().port}/auth/callback`;
|
|
listener.on("request", async (req, res) => {
|
|
let callback;
|
|
try { callback = new URL(req.url || "/", redirectUri); }
|
|
catch { res.writeHead(400).end(); return; }
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.setHeader("Content-Security-Policy", "default-src 'none'");
|
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
if (req.method !== "GET" || callback.origin !== new URL(redirectUri).origin || callback.pathname !== "/auth/callback" || req.headers.host !== new URL(redirectUri).host || consumed) { res.writeHead(404).end(); return; }
|
|
const params = callback.searchParams;
|
|
// Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
|
|
if (["state", "code", "client_id", "error"].some((key) => params.getAll(key).length > 1) || params.get("state") !== expectedState) { res.writeHead(400).end("Sign-in could not be verified."); return; }
|
|
consumed = true;
|
|
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Return to LearnHarness to view the sign-in result. You may close this tab.");
|
|
cleanup();
|
|
try {
|
|
if (params.has("error")) throw fail(params.get("error") === "access_denied" ? "cancelled" : "identity");
|
|
const clientId = params.get("client_id") || selected?.clientId;
|
|
if (!clientId || clientId === "dynamic_agent_client" || clientId.length > 512 || /[\s\x00-\x1f]/.test(clientId) || (selected && clientId !== selected.clientId) || !params.get("code")) throw fail("identity");
|
|
let body;
|
|
try { body = await grant({ grant_type: "authorization_code", client_id: clientId, code: params.get("code"), code_verifier: verifier, redirect_uri: redirectUri }, AbortSignal.any([abort.signal, AbortSignal.timeout(15000)])); }
|
|
catch (err) {
|
|
if (err.oauthCode === "invalid_grant" && !selected?.subject) retryRegistration = { clientId };
|
|
throw err;
|
|
}
|
|
if (typeof body.id_token !== "string") throw fail("identity");
|
|
const verified = await identity(body.id_token, clientId, nonce, selected?.subject);
|
|
const tokens = tokenSet(body);
|
|
await serial(async () => {
|
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
|
|
const existing = state.registrations.find((r) => r.clientId === clientId);
|
|
if (existing && existing.subject !== verified.subject) throw fail("identity");
|
|
const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, background: existing?.background === true, tokens };
|
|
const next = structuredClone(state);
|
|
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
|
next.activeId = record.id;
|
|
const previous = structuredClone(state);
|
|
await save(next); // Active identity changes only AFTER validation and durable encrypted persistence.
|
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
|
await save(previous);
|
|
throw fail("cancelled");
|
|
}
|
|
stop();
|
|
storageDenied = false;
|
|
suspended = false;
|
|
retryRegistration = undefined;
|
|
});
|
|
lastError = planScopes(tokens) ? null : publicError(fail("scope"));
|
|
} catch (err) { if (!abort.signal.aborted) lastError = publicError(err); }
|
|
finally { if (pending?.abort === abort) pending = null; finish(); notify(); }
|
|
});
|
|
timer = setTimeout(() => cancel("timeout"), timeoutMs);
|
|
const url = new URL(AUTHORIZE);
|
|
url.search = new URLSearchParams({
|
|
client_id: selected?.clientId ?? "dynamic_agent_client",
|
|
...(selected ? {} : { agent_name_hint: "LearnHarness" }),
|
|
ext_agent_host_id: state.hostId,
|
|
...(selected?.tokens?.idToken ? { id_token_hint: selected.tokens.idToken } : {}),
|
|
...(selected?.email ? { login_hint: selected.email } : {}),
|
|
response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), resource: RESOURCE,
|
|
state: expectedState, nonce, code_challenge_method: "S256",
|
|
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
|
|
}).toString();
|
|
await openExternal(url.toString()); // Trusted main only. Never return this URL to the renderer.
|
|
} catch (err) { cancel(err.code || "network"); }
|
|
void completion;
|
|
return snapshot();
|
|
}
|
|
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
|
|
|
function authorized(background, expected, capturedEpoch) {
|
|
checkSecure();
|
|
const account = active();
|
|
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
|
if (!planScopes(account.tokens)) throw fail("scope");
|
|
if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog");
|
|
if (background && !backgroundAllowed(account)) throw fail("background");
|
|
return account;
|
|
}
|
|
async function credential(background, expected) {
|
|
await ready();
|
|
return serial(async () => {
|
|
const requestEpoch = epoch;
|
|
const account = authorized(background, expected);
|
|
if (account.tokens.expiresAt <= Date.now() + 60000) {
|
|
const refreshEpoch = epoch;
|
|
const signal = AbortSignal.any([transportAbort.signal, ...(background ? [backgroundAbort.signal] : []), AbortSignal.timeout(15000)]);
|
|
let body;
|
|
try { body = await grant({ grant_type: "refresh_token", client_id: account.clientId, refresh_token: account.tokens.refreshToken }, signal); }
|
|
catch (err) {
|
|
if (err.oauthCode === "invalid_grant") {
|
|
const next = structuredClone(state);
|
|
delete next.registrations.find((r) => r.id === account.id).tokens;
|
|
next.activeId = null;
|
|
await save(next);
|
|
stop();
|
|
notify();
|
|
}
|
|
throw err;
|
|
}
|
|
let tokens;
|
|
try {
|
|
if (body.id_token !== undefined) await identity(body.id_token, account.clientId, undefined, account.subject);
|
|
tokens = tokenSet(body, account.tokens);
|
|
} catch (err) {
|
|
// The fixed token endpoint may have rotated already. Preserve only
|
|
// verified identity and a single likely-latest revocation handle.
|
|
stop();
|
|
const restricted = structuredClone(state);
|
|
const record = restricted.registrations.find((r) => r.id === account.id);
|
|
record.reconnectRequired = true;
|
|
if (nonempty(body.refresh_token)) record.tokens.refreshToken = body.refresh_token;
|
|
await save(restricted);
|
|
lastError = publicError(fail("identity")); notify();
|
|
throw err;
|
|
}
|
|
// A successful remote rotation must be durably retained even when
|
|
// logout, account selection, consent revocation, or pause denies use.
|
|
// Those serialized operations will revoke/clear the LATEST token.
|
|
const next = structuredClone(state);
|
|
next.registrations.find((r) => r.id === account.id).tokens = tokens;
|
|
await save(next);
|
|
if (closed || refreshEpoch !== epoch || signal.aborted) throw fail("disconnected");
|
|
}
|
|
const current = authorized(background, expected, requestEpoch);
|
|
return { accessToken: current.tokens.accessToken, accountId: current.id, ...stamp() };
|
|
});
|
|
}
|
|
async function lease(background, expected) {
|
|
if (typeof background !== "boolean" || !expected || !nonempty(expected.expectedAccountId) || !Number.isSafeInteger(expected.expectedAuthorityRevision) || !nonempty(expected.selectedModelId)) throw fail("catalog");
|
|
return credential(background, expected);
|
|
}
|
|
const modelValue = () => ({ ...stamp(), status: snapshot(), accountId: active()?.id ?? null, models: catalog });
|
|
async function models() {
|
|
const sequence = ++catalogSequence;
|
|
try {
|
|
await ready();
|
|
if (!active()) return modelValue();
|
|
// Discovery is identity/scope authorization, not an invented model lease.
|
|
const token = await credential(false);
|
|
const body = await json(`${RESOURCE}/models`, { headers: { Authorization: `Bearer ${token.accessToken}` }, signal: AbortSignal.any([transportAbort.signal, AbortSignal.timeout(15000)]) });
|
|
authorized(false, undefined, token.epoch);
|
|
if (sequence !== catalogSequence || active()?.id !== token.accountId || !Array.isArray(body.models)) throw fail("catalog");
|
|
const visible = body.models.filter((m) => m && m.visibility === "list" && nonempty(m.slug) && typeof m.display_name === "string").map((m) => ({ id: m.slug, name: m.display_name }));
|
|
const changed = JSON.stringify(visible) !== JSON.stringify(catalog);
|
|
catalog = visible;
|
|
if (changed) authorityRevision++;
|
|
const hadError = catalogError !== null; catalogError = null;
|
|
if (changed || hadError) notify();
|
|
return modelValue();
|
|
} catch {
|
|
if (sequence === catalogSequence) {
|
|
if (catalog.length) { catalog = []; authorityRevision++; }
|
|
catalogError = publicError(fail("catalog")); notify();
|
|
}
|
|
return modelValue();
|
|
}
|
|
}
|
|
async function select(id) {
|
|
pending?.cancel();
|
|
stop();
|
|
const selectionIntent = epoch;
|
|
await ready();
|
|
return serial(async () => {
|
|
const account = state.registrations.find((r) => r.id === id);
|
|
if (!account?.tokens) throw fail("disconnected");
|
|
await save({ ...state, activeId: id });
|
|
if (epoch !== selectionIntent || closed || paused || storageDenied) throw fail("disconnected");
|
|
suspended = false;
|
|
lastError = null;
|
|
notify();
|
|
return snapshot();
|
|
});
|
|
}
|
|
async function consent(id, enabled) {
|
|
if (typeof enabled !== "boolean") throw fail("identity");
|
|
const intent = { enabled: false, version: (backgroundIntent.get(id)?.version ?? 0) + 1 };
|
|
backgroundIntent.set(id, intent);
|
|
if (!enabled) {
|
|
const account = state?.registrations.find((r) => r.id === id);
|
|
if (account) account.background = false;
|
|
stop(true); // Revoke background calls immediately; foreground stays usable.
|
|
}
|
|
await ready();
|
|
return serial(async () => {
|
|
const next = structuredClone(state);
|
|
const account = next.registrations.find((r) => r.id === id);
|
|
if (!account) throw fail("identity");
|
|
account.background = enabled;
|
|
await save(next);
|
|
if (backgroundIntent.get(id) === intent && enabled) backgroundIntent.delete(id);
|
|
notify();
|
|
return snapshot();
|
|
});
|
|
}
|
|
async function logout(id) {
|
|
pending?.cancel();
|
|
stop();
|
|
await ready();
|
|
return serial(async () => {
|
|
const account = state.registrations.find((r) => r.id === id);
|
|
if (!account) throw fail("identity");
|
|
let revoked = false;
|
|
try {
|
|
if (account.tokens?.refreshToken) {
|
|
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
|
|
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
try {
|
|
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
|
|
revoked = response.status === 200;
|
|
if (revoked || response.status < 500) break;
|
|
} catch { /* retry only while the refresh token is still in memory */ }
|
|
}
|
|
}
|
|
} catch { /* local sign-out is mandatory even when revocation is unavailable */ }
|
|
const next = structuredClone(state);
|
|
delete next.registrations.find((r) => r.id === id).tokens;
|
|
if (next.activeId === id) next.activeId = null;
|
|
const logoutEpoch = epoch;
|
|
await save(next);
|
|
if (!closed && epoch === logoutEpoch) suspended = false;
|
|
lastError = revoked ? null : "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings.";
|
|
notify();
|
|
return snapshot();
|
|
});
|
|
}
|
|
function close() { closed = true; pending?.cancel(); stop(); }
|
|
function pause() { paused = true; pending?.cancel(); stop(); notify(); }
|
|
function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
|
|
return { status, login, cancelLogin, lease, models, select, consent, logout, close, pause, resume, stamp };
|
|
}
|
|
|
|
module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
|