Skip to content

Commit 09983b6

Browse files
committed
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.
1 parent e5a7e50 commit 09983b6

5 files changed

Lines changed: 34 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ automatically to `muse-*` model ids):
3636
- **Login** — Meta device-code exchange (RFC 8628) inside `/connect`, then
3737
mints the stable account-bound inference key via the Model API. The key is
3838
cached locally (`~/.config/opencode/muse-code-sub.json`, owner-only
39-
permissions where supported) and never printed.
39+
permissions where supported) and never printed. The cache stores exactly
40+
`apiKey`, `accountId`, and `email` — the OAuth access token from the
41+
login flow is kept in memory only and never written to disk.
4042
- **Runtime** — an auth `loader` injects the cached key on every startup.
4143
The mint endpoint is aggressively rate-limited, so the plugin never
4244
re-mints on its own; re-run `/connect` only if access is revoked (401).

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-muse-auth",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "Muse Spark in opencode billed to the Muse Code monthly subscription (Meta device login, no API key)",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

‎src/auth.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ export interface DeviceAuthorization {
3030
}
3131

3232
export interface MintedCredential {
33+
// In-memory only: writeCache never persists the OAuth token (see below).
3334
oauthAccessToken: string
3435
apiKey: string
3536
accountId: string
3637
email?: string
3738
}
3839

40+
/** The only fields ever written to the credential cache. */
41+
export interface CachedCredential {
42+
apiKey: string
43+
accountId: string
44+
email?: string
45+
}
46+
3947
type DeviceResponse = {
4048
device_code?: unknown
4149
user_code?: unknown
@@ -71,8 +79,13 @@ export async function readCache(path: string = CACHE_PATH): Promise<string> {
7179
}
7280

7381
export async function writeCache(credentials: MintedCredential, path: string = CACHE_PATH): Promise<void> {
82+
// Retention minimization: persist only what inference needs. The OAuth
83+
// access token has unknown broader scope and nothing reads it back, so it
84+
// must never touch disk — sanitize at the sink, whatever callers pass in.
85+
const { apiKey, accountId, email } = credentials
86+
const cached: CachedCredential = { apiKey, accountId, ...(email ? { email } : {}) }
7487
await mkdir(dirname(path), { recursive: true })
75-
await writeFile(path, JSON.stringify(credentials, null, 2))
88+
await writeFile(path, JSON.stringify(cached, null, 2))
7689
try {
7790
await chmod(path, 0o600)
7891
} catch {

‎tests/auth.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test } from "node:test"
22
import assert from "node:assert/strict"
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
5-
import { mkdtempSync } from "node:fs"
5+
import { mkdtempSync, readFileSync } from "node:fs"
66
import {
77
readCache,
88
writeCache,
@@ -48,6 +48,19 @@ test("cache miss resolves empty", async () => {
4848
assert.equal(await readCache(join(tmpdir(), "muse-auth-absent.json")), "")
4949
})
5050

51+
test("cache never persists the OAuth access token", async () => {
52+
const dir = mkdtempSync(join(tmpdir(), "muse-auth-"))
53+
const path = join(dir, "creds.json")
54+
await writeCache(
55+
{ oauthAccessToken: "dca-secret", apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" },
56+
path,
57+
)
58+
const raw = readFileSync(path, "utf8")
59+
assert.ok(!raw.includes("dca-secret"))
60+
assert.ok(!raw.includes("oauthAccessToken"))
61+
assert.deepStrictEqual(JSON.parse(raw), { apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" })
62+
})
63+
5164
test("device authorize validates fields", async () => {
5265
stubFetch(() => ({ status: 200, body: DEVICE_OK }))
5366
const device = await deviceAuthorize()

0 commit comments

Comments
 (0)