From f446cfc91b8ed889fbaa88df1fb0cb377e69ea13 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Sat, 12 Sep 2026 17:53:52 +0300 Subject: [PATCH 1/4] feat(mcp-auth): add initial OAuth 2.1 authorization support --- package.json | 1 + src/integrations/mcp/auth.ts | 271 ++++++++++++++++++++++++++++++++++ src/integrations/mcp/index.ts | 4 +- src/integrations/mcp/mcp.ts | 49 ++++-- yarn.lock | 7 + 5 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 src/integrations/mcp/auth.ts diff --git a/package.json b/package.json index 4edbeae5..f17e14b0 100644 --- a/package.json +++ b/package.json @@ -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.ts b/src/integrations/mcp/auth.ts new file mode 100644 index 00000000..d785f62b --- /dev/null +++ b/src/integrations/mcp/auth.ts @@ -0,0 +1,271 @@ +import express from "express"; +import crypto from "node:crypto"; +import jwt, { Secret } from "jsonwebtoken"; +import { UserJWTData } from "src/types/graphql"; +import { OAuthTokenVerifier } from "@modelcontextprotocol/server"; +import { requireBearerAuth } from "@modelcontextprotocol/express"; + +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') +} + +type AuthCodeData = { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + expiresAt: number; +}; + +const authCodes = new Map(); + +const tokenVerifier: OAuthTokenVerifier = { + verifyAccessToken: async (token: string) => { + const payload = jwt.verify( + token, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret + ) as { + userId: string; + clientId: string; + exp: number; + }; + + return { + token, + clientId: payload.clientId, + scopes: ["mcp:tools", "mcp:resources"], + expiresAt: payload.exp + }; + } +}; + +export const authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + resourceMetadataUrl: + `${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp` +}); + +export const useMCPAuth = (app: express.Application) => { + /** + * 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", (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 = jwt.verify( + loginToken, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret + ) as UserJWTData; + + const code = crypto + .randomBytes(32) + .toString("base64url"); + + authCodes.set(code, { + userId: user.userId, + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + expiresAt: Date.now() + 5 * 60 * 1000 + }); + + /** + * Remove authcode entry + * if it was never used + */ + setTimeout(() => { + authCodes.delete(code); + }, 5 * 60 * 1000); + + 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", (req, res) => { + const { + grant_type, + code, + client_id, + redirect_uri, + code_verifier + } = req.body; + + if (grant_type !== "authorization_code") { + return res.status(400).json({ + error: "unsupported_grant_type" + }); + } + + const auth = authCodes.get(code); + + if (!auth) { + return res.status(400).json({ + error: "invalid_grant" + }); + } + + if (auth.expiresAt < Date.now()) { + authCodes.delete(code); + + 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" + }); + } + + authCodes.delete(code); + + const accessToken = jwt.sign( + { + userId: auth.userId, + clientId: auth.clientId + }, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret, + { + expiresIn: "15m" + } + ); + + const refreshToken = jwt.sign( + { + userId: auth.userId, + clientId: auth.clientId + }, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret, + { expiresIn: "30d" } + ) + + return res.json({ + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer", + expires_in: 900, + scope: "mcp:tools mcp:resources" + }); + }); +}; diff --git a/src/integrations/mcp/index.ts b/src/integrations/mcp/index.ts index 4a6953bf..52586205 100644 --- a/src/integrations/mcp/index.ts +++ b/src/integrations/mcp/index.ts @@ -1,6 +1,7 @@ import express from 'express'; import { ContextFactories } from 'src/types/graphql'; import { createMCPHandler } from './mcp'; +import { useMCPAuth, authMiddleware } from "./auth"; /** * Appends MCP route to Express App @@ -9,7 +10,8 @@ import { createMCPHandler } from './mcp'; * @param factories - context factories for database access */ export function appendMCPRoutes(app: express.Application, factories: ContextFactories): void { + useMCPAuth(app); 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/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" From d9d1a0e83aaf8c26afcdc2a1a092ba24496361be Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Wed, 16 Sep 2026 03:40:46 +0300 Subject: [PATCH 2/4] chore(mcp-auth): refresh token --- src/integrations/mcp/auth.ts | 109 +++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 37 deletions(-) diff --git a/src/integrations/mcp/auth.ts b/src/integrations/mcp/auth.ts index d785f62b..80daf7b1 100644 --- a/src/integrations/mcp/auth.ts +++ b/src/integrations/mcp/auth.ts @@ -1,8 +1,8 @@ import express from "express"; import crypto from "node:crypto"; -import jwt, { Secret } from "jsonwebtoken"; +import jwt, { JwtPayload, Secret } from "jsonwebtoken"; import { UserJWTData } from "src/types/graphql"; -import { OAuthTokenVerifier } from "@modelcontextprotocol/server"; +import { OAuthError, OAuthErrorCode, OAuthTokenVerifier } from "@modelcontextprotocol/server"; import { requireBearerAuth } from "@modelcontextprotocol/express"; if (!process.env.API_URL) { @@ -22,17 +22,53 @@ type AuthCodeData = { }; const authCodes = new Map(); +const accessTokenLifetimeSeconds = "15m"; + +type TokenData = { + userId: string; + clientId: string; + tokenUse: "access" | "refresh"; + exp: number; +}; + +const 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; +}; + +const createTokenResponse = (userId: string, clientId: string) => ({ + access_token: jwt.sign( + { userId, clientId, tokenUse: "access" }, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret, + { expiresIn: accessTokenLifetimeSeconds } + ), + refresh_token: jwt.sign( + { userId, clientId, tokenUse: "refresh" }, + process.env.JWT_SECRET_ACCESS_TOKEN as Secret, + { expiresIn: "30d" } + ), + token_type: "Bearer", + expires_in: accessTokenLifetimeSeconds, + scope: "mcp:tools mcp:resources" +}); const tokenVerifier: OAuthTokenVerifier = { verifyAccessToken: async (token: string) => { - const payload = jwt.verify( - token, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret - ) as { - userId: string; - clientId: string; - exp: number; - }; + let payload: TokenData; + + try { + payload = verifyToken(token, "access"); + } catch { + throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid or expired access token"); + } return { token, @@ -184,12 +220,37 @@ export const useMCPAuth = (app: express.Application) => { app.post("/token/integration/mcp", (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" }); + } + + return res.json(createTokenResponse(auth.userId, auth.clientId)); + } + if (grant_type !== "authorization_code") { return res.status(400).json({ error: "unsupported_grant_type" @@ -240,32 +301,6 @@ export const useMCPAuth = (app: express.Application) => { authCodes.delete(code); - const accessToken = jwt.sign( - { - userId: auth.userId, - clientId: auth.clientId - }, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret, - { - expiresIn: "15m" - } - ); - - const refreshToken = jwt.sign( - { - userId: auth.userId, - clientId: auth.clientId - }, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret, - { expiresIn: "30d" } - ) - - return res.json({ - access_token: accessToken, - refresh_token: refreshToken, - token_type: "Bearer", - expires_in: 900, - scope: "mcp:tools mcp:resources" - }); + return res.json(createTokenResponse(auth.userId, auth.clientId)); }); }; From 3f8ef377e3cefaec67ab53a42aed5f415bd27448 Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Thu, 22 Oct 2026 22:34:06 +0300 Subject: [PATCH 3/4] feat(MCPAuthRequestStore): implement caching MCPAuthRequests using redis for multi-instance deploy or in-memory for single instance deploy, add JSDoc --- .env.sample | 3 + src/integrations/mcp/auth.ts | 306 ------------------ src/integrations/mcp/auth/authMiddleware.ts | 8 + .../mcp/auth/getUserIdFromToken.ts | 21 ++ src/integrations/mcp/auth/index.ts | 257 +++++++++++++++ src/integrations/mcp/auth/tokenVerifier.ts | 38 +++ src/integrations/mcp/index.ts | 5 +- src/integrations/mcp/store/memory.store.ts | 105 ++++++ src/integrations/mcp/store/redis.store.ts | 113 +++++++ src/integrations/mcp/store/storeFactory.ts | 49 +++ src/integrations/mcp/types.ts | 67 ++++ src/types/env.d.ts | 9 + 12 files changed, 673 insertions(+), 308 deletions(-) delete mode 100644 src/integrations/mcp/auth.ts create mode 100644 src/integrations/mcp/auth/authMiddleware.ts create mode 100644 src/integrations/mcp/auth/getUserIdFromToken.ts create mode 100644 src/integrations/mcp/auth/index.ts create mode 100644 src/integrations/mcp/auth/tokenVerifier.ts create mode 100644 src/integrations/mcp/store/memory.store.ts create mode 100644 src/integrations/mcp/store/redis.store.ts create mode 100644 src/integrations/mcp/store/storeFactory.ts create mode 100644 src/integrations/mcp/types.ts 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/src/integrations/mcp/auth.ts b/src/integrations/mcp/auth.ts deleted file mode 100644 index 80daf7b1..00000000 --- a/src/integrations/mcp/auth.ts +++ /dev/null @@ -1,306 +0,0 @@ -import express from "express"; -import crypto from "node:crypto"; -import jwt, { JwtPayload, Secret } from "jsonwebtoken"; -import { UserJWTData } from "src/types/graphql"; -import { OAuthError, OAuthErrorCode, OAuthTokenVerifier } from "@modelcontextprotocol/server"; -import { requireBearerAuth } from "@modelcontextprotocol/express"; - -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') -} - -type AuthCodeData = { - userId: string; - clientId: string; - redirectUri: string; - codeChallenge: string; - expiresAt: number; -}; - -const authCodes = new Map(); -const accessTokenLifetimeSeconds = "15m"; - -type TokenData = { - userId: string; - clientId: string; - tokenUse: "access" | "refresh"; - exp: number; -}; - -const 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; -}; - -const createTokenResponse = (userId: string, clientId: string) => ({ - access_token: jwt.sign( - { userId, clientId, tokenUse: "access" }, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret, - { expiresIn: accessTokenLifetimeSeconds } - ), - refresh_token: jwt.sign( - { userId, clientId, tokenUse: "refresh" }, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret, - { expiresIn: "30d" } - ), - token_type: "Bearer", - expires_in: accessTokenLifetimeSeconds, - scope: "mcp:tools mcp:resources" -}); - -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 - }; - } -}; - -export const authMiddleware = requireBearerAuth({ - verifier: tokenVerifier, - resourceMetadataUrl: - `${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp` -}); - -export const useMCPAuth = (app: express.Application) => { - /** - * 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", (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 = jwt.verify( - loginToken, - process.env.JWT_SECRET_ACCESS_TOKEN as Secret - ) as UserJWTData; - - const code = crypto - .randomBytes(32) - .toString("base64url"); - - authCodes.set(code, { - userId: user.userId, - clientId: client_id, - redirectUri: redirect_uri, - codeChallenge: code_challenge, - expiresAt: Date.now() + 5 * 60 * 1000 - }); - - /** - * Remove authcode entry - * if it was never used - */ - setTimeout(() => { - authCodes.delete(code); - }, 5 * 60 * 1000); - - 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", (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" }); - } - - return res.json(createTokenResponse(auth.userId, auth.clientId)); - } - - if (grant_type !== "authorization_code") { - return res.status(400).json({ - error: "unsupported_grant_type" - }); - } - - const auth = authCodes.get(code); - - if (!auth) { - return res.status(400).json({ - error: "invalid_grant" - }); - } - - if (auth.expiresAt < Date.now()) { - authCodes.delete(code); - - 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" - }); - } - - authCodes.delete(code); - - return res.json(createTokenResponse(auth.userId, auth.clientId)); - }); -}; 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 52586205..1b4931a9 100644 --- a/src/integrations/mcp/index.ts +++ b/src/integrations/mcp/index.ts @@ -1,7 +1,8 @@ import express from 'express'; import { ContextFactories } from 'src/types/graphql'; import { createMCPHandler } from './mcp'; -import { useMCPAuth, authMiddleware } from "./auth"; +import { useMCPAuth } from './auth'; +import { authMiddleware } from './auth/authMiddleware'; /** * Appends MCP route to Express App @@ -10,7 +11,7 @@ import { useMCPAuth, authMiddleware } from "./auth"; * @param factories - context factories for database access */ export function appendMCPRoutes(app: express.Application, factories: ContextFactories): void { - useMCPAuth(app); + useMCPAuth(app, factories); const handler = createMCPHandler(factories); app.use('/integration/mcp', authMiddleware, (req, res) => handler(req, res, req.body)); 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 From 08d50310373f5429bf934ea51d84258395749d69 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:49:46 +0000 Subject: [PATCH 4/4] Bump version up to 1.6.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f17e14b0..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": {