feat(mcp): per-user auth for /mcp (HTTP Basic, server-validated) #13
Reference in New Issue
Block a user
Delete Branch "feat/mcp-per-user-auth"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements
docs/backlog/mcp-per-user-auth.md(chosen variant L: HTTP Basic, server-validated).What
The embedded
/mcpserver acted as one shared service account — every MCP client had that account's CASL and edit attribution. Now each/mcpsession authenticates as the current user: tools run under the user's CASL, edits attribute to them, and the service account is no longer required.How
Authorization: Basic email:password→ validated server-side viaAuthService; the session carries the issued user JWT (not the raw password). Password may contain:(split on first only).Authorization: Bearer <access JWT>→verifyJwt(ACCESS)plus an active-session + non-disabled-user check (matchingJwtStrategy), so revoked/disabled users are rejected.packages/mcpcreateMcpHttpHandlernow accepts a per-request config resolver (static config / stdio path unchanged). Identity is bound to themcp-session-idat init and re-validated from the caller's own credentials on every request.MCP_TOKENmoved offAuthorizationto anX-MCP-Tokenheader (timing-safe compare); the oldcredsConfigured503 gate is replaced by a clear 401 listing accepted methods.Reasoning / decisions (resolved the plan's open questions)
MCP_REQUIRE_USER_TOKEN) — preserves existing installs / headless use.Authorizationcarries user creds.Review findings & fixes (adversarial security review)
The review confirmed the three highest-risk properties hold: no auth-less reuse of an established session; identity re-validated from the caller's own creds every request; no secret logging. Two blockers it found were fixed:
login()ran on every request → a newuser_sessionsrow +USER_LOGINaudit per request (audit spam / session-table DoS). Fixed: the session-mintinglogin()happens once atinitialize; subsequent requests re-verify credentials via a new non-side-effectingAuthService.verifyUserCredentials(lookup + password compare + email-verified + disabled checks, no session/audit).login()now reuses it, so a wrong password on a later request still 401s (anti-fixation intact) without spawning sessions.JwtStrategy.Also: a global per-email limiter key (so X-Forwarded-For rotation can't brute one account),
X-MCP-Tokenarray-value handling +timingSafeEqual, empty-email Basic rejected, and the limiter only counts genuine credential failures (not "email not verified").Verification
pnpm --filter server build,pnpm --filter @docmost/mcp build,pnpm --filter client build— clean.@docmost/mcp239 pass; server-- mcp26 pass (Basic split incl.:in password; good/bad creds; Bearer ACCESS incl. inactive-session/disabled rejection; service-account fallback; anti-fixation distinct identities; limiter threshold/reset/global-email key; X-MCP-Token; resolver called once per session). Decision logic is in framework-free helpers because the full Nest graph doesn't load under this repo's jest (pre-existing).admin→ session established,tools/list(38 tools),list_spacesreturned the admin's real spaces (per-user CASL via the issued JWT); wrong password → 401 "Email or password does not match"; anti-fixation — reusing the valid session id with a wrong password OR no auth both → 401; limiter trips at the 6th rapid failure (429-equivalent); no password echoed in any body.Compatibility
stdio + the static service-account path are unchanged (the resolver is additive; static config still flows through).
.env.examplemarksMCP_DOCMOST_EMAIL/PASSWORDoptional and documents Basic/Bearer +X-MCP-Token.🤖 Generated with Claude Code