import { test } from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; const require = createRequire(import.meta.url); const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" }; const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token"; const initial = () => ({ version: 1, hostId: "urn:uuid:00000000-0000-4000-8000-000000000001", activeId: null as string | null, registrations: [] as any[] }); async function signed(client: string, nonce: string, subject = "scratch-user", overrides: Record = {}) { return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; } function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise; write?: (next: any) => Promise; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise } = {}) { let saved = structuredClone(options.state ?? initial()); let url!: URL; let writeCount = 0; const changed = deferred(); const requests: { url: string; form?: URLSearchParams }[] = []; const service = createChatGPT({ store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } }, openExternal: async (value: string) => { url = new URL(value); }, timeoutMs: options.timeoutMs ?? 5000, onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); }, fetch: async (target: string, init: RequestInit = {}) => { const form = init.body instanceof URLSearchParams ? init.body : undefined; requests.push({ url: target, form }); const response = await options.provider?.(target, init); if (response) return response; if (target.endsWith("jwks.json")) return Response.json({ keys: [jwk] }); if (target === tokenUrl) { if (options.token) return options.token(form!, url); return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form!.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: options.expires ?? 3600, scope: SCOPES.join(" ") }); } if (target.endsWith("openid-configuration")) return Response.json({ issuer: "https://auth.openai.com", revocation_endpoint: "https://auth.openai.com/api/accounts/oauth/revoke" }); if (target.endsWith("/revoke")) return new Response(null, { status: 200 }); if (target.endsWith("/models")) return Response.json({ models: [{ slug: "gpt-5", display_name: "Scratch model", visibility: "list" }, { slug: "hidden", display_name: "Hidden", visibility: "hide" }] }); throw new Error("Unexpected network request in scratch fixture"); }, }); const rawLease = service.lease; let discovered: Promise | undefined; service.lease = async (background: boolean) => { discovered ??= service.models(); const catalog = await discovered; return rawLease(background, { expectedAccountId: catalog.accountId ?? saved.activeId ?? "scratch-missing", expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }); }; return { service, rawLease, requests, changed, get url() { return url; }, get saved() { return saved; }, get writeCount() { return writeCount; }, async callback(params: Record = {}) { const callback = new URL(url.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: url.searchParams.get("state")!, code: "scratch-code", client_id: "oaiapp_scratch", ...params }).toString(); return fetch(callback); } }; } async function loggedIn(options: Parameters[0] = {}) { const f = fixture(options); await f.service.login(); await f.callback(); await f.changed.promise; return f; } test("PKCE/state/nonce/host identity and exact ephemeral 127.0.0.1 callback; no secret renderer snapshot", async () => { const f = fixture(); try { await f.service.login(); const params = f.url.searchParams; assert.equal(params.get("client_id"), "dynamic_agent_client"); assert.equal(params.get("agent_name_hint"), "LearnHarness"); assert.equal(params.get("ext_agent_host_id"), f.saved.hostId); assert.equal(new URL(params.get("redirect_uri")!).hostname, "127.0.0.1"); assert.equal(new URL(params.get("redirect_uri")!).pathname, "/auth/callback"); assert.equal((await f.callback({ state: "wrong" })).status, 400); assert.equal(f.requests.length, 0, "unverified callback must never exchange a code"); await f.callback(); await f.changed.promise; const form = f.requests.find((r) => r.url === tokenUrl)!.form!; assert.equal(form.get("redirect_uri"), params.get("redirect_uri")); assert.equal(form.get("client_id"), "oaiapp_scratch"); assert.equal(createHash("sha256").update(form.get("code_verifier")!).digest("base64url"), params.get("code_challenge")); assert.equal(form.get("resource"), "https://api.openai.com/v1"); const status = await f.service.status(); assert.equal(status.accounts[0].background, false); assert.equal(status.activeId, f.saved.activeId); assert(!JSON.stringify(status).includes("scratch-oauth-token")); assert(!JSON.stringify(status).includes("scratch-refresh")); assert(!JSON.stringify(status).includes("idToken")); assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token"); await assert.rejects(f.service.lease(true), /background/); } finally { f.service.close(); } }); test("returning sign-in reuses issued client/host and fresh PKCE; rejects account/client mismatch without replacing active credentials", async () => { const f = await loggedIn(); try { const first = f.url; const status = await f.service.status(); await f.service.login(status.activeId); assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch"); assert.equal(f.url.searchParams.get("agent_name_hint"), null); assert(f.url.searchParams.get("id_token_hint")); assert.notEqual(f.url.searchParams.get("state"), first.searchParams.get("state")); assert.notEqual(f.url.searchParams.get("nonce"), first.searchParams.get("nonce")); assert.equal(f.url.searchParams.get("ext_agent_host_id"), first.searchParams.get("ext_agent_host_id")); await f.callback({ client_id: "oaiapp_other" }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(f.saved.registrations.length, 1); assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token"); } finally { f.service.close(); } }); for (const [label, claims] of [["nonce", { nonce: "wrong" }], ["issuer", { iss: "https://evil.invalid" }], ["audience", { aud: "oaiapp_wrong" }], ["expiry", { exp: 1 }]] as const) { test(`rejects invalid ID-token ${label}`, async () => { const f = fixture({ token: async (form, url) => { const jwt = new SignJWT(Object.assign({ nonce: url.searchParams.get("nonce")!, email: "dummy@example.invalid", iss: "https://auth.openai.com", aud: form.get("client_id")!, sub: "scratch-user", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, claims)); return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await jwt.setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).sign(privateKey), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); } }); try { await f.service.login(); await f.callback(); await f.changed.promise; assert.equal(f.saved.activeId, null); assert.equal(f.saved.registrations.length, 0); } finally { f.service.close(); } }); } test("cancel and timeout close the listener without exchanging or replacing an active account", async () => { const f = fixture({ timeoutMs: 30 }); try { await f.service.login(); const callback = f.url.searchParams.get("redirect_uri")!; f.service.cancelLogin(); await assert.rejects(fetch(callback)); assert.equal(f.saved.activeId, null); const timeout = fixture({ timeoutMs: 15 }); try { await timeout.service.login(); await timeout.changed.promise; assert.equal((await timeout.service.status()).pending, false); assert.equal(timeout.requests.length, 0); } finally { timeout.service.close(); } assert.equal(f.requests.length, 0); } finally { f.service.close(); } }); test("missing plan scope retains verified registration but refuses inference and catalog", async () => { const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), scope: "openid profile email", token_type: "Bearer" }) }); try { assert.equal(f.saved.registrations.length, 1); assert.equal((await f.service.status()).accounts[0].planEnabled, false); await assert.rejects(f.service.lease(false), /scope/); assert.deepEqual((await f.service.models()).models, []); } finally { f.service.close(); } }); test("durable encrypted save failure never activates a pending login", async () => { const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } }); try { await f.service.login(); await f.callback(); await f.changed.promise; assert.equal(f.saved.activeId, null); await assert.rejects(f.service.lease(false), /disconnected/); } finally { f.service.close(); } }); test("serialized concurrent refresh rotates once and persists before leases", async () => { let refreshes = 0; const f = await loggedIn({ expires: 1, token: async (form, url) => { if (form.get("grant_type") === "refresh_token") { refreshes++; assert.equal(form.get("scope"), null); return Response.json({ access_token: "scratch-new-access", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); } return Response.json({ access_token: "scratch-old-access", refresh_token: "scratch-old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }); } }); try { const leases = await Promise.all([f.service.lease(false), f.service.lease(false), f.service.lease(false)]); assert.equal(refreshes, 1); assert(leases.every((lease) => lease.accessToken === "scratch-new-access")); assert.equal(f.saved.registrations[0].tokens.refreshToken, "scratch-rotated"); } finally { f.service.close(); } }); test("logout wins an in-flight refresh and retains only account/client/host mapping", async () => { const started = deferred(); const release = deferred(); const f = await loggedIn({ token: async (form, url) => { if (form.get("grant_type") === "refresh_token") { started.resolve(); await release.promise; return Response.json({ access_token: "scratch-late", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); } return Response.json({ access_token: "scratch-old", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }); } }); try { const id = f.saved.activeId; const refresh = f.service.lease(false); const rejected = assert.rejects(refresh); await started.promise; const logout = f.service.logout(id); await new Promise((resolve) => setImmediate(resolve)); release.resolve(); await rejected; await logout; assert.equal(f.saved.activeId, null); assert.equal(f.saved.registrations[0].clientId, "oaiapp_scratch"); assert.equal(f.saved.registrations[0].tokens, undefined); assert(f.requests.some((r) => r.form?.get("token_type_hint") === "refresh_token")); await f.service.login(id); assert.equal(f.url.searchParams.get("id_token_hint"), null); assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch"); } finally { f.service.close(); } }); test("account catalog is scoped, visibility filtered, and cleared on disconnect", async () => { const f = await loggedIn(); try { assert.deepEqual((await f.service.models()).models, [{ id: "gpt-5", name: "Scratch model" }]); await f.service.logout(f.saved.activeId); assert.deepEqual((await f.service.models()).models, []); } finally { f.service.close(); } }); test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-")); const file = path.join(dir, "credentials.enc.json"); const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") }; try { const store = encryptedStore(file, secure); const data = { ...initial(), marker: "scratch-secret-never-plaintext" }; await store.write(data); assert(!(await fs.readFile(file, "utf8")).includes(data.marker)); assert.deepEqual(await store.read(), data); assert.equal((await fs.stat(file)).mode & 0o777, 0o600); assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]); for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) { await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/); await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/); } assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke")); assert(!allowedAuthEndpoint("http://127.0.0.1/revoke")); assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke")); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => { const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token" ? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 }) : Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) }); try { const before = structuredClone(f.saved.registrations[0]); await assert.rejects(f.service.lease(false)); const restricted = f.saved.registrations[0]; assert.equal(restricted.reconnectRequired, true); for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]); assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" }); assert(!JSON.stringify(f.saved).includes("never-save-this-access")); const restarted = fixture({ state: f.saved }); try { await restarted.service.status(); await restarted.service.select(restricted.id); restarted.service.resume(); await restarted.service.consent(restricted.id, true); await assert.rejects(restarted.service.lease(false)); assert.equal((await restarted.service.status()).accounts[0].connected, false); assert.deepEqual((await restarted.service.models()).models, []); assert.equal(restarted.requests.filter((r) => r.url === tokenUrl).length, 0, "restricted material cannot refresh for inference/catalog"); await restarted.service.logout(restricted.id); assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle"); assert.equal(restarted.saved.registrations[0].reconnectRequired, true); await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise; // Wait for callback's asynchronous encrypted activation, not the prior status push. for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve)); assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined); } finally { restarted.service.close(); } } finally { f.service.close(); } }); test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => { let lost = false; const pushes: any[] = []; const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) }); try { await f.service.lease(false); const epoch = f.service.stamp().epoch; lost = true; assert.equal((await f.service.status()).available, false); assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch)); await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true)); lost = false; f.service.resume(); await f.service.consent(f.saved.activeId, true); await assert.rejects(f.service.lease(false)); assert.equal((await f.service.status()).accounts[0].connected, false); } finally { f.service.close(); } }); test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => { const f = await loggedIn(); f.service.close(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-")); const file = path.join(dir, "credentials.enc.json"); const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() }; try { for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1]) { const state = structuredClone(f.saved); mutate(state); const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") }); await fs.writeFile(file, bytes); const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") }); try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); } finally { service.close(); } } } finally { await fs.rm(dir, { recursive: true, force: true }); } }); test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => { let hold = false; const entered = deferred(); const release = deferred(); const f = await loggedIn({ write: async (next) => { if (hold && next.registrations[0]?.background) { entered.resolve(); await release.promise; } } }); try { const catalog = await f.service.models(); const id = f.saved.activeId; hold = true; const on = f.service.consent(id, true); await entered.promise; const off = f.service.consent(id, false); assert.equal((await f.service.status()).accounts[0].background, false); release.resolve(); await on; await off; assert.equal(f.saved.registrations[0].background, false); assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision); assert.equal((await f.rawLease(false, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, id); await assert.rejects(f.rawLease(true, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }), /background/); } finally { f.service.close(); } }); test("cancellation during verified durable save cannot activate late credentials", async () => { const entered = deferred(); const release = deferred(); let blocked = true; const f = fixture({ write: async (next) => { if (blocked && next.registrations.length) { entered.resolve(); await release.promise; blocked = false; } } }); try { await f.service.login(); await f.callback(); await entered.promise; f.service.cancelLogin(); release.resolve(); for (let i = 0; i < 30 && f.writeCount < 2; i++) await new Promise((resolve) => setImmediate(resolve)); assert.equal(f.saved.activeId, null); assert.equal((await f.service.status()).accounts.length, 0); } finally { f.service.close(); } }); test("scope-disabled credentials remain revocable and unconfirmed revoke never claims success", async () => { const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), refresh_token: "scope-disabled-refresh", scope: "openid profile email", token_type: "Bearer" }), provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 500 }) : undefined }); try { await assert.rejects(f.service.lease(false)); const status = await f.service.logout(f.saved.activeId); assert.match(status.error, /Remote revocation was not confirmed/); assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2); assert.equal(f.saved.registrations[0].tokens, undefined); } finally { f.service.close(); } }); test("private leases bind active account, authority revision and raw visible model membership", async () => { const f = await loggedIn(); try { const catalog = await f.service.models(); const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }; await assert.rejects(f.rawLease(false)); for (const mutation of [{ expectedAccountId: "another" }, { expectedAuthorityRevision: catalog.authorityRevision - 1 }, { selectedModelId: "hidden" }]) await assert.rejects(f.rawLease(false, { ...expected, ...mutation })); assert.equal((await f.rawLease(false, expected)).accountId, catalog.accountId); } finally { f.service.close(); } });