Add markdown files describing the per‑user authentication mechanism and the ability to expand or collapse all nodes in the page tree, improving guidance for developers working with the MCP backlog feature.
34 KiB
Встроенный /mcp: авторизация под текущим пользователем (а не сервисным аккаунтом)
Статус: план, код не менялся. Фича сервер (apps/server + packages/mcp).
Затрагивает безопасность — менять аккуратно.
Решение принято: основной путь — логин/пароль текущего пользователя через
HTTP Basic (Authorization: Basic base64(email:password)). Токен-варианты
(Bearer access-JWT / community PAT / OAuth) описаны ниже как альтернативы и
возможные доработки, но делаем именно логин/пароль.
Суть
Сейчас встроенный MCP-сервер на /mcp ходит в Docmost под одним сервисным
аккаунтом (MCP_DOCMOST_EMAIL / MCP_DOCMOST_PASSWORD). Любой клиент,
подключившийся к /mcp, действует с правами этого аккаунта — независимо от того,
кто реально сидит за MCP-клиентом. Это значит: единые CASL-права на всех, нет
атрибуции правок конкретному человеку (в истории страниц всё — от сервисного
юзера), и без env-кредов фича вообще не поднимается (отдаёт 503 "MCP is not configured").
Хотим: чтобы /mcp авторизовался под текущим пользователем (его логином и
паролем) — тогда каждый запрос исполняется под его CASL-правами, правки
атрибутируются ему, и сервисный аккаунт перестаёт быть обязательным.
Почему сейчас сервисный аккаунт (контекст)
/mcp — внешний протокольный эндпоинт (MCP Streamable-HTTP / JSON-RPC). В
сессии MCP нет личности Docmost: сессия идентифицируется случайным UUID
(http.ts:68-74, sessionIdGenerator: () => randomUUID()) и заголовком mcp-session-id, а транспорт не несёт JWT/куку
пользователя. Поэтому пакет @docmost/mcp спроектирован как standalone-клиент:
логинится один раз по email/password (auth-utils.ts:41-86,
достаёт куку authToken) и дальше ходит в REST + collab как обычный внешний
клиент.
Контраст — встроенный AI-чат: он крутится внутри авторизованного NestJS-запроса,
поэтому чеканит loopback-токен именно текущего пользователя и каждый инструмент
исполняется под его CASL (ai-chat-tools.service.ts:54-85).
Наша задача — принести эту же модель «per-user токен» во внешний /mcp.
Хорошая новость: клиентская половина уже готова. DocmostClient принимает
union-конфиг — либо {email,password} (сервис-аккаунт, вызывает performLogin),
либо {getToken} (берёт готовый bare-JWT пользователя как Bearer и не
логинится) (client.ts:99-160,
client.ts:223-241). Этот getToken-вариант
уже используется внутренним AI-чатом. Не хватает только связки в самом
/mcp-хендлере — он сейчас строит конфиг статически из env.
Где сейчас живёт код (точные места)
Хендлер /mcp (NestJS-обвязка)
- mcp.service.ts:114-144
handle(req, res): (1) опц. статичный гардMCP_TOKENпротивAuthorization: Bearer(стр. 118-125); (2)isEnabled()— тумблер воркспейсаai.mcp(403 если выкл.); (3)credsConfigured()— наличие env-кредов (это и есть источник твоего503, стр. 132-144); (4)res.hijack()и проброс raw req/res в MCP-транспорт. - mcp.service.ts:47-64
getEmail/getPassword/getApiUrl/credsConfigured— читают env. - mcp.service.ts:85-112
getHandler()— лениво создаёт один HTTP-handler черезcreateMcpHttpHandler({apiUrl,email,password})и кэширует его.
MCP-пакет
- http.ts:13
createMcpHttpHandler(config: DocmostMcpConfig)— принимает один статический конфиг; создаёт поMcpServer+ транспорту на каждую сессию приinitialize(http.ts:68-82:createDocmostMcpServer(config)→server.connect(transport)). Идентичность сессии фиксируется здесь, на инициализации. - index.ts:50-54
createDocmostMcpServer(config)— пробрасывает union-конфиг вnew DocmostClient(config). - client.ts:99-160
DocmostMcpConfig={email,password} | {getToken}(+ опц.getCollabToken); конструктор ветвится:getToken-вариант не логинится, использует bare-JWT как Bearer.
Auth / токены (сервер)
- token.service.ts:30-54
generateAccessToken(user, sessionId, provenance?)→ JWTtype=ACCESS. - token.service.ts:119-138
generateApiToken({apiKeyId,user,workspaceId,expiresIn})→ JWTtype=API_KEY. - token.service.ts:164-176
verifyJwt(token, type)— проверка подписи + типа. - jwt.strategy.ts:26-34
jwtFromRequest = cookie authToken || Bearer— bearer уже принимается на/api. - jwt.strategy.ts:80-81
провенанс: токен без
actor→'user'(нам и нужно — правки как пользователя). - jwt.strategy.ts:86-109
validateApiKey— путьtype=API_KEYтребует EE-модуль (ee/api-key/api-key.service), которого в форке нет → бросает «Enterprise API Key module missing». То есть полноценных PAT сейчас нет. - auth.controller.ts:184-193
POST /auth/collab-tokenподJwtAuthGuard— выдаёт collab-токен по bearer/cookie (этим уже пользуется и сервис-аккаунт, и AI-чат). - environment.service.ts:63-64
JWT_TOKEN_EXPIRES_INпо умолчанию90d— access-JWT долгоживущий, годится как «токен пользователя». - utils.ts:109
extractBearerTokenFromHeader(req)— переиспользуемый парсерAuthorization. - migration 20250912T101500-api-keys.ts
— таблица
api_keys(id, name, creator_id, workspace_id, expires_at, last_used_at, deleted_at) уже существует, но community-сервиса под неё нет. - .env.example:72-79 —
MCP_DOCMOST_EMAIL/PASSWORD,MCP_DOCMOST_API_URL,MCP_TOKEN,MCP_SESSION_IDLE_MS.
Как именно логиниться под пользователем — варианты
Пользователь подключает к /mcp внешний MCP-клиент (Claude Desktop и т.п.).
Авторизоваться «под текущим пользователем» можно несколькими путями с разной
ценой и безопасностью. Все они сводятся к одному и тому же на уровне клиента:
получить пользовательский JWT и ходить под ним; разница — откуда берётся
токен (приносит пользователь / логинит сервер / выдаёт OAuth).
Вариант L — логин/пароль пользователя через HTTP Basic ✅ ВЫБРАН
MCP-клиент шлёт Authorization: Basic base64(email:password); /mcp декодит и
строит per-session конфиг {email, password} → DocmostClient сам делает
performLogin (POST /auth/login) и дальше ходит под этим пользователем. Это
ровно тот же путь, что у сервисного аккаунта сегодня, только с кредами
текущего пользователя — клиентская механика уже готова
(client.ts:99-160,
auth-utils.ts:41-86).
- Плюсы: минимум нового кода (переиспользуется
{email,password}-веткаperformLogin); пользователю не надо доставать токен — привычные логин/пароль; сервисный аккаунт становится необязательным. - Минусы: сырой пароль лежит в конфиге MCP-клиента и уходит на сервер при
каждом коннекте (токен безопаснее — отзываем/скоупится без смены пароля);
не работает с MFA (статические креды не пройдут интерактивный челлендж) —
в этом форке MFA-модуль удалён (EE), поэтому сейчас вопрос моот, но при
возврате MFA или
workspace.enforceMfa(auth.controller.ts:64-103) путь сломается; SSO/OIDC-пользователи могут не иметь локального пароля; логин жмёт/auth/loginthrottle (AUTH_THROTTLER, раз на сессию + переавторизация на 401). - Вывод: хорош для single-user self-host без MFA; как дефолт лучше токен.
Вариант A — pass-through access-JWT (альтернатива / возможна параллельно)
MCP-клиент шлёт Authorization: Bearer <access-JWT>, где токен — это значение
куки authToken пользователя (валиден 90 дней). /mcp извлекает его, валидирует
как ACCESS-JWT и передаёт в DocmostClient как getToken. Все REST + collab
идут под CASL этого пользователя; правки атрибутируются ему (actor='user').
- Плюсы: минимальный диф, переиспользует уже готовый
getToken-путь клиента; bearer уже принимается на/api; сервисный аккаунт становится необязательным. - Минусы: токен надо достать руками (DevTools → Cookies →
authToken), токен привязан к сессии (логаут/revoke сессии убивает его), он же даёт полный доступ как у пользователя (не сужен скоупом). Приемлемо для self-host, но это не «красивый» PAT.
Вариант B — community PAT / API-keys (доработка на будущее)
Реализовать сообществом то, что было в EE: пользователь создаёт в настройках именованный, отзываемый, с TTL персональный токен; его и кладёт в MCP-клиент.
- Таблица
api_keysуже есть;JwtApiKeyPayload+generateApiTokenесть; не хватает communityApiKeyService(хранить хеш/строку ключа, валидировать поapiKeyIdиз JWT, обновлятьlast_used_at, проверятьexpires_at/deleted_at) + CRUD-эндпоинты + UI выдачи/отзыва. - Поправить jwt.strategy.ts:86-109:
путь
API_KEYдолжен звать community-сервис вместоrequire('./../../../ee/...'). - Плюсы: стабильный, отзываемый, именованный токен; не завязан на браузерную сессию; виден и управляем в UI. Это «правильный» долгоживущий ответ.
- Минусы: заметно больше работы (сервис + контроллер + миграция типов + UI),
и это самостоятельная фича auth, шире чем сам
/mcp.
Вариант C — OAuth 2.1 для MCP (доработка на будущее, «с логином» из коробки)
MCP-спека описывает авторизацию через OAuth 2.1: Docmost поднимает authorization-server metadata + token endpoint, а MCP-клиент (Claude Desktop) делает интерактивный логин и сам получает токен — это и есть «mcp с логином».
- Плюсы: самый стандартный и удобный UX (логин в браузере, без копипасты токенов), refresh из коробки.
- Минусы: самый большой объём (discovery-эндпоинты, согласие, refresh, привязка к существующему JWT-стеку). Избыточно для текущего запроса.
Решение: делаем L (логин/пароль через HTTP Basic) основным и единственным путём на этот заход. Это закрывает «авторизация под текущим пользователем» минимальным кодом (переиспользуется
performLogin) и привычным для пользователя способом — логин/пароль. A/B/C оставляем в доке как совместимые доработки на будущее: все варианты сходятся в одной точке — per-sessionDocmostClientпод пользовательским JWT, отличается лишь источник токена (performLoginот сервера / Bearer от пользователя / PAT / OAuth), так что добавить их позже можно поверх той же связки без переделки.
Детальный дизайн выбранного пути — логин/пароль (HTTP Basic)
Идея: вместо одного статического конфига хендлер получает резолвер конфига
от запроса, который на инициализации каждой MCP-сессии решает, под кем ходить.
Для выбранного пути резолвер читает Authorization: Basic, валидирует
логин/пароль на сервере и строит per-session DocmostClient, ходящий под этим
пользователем.
1) packages/mcp/src/http.ts — принять резолвер конфига
// Accept either a static config (service-account / stdio, unchanged) OR a
// per-request resolver. The resolver runs once per MCP session, at initialize,
// so the session's DocmostClient is bound to that request's identity.
export type McpConfigResolver = (
req: IncomingMessage,
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
export function createMcpHttpHandler(
config: DocmostMcpConfig | McpConfigResolver,
) { /* ... */ }
// inside handleRequest, at session init (POST initialize, http.ts:68-82):
const sessionConfig =
typeof config === "function" ? await config(req) : config;
const server = createDocmostMcpServer(sessionConfig);
Обратная совместимость полная: stdio (stdio.ts) и
существующий вызов с объектом-конфигом работают как раньше (это не функция →
ветка else).
2) apps/server/.../mcp.service.ts — разобрать Basic, провалидировать креды, выпустить токен
Креды валидируем на сервере через AuthService.login и в конфиг кладём
уже выпущенный пользовательский JWT (getToken-вариант), а не сам пароль —
тогда пароль не уходит дальше в loopback-клиент, а ошибки логина видны сразу,
чистым JSON-ответом до res.hijack().
// Resolve the per-session identity from the request. Primary path: HTTP Basic
// (current user's email:password) -> validate on the server -> issue the user's
// JWT -> client acts as that user. Bearer (variant A) and the service account
// (back-compat) are accepted as fallbacks.
private async resolveSessionConfig(req): Promise<DocmostMcpConfig> {
const auth = req.headers['authorization'] as string | undefined;
// --- chosen path: Basic login/password ---
if (auth?.startsWith('Basic ')) {
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
const sep = decoded.indexOf(':'); // password may contain ':'
const email = decoded.slice(0, sep);
const password = decoded.slice(sep + 1);
// Single-workspace assumption (loopback) — same as the AI-chat tools path.
const workspace = await this.workspaceRepo.findFirst();
// Throws UnauthorizedException('Email or password does not match') on bad
// creds -> surfaced as a specific 401 (never a generic error). NOTE: calling
// AuthService.login directly BYPASSES the controller's throttle + MFA gate
// (both EE/controller-level) — see Security below.
const authToken = await this.authService.login({ email, password }, workspace.id);
return { apiUrl: this.getApiUrl(), getToken: async () => authToken };
}
// --- fallback A: Bearer access-JWT (user-supplied token) ---
const bearer = extractBearerTokenFromHeader(req); // utils.ts:109
if (bearer) {
await this.tokenService.verifyJwt(bearer, JwtType.ACCESS); // specific 401
return { apiUrl: this.getApiUrl(), getToken: async () => bearer };
}
// --- fallback B: service account (existing behaviour, optional) ---
if (this.credsConfigured()) {
return { apiUrl: this.getApiUrl(), email: this.getEmail()!, password: this.getPassword()! };
}
throw new UnauthorizedException(
'MCP requires Basic auth (email:password) or a Bearer token, ' +
'or a configured MCP_DOCMOST_EMAIL/PASSWORD service account.',
);
}
getHandler()зовётcreateMcpHttpHandler((req) => this.resolveSessionConfig(req))(резолвер, не статический объект).- Auth-разбор (Basic decode +
AuthService.login/verifyJwt) делать вhandle()доres.hijack(), чтобы на плохих кредах вернуть чистый401 {error: "..."}, а не рвать hijack-нутый ответ. Резолвер тогда может просто отдать уже посчитанный конфиг (напр. через(req.raw as any).__mcpConfig). - Проверку
credsConfigured()(стр. 132-144) заменить на «есть Basic ИЛИ Bearer ИЛИ env-креды», иначе осмысленный401/503(не глотать). - Инжектнуть в
McpServiceAuthService(дляlogin) иTokenService(дляverifyJwtв fallback A);WorkspaceRepoуже есть. Подтянуть нужные модули вintegrations/mcp.
3) Гард MCP_TOKEN — развести с пользовательскими кредами
Сейчас MCP_TOKEN едет в Authorization: Bearer
(mcp.service.ts:118-125).
В per-user режиме Authorization занят кредами/токеном пользователя. Решение:
- в per-user режиме убрать статичный
MCP_TOKEN-гард наAuthorization(аутентификацией служат сами креды; эндпоинт по-прежнему закрыт тумблером воркспейса и сетевой изоляцией), или - если нужен доп. общий шлагбаум — перенести
MCP_TOKENв отдельный заголовок (X-MCP-Token), чтобы не конфликтовал сAuthorization.
4) Collab / провенанс — ничего лишнего не нужно
getCollabToken-провайдер не задаём: DocmostClient сам сходит в
POST /auth/collab-token с выпущенным пользовательским JWT
(auth.controller.ts:184-193)
и получит обычный пользовательский collab-токен. Так правки через collab
атрибутируются пользователю (actor='user' по умолчанию,
jwt.strategy.ts:80-81).
Никакого «AI agent»-бейджа здесь не вешаем — это живой человек.
Альтернатива по объёму (если не хочется тянуть
AuthServiceв McpService): отдать креды как есть в конфиг{ email, password }—DocmostClientсам сделаетperformLoginпо loopback (это буквально путь сервис-аккаунта). Минус: пароль идёт в loopback-клиент и ошибка логина всплывает позже, из пакета, после hijack. Серверная валидация (вариант выше) чище и безопаснее — её и берём.
Тонкие моменты / edge cases
- Идентичность привязана к сессии.
DocmostClientсоздаётся один раз на MCP-сессию (наinitialize) и кэширует токен; последующие запросы той жеmcp-session-idпойдут под пользователем, зафиксированным при инициализации. Грань безопасности: на повторных запросах проверять, что предъявленные креды/ токен резолвятся в того же пользователя (email/sub), что и при инициализации сессии, иначе401— чтобы нельзя было «подсесть» в чужую сессию (session fixation / подмена кред). - Новая Docmost-сессия на каждый логин.
AuthService.login→sessionService.createSessionAndToken(auth.service.ts:97) создаёт запись пользовательской сессии на каждый MCP-логин. При частых реконнектах сессии копятся (idle-eviction MCP-сессий их не чистит). Прикинуть: переиспользовать токен в пределах MCP-сессии (одна сессия = один логин, уже так), и/или TTL/чистку висящих сессий — отдельной заботой. - Истечение токена. Выпущенный access-JWT живёт 90 дней — на 401 от loopback
клиент перезайдёт. Удобство Basic: креды у клиента постоянны, поэтому
переавторизация прозрачна (повторный
login), в отличие от вручную вставленного токена. Опционально — per-session mutable-холдер токена, чтобы переавторизация не пересоздавала MCP-сессию. - Откат на сервис-аккаунт. Сохранить как опцию (нет bearer + есть env-креды →
старое поведение). Это не ломает существующие инсталляции и даёт «безличный»
режим, где он нужен (CI, скрипты). Если откат нежелателен — сделать его
переключаемым (
MCP_REQUIRE_USER_TOKEN=true). - Мульти-тенантность / loopback.
127.0.0.1не резолвит воркспейс по субдомену → таргетится дефолтный воркспейс (та же single-workspace-оговорка, что и у сервис-аккаунта и AI-чата, см. ai-chat-tools.service.ts:25-28).jwt.strategyсверяетreq.raw.workspaceIdсpayload.workspaceId(jwt.strategy.ts:41-43); на loopbackreq.raw.workspaceIdне выставлен → проверка проходит. Для мульти-воркспейс деплоя нужен явный workspace-скоуп (отдельная задача). - Idle-eviction. Сессии чистятся по
MCP_SESSION_IDLE_MS(30 мин) (http.ts:21-39) — без изменений; protected per-user сессии тоже истекают по бездействию, это ок. - Ошибки не глотать. Невалидный/просроченный токен →
console/logger с полной ошибкой и конкретный текст в ответе (реальная причина), не «MCP error» (CLAUDE.md «Errors must never be swallowed»). Текущее одноразовое warning про отсутствие кредов — оставить/адаптировать. - Логи/PII. Не логировать сам токен. Сейчас
auth-utilsпрячет тело ответа заDEBUG— сохранить этот принцип.
Безопасность (на ревью проверить отдельно)
- Прямой
AuthService.loginобходит throttle и MFA-гейт. Контроллерный/auth/loginзащищёнThrottlerGuardи (в EE) MFA-проверкой (auth.controller.ts:41, :64-103); вызываяauthService.loginнапрямую, мы их минуем. Следствия: (1) brute-force через/mcp— добавить свой rate-limit на неудачные логины/mcp(по IP/почте); (2) если MFA когда-либо вернётся/enforceMfa— Basic-путь должен повторить MFA-гейт или быть запрещён для MFA-пользователей, а не молча пускать. - Креды в логах/трейсах. Никогда не логировать
Authorization, decodedemail:passwordи тело ответа логина (auth-utilsуже прячет тело заDEBUG— держать тот же принцип). На ошибке логина — конкретный401, но без эха пароля. - Per-user CASL: убедиться, что все инструменты идут только через loopback REST/collab под пользовательским JWT и нигде не остаётся фолбэка на сервис-аккаунт внутри уже инициализированной per-user сессии.
- Привязка к сессии (см. edge case) — анти-fixation проверка
email/sub. MCP_TOKEN-развод: не оставить «дыру», гдеAuthorizationмолча игнорируется.- SSO/OIDC-пользователи без локального пароля: Basic для них не сработает —
вернуть понятный
401, а не generic (и направить на токен-путь, если он есть). - Доработка B (PAT): ключ хранить хешем,
last_used_atобновлять, отзыв (deleted_at) иexpires_atпроверять вvalidateApiKey.
Миграции / конфиг / env / docs
- Выбранный путь (Basic): миграций нет. Обновить
.env.example:72-79: пометить
MCP_DOCMOST_EMAIL/PASSWORDкак опциональные (теперь это фолбэк-сервис-аккаунт, а не обязательный), описать per-user Basic-режим и (если выбран)X-MCP-Token/MCP_REQUIRE_USER_TOKEN. Обновить README: как прописать в MCP-клиентеAuthorization: Basic(свои email:password) — у клиентов это обычно поле «headers» в конфиге сервера. - Доработка B (PAT):
api_keysтаблица уже есть; добавить типы вdb.d.ts(migration:codegen), при необходимости — индексы; новый модуль/сервис/контроллер и клиентский UI вapps/client/src/features/.../settings.
Тесты / проверка
- Сервер (
pnpm --filter server test):mcp.serviceрезолвер:Basic email:password→AuthService.loginзовётся с дефолтным воркспейсом →getToken-конфиг с выпущенным токеном; неверные креды →401с конкретным сообщением (не generic); Bearer-fallback →verifyJwt(ACCESS); нет ничего + есть env-креды → сервис-аккаунт; нет ничего → осмысленный 401/503.- пароль с
:парсится корректно (split по первому:). - анти-fixation: второй запрос с кредами другого пользователя в той же сессии → 401.
- MCP-пакет (
pnpm --filter @docmost/mcp test):createMcpHttpHandlerпринимает и статический конфиг, и резолвер; резолвер зовётся один раз на инициализацию сессии; статический путь (stdio/сервис-аккаунт) не задет. - Ручная: прописать в MCP-клиенте
Authorization: Basic base64(email:pass)своего юзера → проверить, что (1) видны только доступные пользователю спейсы/ страницы (CASL), (2) правки в истории атрибутируются этому пользователю, а не сервисному, (3) без env-кредов/mcpработает по логину/паролю, (4) неверный пароль → понятная ошибка, а не generic, (5) залогировано без утечки пароля.
Открытые вопросы
Какой путь делаем— решено: логин/пароль через HTTP Basic (вариант L). A/B/C — совместимые доработки на будущее.- Сервис-аккаунт: оставить как откат (нет Basic/Bearer → старое поведение)
или полностью убрать в пользу обязательного per-user логина
(
MCP_REQUIRE_USER_TOKEN)? MCP_TOKEN: убрать в per-user режиме или перенести в отдельный заголовокX-MCP-Tokenкак доп. общий шлагбаум?- Brute-force / throttle: добавлять ли свой rate-limit на неудачные логины
/mcp(прямойAuthService.loginминует контроллерныйThrottlerGuard)? - Накопление сессий: нужно ли чистить/ограничивать Docmost-сессии, создаваемые
AuthService.loginна каждый MCP-логин, или достаточно «одна MCP-сессия = один логин»? - Серверная валидация vs pass-through: валидировать креды через
AuthService.login(чище/безопаснее, тянет сервис в McpService) или отдать{email,password}вperformLoginпакета (минимум кода)? В дизайне выбрана серверная валидация. - Мульти-воркспейс: loopback таргетит дефолтный воркспейс (как у AI-чата). Нужен ли явный workspace-скоуп для мульти-тенант деплоя — или отдельная задача?