Add multi-account OAuth, Obsidian integration, product assets, and test tooling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
281
src/oauth.ts
Normal file
281
src/oauth.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import crypto from 'crypto';
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import { getPool, isPoolReady } from './db.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;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export async function registerClient(body: {
|
||||
client_name?: string;
|
||||
redirect_uris?: string[];
|
||||
[key: string]: unknown;
|
||||
}): Promise<Client> {
|
||||
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<Client | undefined> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
'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
|
||||
): Promise<AuthCode> {
|
||||
const code: AuthCode = {
|
||||
code: generateAuthCode(),
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[code.code, clientId, redirectUri, new Date(code.expires_at)]
|
||||
);
|
||||
|
||||
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<Token | null> {
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
client = await getClient(clientId);
|
||||
} catch (err) {
|
||||
console.error('[oauth] getClient error during token exchange:', err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!client || client.client_secret !== clientSecret) {
|
||||
console.log('[oauth] Invalid client credentials');
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const db = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await db.execute<RowDataPacket[]>(
|
||||
'SELECT * FROM oauth_auth_codes WHERE code = ? AND used = FALSE AND expires_at > NOW()',
|
||||
[code]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log('[oauth] Auth code not found or expired');
|
||||
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;
|
||||
}
|
||||
|
||||
// Mark auth code as used
|
||||
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
|
||||
|
||||
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 db.execute(
|
||||
'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[token.access_token, clientId, 'access', new Date(token.expires_at)]
|
||||
);
|
||||
|
||||
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;
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAccessToken(tokenValue: string): Promise<boolean> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
'SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
|
||||
[tokenValue]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[oauth] validateAccessToken error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthorizeHtml(params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
state?: string;
|
||||
scope?: string;
|
||||
}): string {
|
||||
const { client_id, redirect_uri, state, scope } = params;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authorize Hermes MCP</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 400px; width: 100%; }
|
||||
h1 { margin: 0 0 1rem; font-size: 1.5rem; color: #111; }
|
||||
p { color: #555; line-height: 1.5; margin: 0.5rem 0; }
|
||||
.client { font-weight: 600; color: #111; }
|
||||
.scopes { background: #f9f9f9; padding: 0.75rem; border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; color: #333; }
|
||||
.buttons { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
|
||||
button { flex: 1; padding: 0.75rem; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
|
||||
.allow { background: #111; color: white; }
|
||||
.deny { background: #e5e5e5; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Authorize Hermes MCP</h1>
|
||||
<p>Client <span class="client">${escapeHtml(client_id)}</span> wants to access your Hermes tools (email + Obsidian vault).</p>
|
||||
<div class="scopes">Scopes: ${escapeHtml(scope || 'default')}</div>
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
|
||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
||||
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
||||
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
|
||||
<div class="buttons">
|
||||
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
||||
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user