diff --git a/.env.sample b/.env.sample index bb7e7e22..bd13e4fa 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/package.json b/package.json index 4edbeae5..b6083d7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.1", + "version": "1.6.2", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -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", diff --git a/src/integrations/mcp/auth/authMiddleware.ts b/src/integrations/mcp/auth/authMiddleware.ts new file mode 100644 index 00000000..f87d07f8 --- /dev/null +++ b/src/integrations/mcp/auth/authMiddleware.ts @@ -0,0 +1,8 @@ +import { requireBearerAuth } from '@modelcontextprotocol/express'; +import { tokenVerifier } from './tokenVerifier'; + +export const authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + resourceMetadataUrl: + `${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp` +}); diff --git a/src/integrations/mcp/auth/getUserIdFromToken.ts b/src/integrations/mcp/auth/getUserIdFromToken.ts new file mode 100644 index 00000000..ad55d195 --- /dev/null +++ b/src/integrations/mcp/auth/getUserIdFromToken.ts @@ -0,0 +1,21 @@ +import jwt from "jsonwebtoken"; + +import type { Secret } from "jsonwebtoken"; +import type { UserJWTData } from "src/types/graphql"; + +/** + * Decodes JWT and extracts UserJWTData + * + * @param token - JWT + * @returns UserJWTData or null if JWT can not be verified + */ +export function getUserJWTDataFromToken(token: string): UserJWTData | null { + try { + return jwt.verify( + token, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret + ) as UserJWTData; + } catch { + return null; + } +}; diff --git a/src/integrations/mcp/auth/index.ts b/src/integrations/mcp/auth/index.ts new file mode 100644 index 00000000..4f48d328 --- /dev/null +++ b/src/integrations/mcp/auth/index.ts @@ -0,0 +1,257 @@ +import express from 'express'; +import crypto from 'node:crypto'; +import { createMCPStore } from '../store/storeFactory'; +import { AuthenticationError } from 'apollo-server-express'; +import { verifyToken } from './tokenVerifier'; +import { getUserJWTDataFromToken } from './getUserIdFromToken'; + +import type { ContextFactories } from 'src/types/graphql'; +import type { TokenData } from '../types'; + +if (!process.env.API_URL) { + throw new Error('API_URL environment variable must be set to generate redirect URI'); +} + +if (!process.env.GARAGE_URL) { + throw new Error('GARAGE_URL environment variable must be set to generate authorization endpoint') +} + +const ACCESS_TOKEN_LIFETIME = '15m'; + +/** + * Implements MCP Authorization Flow using OAuth 2.1 + * + * + * @param app + * @param factories + */ +export function useMCPAuth(app: express.Application, factories: ContextFactories) { + const store = createMCPStore(); + + async function createTokenResponse(userId: string) { + const tokensPair = await generateTokensPair(userId); + + return { + access_token: tokensPair.accessToken, + refresh_token: tokensPair.refreshToken, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_LIFETIME, + scope: 'mcp:tools mcp:resources' + } + }; + + async function generateTokensPair(userId: string) { + const user = await factories.usersFactory.findById(userId); + + if (!user) { + throw new AuthenticationError('Invalid userId'); + } + + return await user.generateTokensPair(); + }; + + /** + * Dynamic client registration + */ + app.post('/register/integration/mcp', (req, res) => { + res.status(201).json({ + client_id: `hawk-client-${crypto.randomUUID()}`, + client_name: req.body.client_name, + redirect_uris: req.body.redirect_uris, + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + }); + }); + + /** + * Protected resource metadata + */ + app.get( + '/.well-known/oauth-protected-resource/integration/mcp', + (_req, res) => { + res.json({ + resource: `${process.env.API_URL}/integration/mcp`, + authorization_servers: [ + `${process.env.API_URL}/integration/mcp` + ], + scopes_supported: [ + 'mcp:tools', + 'mcp:resources' + ] + }); + } + ); + + /** + * OAuth server metadata + */ + app.get( + '/.well-known/oauth-authorization-server/integration/mcp', + (_req, res) => { + res.json({ + issuer: `${process.env.API_URL}/integration/mcp`, + + authorization_endpoint: + `${process.env.GARAGE_URL}/concent`, + + 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' + ] + }); + } + ); + + /** + * Frontend callback + */ + app.post('/concent/integration/mcp', async (req, res) => { + const { + client_id, + redirect_uri, + state, + code_challenge + } = req.body; + + const header = req.headers.authorization; + + if (!header?.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'unauthorized' + }); + } + + const loginToken = header.slice(7); + + const user = getUserJWTDataFromToken(loginToken); + + if (!user) { + return res.status(400).json({ + error: 'invalid_grant' + }) + } + + const code = crypto + .randomBytes(32) + .toString('base64url'); + + await store.saveAuthRequest(code, { + userId: user.userId, + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + }); + + const callback = new URL(redirect_uri); + + callback.searchParams.set('code', code); + + if (state) { + callback.searchParams.set('state', state); + } + + return res.json({ + redirect_uri: callback.toString() + }); + }); + + /** + * MCP client callback + */ + app.post('/token/integration/mcp', async (req, res) => { + const { + grant_type, + refresh_token, + code, + client_id, + redirect_uri, + code_verifier + } = req.body; + + res.set('Cache-Control', 'no-store'); + res.set('Pragma', 'no-cache'); + + if (grant_type === 'refresh_token') { + if (typeof refresh_token !== 'string' || !refresh_token || + typeof client_id !== 'string' || !client_id) { + return res.status(400).json({ error: 'invalid_request' }); + } + + let auth: TokenData; + + try { + auth = verifyToken(refresh_token, 'refresh'); + } catch { + return res.status(400).json({ error: 'invalid_grant' }); + } + + if (auth.clientId !== client_id) { + return res.status(400).json({ error: 'invalid_grant' }); + } + + const response = await createTokenResponse(auth.userId); + + return res.json(response); + } + + if (grant_type !== 'authorization_code') { + return res.status(400).json({ + error: 'unsupported_grant_type' + }); + } + + const auth = await store.getAuthRequest(code); + + if (!auth) { + return res.status(400).json({ + error: 'invalid_grant' + }); + } + + if (auth.clientId !== client_id) { + return res.status(400).json({ + error: 'invalid_grant' + }); + } + + if (auth.redirectUri !== redirect_uri) { + return res.status(400).json({ + error: 'invalid_grant' + }); + } + + /** + * PKCE + */ + const calculatedChallenge = crypto + .createHash('sha256') + .update(code_verifier) + .digest('base64url'); + + if (calculatedChallenge !== auth.codeChallenge) { + return res.status(400).json({ + error: 'invalid_grant' + }); + } + + const response = await createTokenResponse(auth.userId); + return res.json(response); + }); +}; diff --git a/src/integrations/mcp/auth/tokenVerifier.ts b/src/integrations/mcp/auth/tokenVerifier.ts new file mode 100644 index 00000000..e81e1006 --- /dev/null +++ b/src/integrations/mcp/auth/tokenVerifier.ts @@ -0,0 +1,38 @@ +import jwt from "jsonwebtoken"; +import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server"; + +import type { Secret } from "jsonwebtoken"; +import type { OAuthTokenVerifier } from "@modelcontextprotocol/server"; +import type { TokenData } from "../types"; + +export function verifyToken(token: string, tokenUse: TokenData["tokenUse"]): TokenData { + const payload = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN as Secret); + + if (typeof payload === "string" || payload.tokenUse !== tokenUse || + typeof payload.userId !== "string" || !payload.userId || + typeof payload.clientId !== "string" || !payload.clientId || + typeof payload.exp !== "number") { + throw new Error("Invalid token claims"); + } + + return payload as TokenData; +}; + +export const tokenVerifier: OAuthTokenVerifier = { + verifyAccessToken: async (token: string) => { + let payload: TokenData; + + try { + payload = verifyToken(token, "access"); + } catch { + throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid or expired access token"); + } + + return { + token, + clientId: payload.clientId, + scopes: ["mcp:tools", "mcp:resources"], + expiresAt: payload.exp + }; + } +}; \ No newline at end of file diff --git a/src/integrations/mcp/index.ts b/src/integrations/mcp/index.ts index 4a6953bf..1b4931a9 100644 --- a/src/integrations/mcp/index.ts +++ b/src/integrations/mcp/index.ts @@ -1,6 +1,8 @@ import express from 'express'; import { ContextFactories } from 'src/types/graphql'; import { createMCPHandler } from './mcp'; +import { useMCPAuth } from './auth'; +import { authMiddleware } from './auth/authMiddleware'; /** * Appends MCP route to Express App @@ -9,7 +11,8 @@ import { createMCPHandler } from './mcp'; * @param factories - context factories for database access */ export function appendMCPRoutes(app: express.Application, factories: ContextFactories): void { + useMCPAuth(app, factories); const handler = createMCPHandler(factories); - app.use('/integration/mcp', (req, res) => handler(req, res, req.body)); + app.use('/integration/mcp', authMiddleware, (req, res) => handler(req, res, req.body)); } diff --git a/src/integrations/mcp/mcp.ts b/src/integrations/mcp/mcp.ts index 1e68a115..458b8081 100644 --- a/src/integrations/mcp/mcp.ts +++ b/src/integrations/mcp/mcp.ts @@ -1,6 +1,7 @@ -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import jwt from "jsonwebtoken"; import { toNodeHandler } from '@modelcontextprotocol/node'; -import { ContextFactories } from 'src/types/graphql'; +import { createMcpHandler, McpServer, ServerContext } from '@modelcontextprotocol/server'; +import { ContextFactories, UserJWTData } from 'src/types/graphql'; import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'; @@ -8,29 +9,49 @@ import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'; * Creates MCP server * * @param factories - context factories for database access - * @returns configured MCP server + * @returns configured MCP server */ -function createMCPServer(factories: ContextFactories) { +const createMCPServer = (factories: ContextFactories) => { const server = new McpServer({ - name: 'server', - version: '0.0.0', - description: 'A test server', + name: "server", + version: "0.0.0", + description: "A test server" }); server.registerTool( - 'hello_world', + "hello_world", { - description: 'A test tool to greet the user', + description: "A test tool to greet the user" }, async () => { return { content: [ { - type: 'text' as const, - text: 'Hi', - }, - ], - }; + type: "text" as const, + text: "Hi" + } + ] + } + } + ); + + server.registerTool( + "print_userId", + { + description: "A test tool that pritn userId from auth token" + }, + async (ctx: ServerContext) => { + const token = ctx.http?.req?.headers.get("authorization")?.slice(7)!; + + const auth = jwt.decode(token) as UserJWTData; + return { + content: [ + { + type: "text" as const, + text: String(auth.userId) + } + ] + } } ); diff --git a/src/integrations/mcp/store/memory.store.ts b/src/integrations/mcp/store/memory.store.ts new file mode 100644 index 00000000..13c59337 --- /dev/null +++ b/src/integrations/mcp/store/memory.store.ts @@ -0,0 +1,105 @@ +import type { AuthRequestData, MemoryAuthRequestData, MCPAuthRequestStoreInterface } from "../types"; + +/** + * In-memory store for MCP AuthRequest data + * + * Stores temporary data needed for MCP authentication flow: + * - AuthRequest maps authorization code to AuthRequest data + * + * Note: This implementation is not suitable for multi-instance deployments. + * Use Redis store for production environments with multiple API instances. + */ +export class MemoryMCPAuthRequestStore implements MCPAuthRequestStoreInterface { + /** + * Store type identifier + */ + public readonly type = 'memory'; + + private AuthState: Map = new Map(); + + /** + * Time-to-live for stored data (5 minutes) + */ + private readonly TTL = 5 * 60 * 1000; + + /** + * Interval for cleanup of expired entries (1 minute) + */ + private readonly CLEANUP_INTERVAL = 60 * 1000; + + /** + * Cleanup timer reference + */ + private cleanupTimer: NodeJS.Timeout | null = null; + + /** + * Store constructor + */ + constructor() { + this.startCleanupTimer(); + } + + /** + * Save AuthRequest data + * + * @param code - Authorization code () + * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) + */ + public async saveAuthRequest(code: string, data: MemoryAuthRequestData): Promise { + this.AuthState.set(code, + { + ...data, + expiresAt: Date.now() + this.TTL + } + ); + } + + /** + * Get AuthRequest data + * + * @param code - authorizaton code + * @returns authRequest data or null if not found/expired + */ + public async getAuthRequest(code: string): Promise { + const data = this.AuthState.get(code); + + if (!data) { + return null; + } + + if (Date.now() > data.expiresAt) { + this.AuthState.delete(code); + } + + this.AuthState.delete(code); + + const { expiresAt, ...authRequestData } = data; + + return authRequestData; + } + + /** + * Start periodic cleanup of expired entries + */ + private startCleanupTimer(): void { + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, this.CLEANUP_INTERVAL); + + this.cleanupTimer.unref(); + } + + /** + * Clean up expired entries + */ + private cleanup(): void { + const now = Date.now(); + + for (const [key, value] of this.AuthState) { + if (now > value.expiresAt) { + this.AuthState.delete(key); + } + } + } + +} diff --git a/src/integrations/mcp/store/redis.store.ts b/src/integrations/mcp/store/redis.store.ts new file mode 100644 index 00000000..31d3fcd0 --- /dev/null +++ b/src/integrations/mcp/store/redis.store.ts @@ -0,0 +1,113 @@ +import type { AuthRequestData, MCPAuthRequestStoreInterface } from "../types"; +import type { RedisClientType } from "redis"; + +import RedisHelper from "../../../redisHelper"; + +/** + * Redis-based store for MCP AuthRequest data + * + * Stores temporary data needed for MCP authentication flow: + * - AuthRequest maps authorization code to AuthRequest data + * + * This implementation is suitable for multi-instance deployments as it uses + * Redis as the shared state store. TTLs are handled by Redis automatically. + */ +export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { + /** + * Store type identifier + */ + public readonly type = "redis"; + + /** + * Redis helper instance + */ + private redisHelper: RedisHelper; + + /** + * Time-to-live for stored data in seconds (5 minutes) + */ + private readonly TTL_SECONDS = 5 * 60; + + /** + * Prefix for AuthRequest keys in Redis + */ + private readonly MCP_AUTH_REQUEST_PREFIX = 'mcp:auth'; + + constructor(redisHelper?: RedisHelper) { + this.redisHelper = redisHelper || RedisHelper.getInstance(); + } + + /** + * Save AuthRequest data + * + * @param code - authorization code + * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) + */ + public async saveAuthRequest(code: string, data: AuthRequestData): Promise { + const client = this.getClient(); + const key = `${this.MCP_AUTH_REQUEST_PREFIX}${code}`; + const value = JSON.stringify(data); + + await client.setEx(key, this.TTL_SECONDS, value); + } + + /** + * Get and consume AuthRequest data + * + * @param code - authorization code + * @returns AuthRequset data or null if not found/expired + */ + public async getAuthRequest(code: string): Promise { + const client = this.getClient(); + const key = `${this.MCP_AUTH_REQUEST_PREFIX}${code}`; + + /** + * Get and delete atomically to prevent race conditions + * This ensures the state can only be consumed once + * Using MULTI/EXEC for atomic operation (compatible with Redis 5.0+) + */ + const result = await client + .multi() + .get(key) + .del(key) + .exec(); + + if (!result || result.length < 2) { + return null + } + + const value = result[0] as string | null; + + if (!value) { + return null; + } + + try { + return JSON.parse(value) as AuthRequestData; + } catch (error) { + console.error('[Redis MCP Store] Failed to parse AuthCode', error); + + return null; + } + } + + /** + * Get Redis client + * + * @returns Redis client instance + * @throws Error if Redis client is not available + */ + private getClient(): RedisClientType { + const client = this.redisHelper.getClient(); + + if (!client) { + throw new Error('Redis client is not available. Make sure Redis is initialized.'); + } + + if (!client.isOpen) { + throw new Error('Redis client is not connected. Make sure Redis connection is established.'); + } + + return client; + } +} diff --git a/src/integrations/mcp/store/storeFactory.ts b/src/integrations/mcp/store/storeFactory.ts new file mode 100644 index 00000000..0b7d0466 --- /dev/null +++ b/src/integrations/mcp/store/storeFactory.ts @@ -0,0 +1,49 @@ +import type { MCPAuthRequestStoreInterface } from "../types"; + +import RedisHelper from "../../../redisHelper"; +import { MemoryMCPAuthRequestStore } from "./memory.store"; +import { RedisMCPAuthRequestStore } from "./redis.store"; + +/** + * Create MCPAuthRequest data store instance based on configuration + * + * Store type is determined by MCP_AUTH_REQUEST_STORE_TYPE environment variable: + * - 'redis' (default): Uses Redis store for multi-instance support + * - 'memory': Uses in-memory store (single instance only) + * + * @returns MCPAuthRequest data store instance + */ +export function createMCPStore(): MCPAuthRequestStoreInterface { + const storeType = (process.env.MCP_AUTH_REQUEST_STORE_TYPE || 'redis').toLowerCase(); + + if (storeType === 'memory') { + return new MemoryMCPAuthRequestStore(); + } + + if (storeType === 'redis') { + const redisHelper = RedisHelper.getInstance(); + + if (!redisHelper.isConnected()) { + console.warn( + '[MCP Store] Redis store requested but Redis is not connected. Falling back to memory store.' + ); + return new MemoryMCPAuthRequestStore(); + } + + return new RedisMCPAuthRequestStore(); + } + + /** + * Unknown store type, default to Redis + */ + console.warn( + `[MCP Store] Unknown store type "${storeType}". Defaulting to Redis.` + ); + const redisHelper = RedisHelper.getInstance(); + + if (redisHelper.isConnected()) { + return new RedisMCPAuthRequestStore(); + } + + return new MemoryMCPAuthRequestStore(); +} diff --git a/src/integrations/mcp/types.ts b/src/integrations/mcp/types.ts new file mode 100644 index 00000000..1099a202 --- /dev/null +++ b/src/integrations/mcp/types.ts @@ -0,0 +1,67 @@ +/** + * Set of authorization data needed to verify that the original authorization request + * was made by the same client making token request + * + * Fields: + * - userId - Id of the user trying to authorize with MCP + * - clientId - Id of the mcp-client requesting tokens + * - redirectUri - URI that was used to request the authorization code + * - codeChallenge - + */ +export type AuthRequestData = { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; +}; + +/** + * + */ +export type TokenData = { + userId: string; + clientId: string; + tokenUse: "access" | "refresh"; + exp: number; +}; + +/** + * Used by [In-memory store for MCP AuthRequest data](./store/memory.store.ts) + * to track AuthRequset expiration + */ +export type MemoryAuthRequestData = AuthRequestData & { + expiresAt: number +} + +/** + * Interface for MCP AuthRequset data store implementations + * + * Defines contract for storing temporary data needed for MCP authentication flow: + * - AuthRequest: maps authorization code to AuthRequest data + */ +export interface MCPAuthRequestStoreInterface { + /** + * Store type identifier + * Used for logging and debugging purposes + * + * @example "redis" or "memory" + */ + readonly type: string; + + /** + * Save AuthRequest data + * + * @param code - Authorization code () + * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) + */ + saveAuthRequest(code: string, data: AuthRequestData): Promise; + + /** + * Get and consume AuthRequest data + * + * @param code - authorization code + * @returns AuthRequset data or null if not found/expired + */ + getAuthRequest(code: string): Promise; +} + diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 0308f58e..e80c34bb 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -61,6 +61,15 @@ declare namespace NodeJS { */ SAML_STORE_TYPE?: string; + /** + * MCP AuthRequest data store + + * Determines which store implementation to use for MCP AuthRequest data: + * - 'redis' (default): Uses Redis store for multi-instance support + * - 'memory': Uses in-memory store (single instance only) + */ + MCP_AUTH_REQUEST_STORE_TYPE?: string; + /** * Redis connection URL * Used for caching and TimeSeries data diff --git a/yarn.lock b/yarn.lock index 1caa5422..7a35143f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -881,6 +881,13 @@ dependencies: zod "^4.2.0" +"@modelcontextprotocol/express@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/express/-/express-2.0.0.tgz#0e29ada50d022fd345e8661a337cefeabdc6d0e0" + integrity sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ== + dependencies: + cors "^2.8.5" + "@modelcontextprotocol/node@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@modelcontextprotocol/node/-/node-2.0.0.tgz#3851275cb1c08aeb113d5b7cbe6dbe7adab37c65"