Skip to content

feat(auth): per-user OAuth on downstream MCP connections + no-DCR provider support - #6023

Open
AriOliv wants to merge 6 commits into
decocms:mainfrom
AriOliv:feat/per-user-oauth-apps-api
Open

feat(auth): per-user OAuth on downstream MCP connections + no-DCR provider support#6023
AriOliv wants to merge 6 commits into
decocms:mainfrom
AriOliv:feat/per-user-oauth-apps-api

Conversation

@AriOliv

@AriOliv AriOliv commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Two related features, re-implemented on the current apps/api layout:

1. Per-user OAuth on downstream MCP connections (auth_mode: "shared" | "per_user")

Connections gain an opt-in per_user mode: instead of one org-wide downstream token, each member authorizes with their own account and tokens are keyed by (connection, user). Audit logs at the provider show the real person acting, and members only reach what their own account can see (e.g. each member's own Google Drive).

  • Migration 171: connections.auth_mode (default 'shared', existing rows untouched) + re-introduces downstream_tokens.userId as nullable, enforced by two partial unique indexes (one shared row per connection; one row per (connection, user)).
  • outbound/errors.ts (new): PerUserAuthorizationRequiredError + a 401 renderer (WWW-Authenticate + X-Authorize-Url) so MCP clients can surface the "connect your account" URL. (This is the file that was missing from fix(mcp-oauth): let external OAuth clients use aggregate/virtual MCP endpoints #4263 — sorry about that, and thanks @vibegui for catching it.)
  • headers.ts: per-user mode is strict — the caller's own token or an actionable 401, checked before the shared connection_token fallback so a stale admin token never leaks an identity to another member. The superuser fallback used for JWT minting is intentionally NOT applied to token resolution.
  • Circuit breaker: per-user-authorization-required is an expected state, not a downstream outage — it doesn't trip the breaker or auto-disable the connection (proxy + lazy-client + virtual-mcp routes).
  • Web: a "Per-user authentication" toggle on connection settings (dirty-field-guarded so unrelated saves never demote the policy), per-user copy on the OAuth state, i18n (en + pt-BR).

2. Providers without Dynamic Client Registration (e.g. Google-hosted MCPs)

drivemcp.googleapis.com/mcp/v1 breaks the standard flow twice: initialize/tools/list answer 200 unauthenticated (only tools/call 401s), and accounts.google.com has no DCR endpoint.

  • Detection: a 200 probe no longer short-circuits to "all set" — when the caller has no stored token, RFC 9728 protected-resource discovery runs; published metadata means the server declares itself OAuth-protected, so the authenticate flow is offered.
  • Pre-registered client: when the downstream AS lacks registration_endpoint and the connection carries oauth_config, the oauth-proxy advertises + answers a synthetic registration (token_endpoint_auth_method: "none"), pins the authorize client_id, guarantees a scope (config scopes → resource scopes_supported), appends Google's offline-consent params, and injects the client secret on the token leg server-side only — the secret never reaches the browser.

Context

Testing

  • bun run check green across all 13 workspaces; bun run fmt + bun run lint clean (0 errors, no new warnings).
  • Unit tests updated for the new (connection, user) keying (refresh-access-token, downstream-token integration, sandbox/start).
  • Live-validated on a self-hosted deployment (GKE + Cloud SQL): both paths produced per-user rows with working refresh tokens —
    • DCR path: a standard OAuth MCP server, per_user toggle → 401 challenge → authorize → token stored keyed to the member.
    • No-DCR path: drivemcp.googleapis.com/mcp/v1 with a pre-registered Google client via oauth_config → full flow incl. access_type=offline refresh token. (Google's MCP service itself is Developer Preview-gated; the OAuth flow and the raw Drive API were verified end-to-end with the minted per-user token.)

Follow-ups (not in this PR)

  • Chat/agent UX: when an agent subtask hits PerUserAuthorizationRequiredError, surface the authorize link in the thread instead of a generic failed step.
  • Editing connectors on the synthesized Decopilot agent 500s with an FK violation on conn_agg_parent_fk (COLLECTION_VIRTUAL_MCP_UPDATE inserts aggregations for a parent that has no connections row) — pre-existing, happy to file separately.

🤖 Generated with Claude Code


Summary by cubic

Adds per-user OAuth for downstream MCP connections and supports OAuth providers without Dynamic Client Registration. Previously all connections used a shared org token; in per-user mode the server uses the caller’s token and returns a 401 with an authorize URL when missing. Read caches for per-user connections now key by principal to prevent cross-user leaks.

  • Per-user OAuth

    • Migration 171 adds connections.auth_mode (default shared) and nullable downstream_tokens.userId, enforced by partial unique indexes (one shared row per connection; one row per (connectionId, userId)).
    • Token resolution in per-user mode uses only the caller’s token; if absent, returns 401 with WWW-Authenticate and X-Authorize-Url. No fallback to connection_token.
    • Circuit breaker does not trip on per-user authorization errors in proxy, virtual-mcp, and lazy client paths.
    • Read cache keys by principal for auth_mode: "per_user" to avoid serving one member’s results to another; unidentified callers get isolated scope.
    • Web adds a “Per-user authentication” toggle and guards auth_mode updates so unrelated edits don’t change policy; i18n (en, pt-BR).
    • Required action: run DB migrations; enable per-user mode per connection where user-scoped access is desired.
  • Providers without Dynamic Client Registration

    • Detect OAuth-protected resources via RFC 9728 when unauthenticated probes return 200 and no stored token; offer the authenticate flow.
    • When registration_endpoint is missing and oauth_config provides a pre-registered client, the proxy advertises synthetic register, pins client_id, ensures scope (prefer config scopes, fallback to resource scopes_supported), adds Google access_type=offline and prompt=consent, and injects client_secret server-side on token exchange.
    • Required action: for Google-hosted MCPs, set oauth_config.clientId, oauth_config.clientSecret, and oauth_config.scopes on the connection.

Written for commit 901fc7c. Summary will update on new commits.

Review in cubic

AriOliv and others added 5 commits August 12, 2026 21:07
Re-implementation of the per-user OAuth feature (originally
d35b291 on the apps/mesh layout, see decocms#3388 and
PR decocms#4263) on the current apps/api structure.

- Migration 170: connections.auth_mode ('shared'|'per_user', default
  'shared') + re-introduce downstream_tokens.userId (nullable FK,
  cascade) with partial unique indexes — one shared row per connection,
  one row per (connection, user). Reverts migration 017's assumption
  that all downstream tokens are connection-scoped.
- New outbound/errors.ts: PerUserAuthorizationRequiredError + a 401
  renderer (WWW-Authenticate + X-Authorize-Url) so MCP clients surface
  the "connect your account" URL.
- headers.ts: resolve the downstream token by auth_mode — per_user
  picks the caller's own token (never the superuser fallback) and
  throws the actionable error BEFORE the shared connection_token
  fallback, so a stale admin token never leaks to another member.
- token-refresh.ts: thread userId through getValidDownstreamAccessToken
  and refresh/delete the right row.
- downstream-token storage: (connectionId, userId) keying with
  userId optional (default null) so shared-mode callers are unchanged;
  deleteByConnection for connection removal.
- lazy-client: per-user-authorization-required is an expected state,
  not a downstream outage — don't trip the circuit breaker on it.
- virtual-mcp route: bubble the 401 up for aggregated tools.
- downstream-token routes: scope save/delete/status by auth_mode.
- SDK: auth_mode on ConnectionEntitySchema (default shared), omitted
  from create; kysely insert optional via ColumnType (DB default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AuthModeToggle switch on the connection settings tab (shared ↔
  per_user), dirty-field-guarded so unrelated saves never demote the
  policy.
- OAuthAuthenticationState gains a per-user variant ("Connect your
  account" + provider-audit-log copy).
- auth_mode on the connection form schema, hydrated from the entity.
- i18n: en + pt-br dictionary entries for all new copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The connection proxy's catch treated PerUserAuthorizationRequiredError
as a downstream failure — recordFailure + potential auto-disable —
hiding the very OAuth prompt the error asks for. Render the actionable
401 (WWW-Authenticate + authorize_url) before the failure path, matching
the virtual-mcp route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Google-hosted MCPs (e.g. drivemcp.googleapis.com) break the standard MCP
OAuth flow twice: initialize/tools-list answer 200 unauthenticated (only
tools/call 401s), and accounts.google.com has no DCR endpoint.

- Detection (web sdk): a 200 probe no longer short-circuits to "all
  set" — when the caller has no stored token, RFC 9728 protected-resource
  discovery runs; published metadata means the server declares itself
  OAuth-protected, so the authenticate flow is offered.
- Pre-registered client (oauth-proxy): when the downstream AS lacks a
  registration_endpoint and the connection carries `oauth_config`, the
  proxy synthesizes the DCR response (token_endpoint_auth_method: none),
  pins the authorize client_id, guarantees a scope (oauth_config.scopes,
  falling back to the resource's scopes_supported), appends Google's
  offline-consent params, and injects the client secret on the token
  exchange — server-side only, the secret never reaches the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…egistered clients

The MCP SDK aborts with 'Incompatible auth server: does not support
dynamic client registration' before attempting the flow when the AS
metadata lacks registration_endpoint. Advertise the proxy's own
/register (answered synthetically from connection.oauth_config) so
no-DCR providers like accounts.google.com complete the standard flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-pod read cache for read-only tool results, prompts/get and
resources/read was org-scoped for every connection. For
auth_mode: "per_user" connections that is a cross-user data leak:
results fetched with one member's token could be served from cache to
another member. Key the scope by the calling principal for per_user
connections (the follow-up the resolver comment already promised); an
unidentifiable caller gets an isolated bucket, never the org-shared one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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