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:
Agent 2026-03-13 12:38:12 +00:00
parent fc5fa4e16d
commit 4e78557158
7 changed files with 1592 additions and 0 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ agent_gateway.db
.env
mcp-bridge/node_modules
node_modules/
data/

1149
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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
View 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
View 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,
};

View file

@ -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',

View file

@ -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;