Skip to content

feat(mcp): Dodo-owned DCR OAuth flow + start/complete tools - #91

Merged
jonnyparris merged 1 commit into
mainfrom
feat/dcr-oauth-flow
May 26, 2026
Merged

feat(mcp): Dodo-owned DCR OAuth flow + start/complete tools#91
jonnyparris merged 1 commit into
mainfrom
feat/dcr-oauth-flow

Conversation

@jonnyparris

Copy link
Copy Markdown
Owner

What

Dodo runs its own DCR registration + token exchange against an upstream OAuth provider. A local helper handles only the browser-side authorize step on a loopback callback. Dodo gets a refresh chain that doesn't share fate with any local OAuth client (OpenCode, Cursor, Claude Desktop, etc).

Why

PR #90 added a piggyback path where the user pushes OpenCode's existing tokens into Dodo. That works but refresh tokens rotate — the first time Dodo refreshes, OpenCode's local copy becomes invalid, forcing the user to re-auth locally.

This PR fixes that. Each tool has its own client_id + refresh chain. They never interfere.

How

Two new MCP tools form a two-step dance:

1. start_dcr_oauth_flow({mcpUrl, mcpName})
   → Dodo:
     - discovers OAuth endpoints from /.well-known docs
     - POSTs DCR with redirect_uris=["http://127.0.0.1:PORT/callback"]
     - generates PKCE code_verifier + code_challenge (S256)
     - generates state nonce (32 random bytes hex)
     - persists {client_id, code_verifier, redirect_uri, token_endpoint, ...}
       in encrypted_secrets keyed by state (10-min TTL)
   → returns { state, authUrl, redirectUri }

2. (local helper opens authUrl in browser, binds 127.0.0.1:PORT/callback,
   catches the redirect with ?code= and ?state=)

3. complete_dcr_oauth_flow({state, code})
   → Dodo:
     - loads pending dance by state, validates TTL
     - POSTs to token_endpoint with grant_type=authorization_code,
       code, code_verifier, redirect_uri (loopback), client_id
     - stores resulting tokens via the existing upsertRefreshTokenMcp
       path → mcp_configs row with auth_type='refresh_token'
     - deletes pending state
   → returns { id, name, url } of the new integration

Why this is safe to run from a hosted Worker

  • DCR is unauthenticated — anyone can register a client. cf-portal accepts loopback redirect URIs in DCR.
  • PKCE binds authorize to token exchange. code_verifier never leaves Dodo's storage; a captured code is useless without it.
  • State nonce is 32 random bytes hex; the helper must return the matching state alongside the code.
  • redirect_uri in token exchange matches the one sent in authorize (loopback). cf-portal's authorize endpoint accepts loopback; the token endpoint just checks the redirect_uri matches the registered one (it doesn't enforce loopback).

Storage

Tests

Six new in test/oauth-dcr-flow-unit.test.ts:

  • Discovery from well-known docs, DCR call, authorize URL construction (PKCE + state present, correct shape)
  • Explicit endpoint overrides bypass discovery
  • 502 surfaced when DCR fails
  • Token exchange + persist via refresh-token path; verifies request body shape (grant_type, code, code_verifier, redirect_uri, client_id)
  • Unknown state rejected
  • Token endpoint error surfaced

829/829 pass (823 + 6 new). Typecheck clean.

Also fixes a TypeScript narrowing quirk in the existing PR #90 test — let captured = null inside a closure narrowed to never after stricter type checking. Switched to a {value: T | null} holder.

Follow-up (separate PR)

A small bash helper + slash command in agent-hq that orchestrates the local side:

  1. Calls start_dcr_oauth_flow via Dodo's MCP
  2. Spins up a Python http.server on 127.0.0.1:19876
  3. Opens the authorize URL in the browser
  4. Catches the callback, extracts code + state
  5. Calls complete_dcr_oauth_flow
  6. Reports the result

Not in this PR because Dodo doesn't ship the helper — it lives in agent-hq.

beep-boop-🤖

Adds a two-step OAuth flow that lets Dodo run its own DCR + token
exchange server-side, while delegating only the browser-side
authorize step to a local helper on 127.0.0.1. The result: Dodo has
its own refresh-token chain that doesn't share fate with any local
OAuth client (OpenCode, Cursor, etc).

The previous `set_refresh_token_mcp` tool only piggybacked on
OpenCode's existing chain — refresh-token rotation invalidated
OpenCode's local copy the moment Dodo refreshed. This flow gives
each tool its own client_id + refresh chain.

Flow:

  1. Local helper calls `start_dcr_oauth_flow({mcpUrl, mcpName})`.
     Dodo discovers OAuth endpoints from the MCP server's well-known
     docs, runs DCR against the registration endpoint with a loopback
     redirect URI (which is the only kind cf-portal accepts), generates
     PKCE + state, persists the pending dance in encrypted_secrets,
     and returns the authorize URL.
  2. Local helper opens the URL in a browser, catches the redirect on
     127.0.0.1:PORT/callback, extracts ?code= and ?state=.
  3. Local helper calls `complete_dcr_oauth_flow({state, code})`.
     Dodo exchanges the code for tokens server-side (using the stored
     code_verifier — RFC 6749 §4.1.3 requires redirect_uri to match
     the registered one, which it does because we stored the loopback
     URI) and persists via the existing upsertRefreshTokenMcp path.

Why this is safe to run from a hosted Worker:

  - DCR is unauthenticated — anyone can register a client. cf-portal
    accepts loopback redirect URIs in DCR.
  - PKCE binds authorize to token exchange; the code_verifier never
    leaves Dodo's storage, so a captured `code` is useless without
    the verifier.
  - The state nonce is 32 random bytes hex; the local helper must
    return the same state alongside the code, which it can only get
    from start_dcr_oauth_flow.
  - The redirect_uri sent in token exchange matches the one sent in
    authorize (loopback). cf-portal's authorize endpoint accepts it
    because it's loopback; the token endpoint doesn't enforce loopback
    (it just checks the redirect_uri matches the registered one).

Storage:

  - Pending dances live in encrypted_secrets keyed by
    `oauth_dcr_pending:<state>`. JSON-serialised blob with
    client_id + code_verifier + redirect_uri + token_endpoint +
    mcp_url + mcp_name + created_at. 10-minute TTL.
  - On completion, the pending entry is deleted and tokens are written
    via upsertRefreshTokenMcp — same code path the piggyback flow uses,
    so refresh + connect logic is shared.

Six new tests in test/oauth-dcr-flow-unit.test.ts:
  - Discovery + DCR + authorize-URL construction
  - Explicit endpoint overrides skip discovery
  - 502 surfaced when DCR fails
  - Token exchange + persist via refresh-token path
  - Unknown state rejected with 502
  - Token endpoint error surfaced with 502

Tests: 829/829 pass. Typecheck clean.

Also: fix a TypeScript narrowing quirk in the previous test
(refresh-token-mcp-unit.test.ts) — `let captured = null` inside a
closure narrowed to `never` in stricter contexts; switched to a
`{value: T | null}` holder which TS handles correctly.

beep-boop-🤖
@jonnyparris
jonnyparris merged commit 2160451 into main May 26, 2026
2 checks passed
@jonnyparris
jonnyparris deleted the feat/dcr-oauth-flow branch May 26, 2026 14:03
jonnyparris added a commit that referenced this pull request May 26, 2026
Six holes were identified in the audit of PRs #82-#91. This commit
fixes all of them.

Hole 1 (medium) — /test endpoint broken for refresh_token configs
  POST /mcp-configs/:id/test was building the HttpMcpClient via
  resolveMcpConfigHeaders, which doesn't know about refresh_token
  configs (no headerKeys). The client connected with no Authorization
  header → 401. Every Test click on a refresh_token integration
  returned 'Connection failed'.

  Fix: the /test handler now branches on auth_type. refresh_token
  rows pull the bearer via getMcpAccessToken (which auto-refreshes
  if stale) and inject it as 'Authorization: Bearer <token>'. The
  SELECT was also missing auth_type — added it so the mapper sees
  the real value.

Hole 2 (low) — updateMcpConfigEncrypted could corrupt refresh_token rows
  mcpConfigUpdateSchema only allowed auth_type ∈ {oauth, static_headers}.
  A client sending {auth_type:'static_headers'} would silently
  downgrade a refresh_token row. {headers:{...}} would call
  deleteMcpConfigSecrets, wiping the entire token chain (access_token,
  refresh_token, expires_at).

  Fix: schema now accepts refresh_token. updateMcpConfigEncrypted
  explicitly rejects mutations of headers/url/auth_type/type on
  refresh_token rows with a clear error. Only enabled and name can
  be patched — enough for UI toggle and rename to work.

Hole 3 (minor) — pending DCR row leaked on upsert failure
  completeOauthDcrFlow deleted the pending row AFTER upsertRefreshTokenMcp.
  If the upsert threw (e.g. envelope dropped, sqlite error), the row
  sat around until its 10-min TTL. The captured 'code' is single-use
  at the OAuth provider anyway, so the row is dead either way.

  Fix: wrap delete in try/finally so it runs unconditionally.

Hole 4 (cosmetic) — stale comment in dodo-settings.js
  Comment claimed the callback was at /agents/oauth/callback. Actual
  path is /agents/coding-agent/<userId-hex>/callback (PR #88).

  Fix: comment updated.

Hole 5 (minor) — test blind spot on rotation chaining
  The original suite mocked a rotated refresh token in the response
  but never made a second refresh to assert that the new value was
  what got sent. The code was correct — this just adds the assertion.

  Fix: new test 'rotates the stored refresh token — second refresh
  uses the rotated value' verifies the sent refresh_token over two
  refreshes is [v0, v1] (not [v0, v0]).

Hole 6 (deferred → fixed) — no UI affordance for refresh_token configs
  refresh_token configs rendered through the generic renderIntegCard,
  with no visual indicator and no way to force-refresh.

  Fix: new renderRefreshTokenCard with an explicit 'OAuth ·
  auto-refresh' badge and a 'Refresh token' action. Backed by a
  new POST /api/mcp-configs/:id/refresh-token endpoint that proxies
  to /mcp-configs/:id/access-token?force=1.

Three new tests added in test/refresh-token-mcp-unit.test.ts:
  - /mcp-configs/:id/test injects the refreshed bearer for refresh_token
  - PUT /mcp-configs/:id refuses to mutate headers/url/auth_type on
    a refresh_token config (and that enabled toggles still work)
  - rotation chain — second refresh uses the rotated value

Tests: 832/832 pass (829 + 3 new). Typecheck clean.

beep-boop-🤖
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