Repository navigation
Add per-user sign-in for OAuth2 connectors (Enterprise) - #1302
Merged
Merged
Conversation
An OAuth2 connector could only be authorized once, so every user called the provider with the authorizing account and the provider's own permissions were lost. With credentialScope USER (Enterprise), every user connects their own account and each call carries the caller's own token. - New user_connector_credentials table and connectors.credential_scope column, with migration; tokens encrypted with ENCRYPTION_KEY. - Token cache, refresh lock, refused-refresh memo, persistence and credential status keyed per user, so a revoked sign-in blocks and emails only its owner. - Calls without a user, a sign-in or an Enterprise licence fail before anything is sent; nothing falls back to the connector's token. - Response cache kept per user for these connectors. - Endpoints to switch the mode and to read or remove one's own sign-in; any member may start their own authorization on such a connector. - Limited to REST, GraphQL, MCP and OData connectors with browser OAuth2. - Sign-in card on the connector page and docs/connectors/per-user-sign-in.md.
…rise (#1300) - The migration adds credential_scope with lock_timeout 1s in a retry loop: waiting behind a transaction still open on connectors (a token refresh holds one up to 10 s) stalled every connector query queued behind the ALTER for the whole wait; now queries run between tries. - GET my-credential reports whether per-user sign-in is available, and the Sign-in card is not shown on a shared connector without it, so workspaces without Enterprise see the connector page as before.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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
An OAuth2 connector could only be authorized once: every user of the workspace then called the provider with the authorizing account, so providers that govern access per user (remote MCP servers with per-user OAuth such as mfr, Microsoft 365, Google) lost their own permissions. A shared Outlook connector, for example, showed everyone the authorizer's mailbox.
This adds per-user sign-in (Enterprise): with
credentialScope: USER, every user connects their own account and each call carries the caller's own token. Design and requirements in #1300.Changes
connectors.credential_scope(SHAREDdefault,USER) anduser_connector_credentials(connector, user, organization; tokens encrypted withENCRYPTION_KEY; per-userauth_status*). Migration included; added to the RLS table lists. Rows cascade with the connector, user and organization.<connectorId>:user:<userId>), so the token cache, refresh lock, refused-refresh memo, token persistence and credential status are per user. A revoked sign-in is marked and emailed to its owner only; the connector and other users are unaffected.PATCH /api/connectors/:id/credential-scope(owner/admin;USERneeds Enterprise and a REST, GraphQL, MCP or OData connector with browser OAuth2),GET|DELETE /api/connectors/:id/my-credential. On a per-user connector any member may start their own authorization; the callback stores the tokens on that user's row and refuses if the mode switched during the flow.USERremoves the connector's own tokens (the OAuth client and a dynamically registered client stay); back toSHAREDremoves every user's sign-in.EditionService.assertEnterprise/hasEnterprisefrom AGPL code, as knowledge-folders does. No change underee/. The trial does not include it. Switching back toSHAREDworks without a licence, so a lapsed licence does not trap a connector.docs/connectors/per-user-sign-in.md, linked from the MCP bridge docs and the licence FAQ.Cloud and self-hosted: same behaviour. Cloud checks the workspace's licence, self-hosted the workspace's or the instance's key. The redirect URI is unchanged.
Type
Testing
Automated (
packages/backend, afternpx prisma generate):npm run lint: 0 errors, 11 warnings, all in files this PR does not touch.npx tsc --noEmit -p tsconfig.json: no errors.npm test: 783 suites passed, 4 skipped; 23,175 tests passed, 0 failed. (In earlier full runs,curl.parser,graphql-variablesandresponse-size-limittimed out under load once each; they pass on their own and do not touch this code.)UserCredentialsService,OAuth2TokenServicewith two users and rotating refresh tokens, per-user auth status,DynamicMcpToolsper-user calls and cache, OAuth callback per-user storage and mode-switch race, controller permissions and licence gate, setup status.Frontend (
packages/frontend):npx tsc --noEmit -p tsconfig.jsonpasses.npm run lintshows 0 errors and 49 warnings; none are in the new or changed code.Manual, against a local instance (isolated database, local Enterprise licence row):
not_connectedand no request was sent. A forced expiry refreshed the rotating refresh token on the user's row only.needs_reauthon their row, connector status untouched); signing in again clears it. Completing another user's authorization returns 403. Switching back toSHAREDdeletes the user sign-ins. An EDITOR cannot switch the mode (403).client_credentials/password,LOGIN_TOKEN, no auth, and SOAP.Pre-merge verification (second round)
Shared-mode regression, live against a local mock provider (OAuth 2.1 discovery, dynamic client registration, PKCE, rotating refresh tokens): 26/26 checks passed. Covered shared REST OAuth2 and shared MCP with dynamic client registration, each with authorize, calls from two users, refresh after expiry, Re-discover Tools and Test connection. Also covered switching the same MCP connector to per-user and back, and authorizing it again shared.
A real OAuth MCP client into AnythingMCP (the path claude.ai uses): discovery,
/register,/authorizewith PKCE, the AnythingMCP sign-in and server picker in the browser,/token, thentools/listandtools/callon/mcpwith the OAuth access token. Two local users each got their own provider data (auditauthMethod: jwt).Migration: AnythingMCP Cloud has 5,101 connectors (20 MB, Postgres 17.9; read-only count). Locally, on 51,010 connectors (52 MB, Postgres 17.11), under ~21,500 reads and writes per second on
connectors:prisma migrate deploy, with 0 failed queries and no latency spike;connectorsfor 10 s (like a token refresh waiting on the provider), the original plainALTER TABLEstalled every query on the table for about 8 s (0 tps);lock_timeout1 s in a retry loop. Reads keep flowing at about 35,000 per second during the wait, and the migration completes when the transaction ends.deploy/cloud/release.shrequires: the previous release keeps working on the migrated schema.Without Enterprise:
GET my-credentialreportsavailable: false, and the Sign-in card is not shown on shared connectors, so the page looks as before. A connector already per-user keeps the card, with the way back to a shared sign-in. Its calls fail withnot_licensed. Switching toUSERreturns 403; switching back toSHAREDstill works.Microsoft 365 (Outlook catalog adapter, REST engine) with a dedicated single-tenant Entra app registration, delegated read-only scopes (
User.Read Mail.Read Calendars.Read offline_access) and the 9 write tools disabled. One local user signed in with a real Microsoft account; each user consented themselves, no admin consent. Read-only tools (get_me, mail folders, messages, calendars, events) returned data. The issued token carried only the read scopes. Tokens are stored only on the user row; the connector keeps only the client settings. The second local user, not signed in, was refused on every tool with no request sent to Graph. A forced expiry refreshed the token, and Microsoft rotated the refresh token, on the user row only.tools/listshows no write tools to either user. Not covered: two different Microsoft accounts at the same time (isolation between two real accounts was covered with mfr).Not tested end-to-end: OData (goes through the REST engine) and Google.
Notes for review
Existing issues found while testing, left for separate PRs:
authType: OAUTH2sends no authentication at all. It is excluded from per-user mode here.DynamicMcpTools.buildCacheKey) has no organization in it, so two workspaces with a same-named tool and caching on could share entries. Per-user connectors in this PR use a per-user key.Checklist
npm test)Related Issues
Fixes #1300