Skip to content

fix(mcp): use DO ID hex in OAuth callback path to side-step email URL encoding - #88

Merged
jonnyparris merged 1 commit into
mainfrom
oauth/no-at-in-path
May 26, 2026
Merged

fix(mcp): use DO ID hex in OAuth callback path to side-step email URL encoding#88
jonnyparris merged 1 commit into
mainfrom
oauth/no-at-in-path

Conversation

@jonnyparris

Copy link
Copy Markdown
Owner

Bug

Even after matching Seal's callback path shape, cf-portal kept returning Redirect URI not allowed by application configuration. Worker tail showed why:

registered redirect_uri: https://dodo.../agents/coding-agent/ruskin.constant@gmail.com/callback
authorize redirect_uri:  https://dodo.../agents/coding-agent/ruskin.constant%40gmail.com/callback

The OAuth client URL-encodes the redirect_uri when embedding it in the authorize URL's query string (%40 for @). cf-portal's authorize endpoint does a strict string comparison against the as-registered URI and rejects the encoded form, even though both decode to the same URI.

Seal works because their userId is shaped user-{base64-ish} — no @, no encoding mismatch.

Fix

Use env.USER_CONTROL.idFromName(email).toString() (hex string) as the instance segment. Same per-user uniqueness, all a-z0-9, no encoding ambiguity.

The /agents/* route handler doesn't parse the URL path for routing — it identifies the user via CF Access cookie and forwards to the SDK's MCP callback handler. So changing the path segment is purely cosmetic from the worker's POV.

Verification

  • npm run typecheck clean
  • npx vitest run 817/817 pass
  • Will verify end-to-end via chrome-devtools after deploy

beep-boop-🤖

…il-encoding mismatch)

After hitting "Redirect URI not allowed by application configuration"
from cf-portal even after matching Seal's callback shape, dug into the
actual wire traffic and found the bug:

  registered redirect_uri: https://dodo.../agents/coding-agent/ruskin.constant@gmail.com/callback
  authorize redirect_uri:  https://dodo.../agents/coding-agent/ruskin.constant%40gmail.com/callback

The OAuth client URL-encodes the redirect_uri when it goes into the
authorize URL's query string (`%40` for `@`). cf-portal's authorize
endpoint appears to do a strict string comparison against the
as-registered redirect_uri and rejects the encoded form.

Switch the instance segment from email to UserControl DO ID hex
(`env.USER_CONTROL.idFromName(email).toString()`) which is a-z0-9
only — no encoding ambiguity. The /agents/* route handler doesn't
parse the URL path for routing anyway (uses CF Access cookie to
identify the user), so this changes the URL shape without any
functional impact downstream.

Tests: 817/817 pass. Typecheck clean.

beep-boop-🤖
@jonnyparris
jonnyparris merged commit aec91b0 into main May 26, 2026
1 of 2 checks passed
@jonnyparris
jonnyparris deleted the oauth/no-at-in-path branch May 26, 2026 12:30
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