diff --git a/apps/backend/src/__tests__/auth-callback.test.ts b/apps/backend/src/__tests__/auth-callback.test.ts new file mode 100644 index 00000000..ed6bb4cb --- /dev/null +++ b/apps/backend/src/__tests__/auth-callback.test.ts @@ -0,0 +1,203 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; + +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + + await app.register(cookiePlugin as any); + await app.register(jwtPlugin as any, { + secret: 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + cookie: { cookieName: 'access_Token', signed: false }, + }); + + app.decorate('prisma', { + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + userIdentity: { findUnique: vi.fn(), create: vi.fn() }, + refreshToken: { create: vi.fn() }, + } as any); + + app.decorate('redis', { + set: vi.fn(), + getdel: vi.fn(), + } as any); + + app.decorate('authenticate', async () => {}); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +function cookieCleared(res: any): boolean { + const raw = res.headers['set-cookie'] as string | string[] | undefined; + const cookies = Array.isArray(raw) ? raw : raw ? [raw] : []; + return cookies.some((c) => c.startsWith('oauth_state=;') || c.includes('oauth_state=; ')); +} + +describe('GET /auth/github/callback — Zod validation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('400 — missing code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=&state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — missing state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but no cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=somestate', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but mismatched cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=somestate', + headers: { Cookie: 'oauth_state=differentstate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); +}); + +describe('GET /auth/google/callback — Zod validation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('400 — missing code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=&state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — missing state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but no cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=somestate', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but mismatched cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=somestate', + headers: { Cookie: 'oauth_state=differentstate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 56a7618f..7bfa7075 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -2,10 +2,10 @@ import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/ import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; -import { oAuthStartSchema } from '../validations/auth.validation.js'; +import { oAuthStartSchema, oAuthCallbackSchema } from '../validations/auth.validation.js'; import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; -import type { OAuthStartQuery } from '../validations/auth.validation.js'; +import type { OAuthStartQuery, OAuthCallbackQuery } from '../validations/auth.validation.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; interface GitHubEmailResponse { @@ -21,11 +21,6 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; -interface OAuthCallbackQuery { - code: string; - state?: string; -} - interface GoogleUser { id: string; email: string; @@ -99,18 +94,19 @@ export async function authRoutes(app: FastifyInstance): Promise { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - //TODO: Add zod validation here - const { code, state } = request.query; const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + const parsed = oAuthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid callback parameters' }); + } + const { code, state } = parsed.data; + if (!storedState || state !== storedState) { + reply.clearCookie('oauth_state', { path: '/' }); return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } - try { const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', @@ -317,18 +313,19 @@ export async function authRoutes(app: FastifyInstance): Promise { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - //TODO: Add zod validation here - const { code, state } = request.query; - const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { - return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); + const parsed = oAuthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid callback parameters' }); } - reply.clearCookie('oauth_state', { path: '/' }); + const { code, state } = parsed.data; - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); + if (!storedState || state !== storedState) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } + reply.clearCookie('oauth_state', { path: '/' }); try { const tokenRes = await fetch(GOOGLE_TOKEN_URL, { diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts index 92e79d9c..b6db0dc8 100644 --- a/apps/backend/src/validations/auth.validation.ts +++ b/apps/backend/src/validations/auth.validation.ts @@ -12,4 +12,11 @@ export const oAuthStartSchema = z.object({ ), }); -export type OAuthStartQuery = z.infer; \ No newline at end of file +export type OAuthStartQuery = z.infer; + +export const oAuthCallbackSchema = z.object({ + code: z.string().trim().min(1, 'Authorization code is required'), + state: z.string().trim().min(1, 'State parameter is required'), +}); + +export type OAuthCallbackQuery = z.infer;