From f446cfc91b8ed889fbaa88df1fb0cb377e69ea13 Mon Sep 17 00:00:00 2001 From: Eugeny Date: Sat, 12 Sep 2026 17:53:52 +0300 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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": { From 7e70c7634b0ebae7f916292b38bd46e8233fd58d Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Sun, 25 Oct 2026 20:02:40 +0300 Subject: [PATCH 5/8] fix(token-refresh): fix 400 status code error on token refresh --- src/integrations/mcp/auth/authMiddleware.ts | 2 +- .../mcp/auth/getUserIdFromToken.ts | 21 --- src/integrations/mcp/auth/index.ts | 129 +++++++++--------- src/integrations/mcp/auth/tokenVerifier.ts | 40 ++---- src/integrations/mcp/auth/types.ts | 37 +++++ .../mcp/helpers/generateMCPClientId.ts | 5 + .../mcp/helpers/getUserIdFromToken.ts | 23 ++++ src/integrations/mcp/mcp.ts | 41 +++--- .../MCPAuthRequestStoreInterface.ts} | 44 +----- src/integrations/mcp/store/memory.store.ts | 14 +- src/integrations/mcp/store/redis.store.ts | 21 +-- src/integrations/mcp/store/storeFactory.ts | 15 +- src/integrations/mcp/store/types.ts | 18 +++ src/types/env.d.ts | 2 +- src/types/graphql.ts | 5 + yarn.lock | 50 ++++--- 16 files changed, 251 insertions(+), 216 deletions(-) delete mode 100644 src/integrations/mcp/auth/getUserIdFromToken.ts create mode 100644 src/integrations/mcp/auth/types.ts create mode 100644 src/integrations/mcp/helpers/generateMCPClientId.ts create mode 100644 src/integrations/mcp/helpers/getUserIdFromToken.ts rename src/integrations/mcp/{types.ts => store/MCPAuthRequestStoreInterface.ts} (50%) create mode 100644 src/integrations/mcp/store/types.ts diff --git a/src/integrations/mcp/auth/authMiddleware.ts b/src/integrations/mcp/auth/authMiddleware.ts index f87d07f8..cfc1a6cf 100644 --- a/src/integrations/mcp/auth/authMiddleware.ts +++ b/src/integrations/mcp/auth/authMiddleware.ts @@ -4,5 +4,5 @@ import { tokenVerifier } from './tokenVerifier'; export const authMiddleware = requireBearerAuth({ verifier: tokenVerifier, resourceMetadataUrl: - `${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp` + `${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 deleted file mode 100644 index ad55d195..00000000 --- a/src/integrations/mcp/auth/getUserIdFromToken.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 index 4f48d328..f2763a22 100644 --- a/src/integrations/mcp/auth/index.ts +++ b/src/integrations/mcp/auth/index.ts @@ -2,42 +2,47 @@ 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 { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken'; +import { generateMCPClientId } from '../helpers/generateMCPClientId'; +import type { Request } from 'express'; 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'; +import type { TokenRequest } from './types'; /** * Implements MCP Authorization Flow using OAuth 2.1 - * - * - * @param app - * @param factories + * + * + * @param app + * @param factories */ export function useMCPAuth(app: express.Application, factories: ContextFactories) { + 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 store = createMCPStore(); async function createTokenResponse(userId: string) { const tokensPair = await generateTokensPair(userId); + const data = getUserJWTDataFromToken(tokensPair.accessToken, 'access'); + + if (!data) { + return; + } + return { access_token: tokensPair.accessToken, refresh_token: tokensPair.refreshToken, token_type: 'Bearer', - expires_in: ACCESS_TOKEN_LIFETIME, - scope: 'mcp:tools mcp:resources' - } + expires_in: data.exp, + scope: 'mcp:tools mcp:resources', + }; }; async function generateTokensPair(userId: string) { @@ -55,12 +60,12 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories */ app.post('/register/integration/mcp', (req, res) => { res.status(201).json({ - client_id: `hawk-client-${crypto.randomUUID()}`, + client_id: generateMCPClientId(), 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' + response_types: [ 'code' ], + token_endpoint_auth_method: 'none', }); }); @@ -73,12 +78,12 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories res.json({ resource: `${process.env.API_URL}/integration/mcp`, authorization_servers: [ - `${process.env.API_URL}/integration/mcp` + `${process.env.API_URL}/integration/mcp`, ], scopes_supported: [ 'mcp:tools', - 'mcp:resources' - ] + 'mcp:resources', + ], }); } ); @@ -101,20 +106,20 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories registration_endpoint: `${process.env.API_URL}/register/integration/mcp`, - response_types_supported: ['code'], + response_types_supported: [ 'code' ], grant_types_supported: [ 'authorization_code', - 'refresh_token' + 'refresh_token', ], token_endpoint_auth_methods_supported: [ - 'none' + 'none', ], code_challenge_methods_supported: [ - 'S256' - ] + 'S256', + ], }); } ); @@ -127,25 +132,25 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories client_id, redirect_uri, state, - code_challenge + code_challenge, } = req.body; const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { return res.status(401).json({ - error: 'unauthorized' + error: 'unauthorized', }); } const loginToken = header.slice(7); - const user = getUserJWTDataFromToken(loginToken); + const user = getUserJWTDataFromToken(loginToken, 'access'); if (!user) { return res.status(400).json({ - error: 'invalid_grant' - }) + error: 'invalid_grant', + }); } const code = crypto @@ -168,72 +173,71 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories } return res.json({ - redirect_uri: callback.toString() + 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; + 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 (grant_type === 'refresh_token') { + 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' }); } - let auth: TokenData; + const data = getUserJWTDataFromToken(refresh_token, 'refresh'); - try { - auth = verifyToken(refresh_token, 'refresh'); - } catch { + if (!data) { 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); + const response = await createTokenResponse(data.userId); return res.json(response); } - if (grant_type !== 'authorization_code') { + if (body.grant_type !== 'authorization_code') { return res.status(400).json({ - error: 'unsupported_grant_type' + error: 'unsupported_grant_type', }); } + const { + code, + code_verifier, + client_id, + redirect_uri + } = body; + const auth = await store.getAuthRequest(code); if (!auth) { return res.status(400).json({ - error: 'invalid_grant' + error: 'invalid_grant', }); } if (auth.clientId !== client_id) { return res.status(400).json({ - error: 'invalid_grant' + error: 'invalid_grant', }); } if (auth.redirectUri !== redirect_uri) { return res.status(400).json({ - error: 'invalid_grant' + error: 'invalid_grant', }); } @@ -247,11 +251,12 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories if (calculatedChallenge !== auth.codeChallenge) { return res.status(400).json({ - error: 'invalid_grant' + 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 index e81e1006..ff6be310 100644 --- a/src/integrations/mcp/auth/tokenVerifier.ts +++ b/src/integrations/mcp/auth/tokenVerifier.ts @@ -1,38 +1,26 @@ -import jwt from "jsonwebtoken"; -import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server"; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken'; +import { generateMCPClientId } from '../helpers/generateMCPClientId'; -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; -}; +import type { OAuthTokenVerifier } from '@modelcontextprotocol/server'; +/** + * + */ 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"); + const data = getUserJWTDataFromToken(token, 'access'); + + if (!data) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid or expired access token'); } return { token, - clientId: payload.clientId, + clientId: generateMCPClientId(), scopes: ["mcp:tools", "mcp:resources"], - expiresAt: payload.exp + 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..f91c55bd --- /dev/null +++ b/src/integrations/mcp/auth/types.ts @@ -0,0 +1,37 @@ + +/** + * 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; diff --git a/src/integrations/mcp/helpers/generateMCPClientId.ts b/src/integrations/mcp/helpers/generateMCPClientId.ts new file mode 100644 index 00000000..76d9209c --- /dev/null +++ b/src/integrations/mcp/helpers/generateMCPClientId.ts @@ -0,0 +1,5 @@ +import crypto from 'node:crypto'; + +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..3d35b608 --- /dev/null +++ b/src/integrations/mcp/helpers/getUserIdFromToken.ts @@ -0,0 +1,23 @@ +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/mcp.ts b/src/integrations/mcp/mcp.ts index 458b8081..a774eeeb 100644 --- a/src/integrations/mcp/mcp.ts +++ b/src/integrations/mcp/mcp.ts @@ -9,49 +9,50 @@ import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'; * Creates MCP server * * @param factories - context factories for database access - * @returns configured MCP server + * @returns configured MCP server */ 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", + 'print_userId', { - description: "A test tool that pritn userId from auth token" + description: 'A test tool that pritn userId from auth token', }, async (ctx: ServerContext) => { - const token = ctx.http?.req?.headers.get("authorization")?.slice(7)!; - + 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) - } - ] - } + type: 'text' as const, + text: String(auth.userId), + }, + ], + }; } ); diff --git a/src/integrations/mcp/types.ts b/src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts similarity index 50% rename from src/integrations/mcp/types.ts rename to src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts index 1099a202..a7589d7e 100644 --- a/src/integrations/mcp/types.ts +++ b/src/integrations/mcp/store/MCPAuthRequestStoreInterface.ts @@ -1,41 +1,8 @@ -/** - * 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 -} +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 */ @@ -47,10 +14,10 @@ export interface MCPAuthRequestStoreInterface { * @example "redis" or "memory" */ readonly type: string; - + /** * Save AuthRequest data - * + * * @param code - Authorization code () * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) */ @@ -58,10 +25,9 @@ export interface MCPAuthRequestStoreInterface { /** * 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 index 13c59337..675141ad 100644 --- a/src/integrations/mcp/store/memory.store.ts +++ b/src/integrations/mcp/store/memory.store.ts @@ -1,11 +1,12 @@ -import type { AuthRequestData, MemoryAuthRequestData, MCPAuthRequestStoreInterface } from "../types"; +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. */ @@ -41,7 +42,7 @@ export class MemoryMCPAuthRequestStore implements MCPAuthRequestStoreInterface { /** * Save AuthRequest data - * + * * @param code - Authorization code () * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) */ @@ -49,14 +50,14 @@ export class MemoryMCPAuthRequestStore implements MCPAuthRequestStoreInterface { this.AuthState.set(code, { ...data, - expiresAt: Date.now() + this.TTL + expiresAt: Date.now() + this.TTL, } ); } /** * Get AuthRequest data - * + * * @param code - authorizaton code * @returns authRequest data or null if not found/expired */ @@ -101,5 +102,4 @@ export class MemoryMCPAuthRequestStore implements MCPAuthRequestStoreInterface { } } } - } diff --git a/src/integrations/mcp/store/redis.store.ts b/src/integrations/mcp/store/redis.store.ts index 31d3fcd0..48c017d8 100644 --- a/src/integrations/mcp/store/redis.store.ts +++ b/src/integrations/mcp/store/redis.store.ts @@ -1,14 +1,15 @@ -import type { AuthRequestData, MCPAuthRequestStoreInterface } from "../types"; -import type { RedisClientType } from "redis"; +import RedisHelper from '../../../redisHelper'; -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. */ @@ -16,7 +17,7 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { /** * Store type identifier */ - public readonly type = "redis"; + public readonly type = 'redis'; /** * Redis helper instance @@ -39,7 +40,7 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { /** * Save AuthRequest data - * + * * @param code - authorization code * @param data - AuthRequest data (userId, clientId, redirectUrl, codeChallenge) */ @@ -53,7 +54,7 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { /** * Get and consume AuthRequest data - * + * * @param code - authorization code * @returns AuthRequset data or null if not found/expired */ @@ -71,9 +72,9 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { .get(key) .del(key) .exec(); - + if (!result || result.length < 2) { - return null + return null; } const value = result[0] as string | null; @@ -93,7 +94,7 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { /** * Get Redis client - * + * * @returns Redis client instance * @throws Error if Redis client is not available */ diff --git a/src/integrations/mcp/store/storeFactory.ts b/src/integrations/mcp/store/storeFactory.ts index 0b7d0466..4b54d248 100644 --- a/src/integrations/mcp/store/storeFactory.ts +++ b/src/integrations/mcp/store/storeFactory.ts @@ -1,21 +1,21 @@ -import type { MCPAuthRequestStoreInterface } from "../types"; +import RedisHelper from '../../../redisHelper'; +import { MemoryMCPAuthRequestStore } from './memory.store'; +import { RedisMCPAuthRequestStore } from './redis.store'; -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(); } @@ -27,6 +27,7 @@ export function createMCPStore(): MCPAuthRequestStoreInterface { console.warn( '[MCP Store] Redis store requested but Redis is not connected. Falling back to memory store.' ); + 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..fd85412c --- /dev/null +++ b/src/integrations/mcp/store/types.ts @@ -0,0 +1,18 @@ +/** + * 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 [In-memory store for MCP AuthRequest data](./memory.store.ts) + * 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 e80c34bb..347f9392 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -63,7 +63,7 @@ declare namespace NodeJS { /** * 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) 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 7a35143f..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,33 +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/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== +"@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: 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" - integrity sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg== +"@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: - "@hono/node-server" "^1.19.9" + "@hono/node-server" "^1.19.9 || ^2.1.3" + hono "^4.11.4" -"@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/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.0.0" + "@modelcontextprotocol/core" "2.4.0" zod "^4.2.0" "@mongodb-js/saslprep@^1.3.0": @@ -3997,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" From db7d5c046da4ec555bfe32953cadba70ad9640f5 Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Tue, 27 Oct 2026 01:02:49 +0300 Subject: [PATCH 6/8] add JSDoc and fix lint --- src/integrations/mcp/auth/authMiddleware.ts | 7 + src/integrations/mcp/auth/index.ts | 190 ++++++++++++------ src/integrations/mcp/auth/tokenVerifier.ts | 14 +- src/integrations/mcp/auth/types.ts | 56 +++++- .../mcp/helpers/generateMCPClientId.ts | 4 + .../mcp/helpers/getUserIdFromToken.ts | 1 + src/integrations/mcp/mcp.ts | 2 +- src/integrations/mcp/store/redis.store.ts | 2 +- src/integrations/mcp/store/types.ts | 6 +- 9 files changed, 213 insertions(+), 69 deletions(-) diff --git a/src/integrations/mcp/auth/authMiddleware.ts b/src/integrations/mcp/auth/authMiddleware.ts index cfc1a6cf..312f594e 100644 --- a/src/integrations/mcp/auth/authMiddleware.ts +++ b/src/integrations/mcp/auth/authMiddleware.ts @@ -1,6 +1,13 @@ 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: diff --git a/src/integrations/mcp/auth/index.ts b/src/integrations/mcp/auth/index.ts index f2763a22..e27c88fc 100644 --- a/src/integrations/mcp/auth/index.ts +++ b/src/integrations/mcp/auth/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/camelcase, camelcase */ import express from 'express'; import crypto from 'node:crypto'; import { createMCPStore } from '../store/storeFactory'; @@ -5,35 +6,65 @@ import { AuthenticationError } from 'apollo-server-express'; import { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken'; import { generateMCPClientId } from '../helpers/generateMCPClientId'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; +import type { TokensPair } from '@hawk.so/types'; import type { ContextFactories } from 'src/types/graphql'; -import type { TokenRequest } from './types'; +import type { + AuthorizationServerMetadataResponse, + ClientInformationResponse, + ProtectedResourceMetadataResponse, + TokenRequest, + TokenResponse +} from './types'; /** - * Implements MCP Authorization Flow using OAuth 2.1 + * 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 - * @param factories + * @param app - Express application instance + * @param factories - context factories for database access */ export function useMCPAuth(app: express.Application, factories: ContextFactories) { - if (!process.env.API_URL) { - throw new Error('API_URL environment variable must be set to generate redirect URI'); - } + const store = createMCPStore(); - if (!process.env.GARAGE_URL) { - throw new Error('GARAGE_URL environment variable must be set to generate authorization endpoint'); - } + /** + * 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); - const store = createMCPStore(); + if (!user) { + throw new AuthenticationError('Invalid userId'); + } + + return user.generateTokensPair(); + }; - async function createTokenResponse(userId: string) { + /** + * 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; + return null; } return { @@ -45,36 +76,15 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories }; }; - 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: generateMCPClientId(), - 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 + * 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) => { + (_req, res: Response) => { res.json({ resource: `${process.env.API_URL}/integration/mcp`, authorization_servers: [ @@ -89,11 +99,14 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories ); /** - * OAuth server metadata + * 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) => { + (_req, res: Response) => { res.json({ issuer: `${process.env.API_URL}/integration/mcp`, @@ -125,7 +138,36 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories ); /** - * Frontend callback + * 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 { @@ -137,9 +179,9 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories const header = req.headers.authorization; - if (!header?.startsWith('Bearer ')) { - return res.status(401).json({ - error: 'unauthorized', + if (typeof header !== 'string') { + return res.status(400).json({ + error: 'invalid_grant', }); } @@ -153,10 +195,16 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories }); } + /** + * 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, @@ -164,6 +212,9 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories codeChallenge: code_challenge, }); + /** + * Add parameters to redirect_uri + */ const callback = new URL(redirect_uri); callback.searchParams.set('code', code); @@ -178,7 +229,19 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories }); /** - * MCP client callback + * 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; @@ -189,7 +252,7 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories if (body.grant_type === 'refresh_token') { const { refresh_token, - client_id + client_id, } = body; if (typeof refresh_token !== 'string' || !refresh_token || @@ -205,6 +268,12 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories 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); } @@ -218,44 +287,53 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories code, code_verifier, client_id, - redirect_uri + redirect_uri, } = body; - const auth = await store.getAuthRequest(code); + /** + * Retrieve AuthRequest data from the store + */ + const data = await store.getAuthRequest(code); - if (!auth) { + if (!data) { return res.status(400).json({ error: 'invalid_grant', }); } - if (auth.clientId !== client_id) { + if (data.clientId !== client_id) { return res.status(400).json({ error: 'invalid_grant', }); } - if (auth.redirectUri !== redirect_uri) { + if (data.redirectUri !== redirect_uri) { return res.status(400).json({ error: 'invalid_grant', }); } /** - * PKCE + * Validate codeChallenge */ const calculatedChallenge = crypto .createHash('sha256') .update(code_verifier) .digest('base64url'); - if (calculatedChallenge !== auth.codeChallenge) { + if (calculatedChallenge !== data.codeChallenge) { return res.status(400).json({ error: 'invalid_grant', }); } - const response = await createTokenResponse(auth.userId); + 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 index ff6be310..23b9e5bc 100644 --- a/src/integrations/mcp/auth/tokenVerifier.ts +++ b/src/integrations/mcp/auth/tokenVerifier.ts @@ -2,14 +2,18 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import { getUserJWTDataFromToken } from '../helpers/getUserIdFromToken'; import { generateMCPClientId } from '../helpers/generateMCPClientId'; -import type { OAuthTokenVerifier } from '@modelcontextprotocol/server'; +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) { @@ -19,8 +23,8 @@ export const tokenVerifier: OAuthTokenVerifier = { return { token, clientId: generateMCPClientId(), - scopes: ["mcp:tools", "mcp:resources"], - expiresAt: data.exp + 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 index f91c55bd..d40f4de0 100644 --- a/src/integrations/mcp/auth/types.ts +++ b/src/integrations/mcp/auth/types.ts @@ -1,7 +1,9 @@ +/* eslint-disable @typescript-eslint/camelcase, camelcase */ + /** * Base parameters included in every request to - * '/token/integration/mcp'. + * '/token/integration/mcp' */ type TokenRequestBase = { grant_type: string; @@ -11,7 +13,7 @@ type TokenRequestBase = { /** * Parameters for an authorization code token request - * to '/token/integration/mcp'. + * to '/token/integration/mcp' */ type AuthorizationTokenRequest = TokenRequestBase & { grant_type: 'authorization_code'; @@ -22,7 +24,7 @@ type AuthorizationTokenRequest = TokenRequestBase & { /** * Parameters for a refresh token request - * to '/token/integration/mcp'. + * to '/token/integration/mcp' */ type RefreshTokenRequest = TokenRequestBase & { grant_type: 'refresh_token'; @@ -30,8 +32,54 @@ type RefreshTokenRequest = TokenRequestBase & { }; /** - * Union of authorization code and refresh token requests. + * 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 index 76d9209c..5a2de8f5 100644 --- a/src/integrations/mcp/helpers/generateMCPClientId.ts +++ b/src/integrations/mcp/helpers/generateMCPClientId.ts @@ -1,5 +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 index 3d35b608..b8b963c1 100644 --- a/src/integrations/mcp/helpers/getUserIdFromToken.ts +++ b/src/integrations/mcp/helpers/getUserIdFromToken.ts @@ -12,6 +12,7 @@ import type { UserJWTData } from 'src/types/graphql'; */ 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, diff --git a/src/integrations/mcp/mcp.ts b/src/integrations/mcp/mcp.ts index a774eeeb..fa5c67b5 100644 --- a/src/integrations/mcp/mcp.ts +++ b/src/integrations/mcp/mcp.ts @@ -1,4 +1,4 @@ -import jwt from "jsonwebtoken"; +import jwt from 'jsonwebtoken'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer, ServerContext } from '@modelcontextprotocol/server'; import { ContextFactories, UserJWTData } from 'src/types/graphql'; diff --git a/src/integrations/mcp/store/redis.store.ts b/src/integrations/mcp/store/redis.store.ts index 48c017d8..bc404ebe 100644 --- a/src/integrations/mcp/store/redis.store.ts +++ b/src/integrations/mcp/store/redis.store.ts @@ -86,7 +86,7 @@ export class RedisMCPAuthRequestStore implements MCPAuthRequestStoreInterface { try { return JSON.parse(value) as AuthRequestData; } catch (error) { - console.error('[Redis MCP Store] Failed to parse AuthCode', error); + console.error('[MCP Auth Store] Failed to parse AuthCode', error); return null; } diff --git a/src/integrations/mcp/store/types.ts b/src/integrations/mcp/store/types.ts index fd85412c..7f642de9 100644 --- a/src/integrations/mcp/store/types.ts +++ b/src/integrations/mcp/store/types.ts @@ -1,3 +1,5 @@ +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 @@ -10,8 +12,8 @@ export type AuthRequestData = { }; /** - * Used by [In-memory store for MCP AuthRequest data](./memory.store.ts) - * to track AuthRequest expiration + * Used by {@link MemoryMCPAuthRequestStore} + * to track AuthRequest expiration */ export type MemoryAuthRequestData = AuthRequestData & { expiresAt: number; From a869ee0feebca1b19f03dbf61d4a15b722017118 Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Tue, 27 Oct 2026 20:13:12 +0300 Subject: [PATCH 7/8] chore(generateTokenPair): check enforced sso before issuing tokens --- src/integrations/mcp/auth/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/integrations/mcp/auth/index.ts b/src/integrations/mcp/auth/index.ts index e27c88fc..bb7023b7 100644 --- a/src/integrations/mcp/auth/index.ts +++ b/src/integrations/mcp/auth/index.ts @@ -49,7 +49,15 @@ export function useMCPAuth(app: express.Application, factories: ContextFactories throw new AuthenticationError('Invalid userId'); } - return user.generateTokensPair(); + /** + * 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); }; /** From 170cf37a539517508ae332c84ef5a6e74abfba30 Mon Sep 17 00:00:00 2001 From: Eugeny Koshaev Date: Wed, 28 Oct 2026 15:45:28 +0300 Subject: [PATCH 8/8] fix(print_userId tool): prevent possible unhandled error --- src/integrations/mcp/mcp.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/integrations/mcp/mcp.ts b/src/integrations/mcp/mcp.ts index fa5c67b5..52cdbde2 100644 --- a/src/integrations/mcp/mcp.ts +++ b/src/integrations/mcp/mcp.ts @@ -38,18 +38,25 @@ const createMCPServer = (factories: ContextFactories) => { server.registerTool( 'print_userId', { - description: 'A test tool that pritn userId from auth token', + description: 'A test tool that prints userId from auth token', }, async (ctx: ServerContext) => { - const token = ctx.http?.req?.headers.get('authorization')?.slice(7)!; + let message: string; - const auth = jwt.decode(token) as UserJWTData; + 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(auth.userId), + text: String(message), }, ], };