From 7a4b4530a658de2f2dacaea194e80a574e6c9c69 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Wed, 12 Aug 2026 18:51:42 -0300 Subject: [PATCH 1/6] feat(auth): per-user OAuth on downstream MCP connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implementation of the per-user OAuth feature (originally d35b291af9 on the apps/mesh layout, see decocms/studio#3388 and PR #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 --- apps/api/migrations/171-per-user-oauth.ts | 119 ++++++++++++++++++ apps/api/migrations/index.ts | 2 + apps/api/src/api/routes/dev-connection.ts | 1 + apps/api/src/api/routes/downstream-token.ts | 19 ++- apps/api/src/api/routes/virtual-mcp.ts | 9 ++ apps/api/src/mcp-clients/lazy-client.ts | 10 +- apps/api/src/mcp-clients/outbound/errors.ts | 71 +++++++++++ apps/api/src/mcp-clients/outbound/headers.ts | 44 ++++++- .../src/oauth/refresh-access-token.test.ts | 1 + apps/api/src/oauth/token-refresh.ts | 14 ++- apps/api/src/storage/connection.ts | 3 + .../downstream-token.integration.test.ts | 2 + apps/api/src/storage/downstream-token.ts | 69 ++++++++-- apps/api/src/storage/types.ts | 15 ++- apps/api/src/tools/connection/dev-assets.ts | 1 + apps/api/src/tools/sandbox/start.test.ts | 5 + apps/web/src/utils/extract-connection-data.ts | 1 + packages/shared/src/sdk/lib/constants.ts | 1 + packages/shared/src/sdk/types/connection.ts | 10 ++ 19 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 apps/api/migrations/171-per-user-oauth.ts create mode 100644 apps/api/src/mcp-clients/outbound/errors.ts diff --git a/apps/api/migrations/171-per-user-oauth.ts b/apps/api/migrations/171-per-user-oauth.ts new file mode 100644 index 0000000000..3649ecea14 --- /dev/null +++ b/apps/api/migrations/171-per-user-oauth.ts @@ -0,0 +1,119 @@ +/** + * Migration 171: Per-user OAuth on downstream MCP connections + * + * Adds `auth_mode` to `connections` and re-introduces `userId` on + * `downstream_tokens` as a nullable column. Migration 017 + * (017-downstream-token-remove-userid) had removed `userId` because all + * tokens were treated as connection-scoped. + * + * This change reintroduces user-scoped tokens as an opt-in per connection: + * + * - `connections.auth_mode = 'shared'` → one token per connection + * (legacy behaviour; `downstream_tokens.userId IS NULL`). + * - `connections.auth_mode = 'per_user'` → one token per + * (connection, user) pair; each member authorizes with their own + * identity, and audit logs at the downstream provider show the real + * person acting. + * + * Two partial unique indexes enforce the rule: + * - `downstream_tokens_shared_unique` on (connectionId) WHERE userId IS NULL + * - `downstream_tokens_per_user_unique` on (connectionId, userId) + * WHERE userId IS NOT NULL + * + * Existing rows are untouched: their `userId` becomes NULL (= shared) and + * the parent connection keeps `auth_mode = 'shared'` (column default). + */ + +import { sql, type Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + // 1. connections.auth_mode (default 'shared' for backward compatibility) + await db.schema + .alterTable("connections") + .addColumn("auth_mode", "text", (col) => col.notNull().defaultTo("shared")) + .execute(); + + await sql` + ALTER TABLE connections + ADD CONSTRAINT connections_auth_mode_check + CHECK (auth_mode IN ('shared', 'per_user')) + `.execute(db); + + // 2. downstream_tokens.userId (nullable, FK to user.id, cascade on delete) + await db.schema + .alterTable("downstream_tokens") + .addColumn("userId", "text", (col) => + col.references("user.id").onDelete("cascade"), + ) + .execute(); + + // 3. Drop legacy unique-on-connectionId index created by migration 017. + // Kysely's createIndex concatenated the column name onto the base, so the + // actual index in Postgres is `idx_downstream_tokens_connectionId` (quoted, + // with the camelCase column). DROP IF EXISTS keeps both spellings safe. + await sql`DROP INDEX IF EXISTS "idx_downstream_tokens_connectionId"`.execute( + db, + ); + await sql`DROP INDEX IF EXISTS idx_downstream_tokens_connection`.execute(db); + + // 4. New partial unique indexes — one shared row, many per-user rows. + await sql` + CREATE UNIQUE INDEX downstream_tokens_shared_unique + ON downstream_tokens ("connectionId") + WHERE "userId" IS NULL + `.execute(db); + + await sql` + CREATE UNIQUE INDEX downstream_tokens_per_user_unique + ON downstream_tokens ("connectionId", "userId") + WHERE "userId" IS NOT NULL + `.execute(db); + + // 5. Lookup index for "my connections" views. + await db.schema + .createIndex("idx_downstream_tokens_user") + .on("downstream_tokens") + .column("userId") + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop indexes/constraints in reverse order, then columns. + await db.schema.dropIndex("idx_downstream_tokens_user").execute(); + await db.schema.dropIndex("downstream_tokens_per_user_unique").execute(); + await db.schema.dropIndex("downstream_tokens_shared_unique").execute(); + + // Restore the legacy single-token-per-connection unique index. Any + // per-user rows must be deduplicated first; keep the most recently + // updated row (mirrors the strategy used in migration 017). + await sql` + DELETE FROM downstream_tokens + WHERE id NOT IN ( + SELECT id FROM ( + SELECT id, "connectionId", + ROW_NUMBER() OVER ( + PARTITION BY "connectionId" ORDER BY "updatedAt" DESC + ) AS rn + FROM downstream_tokens + ) ranked + WHERE rn = 1 + ) + `.execute(db); + + await sql` + CREATE UNIQUE INDEX "idx_downstream_tokens_connectionId" + ON downstream_tokens ("connectionId") + `.execute(db); + + await db.schema + .alterTable("downstream_tokens") + .dropColumn("userId") + .execute(); + + await sql` + ALTER TABLE connections + DROP CONSTRAINT connections_auth_mode_check + `.execute(db); + + await db.schema.alterTable("connections").dropColumn("auth_mode").execute(); +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index 5e8af3861e..06e2963544 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -169,6 +169,7 @@ import * as migration167taskboardrunretry from "./167-task-board-run-retry.ts"; import * as migration168orgreposync from "./168-org-repo-sync.ts"; import * as migration169taskboardmergefailedactivity from "./169-task-board-merge-failed-activity.ts"; import * as migration170taskboarditemrepo from "./170-task-board-item-repo.ts"; +import * as migration171peruseroauth from "./171-per-user-oauth.ts"; /** * Core migrations for the Studio application. @@ -367,6 +368,7 @@ const migrations: Record = { "169-task-board-merge-failed-activity": migration169taskboardmergefailedactivity, "170-task-board-item-repo": migration170taskboarditemrepo, + "171-per-user-oauth": migration171peruseroauth, }; export default migrations; diff --git a/apps/api/src/api/routes/dev-connection.ts b/apps/api/src/api/routes/dev-connection.ts index 8d98663e01..02bf37ffdd 100644 --- a/apps/api/src/api/routes/dev-connection.ts +++ b/apps/api/src/api/routes/dev-connection.ts @@ -159,6 +159,7 @@ export async function resolveDevConnection( connection_token: null, connection_headers: null, oauth_config: null, + auth_mode: "shared", configuration_state: null, configuration_scopes: null, metadata: { diff --git a/apps/api/src/api/routes/downstream-token.ts b/apps/api/src/api/routes/downstream-token.ts index 52be9aa8d4..68b4a61042 100644 --- a/apps/api/src/api/routes/downstream-token.ts +++ b/apps/api/src/api/routes/downstream-token.ts @@ -115,9 +115,17 @@ export const createDownstreamTokenRoutes = () => { // Create storage instance const tokenStorage = new DownstreamTokenStorage(ctx.db, ctx.vault); + // Pick the storage scope based on auth_mode: + // - per_user: this token belongs to the authenticated caller. + // - shared: this token is org-wide. The endpoint is admin-facing + // in this mode (any member with org-admin can authorise + // the bot account once). + const tokenUserId = connection.auth_mode === "per_user" ? userId : null; + // Save token const tokenData: DownstreamTokenData = { connectionId, + userId: tokenUserId, accessToken: body.accessToken, refreshToken: body.refreshToken ?? null, scope: body.scope ?? null, @@ -164,7 +172,10 @@ export const createDownstreamTokenRoutes = () => { } const tokenStorage = new DownstreamTokenStorage(ctx.db, ctx.vault); - await tokenStorage.delete(connectionId); + // Same scoping rule as the upsert: per-user callers only erase their + // own token; admins of a shared connection erase the org-wide one. + const tokenUserId = connection.auth_mode === "per_user" ? userId : null; + await tokenStorage.delete(connectionId, tokenUserId); return c.json({ success: true }); }); @@ -198,7 +209,11 @@ export const createDownstreamTokenRoutes = () => { } const tokenStorage = new DownstreamTokenStorage(ctx.db, ctx.vault); - const token = await tokenStorage.get(connectionId); + // Status reflects what THIS user can do. For per-user connections we + // look up their own token; for shared connections we look at the + // org-wide one. + const tokenUserId = connection.auth_mode === "per_user" ? userId : null; + const token = await tokenStorage.get(connectionId, tokenUserId); if (!token) { return c.json({ diff --git a/apps/api/src/api/routes/virtual-mcp.ts b/apps/api/src/api/routes/virtual-mcp.ts index f4f9667f3c..c2987c4333 100644 --- a/apps/api/src/api/routes/virtual-mcp.ts +++ b/apps/api/src/api/routes/virtual-mcp.ts @@ -21,6 +21,10 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { Hono } from "hono"; import { getUserId, type StudioContext } from "../../core/studio-context"; import { MCP_TOOL_CALL_TIMEOUT_MS } from "@/core/constants"; +import { + isPerUserAuthorizationRequiredError, + renderPerUserAuthorizationRequired, +} from "../../mcp-clients/outbound/errors"; import { createVirtualClientFrom } from "../../mcp-clients/virtual-mcp"; import { resolveDevConnection } from "./dev-connection"; import { readSandboxMap } from "../../tools/sandbox/sandbox-map"; @@ -237,6 +241,11 @@ export async function handleVirtualMcpRequest( ); } catch (error) { const err = error as Error; + // Per-user OAuth: a tool in the aggregated set requires the caller to + // authorise their own account first. Bubble the actionable 401 up. + if (isPerUserAuthorizationRequiredError(err)) { + return renderPerUserAuthorizationRequired(err); + } console.error("[virtual-mcp] Error handling virtual MCP request:", err); return c.json( { error: "Internal server error", message: err.message }, diff --git a/apps/api/src/mcp-clients/lazy-client.ts b/apps/api/src/mcp-clients/lazy-client.ts index fce0a7bae5..e735c801b1 100644 --- a/apps/api/src/mcp-clients/lazy-client.ts +++ b/apps/api/src/mcp-clients/lazy-client.ts @@ -30,6 +30,7 @@ import { recordSuccess, } from "./circuit-breaker"; import { clientFromConnection } from "./client"; +import { isPerUserAuthorizationRequiredError } from "./outbound/errors"; import { invalidateConnectionCaches } from "./mcp-cache-invalidation"; import { fetchWithCache, @@ -121,7 +122,14 @@ export function createLazyClient( // Clear cached promise so transient failures don't permanently // break the client — next call will retry the connection. realClientPromise = null; - recordFailure(connection.id); + // A per-user authorization prompt is an expected state for + // `auth_mode: "per_user"` connections without a token for the caller, + // NOT a downstream outage. Counting it as a failure trips the circuit + // breaker, which then 503s the connection — hiding the "Connect your + // account" UI and blocking the very OAuth the error is asking for. + if (!isPerUserAuthorizationRequiredError(err)) { + recordFailure(connection.id); + } throw err; }); } diff --git a/apps/api/src/mcp-clients/outbound/errors.ts b/apps/api/src/mcp-clients/outbound/errors.ts new file mode 100644 index 0000000000..b3f9ebdbd3 --- /dev/null +++ b/apps/api/src/mcp-clients/outbound/errors.ts @@ -0,0 +1,71 @@ +/** + * Errors thrown while resolving the outbound credentials for a downstream + * MCP request. + * + * The MCP proxy catches these and turns them into structured tool errors so + * Claude Desktop (or any other client) can surface an actionable message — + * typically a URL the user has to open to finish authorising. + */ + +/** + * Thrown by the token resolver when the connection is configured with + * `auth_mode = "per_user"` and the caller has not yet authorised the + * downstream provider with their own account. + * + * `authorizeUrl` is an in-Studio URL — opening it kicks off the OAuth + * proxy authorize flow on behalf of the current session. + */ +export class PerUserAuthorizationRequiredError extends Error { + public readonly connectionId: string; + public readonly authorizeUrl: string; + public readonly connectionTitle: string; + + constructor(args: { + connectionId: string; + connectionTitle: string; + authorizeUrl: string; + }) { + super( + `This connection requires per-user authorization. ` + + `Connect your "${args.connectionTitle}" account: ${args.authorizeUrl}`, + ); + this.name = "PerUserAuthorizationRequiredError"; + this.connectionId = args.connectionId; + this.connectionTitle = args.connectionTitle; + this.authorizeUrl = args.authorizeUrl; + } +} + +export function isPerUserAuthorizationRequiredError( + error: unknown, +): error is PerUserAuthorizationRequiredError { + return error instanceof PerUserAuthorizationRequiredError; +} + +/** + * Render a `PerUserAuthorizationRequiredError` as an HTTP 401 response that + * non-browser MCP clients (Claude Desktop, Cursor, etc.) can surface to the + * end user. The body is intentionally simple and the authorize URL is also + * mirrored in custom headers so dumb clients can grab it without parsing + * JSON. + */ +export function renderPerUserAuthorizationRequired( + error: PerUserAuthorizationRequiredError, +): Response { + const body = { + error: "per_user_authorization_required", + message: error.message, + connection_id: error.connectionId, + connection_title: error.connectionTitle, + authorize_url: error.authorizeUrl, + }; + + return new Response(JSON.stringify(body), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer realm="${error.connectionId}", error="per_user_authorization_required", authorize_url="${error.authorizeUrl}"`, + "X-Authorize-Url": error.authorizeUrl, + }, + }); +} diff --git a/apps/api/src/mcp-clients/outbound/headers.ts b/apps/api/src/mcp-clients/outbound/headers.ts index 6b47180759..deca57ff5a 100644 --- a/apps/api/src/mcp-clients/outbound/headers.ts +++ b/apps/api/src/mcp-clients/outbound/headers.ts @@ -15,6 +15,23 @@ import { ensureRepoScopedToken } from "@/oauth/github-mint"; import { getRepoScope } from "@decocms/shared/github-repo-scope"; import type { ConnectionEntity } from "@/tools/connection/schema"; import { writeStudioHeader } from "@/core/studio-headers"; +import { PerUserAuthorizationRequiredError } from "./errors"; + +/** + * Build the in-Studio URL a member must open to authorise their own account + * against a per-user OAuth connection. The OAuth proxy is mounted at + * `/api/:org/oauth-proxy/:connectionId/*` (see + * `apps/api/src/api/routes/oauth-proxy.ts`). + */ +function buildAuthorizeUrl(ctx: StudioContext, connectionId: string): string { + const orgSlug = ctx.organization?.slug; + const base = ctx.baseUrl.replace(/\/+$/, ""); + if (!orgSlug) { + // Unscoped fallback — still functional via the legacy unscoped mount. + return `${base}/oauth-proxy/${connectionId}/authorize`; + } + return `${base}/api/${orgSlug}/oauth-proxy/${connectionId}/authorize`; +} /** * Strip `__binding` from configuration state values before embedding in JWTs. @@ -162,6 +179,18 @@ async function _buildRequestHeaders( // This supports OAuth token refresh for connections that use OAuth let accessToken: string | null = null; + // Pick the downstream token identity by auth_mode: + // - shared: one row per connection (`userId = null`). + // - per_user: one row per (connection, real caller). The superuser + // fallback used for JWT minting is intentionally NOT applied + // here — we don't want a background process to silently act + // on behalf of the connection creator when each member is + // supposed to bring their own identity. + const isPerUser = connection.auth_mode === "per_user"; + const oauthUserId = isPerUser + ? (ctxUser?.id ?? ctx.auth.apiKey?.userId ?? null) + : null; + const repoScope = getRepoScope(connection); const useLegacyRepoMint = !!repoScope?.sourceConnectionId; @@ -180,6 +209,7 @@ async function _buildRequestHeaders( connectionId, connectionUrl: connection.connection_url, tokenStorage, + userId: oauthUserId, }); if (tokenResult.accessToken) { @@ -194,7 +224,19 @@ async function _buildRequestHeaders( // connection token below, so no second log here. } - // Fall back to connection token if no cached token + // Per-user mode is strict: if the caller has no token of their own at this + // point, surface an actionable error pointing to the authorize URL. We must + // do this BEFORE the shared `connection_token` fallback so a stale admin + // token never leaks an identity to a member that hasn't connected yet. + if (isPerUser && !accessToken) { + throw new PerUserAuthorizationRequiredError({ + connectionId, + connectionTitle: connection.title, + authorizeUrl: buildAuthorizeUrl(ctx, connectionId), + }); + } + + // Fall back to connection token if no cached token (shared mode only). if (!accessToken && connection.connection_token) { accessToken = connection.connection_token; } diff --git a/apps/api/src/oauth/refresh-access-token.test.ts b/apps/api/src/oauth/refresh-access-token.test.ts index c53bb6f3c4..e09567e747 100644 --- a/apps/api/src/oauth/refresh-access-token.test.ts +++ b/apps/api/src/oauth/refresh-access-token.test.ts @@ -5,6 +5,7 @@ import type { DownstreamToken } from "../storage/types"; const baseToken: DownstreamToken = { id: "dtok_test", connectionId: "conn_test", + userId: null, accessToken: "stale", refreshToken: "rt", scope: "repo", diff --git a/apps/api/src/oauth/token-refresh.ts b/apps/api/src/oauth/token-refresh.ts index eaf2bd3bc1..34e062e820 100644 --- a/apps/api/src/oauth/token-refresh.ts +++ b/apps/api/src/oauth/token-refresh.ts @@ -99,12 +99,13 @@ async function refreshAndStoreOnce( // must not nuke the user's auth — that turns every blip in the upstream // OAuth server into a forced manual reconnect. if (result.permanent === true) { - await tokenStorage.delete(token.connectionId); + await tokenStorage.delete(token.connectionId, token.userId); } return null; } await tokenStorage.upsert({ connectionId: token.connectionId, + userId: token.userId, accessToken: result.accessToken, refreshToken: result.refreshToken ?? token.refreshToken, scope: result.scope ?? token.scope, @@ -171,9 +172,14 @@ export async function getValidDownstreamAccessToken(params: { connectionUrl?: string | null; tokenStorage: DownstreamTokenStorage; bufferMs?: number; + /** + * Identity of the token to fetch. `null` (default) selects the shared + * connection-scoped token; a user id selects that member's per-user token. + */ + userId?: string | null; }): Promise { - const { connectionId, connectionUrl, tokenStorage } = params; - const token = await tokenStorage.get(connectionId); + const { connectionId, connectionUrl, tokenStorage, userId = null } = params; + const token = await tokenStorage.get(connectionId, userId); if (!token) return { state: "missing", accessToken: null }; const refreshable = canRefresh(token); @@ -186,7 +192,7 @@ export async function getValidDownstreamAccessToken(params: { } if (!refreshable) { - await tokenStorage.delete(connectionId); + await tokenStorage.delete(connectionId, userId); return { state: "expired_without_refresh", accessToken: null }; } diff --git a/apps/api/src/storage/connection.ts b/apps/api/src/storage/connection.ts index dda501df53..2e93fb4ddb 100644 --- a/apps/api/src/storage/connection.ts +++ b/apps/api/src/storage/connection.ts @@ -71,6 +71,7 @@ type RawConnectionRow = { connection_token: string | null; connection_headers: string | null; // JSON, envVars encrypted for STDIO oauth_config: string | OAuthConfig | null; + auth_mode: "shared" | "per_user"; configuration_state: string | null; // Encrypted configuration_scopes: string | string[] | null; metadata: string | Record | null; @@ -94,6 +95,7 @@ const TOP_LEVEL_COLUMNS = new Set([ "connection_type", "connection_url", // connection_token is intentionally excluded — sensitive + "auth_mode", "status", "created_at", "updated_at", @@ -685,6 +687,7 @@ export class ConnectionStorage implements ConnectionStoragePort { connection_token: decryptedToken, connection_headers: connectionParameters, oauth_config: parseJson(row.oauth_config), + auth_mode: row.auth_mode ?? "shared", configuration_state: decryptedConfigState, configuration_scopes: parseJson(row.configuration_scopes), metadata: parseJson>(row.metadata), diff --git a/apps/api/src/storage/downstream-token.integration.test.ts b/apps/api/src/storage/downstream-token.integration.test.ts index 086cc1bd65..392783b4ac 100644 --- a/apps/api/src/storage/downstream-token.integration.test.ts +++ b/apps/api/src/storage/downstream-token.integration.test.ts @@ -44,6 +44,7 @@ describe("DownstreamTokenStorage", () => { const token = { id: "test", connectionId: "c1", + userId: null, accessToken: "at", refreshToken: null, scope: null, @@ -64,6 +65,7 @@ describe("DownstreamTokenStorage", () => { const token = { id: "test", connectionId: "c1", + userId: null, accessToken: "at", refreshToken: null, scope: null, diff --git a/apps/api/src/storage/downstream-token.ts b/apps/api/src/storage/downstream-token.ts index dce556b5d4..6403305ba6 100644 --- a/apps/api/src/storage/downstream-token.ts +++ b/apps/api/src/storage/downstream-token.ts @@ -3,6 +3,15 @@ * * Handles CRUD operations for downstream MCP OAuth tokens. * Supports token caching and refresh for OAuth-enabled MCP connections. + * + * Tokens are keyed by (connection_id, user_id): + * - `userId = null` → shared token (one per connection, used when the + * connection's auth_mode is "shared") + * - `userId = ` → per-user token (one per (connection, user) pair, + * used when auth_mode is "per_user") + * + * The two cases are enforced by partial unique indexes on the database + * (see migration 171). */ import type { Kysely } from "kysely"; @@ -15,6 +24,11 @@ import { generatePrefixedId } from "@decocms/shared/utils/generate-id"; */ export interface DownstreamTokenData { connectionId: string; + /** + * Null (or omitted) for shared tokens; a user id for per-user tokens. + * Optional so shared-token callers don't have to spell out `userId: null`. + */ + userId?: string | null; accessToken: string; refreshToken: string | null; scope: string | null; @@ -34,12 +48,20 @@ export class DownstreamTokenStorage { private vault: CredentialVault, ) {} - async get(connectionId: string): Promise { - const row = await this.db + async get( + connectionId: string, + userId: string | null = null, + ): Promise { + const base = this.db .selectFrom("downstream_tokens") .selectAll() - .where("connectionId", "=", connectionId) - .executeTakeFirst(); + .where("connectionId", "=", connectionId); + + const query = userId + ? base.where("userId", "=", userId) + : base.where("userId", "is", null); + + const row = await query.executeTakeFirst(); if (!row) return null; @@ -58,14 +80,20 @@ export class DownstreamTokenStorage { ? await this.vault.encrypt(data.clientSecret) : null; + const tokenUserId = data.userId ?? null; + // Use transaction to prevent race conditions during upsert return await this.db.transaction().execute(async (trx) => { - // Check for existing token within transaction - const existing = await trx + // Look up existing token for this (connection, user) pair + const existingBase = trx .selectFrom("downstream_tokens") .select(["id", "createdAt"]) - .where("connectionId", "=", data.connectionId) - .executeTakeFirst(); + .where("connectionId", "=", data.connectionId); + + const existing = await (tokenUserId + ? existingBase.where("userId", "=", tokenUserId) + : existingBase.where("userId", "is", null) + ).executeTakeFirst(); if (existing) { // Update existing token @@ -87,6 +115,7 @@ export class DownstreamTokenStorage { return { id: existing.id, connectionId: data.connectionId, + userId: tokenUserId, accessToken: data.accessToken, refreshToken: data.refreshToken, scope: data.scope, @@ -107,6 +136,7 @@ export class DownstreamTokenStorage { .values({ id, connectionId: data.connectionId, + userId: tokenUserId, accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, scope: data.scope, @@ -122,6 +152,7 @@ export class DownstreamTokenStorage { return { id, connectionId: data.connectionId, + userId: tokenUserId, accessToken: data.accessToken, refreshToken: data.refreshToken, scope: data.scope, @@ -135,7 +166,25 @@ export class DownstreamTokenStorage { }); } - async delete(connectionId: string): Promise { + async delete( + connectionId: string, + userId: string | null = null, + ): Promise { + const base = this.db + .deleteFrom("downstream_tokens") + .where("connectionId", "=", connectionId); + + await (userId + ? base.where("userId", "=", userId) + : base.where("userId", "is", null) + ).execute(); + } + + /** + * Delete every token attached to a connection (shared + all per-user + * tokens). Used when a connection is removed. + */ + async deleteByConnection(connectionId: string): Promise { await this.db .deleteFrom("downstream_tokens") .where("connectionId", "=", connectionId) @@ -174,6 +223,7 @@ export class DownstreamTokenStorage { private async decryptToken(row: { id: string; connectionId: string; + userId: string | null; accessToken: string; refreshToken: string | null; scope: string | null; @@ -195,6 +245,7 @@ export class DownstreamTokenStorage { return { id: row.id, connectionId: row.connectionId, + userId: row.userId, accessToken, refreshToken, scope: row.scope, diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index e7a06b5730..e27e0cd43d 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -228,6 +228,17 @@ export interface MCPConnectionTable { // OAuth config for downstream MCP (if MCP supports OAuth) oauth_config: JsonObject | null; + // Authentication mode: + // - "shared": one downstream token shared across the org (legacy). + // - "per_user": each member authorises with their own account; tokens + // are keyed by (connection_id, user_id). + // Insert is optional — the column defaults to 'shared' (migration 171). + auth_mode: ColumnType< + "shared" | "per_user", + "shared" | "per_user" | undefined, + "shared" | "per_user" + >; + // Connection-provided configuration state configuration_state: string | null; // Encrypted JSON state configuration_scopes: JsonArray | null; // Array of scope strings @@ -522,7 +533,8 @@ export interface OAuthRefreshTokenTable { */ export interface DownstreamTokenTable { id: string; // Primary key - connectionId: string; // Foreign key (unique - one token per connection) + connectionId: string; + userId: string | null; // NULL = shared token for the connection accessToken: string; // Encrypted refreshToken: string | null; // Encrypted scope: string | null; @@ -612,6 +624,7 @@ export interface OAuthRefreshToken { export interface DownstreamToken { id: string; connectionId: string; + userId: string | null; accessToken: string; refreshToken: string | null; scope: string | null; diff --git a/apps/api/src/tools/connection/dev-assets.ts b/apps/api/src/tools/connection/dev-assets.ts index 8f27be20d0..f48512458c 100644 --- a/apps/api/src/tools/connection/dev-assets.ts +++ b/apps/api/src/tools/connection/dev-assets.ts @@ -80,6 +80,7 @@ export function createDevAssetsConnectionEntity( connection_token: null, connection_headers: null, oauth_config: null, + auth_mode: "shared", configuration_state: null, configuration_scopes: null, metadata: connectionData.metadata ?? null, diff --git a/apps/api/src/tools/sandbox/start.test.ts b/apps/api/src/tools/sandbox/start.test.ts index 8c100cbdaa..0473c2acbe 100644 --- a/apps/api/src/tools/sandbox/start.test.ts +++ b/apps/api/src/tools/sandbox/start.test.ts @@ -87,6 +87,7 @@ const mockTokenGet = mock( async (_connectionId: string): Promise => ({ id: "dtok_1", connectionId: "conn_github_1", + userId: null, accessToken: "ghu_test_token_123", refreshToken: null, scope: null, @@ -116,6 +117,7 @@ mock.module("../../storage/downstream-token", () => ({ return { id: "dtok_1", connectionId: data.connectionId, + userId: data.userId ?? null, accessToken: data.accessToken, refreshToken: data.refreshToken, scope: data.scope, @@ -315,6 +317,7 @@ describe("SANDBOX_START", () => { mockTokenGet.mockImplementation(async () => ({ id: "dtok_1", connectionId: "conn_github_1", + userId: null, accessToken: "ghu_test_token_123", refreshToken: null, scope: null, @@ -701,6 +704,7 @@ describe("SANDBOX_START", () => { mockTokenGet.mockImplementation(async () => ({ id: "dtok_1", connectionId: "conn_github_1", + userId: null, accessToken: "ghu_stale_token", refreshToken: "ghr_refresh_123", scope: "repo", @@ -940,6 +944,7 @@ describe("SANDBOX_START", () => { mockTokenGet.mockImplementation(async () => ({ id: "dtok_1", connectionId: "conn_github_1", + userId: null, accessToken: "ghu_stale_token", refreshToken: "ghr_refresh_123", scope: "repo", diff --git a/apps/web/src/utils/extract-connection-data.ts b/apps/web/src/utils/extract-connection-data.ts index 05fd777547..64e8afde74 100644 --- a/apps/web/src/utils/extract-connection-data.ts +++ b/apps/web/src/utils/extract-connection-data.ts @@ -202,6 +202,7 @@ export function extractConnectionData( connection_token: null as string | null, connection_headers: connectionHeaders, oauth_config: oauthConfig, + auth_mode: "shared" as const, configuration_state: configState ?? null, configuration_scopes: configScopes ?? null, metadata: { diff --git a/packages/shared/src/sdk/lib/constants.ts b/packages/shared/src/sdk/lib/constants.ts index 873bbc30f8..59c1539717 100644 --- a/packages/shared/src/sdk/lib/constants.ts +++ b/packages/shared/src/sdk/lib/constants.ts @@ -481,6 +481,7 @@ export function getWellKnownDecopilotConnection( connection_token: null, connection_headers: null, oauth_config: null, + auth_mode: "shared", configuration_state: null, configuration_scopes: null, metadata: { diff --git a/packages/shared/src/sdk/types/connection.ts b/packages/shared/src/sdk/types/connection.ts index e1d4bdaeb3..5c3444d1dd 100644 --- a/packages/shared/src/sdk/types/connection.ts +++ b/packages/shared/src/sdk/types/connection.ts @@ -130,6 +130,15 @@ export const ConnectionEntitySchema = z.object({ oauth_config: OAuthConfigSchema.nullable().describe("OAuth configuration"), + auth_mode: z + .enum(["shared", "per_user"]) + .default("shared") + .describe( + "Whether the downstream OAuth token is shared across the org (`shared`) " + + "or scoped per member (`per_user`). When `per_user`, each member must " + + "authorize with their own account before they can call tools.", + ), + // New configuration fields (snake_case) configuration_state: z .record(z.string(), z.unknown()) @@ -171,6 +180,7 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({ tools: true, bindings: true, status: true, + auth_mode: true, }) .partial({ id: true, From 5dbadef8329083f79eab464fd64f86686aa11128 Mon Sep 17 00:00:00 2001 From: AriOliv Date: Wed, 12 Aug 2026 18:58:09 -0300 Subject: [PATCH 2/6] feat(web): per-user auth toggle on connection settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/details/connection/index.tsx | 21 +++++- .../details/connection/settings-tab/index.tsx | 75 +++++++++++++++++-- .../details/connection/settings-tab/schema.ts | 6 ++ apps/web/src/i18n/en/details.ts | 9 +++ apps/web/src/i18n/pt-br/details.ts | 9 +++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/details/connection/index.tsx b/apps/web/src/components/details/connection/index.tsx index 2ea75aad78..bbe664d79b 100644 --- a/apps/web/src/components/details/connection/index.tsx +++ b/apps/web/src/components/details/connection/index.tsx @@ -108,6 +108,7 @@ function connectionToFormValues( title: connection.title, description: connection.description ?? "", icon: connection.icon ?? null, + auth_mode: connection.auth_mode ?? "shared", configuration_state: connection.configuration_state ?? {}, configuration_scopes: scopes || connection.configuration_scopes || [], }; @@ -162,10 +163,18 @@ function connectionToFormValues( } /** - * Convert form values back to connection entity update + * Convert form values back to connection entity update. + * + * `dirtyFields` (from react-hook-form's `formState`) controls which fields + * actually land in the payload. Without this guard, every save would + * overwrite policy fields like `auth_mode` with whatever stale value + * `values:` had hydrated the form with — quietly demoting a `per_user` + * connection back to `shared` when the user only meant to update an + * unrelated field. */ function formValuesToConnectionUpdate( data: ConnectionFormData, + dirtyFields: Partial> = {}, ): Partial { let connectionType: "HTTP" | "SSE" | "Websocket" | "STDIO"; let connectionUrl: string | null = null; @@ -203,6 +212,11 @@ function formValuesToConnectionUpdate( icon: data.icon ?? null, connection_type: connectionType, connection_url: connectionUrl, + // Only send `auth_mode` when the user actually toggled it. Otherwise + // the form's hydrated value (which can briefly disagree with the DB + // during the first render or a race with a server refetch) would + // silently overwrite the canonical setting. + ...(dirtyFields.auth_mode ? { auth_mode: data.auth_mode } : {}), ...(connectionToken && { connection_token: connectionToken }), ...(connectionParameters && { connection_headers: connectionParameters }), configuration_state: data.configuration_state ?? null, @@ -289,7 +303,10 @@ function ConnectionInspectorViewWithConnection({ if (!isValid) return; const data = form.getValues(); - const updateData = formValuesToConnectionUpdate(data); + const updateData = formValuesToConnectionUpdate( + data, + form.formState.dirtyFields, + ); const idToUpdate = configureInstance?.id ?? connectionId; await connectionActions.update.mutateAsync({ id: idToUpdate, diff --git a/apps/web/src/components/details/connection/settings-tab/index.tsx b/apps/web/src/components/details/connection/settings-tab/index.tsx index 42a13d3205..881a10b16e 100644 --- a/apps/web/src/components/details/connection/settings-tab/index.tsx +++ b/apps/web/src/components/details/connection/settings-tab/index.tsx @@ -7,6 +7,7 @@ import { type ConnectionEntity, } from "@/sdk"; import { Button } from "@decocms/ui/components/button.tsx"; +import { Switch } from "@decocms/ui/components/switch.tsx"; import { Key01, File06, Loading01 } from "@untitledui/icons"; import { Suspense } from "react"; import { useWatch, type useForm } from "react-hook-form"; @@ -14,6 +15,41 @@ import { useT } from "@/i18n/use-t.ts"; import { McpConfigurationForm } from "./mcp-configuration-form"; import type { ConnectionFormData } from "./schema"; +/** + * Switch that lets an admin flip a connection between "shared" (one + * org-wide downstream token) and "per_user" (each member authorises with + * their own account). Always shown at the top of the settings tab so the + * policy stays visible regardless of auth state. + */ +function AuthModeToggle({ + form, +}: { + form: ReturnType>; +}) { + const t = useT(); + const value = useWatch({ control: form.control, name: "auth_mode" }); + return ( +
+
+
+ {t("details.settingsTab.perUserAuthTitle")} +
+

+ {t("details.settingsTab.perUserAuthDescription")} +

+
+ + form.setValue("auth_mode", checked ? "per_user" : "shared", { + shouldDirty: true, + }) + } + /> +
+ ); +} + interface SettingsTabProps { connection: ConnectionEntity; form: ReturnType>; @@ -60,26 +96,40 @@ function useMcpConfiguration(connectionId: string) { interface OAuthAuthenticationStateProps { onAuthenticate: () => void | Promise; buttonText?: string; + isPerUser?: boolean; + connectionTitle?: string; } export function OAuthAuthenticationState({ onAuthenticate, - buttonText = "Authenticate", + buttonText, + isPerUser = false, + connectionTitle, }: OAuthAuthenticationStateProps) { const t = useT(); + const title = connectionTitle ?? t("details.settingsTab.yourAccount"); + const headline = isPerUser + ? t("details.settingsTab.connectYourAccount") + : t("details.settingsTab.authenticationRequired"); + const description = isPerUser + ? t("details.settingsTab.perUserOauthDescription", { title }) + : t("details.settingsTab.oauthAuthenticationDescription"); + const cta = + buttonText ?? + (isPerUser + ? t("details.settingsTab.connectAccountCta", { title }) + : t("details.settingsTab.authenticate")); return (
-

- {t("details.settingsTab.authenticationRequired")} -

+

{headline}

- {t("details.settingsTab.oauthAuthenticationDescription")} + {description}

@@ -227,7 +277,13 @@ function SettingsTabContent(props: SettingsTabProps) { return ; } if (supportsOAuth) { - return ; + return ( + + ); } return ( +
+
+ +
); diff --git a/apps/web/src/components/details/connection/settings-tab/schema.ts b/apps/web/src/components/details/connection/settings-tab/schema.ts index 51d06388b7..f44b610595 100644 --- a/apps/web/src/components/details/connection/settings-tab/schema.ts +++ b/apps/web/src/components/details/connection/settings-tab/schema.ts @@ -28,6 +28,12 @@ export const connectionFormSchema = z stdio_cwd: z.string().optional(), // Shared: Environment variables for both NPX and STDIO env_vars: z.array(envVarSchema).optional(), + // Per-user OAuth: when enabled, each member of the org authorises with + // their own downstream account. Tokens are scoped to (connection, user). + // Required at the form level — defaults to "shared" via useForm + // `defaultValues`. Keeping it required keeps zod's input/output types in + // sync so react-hook-form's resolver type stays happy. + auth_mode: z.enum(["shared", "per_user"]), // Preserved fields configuration_scopes: z.array(z.string()).nullable().optional(), configuration_state: z diff --git a/apps/web/src/i18n/en/details.ts b/apps/web/src/i18n/en/details.ts index 72c573e6f8..67c8847695 100644 --- a/apps/web/src/i18n/en/details.ts +++ b/apps/web/src/i18n/en/details.ts @@ -111,7 +111,10 @@ export const details = { "details.prompt.notFoundTitle": "Prompt not found", "details.prompt.title": "Title", "details.prompt.titlePlaceholder": "Untitled prompt", + "details.settingsTab.authenticate": "Authenticate", "details.settingsTab.authenticationRequired": "Authentication Required", + "details.settingsTab.connectAccountCta": "Connect {title}", + "details.settingsTab.connectYourAccount": "Connect your account", "details.settingsTab.manualAuthenticationDescription": "This server requires an API key or token that must be configured manually. Check the server's documentation for instructions on obtaining credentials.", "details.settingsTab.manualAuthenticationRequired": @@ -120,11 +123,17 @@ export const details = { "No additional configuration is needed. Everything is ready to go.", "details.settingsTab.oauthAuthenticationDescription": "This connection requires OAuth authentication to access resources.", + "details.settingsTab.perUserAuthDescription": + "When enabled, each member of your org authorises this connection with their own account. Audit logs at the provider show the real person acting. Disable to share a single org-wide token.", + "details.settingsTab.perUserAuthTitle": "Per-user authentication", + "details.settingsTab.perUserOauthDescription": + "This connection runs each tool call as the member who triggered it. Authorise with your own {title} account to start using these tools — your activity will show up in the provider's audit log under your name.", "details.settingsTab.serverAllSet": "This server is all set!", "details.settingsTab.serverError": "Server Error", "details.settingsTab.serverErrorDescription": "The MCP server is currently experiencing issues. Please try again later or check the server's status.", "details.settingsTab.viewReadme": "View README", + "details.settingsTab.yourAccount": "your account", "details.tool.cancel": "Cancel", "details.tool.connectionNotFound": "Connection not found", "details.tool.connectionNotFoundMessage": diff --git a/apps/web/src/i18n/pt-br/details.ts b/apps/web/src/i18n/pt-br/details.ts index 0e49b9a1ab..f9fe938373 100644 --- a/apps/web/src/i18n/pt-br/details.ts +++ b/apps/web/src/i18n/pt-br/details.ts @@ -115,7 +115,10 @@ export const details = { "details.prompt.notFoundTitle": "Prompt não encontrado", "details.prompt.title": "Título", "details.prompt.titlePlaceholder": "Prompt sem título", + "details.settingsTab.authenticate": "Autenticar", "details.settingsTab.authenticationRequired": "Autenticação Obrigatória", + "details.settingsTab.connectAccountCta": "Conectar {title}", + "details.settingsTab.connectYourAccount": "Conecte sua conta", "details.settingsTab.manualAuthenticationDescription": "Este servidor requer uma chave de API ou token que deve ser configurado manualmente. Verifique a documentação do servidor para instruções sobre como obter credenciais.", "details.settingsTab.manualAuthenticationRequired": @@ -124,11 +127,17 @@ export const details = { "Nenhuma configuração adicional é necessária. Tudo está pronto para começar.", "details.settingsTab.oauthAuthenticationDescription": "Esta conexão requer autenticação OAuth para acessar recursos.", + "details.settingsTab.perUserAuthDescription": + "Quando ativado, cada membro da organização autoriza esta conexão com a própria conta. Os logs de auditoria do provedor mostram a pessoa real agindo. Desative para compartilhar um único token da organização.", + "details.settingsTab.perUserAuthTitle": "Autenticação por usuário", + "details.settingsTab.perUserOauthDescription": + "Esta conexão executa cada chamada de ferramenta como o membro que a acionou. Autorize com a sua própria conta {title} para começar a usar estas ferramentas — sua atividade aparecerá no log de auditoria do provedor em seu nome.", "details.settingsTab.serverAllSet": "Este servidor está pronto!", "details.settingsTab.serverError": "Erro no Servidor", "details.settingsTab.serverErrorDescription": "O servidor MCP está enfrentando problemas no momento. Tente novamente mais tarde ou verifique o status do servidor.", "details.settingsTab.viewReadme": "Ver README", + "details.settingsTab.yourAccount": "sua conta", "details.tool.cancel": "Cancelar", "details.tool.connectionNotFound": "Conexão não encontrada", "details.tool.connectionNotFoundMessage": From c3e67657fc92f62917cca02b0aea3239dbaa4e1b Mon Sep 17 00:00:00 2001 From: AriOliv Date: Wed, 12 Aug 2026 19:44:27 -0300 Subject: [PATCH 3/6] fix(proxy): return per-user 401 challenge without tripping the breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/api/routes/proxy.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/api/src/api/routes/proxy.ts b/apps/api/src/api/routes/proxy.ts index 08718ac597..a94179b9ff 100644 --- a/apps/api/src/api/routes/proxy.ts +++ b/apps/api/src/api/routes/proxy.ts @@ -30,6 +30,10 @@ import { recordFailure, recordSuccess, } from "@/mcp-clients/circuit-breaker"; +import { + isPerUserAuthorizationRequiredError, + renderPerUserAuthorizationRequired, +} from "@/mcp-clients/outbound/errors"; import { getConnectionCircuitStore } from "@/mcp-clients/connection-circuit-store"; import { CONNECTION_ERROR_REPROBE_COOLDOWN_MS } from "@/core/constants"; import { peekRpcMethod, probeDecision } from "./proxy-handshake"; @@ -413,6 +417,15 @@ export const createProxyRoutes = () => { if (error instanceof CircuitOpenError) { throw error; } + // Per-user authorization required is an EXPECTED state for + // `auth_mode: "per_user"` connections when the caller has no token yet + // — not a downstream outage. Return the 401 challenge (with authorize + // URL) WITHOUT tripping the breaker or auto-disabling the connection. + // Handled before recordFailure below, which would otherwise open the + // circuit and 503 the connection (hiding the OAuth the error asks for). + if (isPerUserAuthorizationRequiredError(error)) { + return renderPerUserAuthorizationRequired(error); + } // Check if this is an auth error - if so, return appropriate 401 // Note: This only applies to HTTP connections const connection = await ctx.storage.connections.findById( From 78f9518fe32246df6e81a8430b3cb9ec4093b18b Mon Sep 17 00:00:00 2001 From: AriOliv Date: Wed, 12 Aug 2026 20:26:32 -0300 Subject: [PATCH 4/6] feat(oauth-proxy): support providers without Dynamic Client Registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/api/app.ts | 86 +++++++++++++++++++++++++++++++ apps/web/src/sdk/lib/mcp-oauth.ts | 22 ++++++++ 2 files changed, 108 insertions(+) diff --git a/apps/api/src/api/app.ts b/apps/api/src/api/app.ts index 9e71e5c262..336c7c4a9b 100644 --- a/apps/api/src/api/app.ts +++ b/apps/api/src/api/app.ts @@ -465,12 +465,20 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { : undefined; const resourceIndicator = resourceOverride ?? connection.connection_url; + // Scopes advertised by the downstream resource (RFC 9728). Used as the + // authorize-leg scope fallback for pre-registered-client providers (e.g. + // Google) that reject authorization requests without an explicit scope. + let resourceScopes: string[] | undefined; if (resourceRes.ok) { // Origin has Protected Resource Metadata - use authorization_servers from it const resourceData = (await resourceRes.json()) as { authorization_servers?: string[]; + scopes_supported?: string[]; }; originAuthServer = resourceData.authorization_servers?.[0]; + if (Array.isArray(resourceData.scopes_supported)) { + resourceScopes = resourceData.scopes_supported; + } } // Fall back to origin root if: @@ -493,6 +501,17 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { registration_endpoint?: string; }; + // Pre-registered OAuth client for providers WITHOUT Dynamic Client + // Registration (e.g. accounts.google.com). Operators store the client in + // `connection.oauth_config`; the proxy then (a) synthesizes the DCR + // response, (b) pins the authorize client_id, and (c) injects the client + // secret on the token leg — the secret never reaches the browser. + const preRegisteredClient = + connection.oauth_config?.clientId && + connection.oauth_config.grantType !== "client_credentials" + ? connection.oauth_config + : undefined; + // Map endpoint name to URL let originEndpointUrl: string | undefined; if (endpoint === "authorize") { @@ -501,6 +520,31 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { originEndpointUrl = endpoints.token_endpoint; } else if (endpoint === "register") { originEndpointUrl = endpoints.registration_endpoint; + // No downstream DCR but an operator-supplied client exists: answer the + // registration ourselves so the standard MCP SDK flow proceeds. The + // response advertises `token_endpoint_auth_method: "none"` — the SDK + // must NOT try to authenticate the client itself; the proxy's token leg + // injects the real secret server-side. + if (!originEndpointUrl && preRegisteredClient) { + let redirectUris: string[] = []; + try { + const body = (await c.req.json()) as { redirect_uris?: string[] }; + if (Array.isArray(body.redirect_uris)) + redirectUris = body.redirect_uris; + } catch { + // No/invalid body — fine, redirect_uris stays empty. + } + return c.json( + { + client_id: preRegisteredClient.clientId, + redirect_uris: redirectUris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }, + 201, + ); + } } if (!originEndpointUrl) { @@ -554,6 +598,32 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { targetUrl.searchParams.set("resource", resourceIndicator); } + // Pre-registered client (no-DCR providers): pin the client_id and make + // sure a scope is present — Google rejects authorization requests with + // no scope. Prefer the operator's `oauth_config.scopes`, falling back to + // the resource's advertised `scopes_supported`. + if (preRegisteredClient) { + targetUrl.searchParams.set("client_id", preRegisteredClient.clientId); + if (!targetUrl.searchParams.get("scope")) { + const scopes = preRegisteredClient.scopes?.length + ? preRegisteredClient.scopes + : (resourceScopes ?? []); + if (scopes.length) { + targetUrl.searchParams.set("scope", scopes.join(" ")); + } + } + // Google only issues a refresh_token with explicit offline consent. + // Harmless no-ops for other providers, so gate on the Google AS. + if (targetUrl.hostname.endsWith("google.com")) { + if (!targetUrl.searchParams.has("access_type")) { + targetUrl.searchParams.set("access_type", "offline"); + } + if (!targetUrl.searchParams.has("prompt")) { + targetUrl.searchParams.set("prompt", "consent"); + } + } + } + // Add smart OAuth params for deco-hosted MCPs to skip org/project selection // Wrapped in try-catch to ensure OAuth redirect proceeds even if smart params fail if (isDecoHostedMcp(connection.connection_url)) { @@ -626,6 +696,22 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { if (formData.has("resource")) { formData.set("resource", resourceIndicator); } + // Pre-registered client (no-DCR providers): the browser flow runs with + // `token_endpoint_auth_method: "none"`, so the exchange arrives without + // credentials. Inject them here — server-side only — so providers that + // require a confidential client (Google) accept the exchange. + if (preRegisteredClient) { + if (!formData.get("client_id")) { + formData.set("client_id", preRegisteredClient.clientId); + } + if ( + preRegisteredClient.clientSecret && + !formData.get("client_secret") && + formData.get("client_id") === preRegisteredClient.clientId + ) { + formData.set("client_secret", preRegisteredClient.clientSecret); + } + } const cidRaw = formData.get("client_id"); const csRaw = formData.get("client_secret"); if (typeof cidRaw === "string" && cidRaw) capturedClientId = cidRaw; diff --git a/apps/web/src/sdk/lib/mcp-oauth.ts b/apps/web/src/sdk/lib/mcp-oauth.ts index 4dddbb8e9c..e3d76ec6a6 100644 --- a/apps/web/src/sdk/lib/mcp-oauth.ts +++ b/apps/web/src/sdk/lib/mcp-oauth.ts @@ -920,6 +920,28 @@ export async function isConnectionAuthenticated({ ? await checkOAuthTokenStatus(connectionId, orgSlug, apiBaseUrl) : { hasToken: false }; + // Some servers (e.g. Google-hosted MCPs) accept unauthenticated + // initialize/tools-list and only 401 on tools/call — so a 200 here + // does NOT prove the connection is usable. RFC 9728 metadata is the + // server explicitly declaring "this resource is OAuth-protected": + // when it exists and the caller has no stored token yet, surface the + // authenticate flow instead of reporting the connection as all set. + if (!oauthStatus.hasToken) { + try { + const resourceMetadata = + await discoverOAuthProtectedResourceMetadata(url); + if (resourceMetadata?.authorization_servers?.length) { + return { + isAuthenticated: false, + supportsOAuth: true, + hasOAuthToken: false, + }; + } + } catch { + // No metadata published — genuinely unauthenticated server. + } + } + return { isAuthenticated: true, // When authenticated, we can't determine OAuth support from the response From 484c58fd46918ebf9b67d15447409a8b64aa688d Mon Sep 17 00:00:00 2001 From: AriOliv Date: Wed, 12 Aug 2026 20:38:29 -0300 Subject: [PATCH 5/6] fix(oauth-proxy): advertise synthetic registration_endpoint for pre-registered 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 --- apps/api/src/api/routes/oauth-proxy.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/api/src/api/routes/oauth-proxy.ts b/apps/api/src/api/routes/oauth-proxy.ts index 2a71a9c9a7..3bf9b24f47 100644 --- a/apps/api/src/api/routes/oauth-proxy.ts +++ b/apps/api/src/api/routes/oauth-proxy.ts @@ -801,6 +801,21 @@ const authServerMetadataHandler = async (c: { // Rewrite OAuth endpoint URLs to go through our proxy const rewrittenData = rewriteAuthServerMetadata(data, proxyBase); + // Providers without Dynamic Client Registration (e.g. accounts.google.com) + // publish no registration_endpoint, and the MCP SDK aborts with + // "Incompatible auth server" before ever attempting the flow. When the + // operator stored a pre-registered client on the connection + // (`oauth_config.clientId`), advertise the proxy's own register endpoint — + // the proxy answers it by synthesizing the DCR response from that config + // (see the register branch in the oauth-proxy dispatcher). + if ( + !rewrittenData.registration_endpoint && + connection.oauth_config?.clientId && + connection.oauth_config.grantType !== "client_credentials" + ) { + rewrittenData.registration_endpoint = `${proxyBase}/register`; + } + return new Response(JSON.stringify(rewrittenData), { status: 200, headers: { "Content-Type": "application/json" }, From 901fc7c5e80dfdb5deda1983649c802aa13353ee Mon Sep 17 00:00:00 2001 From: AriOliv Date: Thu, 13 Aug 2026 17:28:16 -0300 Subject: [PATCH 6/6] fix(mcp-cache): key read cache by principal for per_user connections 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 --- apps/api/src/mcp-clients/lazy-client.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/api/src/mcp-clients/lazy-client.ts b/apps/api/src/mcp-clients/lazy-client.ts index e735c801b1..4c9f909a46 100644 --- a/apps/api/src/mcp-clients/lazy-client.ts +++ b/apps/api/src/mcp-clients/lazy-client.ts @@ -70,10 +70,21 @@ async function isReadOnlyTool( /** * Cache scope for a connection's read-only results. Defaults to "org" (shared - * across all members). The per-connection "user" opt-out (key by principal) is - * a follow-up; the cache already keys by scope, so it's a resolver change only. + * across all members). Connections with `auth_mode: "per_user"` are keyed by + * the calling principal instead: their downstream results are fetched with + * the CALLER's own token (each member may see different data for the same + * arguments), so an org-shared cache entry would leak one member's results + * to another. An unidentifiable caller gets an isolated bucket rather than + * the org-shared one — never widen scope on missing identity. */ -function resolveReadCacheScope(): ReadCacheScope { +function resolveReadCacheScope( + connection: ConnectionEntity, + ctx: StudioContext, +): ReadCacheScope { + if (connection.auth_mode === "per_user") { + const userId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId; + return { kind: "user", userId: userId ?? "unidentified" }; + } return { kind: "org" }; } @@ -239,7 +250,7 @@ export function createLazyClient( const result = await readCache.fetch({ type: "tools/call", connectionId: connection.id, - scope: resolveReadCacheScope(), + scope: resolveReadCacheScope(connection, ctx), params: { name: toolName, arguments: (params as { arguments?: unknown })?.arguments, @@ -315,7 +326,7 @@ export function createLazyClient( const result = await readCache.fetch({ type: "prompts/get", connectionId: connection.id, - scope: resolveReadCacheScope(), + scope: resolveReadCacheScope(connection, ctx), params, fetchLive: async () => { const real = await getRealClient(); @@ -341,7 +352,7 @@ export function createLazyClient( const result = await readCache.fetch({ type: "resources/read", connectionId: connection.id, - scope: resolveReadCacheScope(), + scope: resolveReadCacheScope(connection, ctx), params, fetchLive: async () => { const real = await getRealClient();