test: add OAuth login route test suite (22 cases)

Guards the browser OAuth popup flow used by claude.ai and ChatGPT:
- GET /login: return_to URL validation, XSS escaping, error display
- POST /login: first-party cookie properties (httpOnly/secure/lax/domain),
  open redirect blocking, credential rejection paths
- GET /oauth/authorize: must redirect to /login (never app.squaremcp.com),
  return_to encoding, valid session bypasses redirect

Also exports `app` from index.ts and guards main() with NODE_ENV !== 'test'
so the Express app can be imported by supertest without triggering DB init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-14 17:57:29 -04:00
parent 02398258a5
commit d6302a673d
4 changed files with 716 additions and 4 deletions

View File

@@ -2134,7 +2134,11 @@ async function main() {
});
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
export { app };
if (process.env.NODE_ENV !== 'test') {
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
}

View File

@@ -0,0 +1,420 @@
/**
* Tests for the OAuth login flow routes and the /oauth/authorize redirect behavior.
*
* Critical invariants guarded here:
* 1. GET /login serves an HTML form and validates the return_to URL
* 2. POST /login sets a first-party session cookie with correct security properties
* 3. POST /login with bad credentials redirects to /login?error=invalid (no open redirect)
* 4. GET /oauth/authorize unauthenticated → redirects to /login (NOT app.squaremcp.com)
* 5. GET /oauth/authorize with valid session → shows consent page
*
* Why these matter: claude.ai and ChatGPT use browser-based OAuth popups. If the
* authorize endpoint redirects to app.squaremcp.com instead of hermes /login, the
* session cookie is set in the wrong top-level context (browser CHIPS partitioning)
* and the OAuth flow fails silently.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
// ── Hoist mocks before any imports ────────────────────────────────────────────
const {
mockFindCustomerByEmail,
mockVerifyPassword,
mockSignJWT,
mockVerifyJWT,
mockGetClient,
mockIsValidRedirectUri,
mockGetAuthorizeHtml,
mockInitDatabase,
mockGetPool,
mockRedis,
mockEnsureOAuthAppRegistered,
} = vi.hoisted(() => ({
mockFindCustomerByEmail: vi.fn(),
mockVerifyPassword: vi.fn(),
mockSignJWT: vi.fn(),
mockVerifyJWT: vi.fn(),
mockGetClient: vi.fn(),
mockIsValidRedirectUri: vi.fn(),
mockGetAuthorizeHtml: vi.fn(),
mockInitDatabase: vi.fn(),
mockGetPool: vi.fn(() => ({ execute: vi.fn(), query: vi.fn() })),
mockRedis: { get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
mockEnsureOAuthAppRegistered: vi.fn(),
}));
vi.mock('./auth.js', () => ({
findCustomerByEmail: mockFindCustomerByEmail,
findCustomerById: vi.fn(),
verifyPassword: mockVerifyPassword,
signJWT: mockSignJWT,
verifyJWT: mockVerifyJWT,
hashPassword: vi.fn(),
createCustomer: vi.fn(),
setResetToken: vi.fn(),
findCustomerByResetToken: vi.fn(),
clearResetToken: vi.fn(),
updatePassword: vi.fn(),
}));
vi.mock('./oauth.js', () => ({
getClient: mockGetClient,
isValidRedirectUri: mockIsValidRedirectUri,
getAuthorizeHtml: mockGetAuthorizeHtml,
registerClient: vi.fn(),
createAuthCode: vi.fn(),
exchangeCodeForToken: vi.fn(),
validateAccessToken: vi.fn(),
getTokenCustomer: vi.fn(),
ensureOAuthAppRegistered: mockEnsureOAuthAppRegistered,
revokeToken: vi.fn(),
}));
vi.mock('./db.js', () => ({
initDatabase: mockInitDatabase,
getPool: mockGetPool,
isPoolReady: vi.fn(() => true),
}));
vi.mock('./redis.js', () => ({ default: mockRedis }));
vi.mock('./billing/middleware.js', () => ({
meterMiddleware: vi.fn((_req: unknown, _res: unknown, next: () => void) => next()),
resolveCustomerByApiKey: vi.fn(),
resolveCustomerById: vi.fn(),
}));
vi.mock('./billing/usage.js', () => ({
recordUsage: vi.fn(),
getMonthlyUsage: vi.fn(() => 0),
getUsageBreakdown: vi.fn(() => []),
checkLimit: vi.fn(() => ({ allowed: true, limit: 1000 })),
}));
vi.mock('./billing/invoices.js', () => ({
getCustomerInvoices: vi.fn(() => []),
getInvoiceByNumber: vi.fn(),
markInvoiceSent: vi.fn(),
markInvoicePaid: vi.fn(),
generateMonthlyInvoice: vi.fn(),
}));
vi.mock('./billing/cron.js', () => ({ startInvoiceCron: vi.fn() }));
vi.mock('./multitenancy/webhook-router.js', () => ({
routeWhatsAppWebhook: vi.fn(),
registerWhatsAppNumber: vi.fn(),
}));
vi.mock('./multitenancy/credential-store.js', () => ({
storeCredential: vi.fn(),
getCredential: vi.fn(),
}));
vi.mock('./multitenancy/platform-health.js', () => ({
getAllPlatformHealth: vi.fn(() => []),
}));
vi.mock('./multitenancy/audit-log.js', () => ({ logAudit: vi.fn() }));
vi.mock('./webhooks/delivery.js', () => ({
deliverWebhook: vi.fn(),
isValidWebhookUrl: vi.fn(() => true),
}));
vi.mock('./tools.js', () => ({
tools: [],
handleToolCall: vi.fn(),
stripAccountParam: vi.fn((args: unknown) => args),
}));
vi.mock('./manifest.js', () => ({
getManifest: vi.fn(() => ({})),
getOpenApiSpec: vi.fn(() => ({})),
getOpenApiSpecMail: vi.fn(() => ({})),
getOpenApiSpecSocial: vi.fn(() => ({})),
}));
// ── Import app after mocks ─────────────────────────────────────────────────────
process.env.NODE_ENV = 'test';
process.env.SERVER_URL = 'https://hermes.squaremcp.com';
process.env.MCP_API_KEY = 'test-global-key';
const { app } = await import('./index.js');
// ── Shared fixtures ────────────────────────────────────────────────────────────
const HERMES_RETURN_TO = 'https://hermes.squaremcp.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz';
const fakeCustomer = {
id: 'cust-1',
email: 'user@example.com',
plan: 'starter',
role: 'user',
active: true,
api_key: 'api-key-1',
password_hash: '$2b$10$hashedpassword',
};
const fakeClient = {
client_id: 'abc',
client_secret: 'secret',
client_name: 'Test Client',
redirect_uris: ['https://claude.ai/api/mcp/auth_callback'],
created_at: Date.now(),
};
// ── GET /login ─────────────────────────────────────────────────────────────────
describe('GET /login', () => {
it('returns 200 HTML with a login form', async () => {
const res = await request(app).get('/login');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<form method="POST" action="/login">');
expect(res.text).toContain('SquareMCP');
});
it('embeds a valid hermes return_to in the form hidden field', async () => {
const res = await request(app).get(`/login?return_to=${encodeURIComponent(HERMES_RETURN_TO)}`);
expect(res.status).toBe(200);
expect(res.text).toContain('https://hermes.squaremcp.com/oauth/authorize');
});
it('ignores return_to pointing to other domains (uses /)', async () => {
const res = await request(app).get('/login?return_to=https%3A%2F%2Fevil.com%2Fsteal');
expect(res.status).toBe(200);
// Should fall back to / — the malicious URL must NOT appear in the form
expect(res.text).not.toContain('evil.com');
expect(res.text).toContain('value="/"');
});
it('ignores return_to pointing to app.squaremcp.com (different subdomain)', async () => {
const res = await request(app).get('/login?return_to=https%3A%2F%2Fapp.squaremcp.com%2Fdashboard');
expect(res.status).toBe(200);
expect(res.text).not.toContain('app.squaremcp.com');
expect(res.text).toContain('value="/"');
});
it('shows error message for error=invalid', async () => {
const res = await request(app).get('/login?error=invalid');
expect(res.text).toContain('Incorrect email or password');
});
it('shows error message for error=missing', async () => {
const res = await request(app).get('/login?error=missing');
expect(res.text).toContain('Email and password are required');
});
it('HTML-escapes double quotes in return_to to prevent attribute injection', async () => {
const xssAttempt = 'https://hermes.squaremcp.com/oauth/authorize?x="onmouseover=alert(1)"';
const res = await request(app).get(`/login?return_to=${encodeURIComponent(xssAttempt)}`);
// Quotes should be replaced with &quot; in the hidden field value
expect(res.text).not.toContain('"onmouseover=alert(1)"');
expect(res.text).toContain('&quot;');
});
});
// ── POST /login ────────────────────────────────────────────────────────────────
describe('POST /login', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSignJWT.mockReturnValue('jwt-token-abc');
});
it('valid credentials → sets session cookie with correct security properties', async () => {
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
mockVerifyPassword.mockResolvedValue(true);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
const setCookie = res.headers['set-cookie'] as string[] | string;
const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;
expect(cookieStr).toContain('session=jwt-token-abc');
expect(cookieStr.toLowerCase()).toContain('httponly');
expect(cookieStr.toLowerCase()).toContain('secure');
expect(cookieStr.toLowerCase()).toContain('samesite=lax');
expect(cookieStr.toLowerCase()).toContain('domain=.squaremcp.com');
});
it('valid credentials → redirects to return_to on hermes domain', async () => {
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
mockVerifyPassword.mockResolvedValue(true);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
expect(res.headers.location).toBe(HERMES_RETURN_TO);
});
it('valid credentials + non-hermes return_to → redirects to / (open redirect blocked)', async () => {
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
mockVerifyPassword.mockResolvedValue(true);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'user@example.com', password: 'correct', return_to: 'https://evil.com/steal' });
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/');
expect(res.headers.location).not.toContain('evil.com');
});
it('valid credentials + app.squaremcp.com return_to → redirects to / (wrong domain blocked)', async () => {
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
mockVerifyPassword.mockResolvedValue(true);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'user@example.com', password: 'correct', return_to: 'https://app.squaremcp.com/dashboard' });
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/');
});
it('wrong password → redirect to /login?error=invalid (no cookie set)', async () => {
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
mockVerifyPassword.mockResolvedValue(false);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'user@example.com', password: 'wrong', return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
expect(res.headers.location).toMatch(/\/login.*error=invalid/);
expect(res.headers['set-cookie']).toBeUndefined();
});
it('unknown email → redirect to /login?error=invalid', async () => {
mockFindCustomerByEmail.mockResolvedValue(null);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'nobody@example.com', password: 'anything', return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
expect(res.headers.location).toMatch(/\/login.*error=invalid/);
});
it('missing fields → redirect to /login?error=missing', async () => {
const res = await request(app)
.post('/login')
.type('form')
.send({ return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
expect(res.headers.location).toMatch(/\/login.*error=missing/);
});
it('preserves hermes return_to through failed login redirect', async () => {
mockFindCustomerByEmail.mockResolvedValue(null);
const res = await request(app)
.post('/login')
.type('form')
.send({ email: 'x@x.com', password: 'bad', return_to: HERMES_RETURN_TO });
expect(res.status).toBe(302);
expect(res.headers.location).toContain(encodeURIComponent(HERMES_RETURN_TO));
});
});
// ── GET /oauth/authorize — redirect behavior ───────────────────────────────────
describe('GET /oauth/authorize — unauthenticated redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetClient.mockResolvedValue(fakeClient);
mockIsValidRedirectUri.mockReturnValue(true);
});
it('redirects to /login — NOT to app.squaremcp.com', async () => {
const res = await request(app).get(
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&response_type=code&state=xyz'
);
expect(res.status).toBe(302);
// Must redirect to /login (first-party on hermes)
expect(res.headers.location).toMatch(/^\/login\?/);
// Must NOT redirect to app.squaremcp.com (wrong cookie context — breaks browser OAuth popup)
expect(res.headers.location).not.toContain('app.squaremcp.com');
});
it('return_to in redirect encodes the full hermes.squaremcp.com authorize URL', async () => {
const res = await request(app).get(
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz'
);
expect(res.status).toBe(302);
const location = res.headers.location as string;
expect(location).toContain('return_to=');
// The encoded return_to must start with the hermes domain
const returnToEncoded = new URL('http://dummy' + location).searchParams.get('return_to');
expect(returnToEncoded).toMatch(/^https:\/\/hermes\.squaremcp\.com\//);
expect(returnToEncoded).toContain('client_id=abc');
});
it('with invalid cookie → also redirects to /login (not app.squaremcp.com)', async () => {
mockVerifyJWT.mockImplementation(() => { throw new Error('invalid token'); });
const res = await request(app)
.get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
.set('Cookie', 'session=bad-token');
expect(res.status).toBe(302);
expect(res.headers.location).toMatch(/^\/login\?/);
expect(res.headers.location).not.toContain('app.squaremcp.com');
});
it('with valid session cookie → returns 200 consent HTML (no redirect)', async () => {
mockVerifyJWT.mockReturnValue({ sub: 'cust-1', email: 'user@example.com', plan: 'starter' });
mockGetAuthorizeHtml.mockReturnValue('<html>consent</html>');
const res = await request(app)
.get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
.set('Cookie', 'session=valid-jwt');
expect(res.status).toBe(200);
expect(res.text).toContain('consent');
expect(res.headers.location).toBeUndefined();
});
it('missing client_id → 400 (not a redirect)', async () => {
const res = await request(app).get('/oauth/authorize?redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback');
expect(res.status).toBe(400);
});
it('unregistered client_id → 400', async () => {
mockGetClient.mockResolvedValue(null);
const res = await request(app).get(
'/oauth/authorize?client_id=unknown&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback'
);
expect(res.status).toBe(400);
});
it('redirect_uri not registered for client → 400', async () => {
mockIsValidRedirectUri.mockReturnValue(false);
const res = await request(app).get(
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fevil.com%2Fcallback'
);
expect(res.status).toBe(400);
});
});