feat(saas): SquareMCP v2 — multi-tenant MCP platform complete
Steps 0–10 of the v2 plan, 194 tests passing. Core infrastructure - Shared Redis client (src/redis.ts); all four Redis consumers migrated - Vitest test harness with vitest.config.ts and npm test/test:watch scripts Billing & invoicing (Steps 1–2) - Monthly invoice generation with idempotency (MySQL uq_customer_period unique key) - Cron job with Redis distributed lock (Lua compare-delete, 1-hr TTL) - Invoice emailer via nodemailer (FETCHERPAY SMTP) - Billing middleware: checkLimit gate in handleToolCall; platform attribution fix Email multi-tenancy (Step 3) - EmailCtx = Account | EmailCredentials; imap.ts + smtp.ts accept both - resolveEmailCtx helper in tools.ts; all email tools use customer credentials Analytics + platform health (Steps 4–5) - Chart.js bar charts for platform breakdown and daily activity - Token expiry check in getCredential with dynamic import refresh - platform-health.ts: per-platform health probe with 10-min Redis cache - GET /api/health/platforms; "Token expired" amber badge in dashboard Tool schema filtering (Step 6) - stripAccountParam deep-clones tool schemas; multi-tenant sessions never see the internal account enum OAuth hardening (Step 7) - Atomic auth code consumption: UPDATE SET used=TRUE, check affectedRows - customer_id threaded through oauth_auth_codes → oauth_tokens - getTokenCustomer(); requireAuth resolves req.customer from Bearer token - Consent page requires authenticated session; redirect_uri validated against registered URIs; http://localhost:* loopback wildcard DCR browser flow (Step 8) - ensureOAuthAppRegistered() upserts pre-registered SquareMCP OAuth app on startup with redirect URIs for mcp-callback, localhost:*, claude-desktop, opencode - GET /oauth/connect-mcp → server-side redirect (client_id off frontend) - GET /oauth/mcp-callback → exchanges code, renders config snippet page with copy buttons for Claude Desktop and Codex CLI Webhooks (Step 9) - webhook_url + webhook_secret columns on customers - deliverWebhook(): HMAC-SHA256 signing, 3× exponential retry (1s/4s/16s), Redis DLQ with 7-day TTL on total failure - isValidWebhookUrl(): SSRF protection (blocks RFC-1918, localhost, .local) - POST /api/webhooks/config (secret returned once), GET, DELETE - GET /api/admin/webhooks/dlq/:customerId - WhatsApp POST route uses express.raw() for raw body preservation - Dashboard Webhooks tab with secret-once display and copy button Developer docs (Step 10) - docs/ static HTML site (GitHub Pages, no build pipeline) - index.html: landing page with client + platform overview - getting-started.html: tabbed MCP config for Claude Desktop, Codex CLI, opencode - platforms.html: LinkedIn, TikTok, WhatsApp, Instagram, Twitter, Telegram guides - agent-tutorial.html: complete Node.js agent (Anthropic SDK + MCP SDK), LinkedIn posting loop, extensions for multi-platform + inbound webhook reaction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
90
src/oauth.ts
90
src/oauth.ts
@@ -21,6 +21,7 @@ interface AuthCode {
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
expires_at: number;
|
||||
customer_id?: string;
|
||||
}
|
||||
|
||||
interface Token {
|
||||
@@ -72,6 +73,23 @@ function verifyPkce(codeVerifier: string, storedChallenge: string, method?: stri
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureOAuthAppRegistered(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUris: string[]
|
||||
): Promise<void> {
|
||||
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[];
|
||||
@@ -139,7 +157,8 @@ export async function createAuthCode(
|
||||
redirectUri: string,
|
||||
scope?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string
|
||||
codeChallengeMethod?: string,
|
||||
customerId?: string
|
||||
): Promise<AuthCode> {
|
||||
const code: AuthCode = {
|
||||
code: generateAuthCode(),
|
||||
@@ -149,18 +168,27 @@ export async function createAuthCode(
|
||||
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) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)]
|
||||
'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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(
|
||||
clientId: string,
|
||||
clientSecret: string | undefined,
|
||||
@@ -192,19 +220,27 @@ export async function exchangeCodeForToken(
|
||||
}
|
||||
|
||||
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()',
|
||||
// Atomic consume: only one concurrent request can win this UPDATE
|
||||
const [updateResult] = await pool.execute<import('mysql2').ResultSetHeader>(
|
||||
'UPDATE oauth_auth_codes SET used = TRUE 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');
|
||||
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<RowDataPacket[]>(
|
||||
'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;
|
||||
@@ -215,16 +251,12 @@ export async function exchangeCodeForToken(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
@@ -233,9 +265,9 @@ export async function exchangeCodeForToken(
|
||||
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)]
|
||||
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}`);
|
||||
@@ -243,8 +275,6 @@ export async function exchangeCodeForToken(
|
||||
} catch (err) {
|
||||
console.error('[oauth] exchangeCodeForToken error:', err);
|
||||
return null;
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,21 +282,31 @@ 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()',
|
||||
'SELECT token FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
|
||||
[tokenValue]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
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 pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
'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;
|
||||
return { customerId: rows[0].customer_id as string };
|
||||
} catch (err) {
|
||||
console.error('[oauth] getTokenCustomer error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthorizeHtml(params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
|
||||
Reference in New Issue
Block a user