Skip to content

feat: OAuth sign-in for remote MCP servers, run by codeoid itself - #350

Open
saucam wants to merge 1 commit into
mainfrom
feat/mcp-oauth
Open

saucam wants to merge 1 commit into
mainfrom
feat/mcp-oauth

Conversation

@saucam

@saucam saucam commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Remote MCP servers that use OAuth (Notion, Google, Slack, GitHub and most SaaS servers) can now be connected from Settings → MCP Servers, per workspace.
codeoid runs the whole flow itself, with no Highflame dependency.
Routing through Firehog remains a later option behind the McpCredentialSource seam.

Design: docs/mcp-oauth-design.md.

What's in it

  • Config: add "oauth": true to an http registry server, or {clientId, clientSecretEnv, scopes} for a pre-registered client. Set mcpOAuth.redirectBaseUrl (or CODEOID_MCP_OAUTH_REDIRECT_BASE_URL) for a daemon behind a tunnel.
  • Engine (src/daemon/mcp/oauth.ts): runs on the official SDK's auth().
    • Covers discovery (RFC 9728/8414), dynamic registration (RFC 7591), PKCE S256, resource indicators (RFC 8707) and refresh.
    • Refresh is single-flight, and also runs when the server rejects a token with a 401.
  • Credentials: one per (account, project, server, server URL), in an owner-only (0600) SQLite store. They are never sent to a client and never logged.
  • Protocol: mcp.oauth.begin / complete / disconnect, all requiring settings:write, plus the /mcp/oauth/callback route.
  • UI: Connect and Disconnect buttons, the sign-in link, and a paste fallback for remote daemons.
  • Backends:
    • openai, gemini and pi call OAuth servers with the tenant's token through the daemon-owned client.
    • claude, codex, gemini-cli and qwen mount servers natively, so they leave OAuth servers unmounted for now instead of baking a short-lived token into the agent process. Settings names these backends.
    • A loopback proxy that lets the native backends mount OAuth servers is designed (§5) but not part of this PR.

Security (from an independent review round, all addressed)

  • Login CSRF: the unauthenticated callback completes a sign-in only for the browser that started it, which holds a binding cookie the web UI sets.
    • Any other completion must come through the authenticated paste path, and only for the starting tenant.
    • A refused callback leaves the sign-in pending, so its owner can still finish it.
  • URL binding: credentials are bound to the server URL, so re-pointing a server never sends old tokens to a new host.
  • No redirects: requests that carry a token follow no redirect.
  • Per-tenant status: health, tools and errors for an OAuth server are shown per tenant.
  • Pending cap: at most 16 pending sign-ins per tenant.
  • Result page: HTML-escaped and redacted, with no-store, no-referrer and a strict CSP.

Test plan

  • src/tests/mcp-oauth.test.ts (28): runs against a local spec-shaped authorization server plus MCP server.
    • The full flow: discovery, registration, PKCE, scopes, single-use and expiring state, tenant isolation.
    • Refresh: single-flight, on rejection, and when the refresh itself is refused.
    • The hub calling as a tenant; native mounters skipping OAuth servers.
    • The mcp.oauth.* handlers, the login-CSRF refusal, URL binding, the pending cap and per-tenant status.
  • src/tests/mcp-oauth-server.test.ts (3): a real daemon. Begin over the socket, then the provider redirect into the actual callback route, then settings show the server connected. Also covers a browser without the binding (refused, then finished by paste) and an escaped failure page.
  • Web: McpOAuthControls.test.tsx (3).
  • Mutation checks: every security and scoping guard (binding check, tenant check, scope gate, pending cap, per-tenant status, native-mount filter, live redirect port) fails at least one test when removed.
  • Full gate: typecheck, lint, 2740 daemon tests, 576 web tests, build.

🤖 Generated with Claude Code

Remote MCP servers that authenticate with OAuth (Notion, Google, Slack,
GitHub, ...) can now be connected from Settings, per workspace, without
any Highflame dependency. Firehog routing stays a later alternative behind
the McpCredentialSource seam.

- Config: `oauth: true | {clientId, clientSecretEnv, scopes}` on an http
  registry server; `mcpOAuth.redirectBaseUrl` for a tunnelled daemon.
- Engine (src/daemon/mcp/oauth.ts) on the official MCP SDK's auth():
  discovery, dynamic registration, PKCE, resource indicators, refresh
  (single-flight, and on a 401). Credentials per tenant, keyed by server
  URL too, in an owner-only store.
- mcp.oauth.begin / complete / disconnect (settings:write), a callback
  route, and Settings controls with a paste fallback.
- Login CSRF: the callback completes only for the browser that began the
  sign-in (binding cookie); otherwise only the starting tenant can finish
  it by pasting.
- The daemon-run backends (openai, gemini, pi) call OAuth servers with
  the tenant's token. Native-mount backends (claude, codex, gemini-cli,
  qwen) leave them unmounted for now rather than bake a token into the
  agent process; Settings says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam

saucam commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Auto-fixed by pr-shepherd (iteration 1):

Re-running CI. No content changes beyond the rebase.

@saucam

saucam commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on a8809b1 (daemon-check, web-check, Socket Security x2 all SUCCESS). Branch is up to date with main. I'm done here and the PR is ready for human review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant