Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.6.1",
"version": "1.6.2",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -43,6 +43,7 @@
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.2",
"@hawk.so/types": "^0.7.0",
"@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"@n1ru4l/json-patch-plus": "^0.2.0",
Expand Down
8 changes: 8 additions & 0 deletions src/integrations/mcp/auth/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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`

Check failure on line 7 in src/integrations/mcp/auth/authMiddleware.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing trailing comma
});
21 changes: 21 additions & 0 deletions src/integrations/mcp/auth/getUserIdFromToken.ts
Original file line number Diff line number Diff line change
@@ -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
*

Check failure on line 8 in src/integrations/mcp/auth/getUserIdFromToken.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
* @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;

Check failure on line 17 in src/integrations/mcp/auth/getUserIdFromToken.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
} catch {
return null;
}
};
257 changes: 257 additions & 0 deletions src/integrations/mcp/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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')

Check failure on line 16 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing semicolon
}

const ACCESS_TOKEN_LIFETIME = '15m';

/**
* Implements MCP Authorization Flow using OAuth 2.1
*

Check failure on line 23 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
*

Check failure on line 24 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
* @param app

Check failure on line 25 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
* @param factories

Check failure on line 26 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
*/
export function useMCPAuth(app: express.Application, factories: ContextFactories) {
const store = createMCPStore();

async function createTokenResponse(userId: string) {
const tokensPair = await generateTokensPair(userId);

Check failure on line 32 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

'generateTokensPair' was used before it was defined

return {
access_token: tokensPair.accessToken,

Check failure on line 35 in src/integrations/mcp/auth/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Identifier 'access_token' is not in camel case
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);
});
};
38 changes: 38 additions & 0 deletions src/integrations/mcp/auth/tokenVerifier.ts
Original file line number Diff line number Diff line change
@@ -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
};
}
};
15 changes: 12 additions & 3 deletions src/integrations/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import express from 'express';
import { ContextFactories } from 'src/types/graphql';
import { createMCPRouter } from './mcp';
import { useMCPAuth } from './auth';
import { authMiddleware } from './auth/authMiddleware';

/**
* 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
): void {
useMCPAuth(app, factories);
const router = createMCPRouter(factories);

app.use('/integration/mcp', router);
}
/**
* MCP
*/
app.use('/integration/mcp', authMiddleware, router);
};
Loading
Loading