Skip to content

Remote MCP auth #2

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

Merged
merged 3 commits into from
Jun 17, 2025
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
deno.lock
log.txt

# Dependency directories
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ For local development, see [Set up local MCP configuration](CONTRIBUTING.md).

---


## Troubleshooting

### Node Version
Expand Down
25 changes: 25 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

[[redirects]]
from = "/mcp/*"
to = ".netlify/functions/mcp/:splat"
status = 200

[[redirects]]
from = "/oauth-server/*"
to = ".netlify/functions/oauth-server/:splat"
status = 200



[[redirects]]
from = "/.well-known/oauth-protected-resource"
to = ".netlify/functions/oauth-server/.well-known/oauth-protected-resource"
status = 200
[[redirects]]
from = "/.well-known/oauth-authorization-server"
to = ".netlify/functions/oauth-server/.well-known/oauth-authorization-server"
status = 200
[[redirects]]
from = "/.well-known/openid-configuration"
to = ".netlify/functions/oauth-server/openid-configuration"
status = 200
223 changes: 223 additions & 0 deletions netlify/functions/mcp-server/auth-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { HandlerResponse } from "@netlify/functions";
import { createHash } from "crypto";
import { createJWE, decryptJWE } from "./utils.js";

interface CODE_JWE_PAYLOAD {
state: Record<string, any>;
accessToken: string;
}

const NTL_AUTH_CLIENT_ID = process.env.NTL_AUTH_CLIENT_ID || '';


export async function handleAuthStart(req: Request): Promise<HandlerResponse>{

const parsedUrl = new URL(req.url);
const params = parsedUrl.searchParams;
const requiredParams = ['response_type', 'client_id', 'redirect_uri',];
const optionalParams = ['state','scope', 'nonce', 'code_challenge', 'code_challenge_method'];

const missingParams = requiredParams.filter(param => !params.get(param));
if (missingParams.length > 0) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'invalid_request',
error_description: `Missing required parameters: ${missingParams.join(', ')}`
}),
};
}

const paramsObj: Record<string, string> = {};
([] as any[]).concat(requiredParams, optionalParams).filter(param => {
if(params.get(param)){
paramsObj[param] = params.get(param) as string;
}
});

// b64 value for the redirects
const paramsState = Buffer.from(JSON.stringify(paramsObj), 'utf-8').toString('base64');
const netlifyRedirectUri = `${parsedUrl.origin}/oauth-server/client-redirect`;

return {
statusCode: 302,
headers: {
'Location': `https://app.netlify.com/authorize?client_id=${NTL_AUTH_CLIENT_ID}&response_type=token&state=${paramsState}&redirect_uri=${netlifyRedirectUri}`
},
body: ''
};
}


export async function handleClientSideAuthExchange(){
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
body: `
<!DOCTYPE html>
<html>
<head>
<title>OAuth Client Redirect</title>
</head>
<body>
<p>Redirecting to the client application...</p>

<script>
let hash = window.location.hash;
let hashToken = '';
let hashState = '';

if(hash.startsWith('#')){
hash = hash.slice(1);
}

if(hash.startsWith('?')){
hash = hash.slice(1);
}

if(hash.includes('=')) {
const params = new URLSearchParams(hash);
const token = params.get('access_token') || params.get('token');
const state = params.get('state');
if(state) {
hashState = state;
}
if(token) {
hashToken = token;
}
}else {
hashToken = hash;
}

window.location.href = '/oauth-server/server-redirect?token=' + hashToken + '&init-state=' + hashState;
</script>
</body>
</html>
`
};
}


export async function handleServerSideAuthRedirect(req: Request): Promise<HandlerResponse> {
const parsedUrl = new URL(req.url);
const initState = parsedUrl.searchParams.get('init-state');
const token = parsedUrl.searchParams.get('token');

if (!initState || !token) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'invalid_request',
error_description: `Missing required parameters: ${!initState ? 'init-state' : ''} ${!token ? 'token' : ''}`.trim()
}),
};
}

try {
const stateObj = JSON.parse(Buffer.from(initState, 'base64').toString('utf-8'));

const rediredctURL = new URL(stateObj.redirect_uri);

if(stateObj.state) {
rediredctURL.searchParams.set('state', stateObj.state);
}

// TODO: future, we will add specific tools and other context to this for
// downstream validation
const jwe = await createJWE({state: stateObj, accessToken: token} satisfies CODE_JWE_PAYLOAD);

rediredctURL.searchParams.set('code', jwe);

return {
statusCode: 302,
headers: {
'Location': rediredctURL.toString(),
},
body: ''
};

} catch (error) {

console.error('Failed to parse init-state:', error);

return {
statusCode: 400,
body: JSON.stringify({
error: 'invalid_request',
error_description: `Invalid init-state parameter`
}),
};
}
}


export async function handleCodeExchange(req: Request): Promise<HandlerResponse> {

const body = await req.text();

// get data from application/x-www-form-urlencoded body
const bodyParams = new URLSearchParams(body);

const code = bodyParams.get('code');
const codeVerifier = bodyParams.get('code_verifier');

if(!code) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'invalid_request',
error_description: 'Missing required parameter: code'
}),
};
}

const {accessToken, state} = (await decryptJWE(code)) as any as CODE_JWE_PAYLOAD;

if(codeVerifier && !isPKCEValid(codeVerifier, state.code_challenge, state.code_challenge_method)) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'invalid_grant',
error_description: 'PKCE verification failed',
}),
};
}

const accessTokenJEW = await createJWE({accessToken});

return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
body: JSON.stringify({
"access_token": accessTokenJEW,
// "refresh_token": "REFRESH_TOKEN",
"token_type": "Bearer",
"expires_in": 3600
})
}
}


function isPKCEValid(codeVerifier: string, codeChallenge: string, codeChallengeMethod = 'S256') {
if (codeChallengeMethod === 'plain') {
return codeVerifier === codeChallenge;
} else if (codeChallengeMethod === 'S256') {
// SHA-256 hash the code_verifier, base64url encode, and compare
const hash = createHash('sha256').update(codeVerifier).digest();
const base64url = hash
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return base64url === codeChallenge;
} else {
// Unknown/unsupported method
return false;
}
}
91 changes: 91 additions & 0 deletions netlify/functions/mcp-server/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { EncryptJWT, jwtDecrypt } from 'jose'
import type { HandlerEvent, HandlerResponse } from "@netlify/functions";

const JWE_SECRET = process.env.JWE_SECRET || 'mysecrtypekey1234567890123456789012345678901234567890'; // 256-bit key for A256GCM

export function getOAuthIssuer(): string {
// Use the environment variable or default to localhost
return process.env.OAUTH_ISSUER || 'http://localhost:8888';
}

export function addCORSHeaders(response: HandlerResponse): HandlerResponse {
const respHeaders = headersToHeadersObject(response.headers as Record<string, string> | Headers || {});
respHeaders.set('Access-Control-Allow-Origin', '*');
respHeaders.set('Access-Control-Allow-Methods', '*');
respHeaders.set('Access-Control-Allow-Headers', '*');
response.headers = Object.fromEntries(respHeaders.entries());
return response;
}

export function headersToHeadersObject(headers: Headers | Record<string, string>): Headers {
const headersObj = new Headers();
for (const [key, value] of Object.entries(headers)) {
if (typeof value === 'string' || typeof value === 'number') {
headersObj.set(key, value.toString());
}
}
return headersObj;
}

export function getParsedUrl(req: HandlerEvent, overrideUrl?: string): URL {
return new URL(overrideUrl ?? req.rawUrl, getOAuthIssuer() || 'https://unknown.example.com');
}

export function urlsToHTTP(payload: Record<string, any> | string, origin: string): Record<string, any> | string {
let text = typeof payload === 'string' ? payload : JSON.stringify(payload);
const {host: targetHost, origin: targetOrigin} = new URL(origin);
text = text.replace(/(https?:\/\/[^"]+)/g, (match, url) => {

try {
const parsedUrl = new URL(url);
if (parsedUrl.origin.endsWith(targetHost)) {
return parsedUrl.toString().replace(parsedUrl.origin, targetOrigin);
}
} catch {}
return match; // Return original match if not valid or not same origin
});
return typeof payload === 'string' ? text : JSON.parse(text);
}

export function returnNeedsAuthResponse() {
return new Response(`{
"error": "unauthenticated",
"error_description": "You must authenticate to use this tool"
}`, {
status: 401,
headers: {
"Content-Type": "application/json",
// 401s should point to the resource server metadata and that will point to auth endpoints
"WWW-Authenticate": `Bearer resource_metadata="${getOAuthIssuer()}/.well-known/oauth-protected-resource"`,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
}
});
}

export async function createJWE(payload: Record<string, any>) {
const password = JWE_SECRET
const secret = new TextEncoder().encode(password.padEnd(32, '0').slice(0, 32)) // Ensure 32 bytes

const jwe = await new EncryptJWT(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setExpirationTime('1h')
.encrypt(secret)

return jwe
}

export async function decryptJWE(jwe: string) {
const password = JWE_SECRET
const secret = new TextEncoder().encode(password.padEnd(32, '0').slice(0, 32)) // Ensure 32 bytes

try {
const { payload } = await jwtDecrypt(jwe, secret)
return payload
} catch (error) {
console.error('Failed to decrypt JWE:', error)
throw new Error('Invalid JWE token')
}
}

Loading