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..312f594e --- /dev/null +++ b/src/integrations/mcp/auth/authMiddleware.ts @@ -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`, +}); diff --git a/src/integrations/mcp/auth/index.ts b/src/integrations/mcp/auth/index.ts new file mode 100644 index 00000000..bb7023b7 --- /dev/null +++ b/src/integrations/mcp/auth/index.ts @@ -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) { + 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 { + const tokensPair = await generateTokensPair(userId); + + const data = getUserJWTDataFromToken(tokensPair.accessToken, 'access'); + + if (!data) { + return null; + } + + return { + access_token: tokensPair.accessToken, + refresh_token: tokensPair.refreshToken, + token_type: 'Bearer', + expires_in: data.exp, + 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) => { + 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) => { + 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', + ], + }); + } + ); + + /** + * 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) => { + 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) => { + 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'); + + 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); + + 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) => { + 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); + }); +}; diff --git a/src/integrations/mcp/auth/tokenVerifier.ts b/src/integrations/mcp/auth/tokenVerifier.ts new file mode 100644 index 00000000..23b9e5bc --- /dev/null +++ b/src/integrations/mcp/auth/tokenVerifier.ts @@ -0,0 +1,30 @@ +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken'; +import { generateMCPClientId } from '../helpers/generateMCPClientId'; + +import type { authMiddleware } from './authMiddleware'; +import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server'; + +/** + * Used by {@link authMiddleware}. Checks access token validity + * + * On SUCCESS returns the resulting {@link AuthInfo}, which the serving entry surfaces to MCP request handlers + * + * On ERROR throws OAuthError + */ +export const tokenVerifier: OAuthTokenVerifier = { + verifyAccessToken: async (token: string) => { + const data = getUserJWTDataFromToken(token, 'access'); + + if (!data) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid or expired access token'); + } + + return { + token, + clientId: generateMCPClientId(), + scopes: ['mcp:tools', 'mcp:resources'], + expiresAt: data.exp, + }; + }, +}; \ No newline at end of file diff --git a/src/integrations/mcp/auth/types.ts b/src/integrations/mcp/auth/types.ts new file mode 100644 index 00000000..d40f4de0 --- /dev/null +++ b/src/integrations/mcp/auth/types.ts @@ -0,0 +1,85 @@ + +/* eslint-disable @typescript-eslint/camelcase, camelcase */ + +/** + * Base parameters included in every request to + * '/token/integration/mcp' + */ +type TokenRequestBase = { + grant_type: string; + client_id: string; + resource: string; +}; + +/** + * Parameters for an authorization code token request + * to '/token/integration/mcp' + */ +type AuthorizationTokenRequest = TokenRequestBase & { + grant_type: 'authorization_code'; + code: string; + code_verifier: string; + redirect_uri: string; +}; + +/** + * Parameters for a refresh token request + * to '/token/integration/mcp' + */ +type RefreshTokenRequest = TokenRequestBase & { + grant_type: 'refresh_token'; + refresh_token: string; +}; + +/** + * Union of authorization code and refresh token requests + */ +export type TokenRequest = + | AuthorizationTokenRequest + | RefreshTokenRequest; + +/** + * Parameters for {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16#name-token-endpoint-response | Token Endpoint Response} + */ +export type TokenResponse = { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; +}; + +/** + * Parameters for {@link https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata-r | Protected Resource Metadata Response} + */ +export type ProtectedResourceMetadataResponse = { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + resource_documentation?: string; +}; + +/** + * Parameters for {@link https://datatracker.ietf.org/doc/html/rfc8414 | Server Metadata Response} + */ +export type AuthorizationServerMetadataResponse = { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + response_types_supported: string[]; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + code_challenge_methods_supported: string[]; +}; + +/** + * Parameters for {@link https://www.rfc-editor.org/info/rfc7591/#section-3.2.1 | Client Information Response} + */ +export type ClientInformationResponse = { + client_id: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + response_types: string[]; +}; diff --git a/src/integrations/mcp/helpers/generateMCPClientId.ts b/src/integrations/mcp/helpers/generateMCPClientId.ts new file mode 100644 index 00000000..5a2de8f5 --- /dev/null +++ b/src/integrations/mcp/helpers/generateMCPClientId.ts @@ -0,0 +1,9 @@ +import crypto from 'node:crypto'; + +/** + * Generates random MCP clientId + * @returns string + */ +export function generateMCPClientId() { + return `hawk-client:${crypto.randomUUID()}`; +}; diff --git a/src/integrations/mcp/helpers/getUserIdFromToken.ts b/src/integrations/mcp/helpers/getUserIdFromToken.ts new file mode 100644 index 00000000..b8b963c1 --- /dev/null +++ b/src/integrations/mcp/helpers/getUserIdFromToken.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; + +import type { Secret } from 'jsonwebtoken'; +import type { UserJWTData } from 'src/types/graphql'; + +/** + * Decodes JWT and extracts UserJWTData + * + * @param token - JWT + * @param tokenType - type of supplied jwt token ('refresh' or 'access') + * @returns UserJWTData or null if JWT can not be verified + */ +export function getUserJWTDataFromToken(token: string, tokenType: 'refresh' | 'access'): UserJWTData | null { + const jwtSecret = (tokenType === 'refresh' ? process.env.JWT_SECRET_REFRESH_TOKEN : process.env.JWT_SECRET_ACCESS_TOKEN) as Secret; + + try { + return jwt.verify( + token, + jwtSecret + ) as UserJWTData; + } catch { + return null; + } +}; 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..52cdbde2 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'; @@ -10,7 +11,7 @@ import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'; * @param factories - context factories for database access * @returns configured MCP server */ -function createMCPServer(factories: ContextFactories) { +const createMCPServer = (factories: ContextFactories) => { const server = new McpServer({ name: 'server', version: '0.0.0', @@ -34,6 +35,34 @@ function createMCPServer(factories: ContextFactories) { } ); + server.registerTool( + 'print_userId', + { + description: 'A test tool that prints userId from auth token', + }, + async (ctx: ServerContext) => { + let message: string; + + try { + const token = ctx.http?.req?.headers.get('authorization')?.slice(7)!; + const auth = jwt.decode(token) as UserJWTData; + + message = auth.userId; + } catch { + message = 'something went wrong'; + } + + return { + content: [ + { + type: 'text' as const, + text: String(message), + }, + ], + }; + } + ); + return server; }; diff --git a/src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts b/src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts new file mode 100644 index 00000000..a7589d7e --- /dev/null +++ b/src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts @@ -0,0 +1,33 @@ +import type { AuthRequestData } from './types'; + +/** + * 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/integrations/mcp/store/memory.store.ts b/src/integrations/mcp/store/memory.store.ts new file mode 100644 index 00000000..675141ad --- /dev/null +++ b/src/integrations/mcp/store/memory.store.ts @@ -0,0 +1,105 @@ +import type { AuthRequestData, MemoryAuthRequestData } from './types'; +import type { MCPAuthRequestStoreInterface } from './MCPAuthRequestStoreInterface'; + +/** + * 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..bc404ebe --- /dev/null +++ b/src/integrations/mcp/store/redis.store.ts @@ -0,0 +1,114 @@ +import RedisHelper from '../../../redisHelper'; + +import type { MCPAuthRequestStoreInterface } from './MCPAuthRequestStoreInterface'; +import type { AuthRequestData } from './types'; +import type { RedisClientType } from 'redis'; + +/** + * 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('[MCP Auth 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..4b54d248 --- /dev/null +++ b/src/integrations/mcp/store/storeFactory.ts @@ -0,0 +1,50 @@ +import RedisHelper from '../../../redisHelper'; +import { MemoryMCPAuthRequestStore } from './memory.store'; +import { RedisMCPAuthRequestStore } from './redis.store'; + +import type { MCPAuthRequestStoreInterface } from './MCPAuthRequestStoreInterface'; + +/** + * 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/store/types.ts b/src/integrations/mcp/store/types.ts new file mode 100644 index 00000000..7f642de9 --- /dev/null +++ b/src/integrations/mcp/store/types.ts @@ -0,0 +1,20 @@ +import type { MemoryMCPAuthRequestStore } from './memory.store'; + +/** + * Authorization data needed to verify that the original authorization request + * was made by the same client making token request + */ +export type AuthRequestData = { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; +}; + +/** + * Used by {@link MemoryMCPAuthRequestStore} + * to track AuthRequest expiration + */ +export type MemoryAuthRequestData = AuthRequestData & { + expiresAt: number; +} diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 0308f58e..347f9392 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/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36..67e50a4d 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -57,6 +57,11 @@ export interface UserJWTData { * User id */ userId: string; + + /** + * Expiration time (Unix timestamp in seconds) + */ + exp: number; } /** diff --git a/yarn.lock b/yarn.lock index 1caa5422..a8accdaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -531,10 +531,10 @@ dependencies: bson "^7.0.0" -"@hono/node-server@^1.19.9": - version "1.19.17" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.17.tgz#99bd8625cdbae82652a135c71363cd6322fae483" - integrity sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ== +"@hono/node-server@^1.19.9 || ^2.1.3": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-2.1.5.tgz#273db93e8bfb44ef361e55f3d00048013668aa09" + integrity sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q== "@isaacs/cliui@^8.0.2": version "8.0.2" @@ -874,26 +874,34 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@modelcontextprotocol/core@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/core/-/core-2.0.0.tgz#c918a4c6aef22a7c59dfb0aabc2a82c187cce157" - integrity sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA== +"@modelcontextprotocol/core@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/core/-/core-2.4.0.tgz#c7f1a505452c3a0ad666d9a6e78a5804d8d8d0d9" + integrity sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q== dependencies: zod "^4.2.0" -"@modelcontextprotocol/node@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/node/-/node-2.0.0.tgz#3851275cb1c08aeb113d5b7cbe6dbe7adab37c65" - integrity sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg== +"@modelcontextprotocol/express@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/express/-/express-2.0.2.tgz#4d2464f8bd588c98071d5ba477ab49425dc4a2ed" + integrity sha512-lUvfV7gT/tgP8nLRs0ajUtgL+MlMNWWHAls5c9cf4PyBexq3EgmhWWrS4irQOC9OoKuUsHHcNjUw9urhHz542g== dependencies: - "@hono/node-server" "^1.19.9" + cors "^2.8.5" -"@modelcontextprotocol/server@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/server/-/server-2.0.0.tgz#4fae5ccb8f7925ed9a6bf6595bb3b58617063ffd" - integrity sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw== +"@modelcontextprotocol/node@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/node/-/node-2.1.2.tgz#fc90e849adbc14d15bb3a50c1b01732012bf1a68" + integrity sha512-IIcIqBpr6NCQQs5DgJTgvbSOAw+3iaR/uquqg+e0nXCdudfjPg/80x0TseUnQx14AUAtE4fsJCEpJKPyb6WPVA== dependencies: - "@modelcontextprotocol/core" "2.0.0" + "@hono/node-server" "^1.19.9 || ^2.1.3" + hono "^4.11.4" + +"@modelcontextprotocol/server@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/server/-/server-2.4.0.tgz#282a61ccbdc531361f03525a33e33283534d6fc7" + integrity sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q== + dependencies: + "@modelcontextprotocol/core" "2.4.0" zod "^4.2.0" "@mongodb-js/saslprep@^1.3.0": @@ -3990,6 +3998,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hono@^4.11.4: + version "4.13.13" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.13.tgz#8dd5459ce4aa44b3e297f0b350f86b0f6d4a79f0" + integrity sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"