Skip to content

Feat/setup mcp auth - #681

Open
akulistus wants to merge 8 commits into
feat/setup-mcpfrom
feat/setup-mcp-auth
Open

akulistus wants to merge 8 commits into
feat/setup-mcpfrom
feat/setup-mcp-auth

Conversation

@akulistus

@akulistus akulistus commented Sep 16, 2026 •

Copy link
Copy Markdown

Adds implementation of MCP authorization flow based on OAuth 2.1 and official MCP Docs

  • Adds endpoints and logic for the OAuth 2.1 authorization flow
  • Stores AuthRequset data in shared redis store for multi-instance deployments and uses in-memory store for single-instance deployment

@akulistus
akulistus added this pull request to stack #682 September 16, 2026 00:43
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/mcp.ts Resolved
Comment thread src/integrations/mcp/index.ts Outdated
/**
* MCP
*/
app.use("/integration/mcp", authMiddleware, router);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 OPTIONS before authMiddleware to respond with 204 to keep changes local to this route
  • terminating OPTIONS preflights with 204 and adding Authorization to Access-Control-Allow-Headers

For discovery endpoints (/.well-known/oauth-*), RFC 8414 §3 also recommends Access-Control-Allow-Origin: * for external clients.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a global cors policy that handles OPTION requests

Comment thread src/integrations/mcp/auth.ts Resolved
Comment thread src/integrations/mcp/mcp.ts Resolved
@akulistus
akulistus force-pushed the feat/setup-mcp-auth branch from 4a261e7 to c83154d Compare October 14, 2026 15:40
@akulistus
akulistus force-pushed the feat/setup-mcp-auth branch 2 times, most recently from 6fe3738 to 7401a65 Compare October 22, 2026 20:16
@akulistus
akulistus force-pushed the feat/setup-mcp-auth branch from 7401a65 to 08d5031 Compare October 23, 2026 21:16

@neSpecc neSpecc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Основное, что стоит поправить до мержа:

  1. redirect_uri не валидируется — кража кода и XSS через consent-страницу.
  2. expires_in отдаётся абсолютным timestamp.
  3. Memory-store принимает просроченные коды.
  4. Async-хендлеры без обработки ошибок — запрос виснет или падает процесс.

Отдельно стоит обсудить audience токенов: сейчас MCP-клиент получает полноценную сессию Hawk. Подробности в комментариях.

/**
* Add parameters to redirect_uri
*/
const callback = new URL(redirect_uri);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Блокер: 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

После удаления просроченной записи не хватает 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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Токены, которые получает 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Почти дословная копия проверки enforced SSO из refreshTokens (src/resolvers/user.ts). Предлагаю вынести в модель или фабрику и звать в обоих местах, иначе при изменении SSO-политики MCP-путь разойдётся с основным.

*
* @returns MCPAuthRequest data store instance
*/
export function createMCPStore(): MCPAuthRequestStoreInterface {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это уже третья копия фабрики и сторов (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',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тестовые 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`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Опечатка: 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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

На весь флоу нет ни одного теста, хотя для SAML есть test/sso/saml/store.test.ts и controller.test.ts. Тесты «просроченный код отклоняется» и «expires_in ≤ 900» поймали бы две проблемы выше. Хорошо бы покрыть хотя бы обмен кода (PKCE, несовпадение client_id / redirect_uri, повторное использование кода) и refresh.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why file is called getUserIdFromToken but an exported function is called getUserJWTDataFromToken

@neSpecc

neSpecc commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Отзыв доступа для MCP-клиентов

Сейчас MCP-клиент получает обычную сессию Hawk: полный доступ к GraphQL и refresh на 30 дней, который продлевается бесконечно. Отозвать такой доступ нельзя ни пользователю, ни нам: смена пароля JWT не инвалидирует, остаётся только сменить JWT_SECRET для всех. DCR открыт для любого клиента, так что утёкший токен из стороннего приложения — это доступ к аккаунту без срока. К тому же OAuth 2.1 требует для public clients ротацию refresh-токенов.

Заложить отзыв нужно в этом PR: если выпустить stateless-версию, уже выданные токены потом не отозвать без смены секрета. Что для этого нужно:

  1. Хранить grant (Mongo): userId, clientId, clientName, redirectHost, scopes, refreshJti, tokenHint, createdAt, lastUsedAt, revokedAt. Создавать в /token при обмене кода (upsert по userId + clientId). Имя и redirect_uris брать из сохранённой регистрации — значит, /register тоже должен сохранять клиентов, это же нужно для проверки redirect_uri. tokenHint — превью mcp токена типа hwk_…91c2
  2. Отдельные MCP-токены вместо user.generateTokensPair(): в access — gid, client_id и aud: ${API_URL}/integration/mcp, в refresh — gid и jti. В createContext не принимать токены с MCP-aud.
  3. Ротация refresh: проверить JWT → загрузить grant по gid → если grant отозван или jti не совпадает с refreshJti, вернуть invalid_grant. При несовпадении jti отзывать весь grant: это признак повторного использования украденного токена. Иначе выдать новую пару и атомарно обновить refreshJti (findOneAndUpdate({ _id, refreshJti: old }, ...)).
  4. tokenVerifier проверяет по gid, что grant не отозван (с кэшем в Redis на минуту, сбрасывать при отзыве), берёт clientId из токена и не чаще раза в час обновляет lastUsedAt.

UI можно сделать следующим PR: query со списком grant'ов пользователя и mutation revokeMcpGrant(id) для блока «Подключённые приложения» в настройках garage. Рядом с названием стоит показывать домен redirect_uri: client_name клиент присылает сам и может назваться как угодно.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants