feat(auth): per-user OAuth on downstream MCP connections + no-DCR provider support - #6023
Open
AriOliv wants to merge 6 commits into
Open
feat(auth): per-user OAuth on downstream MCP connections + no-DCR provider support#6023AriOliv wants to merge 6 commits into
AriOliv wants to merge 6 commits into
Conversation
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>
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.
Summary
Two related features, re-implemented on the current
apps/apilayout:1. Per-user OAuth on downstream MCP connections (
auth_mode: "shared" | "per_user")Connections gain an opt-in
per_usermode: 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).connections.auth_mode(default'shared', existing rows untouched) + re-introducesdownstream_tokens.userIdas 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 sharedconnection_tokenfallback 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.2. Providers without Dynamic Client Registration (e.g. Google-hosted MCPs)
drivemcp.googleapis.com/mcp/v1breaks the standard flow twice:initialize/tools/listanswer 200 unauthenticated (onlytools/call401s), andaccounts.google.comhas no DCR endpoint.registration_endpointand the connection carriesoauth_config, the oauth-proxy advertises + answers a synthetic registration (token_endpoint_auth_method: "none"), pins the authorizeclient_id, guarantees a scope (config scopes → resourcescopes_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
apps/meshlayout and missingoutbound/errors.ts); the other pieces of that PR (namespaceCode,metadata.oauthResource) were already cherry-picked in dc1dca2.Testing
bun run checkgreen across all 13 workspaces;bun run fmt+bun run lintclean (0 errors, no new warnings).(connection, user)keying (refresh-access-token,downstream-tokenintegration,sandbox/start).per_usertoggle → 401 challenge → authorize → token stored keyed to the member.drivemcp.googleapis.com/mcp/v1with a pre-registered Google client viaoauth_config→ full flow incl.access_type=offlinerefresh 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)
PerUserAuthorizationRequiredError, surface the authorize link in the thread instead of a generic failed step.conn_agg_parent_fk(COLLECTION_VIRTUAL_MCP_UPDATEinserts aggregations for a parent that has noconnectionsrow) — 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
connections.auth_mode(defaultshared) and nullabledownstream_tokens.userId, enforced by partial unique indexes (one shared row per connection; one row per(connectionId, userId)).WWW-AuthenticateandX-Authorize-Url. No fallback toconnection_token.proxy,virtual-mcp, and lazy client paths.auth_mode: "per_user"to avoid serving one member’s results to another; unidentified callers get isolated scope.auth_modeupdates so unrelated edits don’t change policy; i18n (en, pt-BR).Providers without Dynamic Client Registration
registration_endpointis missing andoauth_configprovides a pre-registered client, the proxy advertises syntheticregister, pinsclient_id, ensuresscope(prefer config scopes, fallback to resourcescopes_supported), adds Googleaccess_type=offlineandprompt=consent, and injectsclient_secretserver-side on token exchange.oauth_config.clientId,oauth_config.clientSecret, andoauth_config.scopeson the connection.Written for commit 901fc7c. Summary will update on new commits.