From c580c7c3572fc145e395266e73fcd5e3745374fb Mon Sep 17 00:00:00 2001 From: Chaitanya Bhardwaj Date: Sun, 14 Jun 2026 22:25:33 +0530 Subject: [PATCH 1/2] feat(auth): add request validation for utility endpoints --- apps/backend/src/__tests__/auth-util.test.ts | 111 ++++++++++++++++++ apps/backend/src/routes/auth.ts | 16 ++- .../src/validations/auth.validation.ts | 15 +++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/__tests__/auth-util.test.ts create mode 100644 apps/backend/src/validations/auth.validation.ts diff --git a/apps/backend/src/__tests__/auth-util.test.ts b/apps/backend/src/__tests__/auth-util.test.ts new file mode 100644 index 00000000..31346ba2 --- /dev/null +++ b/apps/backend/src/__tests__/auth-util.test.ts @@ -0,0 +1,111 @@ +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() { + const app = Fastify({ logger: false }); + await app.register(cookiePlugin as any); + await app.register(jwtPlugin as any, { secret: 'test-secret-for-unit-tests-only' }); + + app.decorate('prisma', { + refreshToken: { + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + }, + user: { + findUnique: vi.fn() + } + } as any); + + app.decorate('redis', { + getdel: vi.fn(), + set: vi.fn(), + exists: vi.fn(), + } as any); + + app.decorate('authenticate', async () => {}); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('auth validation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /auth/mobile/exchange', () => { + it('rejects missing code', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/mobile/exchange', + payload: {} + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid request body'); + }); + + it('rejects non-string code', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/mobile/exchange', + payload: { code: 123 } + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid request body'); + }); + + it('rejects empty code', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/mobile/exchange', + payload: { code: '' } + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid request body'); + }); + }); + + describe('POST /auth/refresh', () => { + it('rejects empty refresh_token in body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + payload: { refresh_token: '' } + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid request body'); + }); + + it('rejects non-string refresh_token in body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + payload: { refresh_token: 123 } + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid request body'); + }); + + it('fails when no token is provided', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh' + }); + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Refresh token missing'); + }); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 3bc39ad4..59143909 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -2,6 +2,7 @@ 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 { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js'; import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -495,7 +496,14 @@ export async function authRoutes(app: FastifyInstance): Promise { }); app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => { - const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + if (request.body && typeof request.body === 'object' && Object.keys(request.body).length > 0) { + const parsed = refreshTokenSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.flatten() }); + } + } + + const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; if (!refreshToken) { return reply.status(401).send({ @@ -600,7 +608,11 @@ export async function authRoutes(app: FastifyInstance): Promise { }) app.post('/mobile/exchange', async (request: FastifyRequest<{Body: {code: string}}>, reply: FastifyReply) => { - const { code } = request.body; + const parsed = mobileExchangeSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.flatten() }); + } + const { code } = parsed.data; const raw = await app.redis.getdel(`mobile_exchange:${code}`); if (!raw) {return reply.status(400).send({ error: 'Invalid or expired exchange code' });} diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts new file mode 100644 index 00000000..20925f9a --- /dev/null +++ b/apps/backend/src/validations/auth.validation.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const mobileExchangeSchema = z.object({ + code: z.string({ + required_error: 'missing code', + invalid_type_error: 'code must be a string' + }).min(1, 'code is empty') +}); + +export const refreshTokenSchema = z.object({ + refresh_token: z.string({ + required_error: 'missing refresh_token', + invalid_type_error: 'refresh_token must be a string' + }).min(1, 'refresh_token is empty') +}); From 15276369864ef5d68cfde2776bbcf2ad53724561 Mon Sep 17 00:00:00 2001 From: Chaitanya Bhardwaj Date: Sun, 14 Jun 2026 22:46:28 +0530 Subject: [PATCH 2/2] fix(auth): address Copilot review feedback --- .../__tests__/{auth-util.test.ts => auth-validation.test.ts} | 0 apps/backend/src/routes/auth.ts | 2 +- apps/backend/src/validations/auth.validation.ts | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename apps/backend/src/__tests__/{auth-util.test.ts => auth-validation.test.ts} (100%) diff --git a/apps/backend/src/__tests__/auth-util.test.ts b/apps/backend/src/__tests__/auth-validation.test.ts similarity index 100% rename from apps/backend/src/__tests__/auth-util.test.ts rename to apps/backend/src/__tests__/auth-validation.test.ts diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 59143909..04f3535a 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -496,7 +496,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => { - if (request.body && typeof request.body === 'object' && Object.keys(request.body).length > 0) { + if (request.body && typeof request.body === 'object' && 'refresh_token' in request.body) { const parsed = refreshTokenSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.flatten() }); diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts index 20925f9a..b71f9483 100644 --- a/apps/backend/src/validations/auth.validation.ts +++ b/apps/backend/src/validations/auth.validation.ts @@ -4,12 +4,12 @@ export const mobileExchangeSchema = z.object({ code: z.string({ required_error: 'missing code', invalid_type_error: 'code must be a string' - }).min(1, 'code is empty') + }).trim().min(1, 'code is empty') }); export const refreshTokenSchema = z.object({ refresh_token: z.string({ required_error: 'missing refresh_token', invalid_type_error: 'refresh_token must be a string' - }).min(1, 'refresh_token is empty') + }).trim().min(1, 'refresh_token is empty') });