feat(mcp): Dodo-owned DCR OAuth flow + start/complete tools - #91
Merged
Conversation
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
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-🤖
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Why this is safe to run from a hosted Worker
codeis useless without it.Storage
encrypted_secrets["oauth_dcr_pending:<state>"]— JSON blob, 10-min TTL.auth_type='refresh_token'+encrypted_secrets["mcp:<id>:{access_token,refresh_token,expires_at}"]. Reuses everything from PR feat(mcp): refresh-token auth type + set_refresh_token_mcp tool #90.Tests
Six new in
test/oauth-dcr-flow-unit.test.ts:829/829 pass (823 + 6 new). Typecheck clean.
Also fixes a TypeScript narrowing quirk in the existing PR #90 test —
let captured = nullinside a closure narrowed toneverafter 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:
start_dcr_oauth_flowvia Dodo's MCPhttp.serveron 127.0.0.1:19876complete_dcr_oauth_flowNot in this PR because Dodo doesn't ship the helper — it lives in agent-hq.
beep-boop-🤖