Summary
src/server/auth.ts keeps the callback server and pending auth registry in module-level state, and the server's fetch handler captures the first input/config/deps passed to ensureServer().
If the same OpenCode process serves multiple plugin instances/directories while the callback server is still alive, a later Supabase auth flow can complete successfully but write tokens into the wrong .opencode/supabase-auth.json file.
This is the most severe plugin-local auth bug I found because it can cross-contaminate credentials across directories/sessions in the same process.
Evidence
Module-level singleton state
src/server/auth.ts
36-38: module globals
let server
let serverPort
const pendingAuths = new Map<string, PendingAuth>()
First caller wins the server context
src/server/auth.ts
64-95: ensureServer() creates Bun.serve({ fetch(...) { ... } })
72-77: if server already exists and the port matches, ensureServer() returns the existing server instead of rebuilding it
79-81: brokerConfig is created from the _config passed to that first call
94: the fetch handler is defined inside ensureServer(), so it closes over that call's input, _config, and deps
Callback writes using captured input
src/server/auth.ts
113-119: callback finds a pending auth entry by state
155-164: exchanges the code using the pending entry's PKCE/redirect data
167-171: persists tokens with writeSavedAuth(input, { ... })
That input is not taken from the pending auth entry. It is the input captured when the singleton server was first created.
Why this is realistic in OpenCode
Verified against current OpenCode host code:
~/Code/github/opencode/packages/opencode/src/plugin/index.ts:133-145 passes per-directory PluginInput into plugin server hooks
~/Code/github/opencode/packages/opencode/src/effect/instance-state.ts:36-64 scopes instance state by directory
~/Code/github/opencode/packages/opencode/src/project/instance.ts:58-74 caches/runs instances by resolved directory
So multiple directories can exist in the same long-lived process, which makes the singleton callback server reuse real, not theoretical.
Reproduction
Scenario
- Start auth flow in directory A.
- The plugin starts the callback server and captures directory A's
input.
- Before that server is torn down, start auth flow in directory B in the same process.
ensureServer() reuses the existing server.
- Complete the browser callback for directory B.
- The callback resolves B's
state, but writeSavedAuth(...) still uses directory A's captured input.
Expected
Directory B's tokens should be written to directory B's .opencode/supabase-auth.json.
Actual
The callback can write B's tokens to directory A's auth store.
Impact
- Wrong workspace/directory can become authenticated with another session's tokens.
- User can appear to have authenticated successfully, but the credentials land in a different store file.
- Hard to debug because the browser flow itself succeeds.
Suggested Fix
Make the callback server stateless with respect to per-auth-flow storage context.
Recommended approach:
- Stop capturing
input/config/deps in the singleton fetch handler.
- Store all per-flow data inside
PendingAuth, including:
- storage target (
directory, worktree, or equivalent)
- broker config if needed
- any per-flow logger/fetch deps if needed
- On callback, persist using the
PendingAuth entry for that state, not outer captured variables.
- Consider eliminating the singleton server entirely, or at minimum make it a pure router over per-state data.
Acceptance Criteria
- Two concurrent auth flows for different directories cannot write to each other's auth store.
- Callback persistence target is derived from the pending auth entry keyed by
state.
- Regression test covers two overlapping auth flows in one process with different directories/worktrees.
Related Findings
Summary
src/server/auth.tskeeps the callback server and pending auth registry in module-level state, and the server'sfetchhandler captures the firstinput/config/deps passed toensureServer().If the same OpenCode process serves multiple plugin instances/directories while the callback server is still alive, a later Supabase auth flow can complete successfully but write tokens into the wrong
.opencode/supabase-auth.jsonfile.This is the most severe plugin-local auth bug I found because it can cross-contaminate credentials across directories/sessions in the same process.
Evidence
Module-level singleton state
src/server/auth.ts36-38: module globalslet serverlet serverPortconst pendingAuths = new Map<string, PendingAuth>()First caller wins the server context
src/server/auth.ts64-95:ensureServer()createsBun.serve({ fetch(...) { ... } })72-77: ifserveralready exists and the port matches,ensureServer()returns the existing server instead of rebuilding it79-81:brokerConfigis created from the_configpassed to that first call94: thefetchhandler is defined insideensureServer(), so it closes over that call'sinput,_config, anddepsCallback writes using captured
inputsrc/server/auth.ts113-119: callback finds a pending auth entry bystate155-164: exchanges the code using the pending entry's PKCE/redirect data167-171: persists tokens withwriteSavedAuth(input, { ... })That
inputis not taken from the pending auth entry. It is theinputcaptured when the singleton server was first created.Why this is realistic in OpenCode
Verified against current OpenCode host code:
~/Code/github/opencode/packages/opencode/src/plugin/index.ts:133-145passes per-directoryPluginInputinto plugin server hooks~/Code/github/opencode/packages/opencode/src/effect/instance-state.ts:36-64scopes instance state by directory~/Code/github/opencode/packages/opencode/src/project/instance.ts:58-74caches/runs instances by resolved directorySo multiple directories can exist in the same long-lived process, which makes the singleton callback server reuse real, not theoretical.
Reproduction
Scenario
input.ensureServer()reuses the existing server.state, butwriteSavedAuth(...)still uses directory A's capturedinput.Expected
Directory B's tokens should be written to directory B's
.opencode/supabase-auth.json.Actual
The callback can write B's tokens to directory A's auth store.
Impact
Suggested Fix
Make the callback server stateless with respect to per-auth-flow storage context.
Recommended approach:
input/config/deps in the singletonfetchhandler.PendingAuth, including:directory,worktree, or equivalent)PendingAuthentry for thatstate, not outer captured variables.Acceptance Criteria
state.Related Findings
P1-16-style persistence handling because the callback path is the only place that writes the local auth file.