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:
12
src/index.ts
12
src/index.ts
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
420
src/oauth-login-routes.test.ts
Normal file
420
src/oauth-login-routes.test.ts
Normal 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 " in the hidden field value
|
||||
expect(res.text).not.toContain('"onmouseover=alert(1)"');
|
||||
expect(res.text).toContain('"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user