From 09983b6edf9c1641cdab9ee2cdb55f475e899c19 Mon Sep 17 00:00:00 2001 From: TheStreamCode Date: Sun, 27 Sep 2026 22:06:59 +0200 Subject: [PATCH] fix(security): never persist the OAuth access token (v0.1.2) writeCache now persists only apiKey/accountId/email; the OAuth token stays in memory (nothing ever read it back). Test asserts absence from the raw cache file. README documents the stored fields. --- README.md | 4 +++- package-lock.json | 4 ++-- package.json | 2 +- src/auth.ts | 15 ++++++++++++++- tests/auth.test.ts | 15 ++++++++++++++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b5838cf..9e53306 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ automatically to `muse-*` model ids): - **Login** — Meta device-code exchange (RFC 8628) inside `/connect`, then mints the stable account-bound inference key via the Model API. The key is cached locally (`~/.config/opencode/muse-code-sub.json`, owner-only - permissions where supported) and never printed. + permissions where supported) and never printed. The cache stores exactly + `apiKey`, `accountId`, and `email` — the OAuth access token from the + login flow is kept in memory only and never written to disk. - **Runtime** — an auth `loader` injects the cached key on every startup. The mint endpoint is aggressively rate-limited, so the plugin never re-mints on its own; re-run `/connect` only if access is revoked (401). diff --git a/package-lock.json b/package-lock.json index ee3f247..d593a8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-muse-auth", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-muse-auth", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "devDependencies": { "@opencode-ai/plugin": "^1.18.31", diff --git a/package.json b/package.json index 52d0512..4bf9502 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-muse-auth", - "version": "0.1.1", + "version": "0.1.2", "description": "Muse Spark in opencode billed to the Muse Code monthly subscription (Meta device login, no API key)", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/auth.ts b/src/auth.ts index 5b9cd4d..1717bae 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -30,12 +30,20 @@ export interface DeviceAuthorization { } export interface MintedCredential { + // In-memory only: writeCache never persists the OAuth token (see below). oauthAccessToken: string apiKey: string accountId: string email?: string } +/** The only fields ever written to the credential cache. */ +export interface CachedCredential { + apiKey: string + accountId: string + email?: string +} + type DeviceResponse = { device_code?: unknown user_code?: unknown @@ -71,8 +79,13 @@ export async function readCache(path: string = CACHE_PATH): Promise { } export async function writeCache(credentials: MintedCredential, path: string = CACHE_PATH): Promise { + // Retention minimization: persist only what inference needs. The OAuth + // access token has unknown broader scope and nothing reads it back, so it + // must never touch disk — sanitize at the sink, whatever callers pass in. + const { apiKey, accountId, email } = credentials + const cached: CachedCredential = { apiKey, accountId, ...(email ? { email } : {}) } await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(credentials, null, 2)) + await writeFile(path, JSON.stringify(cached, null, 2)) try { await chmod(path, 0o600) } catch { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index ec32800..f470f74 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test" import assert from "node:assert/strict" import { tmpdir } from "node:os" import { join } from "node:path" -import { mkdtempSync } from "node:fs" +import { mkdtempSync, readFileSync } from "node:fs" import { readCache, writeCache, @@ -48,6 +48,19 @@ test("cache miss resolves empty", async () => { assert.equal(await readCache(join(tmpdir(), "muse-auth-absent.json")), "") }) +test("cache never persists the OAuth access token", async () => { + const dir = mkdtempSync(join(tmpdir(), "muse-auth-")) + const path = join(dir, "creds.json") + await writeCache( + { oauthAccessToken: "dca-secret", apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" }, + path, + ) + const raw = readFileSync(path, "utf8") + assert.ok(!raw.includes("dca-secret")) + assert.ok(!raw.includes("oauthAccessToken")) + assert.deepStrictEqual(JSON.parse(raw), { apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" }) +}) + test("device authorize validates fields", async () => { stubFetch(() => ({ status: 200, body: DEVICE_OK })) const device = await deviceAuthorize()