-
Notifications
You must be signed in to change notification settings - Fork 2
Feat/setup mcp auth #681
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/setup mcp auth #681
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,306 @@ | ||||||
| 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<string, AuthCodeData>(); | ||||||
|
|
||||||
| 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) => ({ | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add docs |
||||||
| 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, | ||||||
|
Comment on lines
+53
to
+55
|
||||||
| { expiresIn: "30d" } | ||||||
| ), | ||||||
| token_type: "Bearer", | ||||||
| expires_in: accessTokenLifetimeSeconds, | ||||||
|
|
||||||
| scope: "mcp:tools mcp:resources" | ||||||
| }); | ||||||
|
Comment on lines
+47
to
+61
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we reuse |
||||||
|
|
||||||
| 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: | ||||||
|
Comment on lines
+83
to
+84
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add docs |
||||||
| `${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp` | ||||||
| }); | ||||||
|
|
||||||
| export const useMCPAuth = (app: express.Application) => { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use named functions instead of anonymous. It's better for debugging.
Suggested change
|
||||||
| /** | ||||||
| * Dynamic client registration | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please, provide a little more descriptive docs. |
||||||
| */ | ||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here. Explain why this method is needed and what is does. |
||||||
| */ | ||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. improve docs |
||||||
| */ | ||||||
| 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; | ||||||
|
Comment on lines
+179
to
+182
|
||||||
|
|
||||||
| const code = crypto | ||||||
| .randomBytes(32) | ||||||
| .toString("base64url"); | ||||||
|
|
||||||
| authCodes.set(code, { | ||||||
| userId: user.userId, | ||||||
| clientId: client_id, | ||||||
| redirectUri: redirect_uri, | ||||||
| codeChallenge: code_challenge, | ||||||
|
Comment on lines
+188
to
+192
|
||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. bad docs |
||||||
| */ | ||||||
| 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"); | ||||||
|
Comment on lines
+291
to
+294
|
||||||
|
|
||||||
| if (calculatedChallenge !== auth.codeChallenge) { | ||||||
| return res.status(400).json({ | ||||||
| error: "invalid_grant" | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| authCodes.delete(code); | ||||||
|
|
||||||
| return res.json(createTokenResponse(auth.userId, auth.clientId)); | ||||||
| }); | ||||||
| }; | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,17 @@ | ||
| import express from 'express'; | ||
| import { ContextFactories } from 'src/types/graphql'; | ||
| import express from "express"; | ||
| import { ContextFactories } from "src/types/graphql"; | ||
| import { createMCPRouter } from "./mcp"; | ||
| import { useMCPAuth, authMiddleware } from "./auth"; | ||
|
|
||
| /** | ||
| * Append MCP route to Express App | ||
| * | ||
| * @param app - Express application instance | ||
| * @param factories - context factories for database access | ||
| */ | ||
| export function appendMCPRoutes(app: express.Application, factories: ContextFactories): void { | ||
| export function appendMCPRoutes( | ||
| app: express.Application, | ||
| factories: ContextFactories | ||
| ) { | ||
| useMCPAuth(app); | ||
| const router = createMCPRouter(factories); | ||
|
|
||
| app.use('/integration/mcp', router); | ||
| } | ||
| /** | ||
| * MCP | ||
| */ | ||
| app.use("/integration/mcp", authMiddleware, router); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this maps will be shared across all users, is it ok? Also, add docs please