Skip to content

Callback server can write OAuth tokens to wrong local auth store #32

Description

@jumski

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

  1. Start auth flow in directory A.
  2. The plugin starts the callback server and captures directory A's input.
  3. Before that server is torn down, start auth flow in directory B in the same process.
  4. ensureServer() reuses the existing server.
  5. Complete the browser callback for directory B.
  6. 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:

  1. Stop capturing input/config/deps in the singleton fetch handler.
  2. 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
  3. On callback, persist using the PendingAuth entry for that state, not outer captured variables.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p0Handle immediatelystatus:nextRecommended next issuestatus:triagedReviewed and ranked

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions