diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 371cec7..4850f3b 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,39 +1,322 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import { connectRoutes } from '../routes/connect.js'; +import type { PrismaClient } from '@prisma/client'; -// Mock test for GitHub OAuth callback state validation -// Note: This test verifies the expected behavior of the -// /api/connect/github/callback endpoint when invalid or -// malformed OAuth state values are received. -// -// The implementation in connect.ts now: -// - safely parses OAuth state via parseGoogleState() -// - validates required fields (userId + nonce) -// - redirects invalid callbacks safely -// -// Security note: -// OAuth state validation helps prevent tampered callback -// requests and malformed state payload attacks. +// ── Shared test helpers ─────────────────────────────────────────────────────── -describe('GET /api/connect/github/callback - Invalid OAuth State', () => { +/** Build a valid base64-encoded state string the way connect.ts does. */ +function makeState(userId: string, nonce: string): string { + return Buffer.from(JSON.stringify({ userId, nonce })).toString('base64'); +} - it('should redirect with connect_failed when state is invalid', async () => { - // Expected behavior: - // parseGoogleState('invalid_state') -> null - // reply.redirect(`${PUBLIC_APP_URL}/settings?error=connect_failed`) +/** Corrupt a valid base64 string so JSON.parse throws. */ +function malformedBase64(): string { + return 'not!!valid%%base64'; +} - expect(true).toBe(true); +/** Valid base64 but wrong shape (missing required fields). */ +function missingFieldState(): string { + return Buffer.from(JSON.stringify({ bad: 'payload' })).toString('base64'); +} + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock('../utils/encryption.js', () => ({ + encrypt: vi.fn().mockReturnValue('encrypted-test-token'), + decrypt: vi.fn().mockReturnValue('plain-test-token'), +})); + +// ── Mock setup ──────────────────────────────────────────────────────────────── + +const USER_ID = 'user-abc'; +const NONCE = 'a'.repeat(64); // 32 bytes hex + +const mockPrisma = { + oAuthToken: { + findMany: vi.fn().mockResolvedValue([]), + upsert: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + }, +} as unknown as PrismaClient; + +// Redis mock: get/set/del are replaced per-test in beforeEach +const mockRedis = { + set: vi.fn(), + get: vi.fn(), + del: vi.fn(), +}; + +// Capture fetch calls so we can assert token exchange never fires for bad state +const mockFetch = vi.fn(); + +async function buildApp() { + const app = Fastify({ logger: false }); + + app.decorate('prisma', mockPrisma); + app.decorate('redis', mockRedis); + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; + }); + + // Expose jwtVerify on request so the route plugin doesn't blow up if it + // tries to call it (it doesn't, but some Fastify internals reference it). + app.decorateRequest('jwtVerify', async function () { + return { id: USER_ID }; + }); + + app.register(connectRoutes, { prefix: '/api/connect' }); + await app.ready(); + return app; +} + +// Replace global fetch with our mock for every test +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as any).fetch = mockFetch; + + // Default Redis behaviours (override in individual tests) + mockRedis.set.mockResolvedValue('OK'); + mockRedis.get.mockResolvedValue(null); + mockRedis.del.mockResolvedValue(1); + + // Default: GitHub returns a valid access token + mockFetch.mockResolvedValue({ + json: async () => ({ access_token: 'ghs_test_token', scope: 'user:follow' }), + }); + + process.env.PUBLIC_APP_URL = 'http://localhost:5173'; + process.env.BACKEND_URL = 'http://localhost:3000'; + process.env.GITHUB_CLIENT_ID = 'test-client-id'; +}); + +// ── GET /api/connect/github — initiation ───────────────────────────────────── + +describe('GET /api/connect/github — nonce initiation', () => { + it('persists a nonce in Redis before redirecting', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/connect/github' }); + + expect(res.statusCode).toBe(302); + expect(mockRedis.set).toHaveBeenCalledOnce(); + + const [key, value, ex, ttl] = mockRedis.set.mock.calls[0]; + expect(key).toMatch(/^oauth:nonce:/); + expect(value).toBe(USER_ID); + expect(ex).toBe('EX'); + expect(ttl).toBe(600); + }); + + it('embeds the nonce in the state query param', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/connect/github' }); + + const location = res.headers['location'] as string; + const url = new URL(location); + const state = JSON.parse(Buffer.from(url.searchParams.get('state')!, 'base64').toString()); + + expect(state.userId).toBe(USER_ID); + expect(typeof state.nonce).toBe('string'); + expect(state.nonce.length).toBeGreaterThan(0); + + // The nonce in the redirect must match what was stored in Redis + const storedKey = mockRedis.set.mock.calls[0][0] as string; + expect(storedKey).toBe(`oauth:nonce:${state.nonce}`); + }); + + it('fails closed with 500 when Redis is unavailable', async () => { + mockRedis.set.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/connect/github' }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Failed to initiate OAuth flow'); + // No redirect issued — attacker cannot initiate unprotected flow + expect(res.headers['location']).toBeUndefined(); + }); +}); + +// ── GET /api/connect/github/callback — validation ──────────────────────────── + +describe('GET /api/connect/github/callback — nonce validation', () => { + + // ── Happy path ───────────────────────────────────────────────────────────── + + it('completes the connect flow for a valid nonce', async () => { + mockRedis.get.mockResolvedValue(USER_ID); // nonce exists in Redis + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${makeState(USER_ID, NONCE)}`, }); - it('should reject malformed oauth state payloads', async () => { - // Example malformed payload: - // { invalid: true } - // - // Expected: - // - missing userId - // - missing nonce - // - redirect to connect_failed + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('connected=github'); + + // Token exchange happened + expect(mockFetch).toHaveBeenCalledOnce(); + // Token was stored + expect((mockPrisma.oAuthToken.upsert as ReturnType)).toHaveBeenCalledOnce(); + }); + + it('consumes the nonce (deletes from Redis) after a successful flow', async () => { + mockRedis.get.mockResolvedValue(USER_ID); + + const app = await buildApp(); + await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${makeState(USER_ID, NONCE)}`, + }); + + expect(mockRedis.del).toHaveBeenCalledWith(`oauth:nonce:${NONCE}`); + }); + + // ── Forged / unknown nonce ───────────────────────────────────────────────── + + it('rejects a forged state with an unknown nonce (Redis returns null)', async () => { + mockRedis.get.mockResolvedValue(null); // nonce never persisted + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${makeState(USER_ID, 'forged-nonce')}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=connect_failed'); + + // Token exchange must NOT fire for an unvalidated state + expect(mockFetch).not.toHaveBeenCalled(); + expect((mockPrisma.oAuthToken.upsert as ReturnType)).not.toHaveBeenCalled(); + }); + + // ── Replay attack ────────────────────────────────────────────────────────── + + it('blocks a replay of a previously consumed nonce', async () => { + // First request: valid nonce is consumed + mockRedis.get + .mockResolvedValueOnce(USER_ID) // first call succeeds + .mockResolvedValueOnce(null); // second call: nonce gone + + const app = await buildApp(); + const state = makeState(USER_ID, NONCE); + + const first = await app.inject({ method: 'GET', url: `/api/connect/github/callback?code=code1&state=${state}` }); + const second = await app.inject({ method: 'GET', url: `/api/connect/github/callback?code=code2&state=${state}` }); + + expect(first.statusCode).toBe(302); + expect(first.headers['location']).toContain('connected=github'); + + expect(second.statusCode).toBe(302); + expect(second.headers['location']).toContain('error=connect_failed'); + + // Fetch only fired for the first (valid) request + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + // ── userId mismatch ──────────────────────────────────────────────────────── + + it('rejects a state where userId does not match the stored nonce owner', async () => { + // Nonce was issued for USER_ID; attacker claims it belongs to another user + mockRedis.get.mockResolvedValue(USER_ID); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${makeState('attacker-user-id', NONCE)}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=connect_failed'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // ── Malformed inputs ─────────────────────────────────────────────────────── + + it('rejects malformed base64 state gracefully', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${malformedBase64()}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=connect_failed'); + expect(mockRedis.get).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects valid base64 with a missing-field JSON payload', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${missingFieldState()}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=connect_failed'); + expect(mockRedis.get).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects requests with no state parameter', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/callback?code=gh_code', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=missing_params'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects requests with no code parameter', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?state=${makeState(USER_ID, NONCE)}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=missing_params'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // ── Redis failures ───────────────────────────────────────────────────────── + + it('fails closed when Redis throws during nonce lookup', async () => { + mockRedis.get.mockRejectedValueOnce(new Error('Redis connection lost')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${makeState(USER_ID, NONCE)}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=server_error'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // ── Token exchange error ─────────────────────────────────────────────────── + + it('redirects with connect_failed when GitHub rejects the code', async () => { + mockRedis.get.mockResolvedValue(USER_ID); + mockFetch.mockResolvedValueOnce({ + json: async () => ({ error: 'bad_verification_code' }), + }); - expect(true).toBe(true); + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=bad_code&state=${makeState(USER_ID, NONCE)}`, }); -}); \ No newline at end of file + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toContain('error=connect_failed'); + expect((mockPrisma.oAuthToken.upsert as ReturnType)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 02f6dce..930c301 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -5,6 +5,10 @@ import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; +// Nonce TTL: 10 minutes — generous for a login round-trip, short enough to +// limit the window a leaked state URL could be abused. +const OAUTH_NONCE_TTL_SECONDS = 600; + interface OAuthCallbackQuery { code: string; state?: string; @@ -33,33 +37,39 @@ export async function connectRoutes(app: FastifyInstance) { // ─── GitHub Connect ─── -app.get('/github', { - preHandler: [app.authenticate], -}, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; - const nonce = generateState(); - - // Store nonce in Redis with 10-minute TTL. - // The callback verifies this to prevent CSRF attacks. - await app.redis.set( - `oauth:nonce:${nonce}`, - userId, - 'EX', - 600 - ); - - const state = JSON.stringify({ userId, nonce }); - - const redirectUri = `${process.env.BACKEND_URL}/api/connect/github/callback`; - const params = new URLSearchParams({ - client_id: process.env.GITHUB_CLIENT_ID || '', - redirect_uri: redirectUri, - scope: 'user:follow', - state: Buffer.from(state).toString('base64'), - }); + app.get('/github', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + const nonce = generateNonce(); - return reply.redirect(`${GITHUB_AUTH_URL}?${params}`); -}); + // Persist the nonce server-side before issuing the redirect. + // The callback will look up this key to prove the request originated here. + // Fail closed: if Redis is unavailable we must not issue the redirect — + // a missing nonce store would leave the callback with no way to validate state. + try { + await app.redis.set( + `oauth:nonce:${nonce}`, + userId, + 'EX', + OAUTH_NONCE_TTL_SECONDS, + ); + } catch (err) { + app.log.error({ err }, 'Failed to persist OAuth nonce — aborting connect flow'); + return reply.status(500).send({ error: 'Failed to initiate OAuth flow' }); + } + + const state = Buffer.from(JSON.stringify({ userId, nonce })).toString('base64'); + const redirectUri = `${process.env.BACKEND_URL}/api/connect/github/callback`; + const params = new URLSearchParams({ + client_id: process.env.GITHUB_CLIENT_ID || '', + redirect_uri: redirectUri, + scope: 'user:follow', + state, + }); + + return reply.redirect(`${GITHUB_AUTH_URL}?${params}`); + }); app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; @@ -69,27 +79,47 @@ app.get('/github', { } try { - // Decode state to find which user requested the connect + // ── Step 1: parse state ──────────────────────────────────────────────── const decodedState = parseOAuthState(state); - if (!decodedState) { + app.log.warn('OAuth callback received malformed or unparseable state payload'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - // Verify nonce was issued by this server — prevents CSRF - const storedUserId = await app.redis.get(`oauth:nonce:${decodedState.nonce}`); + // ── Step 2: validate nonce server-side ──────────────────────────────── + // Look up the nonce in Redis and consume it immediately (single-use). + // Any failure — unknown nonce, expired nonce, replay, userId mismatch, + // or Redis error — is treated as a forged request and fails closed. + let storedUserId: string | null; + try { + const nonceKey = `oauth:nonce:${decodedState.nonce}`; + storedUserId = await app.redis.get(nonceKey); + if (storedUserId !== null) { + // Delete before token exchange so a mid-flight error cannot leave + // a reusable nonce in the store. + await app.redis.del(nonceKey); + } + } catch (err) { + app.log.error({ err }, 'Redis error during OAuth nonce lookup — aborting callback'); + return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`); + } - if (!storedUserId || storedUserId !== decodedState.userId) { - app.log.warn({ nonce: decodedState.nonce }, 'OAuth CSRF check failed — nonce mismatch'); - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`); + if (storedUserId === null) { + // Nonce unknown or already expired/consumed — replay or forged request + app.log.warn('OAuth callback nonce not found in Redis — possible replay or forged state'); + return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - // Consume the nonce — one-time use only - await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (storedUserId !== decodedState.userId) { + // Nonce exists but was issued for a different user — state was tampered + app.log.warn('OAuth nonce userId mismatch — state payload does not match issuing session'); + return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); + } - const userId = decodedState.userId; + // Use the Redis-sourced userId as authoritative — never trust state alone. + const userId = storedUserId; - // Exchange code for token + // ── Step 3: exchange code for token ─────────────────────────────────── const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', headers: { @@ -107,11 +137,11 @@ app.get('/github', { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('GitHub connect token error:', tokenData); + app.log.error('GitHub token exchange failed during connect flow'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - // Encrypt and store the token + // ── Step 4: persist encrypted token ─────────────────────────────────── const encryptedToken = encrypt(tokenData.access_token); await app.prisma.oAuthToken.upsert({ @@ -133,8 +163,6 @@ app.get('/github', { }, }); - // Redirect back to app settings - // If mobile, use custom scheme if (decodedState.nonce.startsWith('mobile_')) { return reply.redirect(`${process.env.MOBILE_REDIRECT_URI}?connected=github`); } @@ -182,8 +210,7 @@ function parseOAuthState(state: string): ParsedOAuthState | null { try { const decoded = JSON.parse(Buffer.from(state, 'base64').toString('utf-8')); - // validating the OAuth state structure which is expected - if (typeof decoded.userId !== "string" || typeof decoded.nonce !== "string") { + if (typeof decoded.userId !== 'string' || typeof decoded.nonce !== 'string') { return null; } return decoded; @@ -192,6 +219,6 @@ function parseOAuthState(state: string): ParsedOAuthState | null { } } -function generateState(): string { +function generateNonce(): string { return randomBytes(32).toString('hex'); }