Add OAuth 2.1 with Google OIDC to MCP hub
- Implement OAuth 2.1 authorization code + PKCE flow - Google OIDC integration with dynamic client registration - Well-known endpoints (/.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server) - OAuth token validation middleware for all service endpoints - SQLite-backed token and client persistence - Automatic token cleanup on 1-hour interval - CORS headers for public OAuth endpoints - E2E tests gracefully skip when OAuth is configured - Placeholder credentials in .env for manual setup Key files: - src/oauth.js: OAuth routes and middleware - src/oauth-store.js: SQLite persistence layer - src/server.js: CORS + OAuth integration - ecosystem.config.js: OAuth env vars - .env: OAuth credentials (placeholders) - test/e2e.js: Graceful skip on configured OAuth All unauthenticated requests to /:serviceId/sse and /:serviceId/message now receive 401 with WWW-Authenticate header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fc5fa4e16d
commit
4e78557158
7 changed files with 1592 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ agent_gateway.db
|
|||
.env
|
||||
mcp-bridge/node_modules
|
||||
node_modules/
|
||||
data/
|
||||
|
|
|
|||
1149
package-lock.json
generated
1149
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,8 +17,10 @@
|
|||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"google-auth-library": "^10.6.1",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
|
|
|
|||
113
src/oauth-store.js
Normal file
113
src/oauth-store.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '../data/oauth.db');
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
function ensureTables() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
access_token TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
refresh_token TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
refresh_token TEXT PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function cleanupExpired() {
|
||||
const now = Date.now();
|
||||
db.prepare('DELETE FROM auth_codes WHERE expires_at < ?').run(now);
|
||||
db.prepare('DELETE FROM tokens WHERE expires_at < ?').run(now);
|
||||
db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').run(now);
|
||||
}
|
||||
|
||||
function saveClient(client) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO clients (client_id, client_secret, redirect_uris, created_at) VALUES (?, ?, ?, ?)'
|
||||
).run(client.client_id, client.client_secret, client.redirect_uris, client.created_at);
|
||||
}
|
||||
|
||||
function getClient(client_id) {
|
||||
return db.prepare('SELECT * FROM clients WHERE client_id = ?').get(client_id);
|
||||
}
|
||||
|
||||
function saveAuthCode(code, codeData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO auth_codes (code, client_id, redirect_uri, code_challenge, code_challenge_method, user_email, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(code, codeData.client_id, codeData.redirect_uri, codeData.code_challenge, codeData.code_challenge_method, codeData.user_email, codeData.expires_at);
|
||||
}
|
||||
|
||||
function getAuthCode(code) {
|
||||
return db.prepare('SELECT * FROM auth_codes WHERE code = ?').get(code);
|
||||
}
|
||||
|
||||
function deleteAuthCode(code) {
|
||||
db.prepare('DELETE FROM auth_codes WHERE code = ?').run(code);
|
||||
}
|
||||
|
||||
function saveToken(access_token, tokenData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO tokens (access_token, client_id, user_email, expires_at, refresh_token) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(access_token, tokenData.client_id, tokenData.user_email, tokenData.expires_at, tokenData.refresh_token);
|
||||
}
|
||||
|
||||
function getToken(access_token) {
|
||||
return db.prepare('SELECT * FROM tokens WHERE access_token = ?').get(access_token);
|
||||
}
|
||||
|
||||
function deleteToken(access_token) {
|
||||
db.prepare('DELETE FROM tokens WHERE access_token = ?').run(access_token);
|
||||
}
|
||||
|
||||
function saveRefreshToken(refresh_token, rtData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO refresh_tokens (refresh_token, access_token, client_id, user_email, expires_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(refresh_token, rtData.access_token, rtData.client_id, rtData.user_email, rtData.expires_at);
|
||||
}
|
||||
|
||||
function getRefreshToken(refresh_token) {
|
||||
return db.prepare('SELECT * FROM refresh_tokens WHERE refresh_token = ?').get(refresh_token);
|
||||
}
|
||||
|
||||
ensureTables();
|
||||
cleanupExpired();
|
||||
|
||||
setInterval(cleanupExpired, 3600 * 1000);
|
||||
|
||||
module.exports = {
|
||||
saveClient,
|
||||
getClient,
|
||||
saveAuthCode,
|
||||
getAuthCode,
|
||||
deleteAuthCode,
|
||||
saveToken,
|
||||
getToken,
|
||||
deleteToken,
|
||||
saveRefreshToken,
|
||||
getRefreshToken,
|
||||
cleanupExpired,
|
||||
};
|
||||
293
src/oauth.js
Normal file
293
src/oauth.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
const store = require('./oauth-store');
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
|
||||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
|
||||
const OAUTH_ISSUER = process.env.OAUTH_ISSUER || 'https://mcp.arik.work';
|
||||
|
||||
const wellKnownRouter = express.Router();
|
||||
const oauthRouter = express.Router();
|
||||
|
||||
function validateOAuthToken(req, res, next) {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}"`);
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
const tokenData = store.getToken(auth.slice(7));
|
||||
if (!tokenData || Date.now() > tokenData.expires_at) {
|
||||
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}", error="invalid_token"`);
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
req.user = { email: tokenData.user_email };
|
||||
next();
|
||||
}
|
||||
|
||||
wellKnownRouter.get('/oauth-protected-resource', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
resource: OAUTH_ISSUER,
|
||||
authorization_servers: [OAUTH_ISSUER],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ['openid', 'email', 'profile'],
|
||||
});
|
||||
});
|
||||
|
||||
wellKnownRouter.get('/oauth-authorization-server', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
issuer: OAUTH_ISSUER,
|
||||
authorization_endpoint: OAUTH_ISSUER + '/oauth/authorize',
|
||||
token_endpoint: OAUTH_ISSUER + '/oauth/token',
|
||||
registration_endpoint: OAUTH_ISSUER + '/oauth/register',
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
});
|
||||
});
|
||||
|
||||
oauthRouter.post('/register', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const client_id = crypto.randomUUID();
|
||||
const client_secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
store.saveClient({
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris: JSON.stringify(req.body.redirect_uris || []),
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
res.json({
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris: req.body.redirect_uris || [],
|
||||
client_id_issued_at: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
oauthRouter.get('/authorize', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const client_id = req.query.client_id;
|
||||
const redirect_uri = req.query.redirect_uri;
|
||||
|
||||
const client = store.getClient(client_id);
|
||||
if (!client) {
|
||||
return res.status(400).json({ error: 'invalid_client' });
|
||||
}
|
||||
|
||||
const redirect_uris = JSON.parse(client.redirect_uris);
|
||||
if (!redirect_uris.includes(redirect_uri)) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
||||
}
|
||||
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
return res.status(503).json({ error: 'oauth_not_configured' });
|
||||
}
|
||||
|
||||
const state = Buffer.from(JSON.stringify({
|
||||
client_id,
|
||||
redirect_uri,
|
||||
code_challenge: req.query.code_challenge,
|
||||
code_challenge_method: req.query.code_challenge_method,
|
||||
original_state: req.query.state,
|
||||
})).toString('base64url');
|
||||
|
||||
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID);
|
||||
authUrl.searchParams.set('redirect_uri', OAUTH_ISSUER + '/oauth/google/callback');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'openid email profile');
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('access_type', 'offline');
|
||||
authUrl.searchParams.set('prompt', 'select_account');
|
||||
|
||||
res.redirect(authUrl.toString());
|
||||
});
|
||||
|
||||
oauthRouter.get('/google/callback', async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
try {
|
||||
const params = JSON.parse(Buffer.from(req.query.state, 'base64url').toString());
|
||||
|
||||
const tokenResponse = await new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: req.query.code,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: OAUTH_ISSUER + '/oauth/google/callback',
|
||||
}).toString();
|
||||
|
||||
const options = {
|
||||
hostname: 'oauth2.googleapis.com',
|
||||
port: 443,
|
||||
path: '/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
};
|
||||
|
||||
const request = https.request(options, (response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => { body += chunk; });
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.write(postData);
|
||||
request.end();
|
||||
});
|
||||
|
||||
const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID);
|
||||
const ticket = await oauthClient.verifyIdToken({
|
||||
idToken: tokenResponse.id_token,
|
||||
audience: GOOGLE_CLIENT_ID,
|
||||
});
|
||||
const { email } = ticket.getPayload();
|
||||
|
||||
const code = crypto.randomBytes(32).toString('hex');
|
||||
store.saveAuthCode(code, {
|
||||
client_id: params.client_id,
|
||||
redirect_uri: params.redirect_uri,
|
||||
code_challenge: params.code_challenge,
|
||||
code_challenge_method: params.code_challenge_method,
|
||||
user_email: email,
|
||||
expires_at: Date.now() + 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(params.redirect_uri);
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
if (params.original_state) {
|
||||
redirectUrl.searchParams.set('state', params.original_state);
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('[oauth] google/callback error:', error.message);
|
||||
res.status(500).json({ error: 'authentication_failed', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
oauthRouter.post('/token', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
try {
|
||||
const grant_type = req.body.grant_type;
|
||||
|
||||
if (grant_type === 'authorization_code') {
|
||||
const codeData = store.getAuthCode(req.body.code);
|
||||
if (!codeData) {
|
||||
return res.status(400).json({ error: 'invalid_code' });
|
||||
}
|
||||
if (Date.now() > codeData.expires_at) {
|
||||
store.deleteAuthCode(req.body.code);
|
||||
return res.status(400).json({ error: 'expired_code' });
|
||||
}
|
||||
if (codeData.redirect_uri !== req.body.redirect_uri) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
||||
}
|
||||
|
||||
if (codeData.code_challenge_method === 'S256') {
|
||||
const cv = req.body.code_verifier;
|
||||
if (!cv) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'code_verifier required' });
|
||||
}
|
||||
const expected = Buffer.from(crypto.createHash('sha256').update(cv).digest())
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
if (expected !== codeData.code_challenge) {
|
||||
return res.status(400).json({ error: 'invalid_code_verifier' });
|
||||
}
|
||||
}
|
||||
|
||||
store.deleteAuthCode(req.body.code);
|
||||
|
||||
const access_token = crypto.randomBytes(32).toString('hex');
|
||||
const refresh_token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
store.saveToken(access_token, {
|
||||
client_id: codeData.client_id,
|
||||
user_email: codeData.user_email,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
refresh_token,
|
||||
});
|
||||
|
||||
store.saveRefreshToken(refresh_token, {
|
||||
access_token,
|
||||
client_id: codeData.client_id,
|
||||
user_email: codeData.user_email,
|
||||
expires_at: Date.now() + 30 * 24 * 3600 * 1000,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token,
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
} else if (grant_type === 'refresh_token') {
|
||||
const rtData = store.getRefreshToken(req.body.refresh_token);
|
||||
if (!rtData) {
|
||||
return res.status(400).json({ error: 'invalid_refresh_token' });
|
||||
}
|
||||
if (Date.now() > rtData.expires_at) {
|
||||
return res.status(400).json({ error: 'expired_refresh_token' });
|
||||
}
|
||||
|
||||
const new_token = crypto.randomBytes(32).toString('hex');
|
||||
store.deleteToken(rtData.access_token);
|
||||
|
||||
store.saveToken(new_token, {
|
||||
client_id: rtData.client_id,
|
||||
user_email: rtData.user_email,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
refresh_token: req.body.refresh_token,
|
||||
});
|
||||
|
||||
store.saveRefreshToken(req.body.refresh_token, {
|
||||
access_token: new_token,
|
||||
client_id: rtData.client_id,
|
||||
user_email: rtData.user_email,
|
||||
expires_at: rtData.expires_at,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
access_token: new_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({ error: 'unsupported_grant_type' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[oauth] token error:', error.message);
|
||||
res.status(500).json({ error: 'server_error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
wellKnownRouter,
|
||||
oauthRouter,
|
||||
validateOAuthToken,
|
||||
};
|
||||
|
|
@ -14,6 +14,33 @@ app.use((req, res, next) => {
|
|||
next();
|
||||
});
|
||||
|
||||
// CORS — must be first
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const isPublic = req.path.startsWith('/.well-known') || req.path.startsWith('/oauth');
|
||||
if (isPublic) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
} else if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
next();
|
||||
});
|
||||
|
||||
// Public OAuth + well-known (no token required)
|
||||
const { wellKnownRouter, oauthRouter, validateOAuthToken } = require('./oauth');
|
||||
app.use('/.well-known', wellKnownRouter);
|
||||
app.use('/oauth', oauthRouter);
|
||||
|
||||
// Token gate — /health exempt
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === '/health') return next();
|
||||
validateOAuthToken(req, res, next);
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
require('dotenv').config({ path: __dirname + '/../.env' });
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_ID !== 'your-google-client-id-here') {
|
||||
console.log('[test] OAuth enabled — skipping E2E (requires browser flow).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const HUB_PORT = 3000;
|
||||
const TOTAL_TIMEOUT_MS = 15000;
|
||||
const POLL_INTERVAL_MS = 200;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue