Skip to content

Add per-user sign-in for OAuth2 connectors (Enterprise) - #1302

Merged
helpcodeai merged 5 commits into
mainfrom
helpcodeai/pro-user-signin
Oct 27, 2026
Merged

helpcodeai merged 5 commits into
mainfrom
helpcodeai/pro-user-signin

Conversation

@helpcodeai

@helpcodeai helpcodeai commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Schema: connectors.credential_scope (SHARED default, USER) and user_connector_credentials (connector, user, organization; tokens encrypted with ENCRYPTION_KEY; per-user auth_status*). Migration included; added to the RLS table lists. Rows cascade with the connector, user and organization.
  • Token engines: a user credential has its own key (<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.
  • Calls (MCP, Run Test, Test connection, Re-discover Tools): carry the caller's own token. A call without a user, without a sign-in or without an Enterprise licence fails before anything is sent, with a link to the connector page. The connector's own token is never used. The response cache is per user for these connectors. User tokens are masked in errors like connector secrets.
  • Endpoints: PATCH /api/connectors/:id/credential-scope (owner/admin; USER needs 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.
  • Switching: to USER removes the connector's own tokens (the OAuth client and a dynamically registered client stay); back to SHARED removes every user's sign-in.
  • Licence gate: EditionService.assertEnterprise / hasEnterprise from AGPL code, as knowledge-folders does. No change under ee/. The trial does not include it. Switching back to SHARED works without a licence, so a lapsed licence does not trap a connector.
  • Frontend: "Sign-in" card on the connector page (mode switch with confirmation dialog for owner/admin; Connect / Sign in again / Disconnect my account for everyone).
  • Docs: 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

  • Bug fix
  • New feature
  • Documentation
  • Refactoring
  • Performance improvement
  • Other (describe below)

Testing

  • Added/updated unit tests
  • Tested manually (describe steps below)
  • No tests needed (explain why)

Automated (packages/backend, after npx 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-variables and response-size-limit timed out under load once each; they pass on their own and do not touch this code.)
  • New specs: credential keys, merge/split of user tokens, UserCredentialsService, OAuth2TokenService with two users and rotating refresh tokens, per-user auth status, DynamicMcpTools per-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.json passes. npm run lint shows 0 errors and 49 warnings; none are in the new or changed code.

Manual, against a local instance (isolated database, local Enterprise licence row):

  • A real vendor MCP server with OAuth 2.1 and dynamic client registration (mfr): registration accepted the localhost redirect. Two local users signed in with two different mfr accounts. Read-only tools returned different results per account (for example users 82 vs 38, invoices 30 vs 1). Tokens are stored only on the user rows, never on the connector. A user without a sign-in got not_connected and no request was sent. A forced expiry refreshed the rotating refresh token on the user's row only.
  • A local mock OAuth2 provider (PKCE, rotating refresh tokens) with REST and GraphQL connectors. Each user gets their own data. After expiry, tokens renew per user. A refresh token revoked for one user blocks only that user (needs_reauth on their row, connector status untouched); signing in again clears it. Completing another user's authorization returns 403. Switching back to SHARED deletes the user sign-ins. An EDITOR cannot switch the mode (403).
  • Per-user mode refused (400) for API key, bearer, basic, OAuth2 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, /authorize with PKCE, the AnythingMCP sign-in and server picker in the browser, /token, then tools/list and tools/call on /mcp with the OAuth access token. Two local users each got their own provider data (audit authMethod: 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:

    • the migration takes 7 to 12 ms of SQL and about 0.6 s for prisma migrate deploy, with 0 failed queries and no latency spike;
    • behind a transaction kept open on connectors for 10 s (like a token refresh waiting on the provider), the original plain ALTER TABLE stalled every query on the table for about 8 s (0 tps);
    • the migration now asks for the lock with lock_timeout 1 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.
    • It is additive (new table, a column with a constant default), as deploy/cloud/release.sh requires: the previous release keeps working on the migrated schema.
  • Without Enterprise: GET my-credential reports available: 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 with not_licensed. Switching to USER returns 403; switching back to SHARED still 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/list shows 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:

  • The SOAP engine has no OAuth2 support, so a SOAP connector with authType: OAUTH2 sends no authentication at all. It is excluded from per-user mode here.
  • The tool response cache key (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

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable)
  • All existing tests pass (npm test)
  • This PR has a descriptive title

Related Issues

Fixes #1300

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.
@helpcodeai
helpcodeai enabled auto-merge (squash) October 27, 2026 14:34
@helpcodeai
helpcodeai merged commit 6d4d875 into main Oct 27, 2026
14 checks passed
@helpcodeai
helpcodeai deleted the helpcodeai/pro-user-signin branch October 27, 2026 14:44
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-user sign-in for OAuth2 connectors (Enterprise)

1 participant