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:
192
src/oauth.test.ts
Normal file
192
src/oauth.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ResultSetHeader } from 'mysql2';
|
||||
|
||||
// ── DB mock ────────────────────────────────────────────────────────────────
|
||||
const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }));
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
getPool: vi.fn(() => ({ execute: mockExecute })),
|
||||
isPoolReady: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import {
|
||||
createAuthCode,
|
||||
exchangeCodeForToken,
|
||||
validateAccessToken,
|
||||
getTokenCustomer,
|
||||
isValidRedirectUri,
|
||||
} from './oauth.js';
|
||||
|
||||
// Helper: build a minimal ResultSetHeader-shaped object
|
||||
function affected(n: number): [ResultSetHeader, unknown[]] {
|
||||
return [{ affectedRows: n } as ResultSetHeader, []];
|
||||
}
|
||||
|
||||
// Helper: build a row-result
|
||||
function rows(data: object[]): [object[], unknown[]] {
|
||||
return [data, []];
|
||||
}
|
||||
|
||||
describe('isValidRedirectUri', () => {
|
||||
it('exact match is valid', () => {
|
||||
expect(isValidRedirectUri('https://app.example.com/callback', ['https://app.example.com/callback'])).toBe(true);
|
||||
});
|
||||
|
||||
it('http://localhost:* wildcard matches any localhost port', () => {
|
||||
expect(isValidRedirectUri('http://localhost:3000', ['http://localhost:*'])).toBe(true);
|
||||
expect(isValidRedirectUri('http://localhost:9876/callback', ['http://localhost:*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('http://localhost:* does not match non-localhost', () => {
|
||||
expect(isValidRedirectUri('http://evil.com', ['http://localhost:*'])).toBe(false);
|
||||
expect(isValidRedirectUri('https://localhost:3000', ['http://localhost:*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('unregistered URI is rejected', () => {
|
||||
expect(isValidRedirectUri('https://attacker.com', ['https://app.example.com/callback'])).toBe(false);
|
||||
});
|
||||
|
||||
it('other wildcards are not supported', () => {
|
||||
expect(isValidRedirectUri('https://any.example.com', ['https://*.example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('empty registered list rejects everything', () => {
|
||||
expect(isValidRedirectUri('https://app.example.com/callback', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthCode', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('inserts with customer_id when provided', async () => {
|
||||
mockExecute
|
||||
// getClient SELECT (called by nothing here; we call createAuthCode directly)
|
||||
.mockResolvedValueOnce([[], []]); // INSERT returns ResultSetHeader but we ignore it
|
||||
|
||||
// The first execute is the INSERT
|
||||
mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);
|
||||
|
||||
const code = await createAuthCode('client1', 'http://localhost:3000', 'read', undefined, undefined, 'cust-42');
|
||||
|
||||
expect(code.customer_id).toBe('cust-42');
|
||||
const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('customer_id');
|
||||
expect(params).toContain('cust-42');
|
||||
});
|
||||
|
||||
it('inserts NULL customer_id when not provided', async () => {
|
||||
mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);
|
||||
|
||||
const code = await createAuthCode('client1', 'http://localhost:3000');
|
||||
expect(code.customer_id).toBeUndefined();
|
||||
|
||||
const [, params] = mockExecute.mock.calls[0] as [string, unknown[]];
|
||||
// Last param should be null
|
||||
expect(params[params.length - 1]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCodeForToken — replay protection', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const fakeClient = {
|
||||
client_id: 'c1',
|
||||
client_secret: 'secret',
|
||||
client_name: 'Test',
|
||||
redirect_uris: ['http://localhost:3000'],
|
||||
created_at: Date.now(),
|
||||
};
|
||||
const fakeAuthCode = {
|
||||
code: 'abc',
|
||||
client_id: 'c1',
|
||||
redirect_uri: 'http://localhost:3000',
|
||||
scope: 'read',
|
||||
code_challenge: null,
|
||||
code_challenge_method: null,
|
||||
customer_id: 'cust-1',
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
used: true,
|
||||
};
|
||||
|
||||
it('returns null when UPDATE affectedRows = 0 (already used)', async () => {
|
||||
// getClient SELECT
|
||||
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
|
||||
// UPDATE oauth_clients last_used
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
// Atomic UPDATE auth code — 0 rows (already used)
|
||||
mockExecute.mockResolvedValueOnce(affected(0));
|
||||
|
||||
const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('issues token and threads customer_id on success', async () => {
|
||||
// getClient SELECT
|
||||
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
|
||||
// getClient UPDATE last_used
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
// Atomic UPDATE auth code — 1 row consumed
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
// SELECT auth code data
|
||||
mockExecute.mockResolvedValueOnce(rows([fakeAuthCode]));
|
||||
// INSERT token
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
|
||||
const token = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
|
||||
expect(token).not.toBeNull();
|
||||
expect(token!.access_token).toBeTruthy();
|
||||
|
||||
// Verify INSERT includes customer_id = 'cust-1'
|
||||
const insertCall = mockExecute.mock.calls.find((c) => (c[0] as string).includes('INSERT INTO oauth_tokens'));
|
||||
expect(insertCall).toBeDefined();
|
||||
const insertParams = insertCall![1] as unknown[];
|
||||
expect(insertParams).toContain('cust-1');
|
||||
});
|
||||
|
||||
it('returns null when client/redirect mismatch', async () => {
|
||||
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
mockExecute.mockResolvedValueOnce(affected(1));
|
||||
// SELECT returns code with different client_id
|
||||
mockExecute.mockResolvedValueOnce(rows([{ ...fakeAuthCode, client_id: 'other' }]));
|
||||
|
||||
const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenCustomer', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns customerId when token has one', async () => {
|
||||
mockExecute.mockResolvedValue(rows([{ customer_id: 'cust-99' }]));
|
||||
const result = await getTokenCustomer('tok-abc');
|
||||
expect(result).toEqual({ customerId: 'cust-99' });
|
||||
});
|
||||
|
||||
it('returns null when token not found', async () => {
|
||||
mockExecute.mockResolvedValue(rows([]));
|
||||
const result = await getTokenCustomer('bad-token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when token has no customer_id', async () => {
|
||||
mockExecute.mockResolvedValue(rows([{ customer_id: null }]));
|
||||
const result = await getTokenCustomer('tok-legacy');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns true for valid token', async () => {
|
||||
mockExecute.mockResolvedValue(rows([{ token: 'tok' }]));
|
||||
expect(await validateAccessToken('tok')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for expired/missing token', async () => {
|
||||
mockExecute.mockResolvedValue(rows([]));
|
||||
expect(await validateAccessToken('bad')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user