Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,8 +79,13 @@ export async function readCache(path: string = CACHE_PATH): Promise<string> {
}

export async function writeCache(credentials: MintedCredential, path: string = CACHE_PATH): Promise<void> {
// 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 {
Expand Down
15 changes: 14 additions & 1 deletion tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading