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:
Garfield
2026-04-29 09:52:53 -04:00
parent 166f5d55a6
commit e3a272c332
67 changed files with 6204 additions and 94 deletions

281
src/oauth.ts Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}