Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ SSO_SP_ENTITY_ID=urn:hawk:tracker:saml
## SAML state store type (memory or redis, default: redis)
SAML_STORE_TYPE=redis

## MCP AuthRequest data store type (memory or redis, default: redis)
MCP_AUTH_REQUEST_STORE_TYPE=redis

# String generated when we create GitHub App, see task-managers-integration-implementation-plan.md -> 2.1.1
GITHUB_WEBHOOK_SECRET=623f6ed30b1f762803149893263a95cc2687fe3ce5a9f30648dcbf25712afc9e

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.6.1",
"version": "1.6.2",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -45,6 +45,7 @@
"@hawk.so/types": "^0.7.0",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/server": "^2.4.0",
"@modelcontextprotocol/express": "^2.0.2",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
"@octokit/oauth-methods": "^4.0.0",
Expand Down
15 changes: 15 additions & 0 deletions src/integrations/mcp/auth/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { requireBearerAuth } from '@modelcontextprotocol/express';
import { tokenVerifier } from './tokenVerifier';

/**
* Express middleware that requires a valid Bearer token in the Authorization header
*
* On failure the middleware sends a JSON OAuth error body and a WWW-Authenticate: Bearer …
* challenge that includes the configured resource_metadata URL
* so clients can discover the Authorization Server
*/
export const authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
resourceMetadataUrl:
`${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp`,
});
348 changes: 348 additions & 0 deletions src/integrations/mcp/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
/* eslint-disable @typescript-eslint/camelcase, camelcase */
import express from 'express';
import crypto from 'node:crypto';
import { createMCPStore } from '../store/storeFactory';
import { AuthenticationError } from 'apollo-server-express';
import { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken';
import { generateMCPClientId } from '../helpers/generateMCPClientId';

import type { Request, Response } from 'express';
import type { TokensPair } from '@hawk.so/types';
import type { ContextFactories } from 'src/types/graphql';
import type {
AuthorizationServerMetadataResponse,
ClientInformationResponse,
ProtectedResourceMetadataResponse,
TokenRequest,
TokenResponse
} from './types';

/**
* Implements MCP Authorization Flow following
* {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16 | OAuth 2.1}
* and
* {@link https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/authorization | Authorization in MCP}
*
* Flow:
* 1. The client discovers the OAuth metadata and authorization endpoints.
* 2. The user signs in and supplies an authorization header.
* 3. The server generates an authorization code and stores the pending request data.
* 4. The client exchanges the code together with the PKCE verifier for a token pair.
* 5. Refresh tokens can be exchanged for a new access and refresh token pair.
*
* @param app - Express application instance
* @param factories - context factories for database access
*/
export function useMCPAuth(app: express.Application, factories: ContextFactories) {
const store = createMCPStore();

/**
* Generates new {@link TokensPair}
*
* @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-путь разойдётся с основным.

const user = await factories.usersFactory.findById(userId);

if (!user) {
throw new AuthenticationError('Invalid userId');
}

/**
* Check if user is member of any workspace with enforced SSO
* to use shorter token lifetime
*/
const workspacesIds = await user.getWorkspacesIds([]);
const workspaces = await factories.workspacesFactory.findManyByIds(workspacesIds);
const hasEnforcedSso = workspaces.some(w => w.sso?.enabled && w.sso?.enforced);

return user.generateTokensPair(hasEnforcedSso);
};

/**
* Creates {@link TokenResponse}
*
* @param userId - user ID
* @returns TokenResponse or null
*/
async function createTokenResponse(userId: string): Promise<TokenResponse | null> {
const tokensPair = await generateTokensPair(userId);

const data = getUserJWTDataFromToken(tokensPair.accessToken, '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.

Эта ветка фактически недостижима: если пользователя нет, generateTokensPair бросает AuthenticationError, а не возвращает null. null будет, только если только что подписанный токен не прошёл verify. Ещё в ветке authorization_code (стр. 341) лог скопирован — там речь не о refresh_token.

return null;
}

return {
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) не понадобится.

scope: 'mcp:tools mcp:resources',
};
};

/**
* Protected Resource Metadata (PRM) discovery
*
* MCP client retrieves metadata the authorization server,
* supported scopes, and other resource information
*/
app.get(
'/.well-known/oauth-protected-resource/integration/mcp',
(_req, res: Response<ProtectedResourceMetadataResponse>) => {
res.json({
resource: `${process.env.API_URL}/integration/mcp`,
authorization_servers: [
`${process.env.API_URL}/integration/mcp`,
],
scopes_supported: [
'mcp:tools',
'mcp:resources',
],
});
}
);

/**
* Authorization Server Discovery
*
* MCP client retrieves another set of metadata properties
* that will allow it to know the endpoints it needs to complete the authorization flow
*/
app.get(
'/.well-known/oauth-authorization-server/integration/mcp',
(_req, res: Response<AuthorizationServerMetadataResponse>) => {
res.json({
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».


token_endpoint:
`${process.env.API_URL}/token/integration/mcp`,

registration_endpoint:
`${process.env.API_URL}/register/integration/mcp`,

response_types_supported: [ 'code' ],

grant_types_supported: [
'authorization_code',
'refresh_token',
],

token_endpoint_auth_methods_supported: [
'none',
],

code_challenge_methods_supported: [
'S256',
],
});
}
);

/**
* Dynamic Client Registration (DCR)
*
* Generates random clientId
*
* Typically used to register MCP with the authorization server dynamically.
* Currently we do not have any real authorization server, but this endpoint is still
* implemented in order not to bug the user with manual clientId creation in some IDEs (VSCode)
*/
app.post('/register/integration/mcp', (req, res: Response<ClientInformationResponse>) => {
res.status(201).json({
client_id: generateMCPClientId(),
client_name: req.body.client_name,
redirect_uris: req.body.redirect_uris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: [ 'code' ],
});
});

/**
* Authorization Endpoint callback
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16#name-authorization-endpoint
*
* Authenticates the resource owner and establishes
* whether the resource owner grants or denies the client's access request
*
* Assuming the resource owner grants access, the authorization server redirects
* the user agent back to the client using the redirect URI provided earlier
* (in the request or during client registration).
* 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.

const {
client_id,
redirect_uri,
state,
code_challenge,
} = req.body;

const header = req.headers.authorization;

if (typeof header !== 'string') {
return res.status(400).json({
error: 'invalid_grant',
});
}

const loginToken = header.slice(7);

const user = getUserJWTDataFromToken(loginToken, 'access');

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.

Глобальный middleware (createContext в src/index.ts) уже проверяет этот заголовок и кладёт req.context.user.id / accessTokenExpired — можно взять оттуда, а не верифицировать токен в третьем месте. Сейчас ещё slice(7) без проверки префикса Bearer , и на отсутствующую авторизацию отдаётся 400 invalid_grant вместо 401 — garage не отличит истёкшую сессию от ошибки.


if (!user) {
return res.status(400).json({
error: 'invalid_grant',
});
}

/**
* Authorization code generation
*/
const code = crypto
.randomBytes(32)
.toString('base64url');

/**
* Save AuthRequest data to retrieve data on the next step
*/
await store.saveAuthRequest(code, {
userId: user.userId,
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
});

/**
* 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.


callback.searchParams.set('code', code);

if (state) {
callback.searchParams.set('state', state);
}

return res.json({
redirect_uri: callback.toString(),
});
});

/**
* Token Endpoint
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16#name-token-endpoint
*
* Token endpoint is used by the MCP client to exchange the authorization code to access token or
* generate new {@link TokensPair} by refresh token
*
* ON 'authorization_code' GRANT TYPE:
* - Validates the authorization code, validates the code verifier, and ensures that
* the redirect URI received matches the URI used to redirect the user agent to the client in previous step.
* If valid, the authorization server responds back with an access token and a refresh token
*
* 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.

const { body } = req;

res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');

if (body.grant_type === 'refresh_token') {
const {
refresh_token,
client_id,
} = body;

if (typeof refresh_token !== 'string' || !refresh_token ||
typeof client_id !== 'string' || !client_id) {
return res.status(400).json({ error: 'invalid_request' });
}

const data = getUserJWTDataFromToken(refresh_token, 'refresh');

if (!data) {
return res.status(400).json({ error: 'invalid_grant' });
}

const response = await createTokenResponse(data.userId);

if (!response) {
console.warn(`[MCP Auth] refresh_token was successfully verified, but ${data.userId} could not be found`);

return res.status(400).json({ error: 'invalid_grant' });
}

return res.json(response);
}

if (body.grant_type !== 'authorization_code') {
return res.status(400).json({
error: 'unsupported_grant_type',
});
}

const {
code,
code_verifier,
client_id,
redirect_uri,
} = body;

/**
* Retrieve AuthRequest data from the store
*/
const data = await store.getAuthRequest(code);

if (!data) {
return res.status(400).json({
error: 'invalid_grant',
});
}

if (data.clientId !== client_id) {
return res.status(400).json({
error: 'invalid_grant',
});
}

if (data.redirectUri !== redirect_uri) {
return res.status(400).json({
error: 'invalid_grant',
});
}

/**
* Validate codeChallenge
*/
const calculatedChallenge = crypto
.createHash('sha256')
.update(code_verifier)
.digest('base64url');

if (calculatedChallenge !== data.codeChallenge) {
return res.status(400).json({
error: 'invalid_grant',
});
}

const response = await createTokenResponse(data.userId);

if (!response) {
console.warn(`[MCP Auth] refresh_token was successfully verified, but ${data.userId} could not be found`);

return res.status(400).json({ error: 'invalid_grant' });
}

return res.json(response);
});
};
Loading
Loading