Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Token isolation, redirect validation, CORS, and distributed authorization-code storage must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds OAuth authorization and bearer-token protection to the MCP integration.
Changes:
- Adds OAuth discovery, registration, consent, token, PKCE, and refresh flows.
- Protects MCP routes and adds an authenticated user-ID tool.
- Adds the MCP Express dependency.
File summaries
| File | Description |
|---|---|
src/integrations/mcp/auth.ts |
Implements MCP OAuth flow. |
src/integrations/mcp/index.ts |
Applies authentication middleware. |
src/integrations/mcp/mcp.ts |
Adds an authenticated test tool. |
package.json |
Adds MCP Express dependency. |
yarn.lock |
Locks the new dependency. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 9
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /** | ||
| * MCP | ||
| */ | ||
| app.use("/integration/mcp", authMiddleware, router); |
There was a problem hiding this comment.
Under WHATWG Fetch §3.2.1, browser CORS preflights (OPTIONS) do not carry credentials (Authorization). Because OPTIONS currently cascades into authMiddleware (requireBearerAuth), it responds with 401 Unauthorized per RFC 6750 §3.1, causing browsers to abort cross-origin MCP requests.
Consider either:
- intercepting
OPTIONSbeforeauthMiddlewareto respond with204to keep changes local to this route - terminating
OPTIONSpreflights with204and addingAuthorizationtoAccess-Control-Allow-Headers
For discovery endpoints (/.well-known/oauth-*), RFC 8414 §3 also recommends Access-Control-Allow-Origin: * for external clients.
There was a problem hiding this comment.
There is a global cors policy that handles OPTION requests
4a261e7 to
c83154d
Compare
6fe3738 to
7401a65
Compare
…dis for multi-instance deploy or in-memory for single instance deploy, add JSDoc
7401a65 to
08d5031
Compare
neSpecc
left a comment
There was a problem hiding this comment.
Основное, что стоит поправить до мержа:
redirect_uriне валидируется — кража кода и XSS через consent-страницу.expires_inотдаётся абсолютным timestamp.- Memory-store принимает просроченные коды.
- Async-хендлеры без обработки ошибок — запрос виснет или падает процесс.
Отдельно стоит обсудить audience токенов: сейчас MCP-клиент получает полноценную сессию Hawk. Подробности в комментариях.
| /** | ||
| * Add parameters to redirect_uri | ||
| */ | ||
| const callback = new URL(redirect_uri); |
There was a problem hiding this comment.
Блокер: redirect_uri ничем не ограничен. /register ничего не сохраняет, поэтому client_id ни к чему не привязан, и consent примет любой redirect_uri.
- Кража кода: атакующий даёт жертве ссылку
${GARAGE_URL}/concent?client_id=x&redirect_uri=https://evil/cb&code_challenge=<свой>. После «Разрешить» код уходит на evil, атакующий меняет его своимcode_verifierна access + refresh жертвы. - XSS:
redirect_uri=javascript:...//проходитnew URL, аConcent.vueделаетwindow.location.href = result.redirect_uri→ скрипт выполняется на origin garage.
Предлагаю хранить зарегистрированных клиентов (Redis/Mongo или подписанный client_id с зашитыми redirect_uris), требовать точное совпадение redirect_uri и разрешать только https: и loopback http://localhost / http://127.0.0.1.
| access_token: tokensPair.accessToken, | ||
| refresh_token: tokensPair.refreshToken, | ||
| token_type: 'Bearer', | ||
| expires_in: data.exp, |
There was a problem hiding this comment.
expires_in по RFC 6749 — время жизни токена в секундах, а data.exp — абсолютный timestamp (~1.8e9). Клиенты будут считать токен вечным и не станут обновлять его заранее; через 15 минут часть клиентов на 401 запускает полный flow заново вместо refresh.
Можно data.exp - Math.floor(Date.now() / 1000) или просто константа из TTL — тогда и jwt.verify только что подписанного токена (стр. 72) не понадобится.
| return null; | ||
| } | ||
|
|
||
| if (Date.now() > data.expiresAt) { |
There was a problem hiding this comment.
После удаления просроченной записи не хватает return null (в SAML-сторе он есть). Сейчас просроченный код всё равно возвращается и обменивается на токены, пока его не удалит cleanup (до минуты).
| * The redirect URI includes an authorization code and any local state | ||
| * provided by the client earlier | ||
| */ | ||
| app.post('/concent/integration/mcp', async (req, res) => { |
There was a problem hiding this comment.
Express 4 не ловит ошибки из async-хендлеров. Здесь и в /token могут бросить: new URL(redirect_uri) при невалидном или отсутствующем redirect_uri, createHash().update(code_verifier) без code_verifier, generateTokensPair для удалённого пользователя, getClient() redis-стора при разрыве соединения. Запрос висит до таймаута, а без HAWK_CATCHER_TOKEN процесс падает на unhandled rejection — любой залогиненный пользователь может уронить API запросом с redirect_uri: "x".
Нужен try/catch + next(err), как в роутах GitHub/SAML, и валидация входа: строки client_id, redirect_uri, code_challenge, code_challenge_method === 'S256'. Код лучше сохранять в store уже после проверки redirect_uri.
| verifyAccessToken: async (token: string) => { | ||
| const data = getUserJWTDataFromToken(token, 'access'); | ||
|
|
||
| if (!data) { |
There was a problem hiding this comment.
Токены, которые получает MCP-клиент, — это обычные сессии Hawk: тот же секрет, без aud и скоупов. Поэтому verifier принимает любой access-токен Hawk, а токен стороннего MCP-клиента даёт полный доступ к GraphQL. Скоупы mcp:tools mcp:resources ни на что не влияют, параметр resource (RFC 8707) игнорируется, хотя спека MCP требует проверять, что токен выпущен именно для этого сервера. Refresh живёт 30 дней без ротации, отозвать его нельзя (OAuth 2.1 требует для public clients ротацию или sender-constrained токены).
Как минимум: aud: ${API_URL}/integration/mcp в токене, проверка aud здесь и запрет таких токенов в createContext; refresh сделать одноразовым (jti в Redis).
| * @param userId - user ID | ||
| * @returns TokensPair - newly generated access and refresh tokens | ||
| */ | ||
| async function generateTokensPair(userId: string) { |
There was a problem hiding this comment.
Почти дословная копия проверки enforced SSO из refreshTokens (src/resolvers/user.ts). Предлагаю вынести в модель или фабрику и звать в обоих местах, иначе при изменении SSO-политики MCP-путь разойдётся с основным.
| * | ||
| * @returns MCPAuthRequest data store instance | ||
| */ | ||
| export function createMCPStore(): MCPAuthRequestStoreInterface { |
There was a problem hiding this comment.
Это уже третья копия фабрики и сторов (SAML, GitHub install state, теперь MCP), и при копировании потерялся return null в memory-сторе. Может, вынести общий одноразовый TTL-store с префиксом и TTL в параметрах?
Мелочи: ключ mcp:auth${code} без разделителя :; new RedisMCPAuthRequestStore() не получает уже взятый redisHelper; если Redis не подключён на старте, стор навсегда остаётся memory — в multi-instance это даст плавающий invalid_grant.
| ); | ||
|
|
||
| server.registerTool( | ||
| 'print_userId', |
There was a problem hiding this comment.
Тестовые hello_world / print_userId и 'A test server' уедут в прод, их увидят все пользователи. Если print_userId нужен для отладки, лучше брать проверенный AuthInfo (requireBearerAuth кладёт его в req.auth), а не jwt.decode из сырого заголовка с ! — иначе этот паттерн расползётся по настоящим тулам.
| issuer: `${process.env.API_URL}/integration/mcp`, | ||
|
|
||
| authorization_endpoint: | ||
| `${process.env.GARAGE_URL}/concent`, |
There was a problem hiding this comment.
Опечатка: concent → consent. Этот URL публикуется в metadata и кешируется клиентами, после релиза переименовывать будет больно. Стоит поправить сразу здесь, в /concent/integration/mcp и в garage (Concent.vue, роут). В JSDoc ещё встречается «AuthRequset».
| * ON 'refresh_token' GRANT TYPE: | ||
| * - Validates refresh token. If valid, the authorization server responds back with an access token and a refresh token | ||
| */ | ||
| app.post('/token/integration/mcp', async (req: Request<{}, {}, TokenRequest>, res) => { |
There was a problem hiding this comment.
На весь флоу нет ни одного теста, хотя для SAML есть test/sso/saml/store.test.ts и controller.test.ts. Тесты «просроченный код отклоняется» и «expires_in ≤ 900» поймали бы две проблемы выше. Хорошо бы покрыть хотя бы обмен кода (PKCE, несовпадение client_id / redirect_uri, повторное использование кода) и refresh.
There was a problem hiding this comment.
why file is called getUserIdFromToken but an exported function is called getUserJWTDataFromToken
|
Отзыв доступа для MCP-клиентов Сейчас MCP-клиент получает обычную сессию Hawk: полный доступ к GraphQL и refresh на 30 дней, который продлевается бесконечно. Отозвать такой доступ нельзя ни пользователю, ни нам: смена пароля JWT не инвалидирует, остаётся только сменить Заложить отзыв нужно в этом PR: если выпустить stateless-версию, уже выданные токены потом не отозвать без смены секрета. Что для этого нужно:
UI можно сделать следующим PR: query со списком grant'ов пользователя и mutation |
Adds implementation of MCP authorization flow based on OAuth 2.1 and official MCP Docs
AuthRequsetdata in shared redis store for multi-instance deployments and uses in-memory store for single-instance deployment