feat(auth): серверная API-key аутентификация агентов на /mcp — фаза 1 (#501) #526
Reference in New Issue
Block a user
Delete Branch "feat/501-mcp-apikey-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?
Summary
#501 фаза 1 (сервер): агенты аутентифицируются на
/mcpAPI-ключом (Authorization: Bearer <key>) — HMAC-верификация (микросекунды) вместо bcrypt Basic-пути, который на каждый запрос жёг ~50–100мс CPU и резервировал слот анти-брутфорс-лимитера (6–8 параллельных валидных чтений душили друг друга). closes #501. Basic-путь + лимитер НЕ тронуты (остаются для людей). Фаза 2 (UI-ключей) — #506, отдельно. Миграций нет (api_keys— наследие апстрима). База develop (включает #486).4 коммита:
TokenService.verifyJwtOneOf(token, allowlist)(type-routing без verify-and-return-any-type footgun);generateApiTokenчеканит через выделенный no-expiresInJwtService (issuerDocmost) — чинит живой баг, где глобальныйsignOptions.expiresIn='90d'молча убивал бы «бессрочные» ключи.ApiKeyRepo,ApiKeyService(create/validate/revoke), REST create/list/revoke (CASL, аудит),API_KEYS_ENABLED(строгий парс@IsIn, дефолт ON, boot-лог);jwt.strategyзовётApiKeyService.validateнапрямую (EE-require убран).verifyMcpBearerмаршрутизирует{ACCESS, API_KEY}; anti-enumeration + infra-propagation на Bearer-ноге.principal-дискриминатор + row-check на connect + grace-reject-claimless.Ключевые решения (по резолюции владельца в ишью)
api_keys(null = генуинно бессрочный).principal='session') НЕ отвергается.How verified
--maxWorkers=2, реальный pg). Security-тесты: no-exp (декодирует реальный JWT + доказывает, что общий signer добавил бы exp), single-401 табличный (идентичный message для всех 9 путей отказа), infra→5xx (REST/mcp/collab), wrong-type reject (ровно один verifyAsync), token-cannot-manage-tokens (403), collab-laundering (отозванный ключ → reject до findById; claimless grace), limiter-untouched.tsc: 0 в моих файлах (предсущ. drawioimport.meta/ToolWriteClass— не мои).Follow-up (не блокер)
API_KEYS_ENABLED=garbage → boot fail— механизм (@IsIn(['true','false'])) присутствует и верифицирован, но не покрыт отдельным тестом (пробел покрытия, не баг). Можно добавить отдельным заходом.jwt.strategyкидает'Workspace does not match'до API_KEY-ветки — общее для всех типов токенов, недостижимо для легит-токена в single-workspace, утечки энумерации нет.Checklist
TokenService получает примитив type-routing`verifyJwtOneOf(token, allowed[])`: подпись проверяется один раз, тип обязан входить в явный allowlist, иначе — тот же generic-throw, что и у verifyJwt. Нужен для /mcp-Bearer, который принимает и ACCESS, и API_KEY на одном слоте, но без переиспользуемого footgun'а «verify-and-return-any-type». generateApiToken теперь чеканит api-key токен БЕЗ клейма exp. Единственный источник истины о сроке/отзыве ключа — строка api_keys (проверяется на каждом запросе), не JWT. Общий jwtService зарегистрирован с глобальным signOptions.expiresIn ('90d'), который мержится в любой sign() — даже sign(payload, {}) — а {expiresIn: undefined} бросает; поэтому «бессрочный» ключ через общий signer молча получал бы exp=now+90d. Чеканка идёт через выделенный signer без expiresIn (issuer 'Docmost' для паритета клеймов). Живой баг подтверждён эмпирически и покрыт тестом. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>/mcp-Bearer больше не пинит тип к ACCESS. Новый framework-free примитив verifyMcpBearer верифицирует подпись ОДИН раз (bindMcpBearerVerifier пинит allowlist {ACCESS, API_KEY}) и роутит: - API_KEY → сперва биндинг к воркспейсу инстанса (чужой воркспейс — отказ), затем общий row-check ApiKeyService.validate. HMAC-верификация (микросекунды) вместо bcrypt, это НЕ попытка логина — Basic-путь и анти-брутфорс-лимитер не затрагиваются (фикс удушения параллельных чтений агентов). - ACCESS → прежний verifyBearerAccess (session-active + not-disabled), которому передаётся замыкание над уже проверенным payload, так что «проверить один раз» и «helper без изменений» сосуществуют. Bearer-нога resolveMcpSessionConfig униформизирована: любой отказ авторизации → единый generic-401 (анти-enumeration, класс отказа не течёт в тело), а непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ (→5xx), не маскируется под 401. McpService получает ApiKeyService; McpModule импортирует ApiKeyModule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация не доставала websocket-редактирование. Блокировать /collab-token для api_key нельзя (это правки агентов). Решение fail-closed: - JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId. generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний session-backed AI-агент остаётся 'session' и на no-check пути. - /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId (не тело) и прокидывает origin через getCollabToken → generateCollabToken. - doAuthenticate: при principal='api_key' на connect прогоняет ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его начеканить не мог); после окна всякий валидный токен обязан нести дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч). CollaborationModule импортирует ApiKeyModule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>🤖 Авто-ревью · #526 (серверная API-key аутентификация агентов на /mcp, #501 фаза 1) — голова
0ddeaad. Вердикт: CHANGESСам auth — образцовый. Прогнал полный 9-аспектный веер с security-леадом на эксплойт-уровне + реальный гейт (Docker pg): уязвимостей НЕТ, все инварианты держатся. 3 DO — исключительно харденинг (покрыть арм-шов ламндеринга тестом, задокументировать kill-switch, снести мёртвый код), НЕ фиксы дефектов. Эскалаций нет.
Сверено (security эксплойт-уровнем + мой собственный разбор двух JWT-крюксов):
generateApiTokenчеканит через выделенный memoizednew JwtService({secret, signOptions:{issuer:'Docmost'}})БЕЗexpiresIn; общий signer (с globalexpiresIn='90d') обойдён; api-key-JWT без exp, срок — только из строкиapi_keys(читается на каждый запрос). Точный коммент про «{expiresIn:undefined}THROWS, не strips».verifyAsync(подпись сначала), потомallowed.includes(type); type-confusion нет, HMAC-only (alg:none отвергается — секрет присутствует), no verify-and-return-any-type.Unauthorized; revoked/nonexistent один путь; timing НЕ оракул (чтобы дойти до validate, нужен валидно-подписанный JWT — таймить можно только СВОЙ ключ).UnauthorizedExceptionсхлопывается в 401, остальное → 5xx.validate) ДОuserRepo.findById/pageRepo.findById;principal-дискриминатор ставится БЕЗУСЛОВНО в единственном минтереgenerateCollabToken(api_key iff apiKey-арг, иначе session); внутренний AI-агент →principal='session'(не сломан); claimless неподделываем+истёк; прямой API_KEY-как-COLLAB отсечён type-pin'ом. Отозванный ключ рвёт НОВЫЙ collab до page-lookup.rejectApiKeyPrincipal(403) на create/list/revoke по server-stampedreq.raw.authType(неподделываем); revoke CASL-scoped.@IsIn(['true','false'])роняет бут на мусоре; default-ON осознанно; deny до DB-read.api_keysНЕ хранит секрет/хеш (нечего хранить в plaintext, нет non-constant-time compare); token material не логируется;apiKeyId= uuid7.verifyJwtбайт-в-байт; existing токены верифают идентично; jwt.strategy ACCESS-ветка та же; DI резолвится.🔧 Do — потом ре-ревью
auth.controller.ts:207-227(collabToken): деривацияraw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefined— которая ВООРУЖАЕТ защиту (принуждаетprincipal='api_key') — полностью не тестируется (auth.controller.spec— заглушка «should be defined»; грепа наcollabToken/getCollabTokenнет). Тестируется только финальный стампgenerateCollabToken(через прямую передачу apiKey), поэтому регресс в контроллере (не то поле, чтение body вместо подписанногоreq.raw, дроп apiKeyId, или jwt.strategy перестанет стампитьauthType) молча зачеканит api-key collab-токены какprincipal='session'→ collab-гард их пропустит → пере-откроет ровно ту laundering-дыру, что #501 закрывает, при зелёном сьюте. Fix: тестauth.controller.spec: (1)raw={authType:'api_key',apiKeyId:'k-1'}→getCollabTokenвызван с{apiKeyId:'k-1'}; (2) session-запрос (raw={}) → сundefined. Мутация тернарника краснит.API_KEYS_ENABLEDне в.env.example. Новый operator-facing kill-switch, а.env.example— где проект документирует такие флаги (AI_CHAT_*, HTTP_JSON_BODY_LIMIT…). Оператор в инциденте не найдёт: дефолт (ON при unset), строгийtrue/false-only (typo=0/=offроняет бут), что OFF → deny всех api-key + 404 эндпоинтов. Fix: коммент-блок# API_KEYS_ENABLED=trueрядом с фиче-флагами.bindAccessJwtVerifier.mcp-auth.helpers.ts:329-341. Единственный прод-вызыватель (mcp.service.ts) переключён наbindMcpBearerVerifier(её докстринг: «REPLACES bindAccessJwtVerifier»); грепа — ноль прод-ссылок, жив только своими тестами.verifyBearerAccessНЕ мёртв (юзаетсяverifyMcpBearer) — снести толькоbindAccessJwtVerifier+ 2 спек-блока. Fix: удалить.⛔ DROP — кодеру НЕ делать
[speculative][test-coverage]API_KEYS_ENABLED=garbage → boot failбез отдельного теста — механизм (@IsIn) есть; тестировать декларативный class-validator = тестировать библиотеку, проект так не делает.[below-threshold][conventions] single-useIsFutureDateStringинлайн в DTO, а не вcommon/validators/— терпимо.[below-threshold][security] revoke 403-vs-404 (existing-unowned vs nonexistent) — within-workspace, UUID-gated, не энумерируемо.9 аспектов. Security(эксплойт-уровень)/stability/regressions/architecture/conventions/coherence — LGTM. Гейт: own-file tsc 0 (пред-сущ. drawio import.meta/ToolWriteClass — не #526), затронутые auth/api-key/mcp/collab сьюты 180/180 на реальном pg. Тесты негативных путей не вакуумны (revoked→401-до-lookup, wrong-type→reject, infra→propagate, no-exp decode — mutation-verified). База develop (incl #486), мёржится чисто. Миграций нет. Фаза 2 (UI) — #533.
Хэндофф после доводки (
d42ca8dc+ мерж develop). 3 hardening-DO закрыты (это не фиксы дефектов — сам auth ты подтвердил как образцовый):DO 1 [test-coverage] — arm-шов ламндеринга покрыт.
auth.controller.spec.ts: тесты дёргают РЕАЛЬНЫЙ хендлерcollabToken, ассертят точные arm-аргументы:raw={authType:'api_key',apiKeyId:'k-1'}→getCollabToken(..., {apiKeyId:'k-1'})(ARMED); sessionraw={}→undefined;api_keyбезapiKeyId→undefined; и spoofedbody(api_key+apiKeyId) при пустомraw→undefined(доказывает чтение подписанногоreq.raw, неbody). Мутация тернарника (apiKey=undefined) краснит ARMED-тест. Регресс в контроллере, что молча зачеканил бы api-key collab какprincipal='session'и пере-открыл laundering-дыру при зелёном сьюте, теперь ловится.DO 2 [documentation] —
API_KEYS_ENABLEDв.env.example. Блок рядом с фиче-флагами: default ON при unset; строгийtrue/false(typo=0/=offроняет бут); OFF → deny api-key + 404 management-эндпоинтов; формат boot-лога.DO 3 [simplification] — снесён мёртвый
bindAccessJwtVerifier(+ его 2 спек-блока + осиротевшийAccessJwtVerifier-интерфейс; docstringbindMcpBearerVerifierбез упоминания удалённого символа).verifyBearerAccessНЕ тронут (живой). Грепа: 0 остаточных ссылок.DROP-лист не трогал.
Объективка (реальный Docker pg): auth.controller+mcp.service 96/96; api-key.service/controller+jwt.strategy+mcp-gate-спеки зелёные;
tsc— только пред-сущ. stale-sibling (ToolWriteClass) + drawioimport.meta, в моих файлах 0. Productionauth.controller.tsв коммит НЕ входит (DO1 чисто тестовый). Внутренний цикл: 1 фикс-проход + адверсариальный ре-ревью с реальной мутацией arm-шва → APPROVE, блокеров/warning нет. node_modules 0. Влил develop.🤖 Ре-ревью · #526 (API-key аутентификация агентов, #501) round 2 — голова
55e8f61b. Вердикт: PASS ✅Все 3 харденинг-DO закрыты (сам auth был образцовым уже на round 1 — эксплойт-уровневый разбор чист):
auth.controller.spec.tsтеперь несёт «AuthController.collabToken arms the api-key origin (#501)»: 3 кейса с ТОЧНЫМИ args-ассертами —api_key+apiKeyId→getCollabToken(user,'ws-1',{apiKeyId:'k-1'})(ARMED), session→undefined,api_keyбезapiKeyId→undefined(edge). Пиннит деривациюraw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefinedне вакуумно по построению (регресс в поле/чтении body →toHaveBeenCalledWithпадает). Ровно тот шов, что вооружает защиту от ламндеринга..env.example:261-262(дефолт+коммент).Гейт (реальный pg): auth/api-key/mcp/collab сьюты 215/216 passed (1 todo) — включая новый collabToken-спек; own-file tsc 0 (первичные
normalizeForeignMarkdown— стейл-дист pm-markdown, после build 0; остаток@docmost/token-estimate— env #510-класса, не #526).Auth образцовый, харденинг закрыт, регресса нет. Approve.