import crypto from 'crypto'; import type { RowDataPacket } from 'mysql2/promise'; import { getPool, isPoolReady } from './db.js'; import redis from './redis.js'; const AUTH_CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours export interface Client { client_id: string; client_secret: string; client_name: string; redirect_uris: string[]; created_at: number; } interface AuthCode { code: string; client_id: string; redirect_uri: string; scope?: string; code_challenge?: string; code_challenge_method?: string; expires_at: number; customer_id?: string; } interface Token { access_token: string; token_type: string; expires_in: number; scope?: string; expires_at: number; } // Cleanup expired rows every 60 seconds setInterval(async () => { if (!isPoolReady()) return; try { const pool = getPool(); await pool.execute('DELETE FROM oauth_auth_codes WHERE expires_at < NOW() OR used = TRUE'); await pool.execute('DELETE FROM oauth_tokens WHERE expires_at < NOW()'); } catch (err) { console.error('[oauth] Cleanup error:', err); } }, 60_000); export function generateClientId(): string { return crypto.randomUUID(); } export function generateClientSecret(): string { return crypto.randomBytes(32).toString('hex'); } export function generateAuthCode(): string { return crypto.randomBytes(32).toString('hex'); } export function generateAccessToken(): string { return crypto.randomBytes(32).toString('hex'); } function verifyPkce(codeVerifier: string, storedChallenge: string, method?: string): boolean { if (!method || method === 'plain') { return codeVerifier === storedChallenge; } if (method === 'S256') { const hashed = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); return hashed === storedChallenge; } return false; } export async function ensureOAuthAppRegistered( clientId: string, clientSecret: string, redirectUris: string[] ): Promise { const pool = getPool(); await pool.execute( `INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE client_secret = VALUES(client_secret), redirect_urls = VALUES(redirect_urls)`, [clientId, clientSecret, 'SquareMCP App', JSON.stringify(redirectUris), JSON.stringify(['authorization_code'])] ); console.log(`[oauth] Pre-registered app ${clientId} ensured`); } export async function registerClient(body: { client_name?: string; redirect_uris?: string[]; [key: string]: unknown; }): Promise { const client: Client = { client_id: generateClientId(), client_secret: generateClientSecret(), client_name: body.client_name || 'Unnamed Client', redirect_uris: Array.isArray(body.redirect_uris) ? body.redirect_uris : [], created_at: Date.now(), }; const pool = getPool(); await pool.execute( 'INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types) VALUES (?, ?, ?, ?, ?)', [ client.client_id, client.client_secret, client.client_name, JSON.stringify(client.redirect_uris), JSON.stringify(['authorization_code']), ] ); console.log(`[oauth] Registered client ${client.client_id} (${client.client_name})`); return client; } export async function getClient(clientId: string): Promise { try { const pool = getPool(); const [rows] = await pool.execute( 'SELECT * FROM oauth_clients WHERE client_id = ?', [clientId] ); if (!Array.isArray(rows) || rows.length === 0) { return undefined; } const row = rows[0]; // Update last_used await pool.execute( 'UPDATE oauth_clients SET last_used = CURRENT_TIMESTAMP WHERE client_id = ?', [clientId] ); return { client_id: row.client_id, client_secret: row.client_secret, client_name: row.client_name, redirect_uris: Array.isArray(row.redirect_urls) ? row.redirect_urls : JSON.parse(row.redirect_urls || '[]'), created_at: new Date(row.created_at).getTime(), }; } catch (err) { console.error('[oauth] getClient error:', err); return undefined; } } export async function createAuthCode( clientId: string, redirectUri: string, scope?: string, codeChallenge?: string, codeChallengeMethod?: string, customerId?: string ): Promise { const code: AuthCode = { code: generateAuthCode(), client_id: clientId, redirect_uri: redirectUri, scope, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, expires_at: Date.now() + AUTH_CODE_EXPIRY_MS, customer_id: customerId, }; const pool = getPool(); await pool.execute( 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at, customer_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at), customerId || null] ); console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`); return code; } const CHATGPT_CALLBACK_RE = /^https:\/\/chat\.openai\.com\/aip\/g-[a-f0-9]+\/oauth\/callback$/; export function isValidRedirectUri(uri: string, registeredUris: string[]): boolean { for (const registered of registeredUris) { if (registered === uri) return true; if (registered === 'http://localhost:*' && /^http:\/\/localhost:\d+(\/|$)/.test(uri)) return true; // Allow any ChatGPT GPT callback — GPT ID changes every time the GPT is saved if (registered === 'https://chat.openai.com/aip/*/oauth/callback' && CHATGPT_CALLBACK_RE.test(uri)) return true; } return false; } export async function exchangeCodeForToken( clientId: string, clientSecret: string | undefined, code: string, redirectUri: string, codeVerifier?: string ): Promise { let client: Client | undefined; try { client = await getClient(clientId); } catch (err) { console.error('[oauth] getClient error during token exchange:', err); return null; } if (!client) { console.log('[oauth] Invalid client'); return null; } if (clientSecret) { if (client.client_secret !== clientSecret) { console.log('[oauth] Invalid client credentials'); return null; } } else if (!codeVerifier) { console.log('[oauth] Invalid client credentials'); return null; } const pool = getPool(); try { // Atomic consume: only one concurrent request can win this UPDATE const [updateResult] = await pool.execute( 'UPDATE oauth_auth_codes SET used = TRUE WHERE code = ? AND used = FALSE AND expires_at > NOW()', [code] ); if (updateResult.affectedRows === 0) { console.log('[oauth] Auth code not found, expired, or already used'); return null; } // Fetch the row now that it is consumed const [rows] = await pool.execute( 'SELECT * FROM oauth_auth_codes WHERE code = ?', [code] ); if (!Array.isArray(rows) || rows.length === 0) { return null; } const authCode = rows[0]; if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) { console.log('[oauth] Auth code client/redirect mismatch'); return null; } if (authCode.code_challenge) { if (!codeVerifier) { console.log('[oauth] Missing code_verifier for PKCE exchange'); return null; } if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) { console.log('[oauth] PKCE verification failed'); return null; } } const token: Token = { access_token: generateAccessToken(), token_type: 'Bearer', expires_in: TOKEN_EXPIRY_MS / 1000, scope: authCode.scope || undefined, expires_at: Date.now() + TOKEN_EXPIRY_MS, }; await pool.execute( 'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at, customer_id) VALUES (?, ?, ?, ?, ?)', [token.access_token, clientId, 'access', new Date(token.expires_at), authCode.customer_id || null] ); console.log(`[oauth] Issued token ${token.access_token.slice(0, 8)}... for client ${clientId}`); return token; } catch (err) { console.error('[oauth] exchangeCodeForToken error:', err); return null; } } export async function validateAccessToken(tokenValue: string): Promise { try { const pool = getPool(); const [rows] = await pool.execute( 'SELECT token FROM oauth_tokens WHERE token = ? AND expires_at > NOW()', [tokenValue] ); return Array.isArray(rows) && rows.length > 0; } catch (err) { console.error('[oauth] validateAccessToken error:', err); return false; } } export async function getTokenCustomer(tokenValue: string): Promise<{ customerId: string } | null> { try { const cacheKey = `oauth:token:${tokenValue}`; const cached = await redis.get(cacheKey); if (cached) return { customerId: cached }; const pool = getPool(); const [rows] = await pool.execute( 'SELECT customer_id FROM oauth_tokens WHERE token = ? AND expires_at > NOW()', [tokenValue] ); if (!Array.isArray(rows) || rows.length === 0 || !rows[0].customer_id) return null; const customerId = rows[0].customer_id as string; await redis.setEx(cacheKey, 60, customerId); return { customerId }; } catch (err) { console.error('[oauth] getTokenCustomer error:', err); return null; } } export function getAuthorizeHtml(params: { client_id: string; redirect_uri: string; state?: string; scope?: string; code_challenge?: string; code_challenge_method?: string; }): string { const { client_id, redirect_uri, state, scope, code_challenge, code_challenge_method } = params; return ` Authorize Hermes MCP

Authorize Hermes MCP

Client ${escapeHtml(client_id)} wants to access your Hermes tools (email + Obsidian vault).

Scopes: ${escapeHtml(scope || 'default')}
${state ? `` : ''} ${scope ? `` : ''} ${code_challenge ? `` : ''} ${code_challenge_method ? `` : ''}
`; } function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }