From 6c8003461acc99e1ec259c6cc8b4a54d41659a7a Mon Sep 17 00:00:00 2001 From: sheeeuWu Date: Tue, 16 Jun 2026 23:15:24 +0530 Subject: [PATCH] Added Zod Validation for OAuth Authentication Initiation Endpoints --- apps/backend/src/__tests__/auth.test.ts | 210 ++++++++++++++++++ apps/backend/src/routes/auth.ts | 42 ++-- .../src/validations/auth.validation.ts | 15 ++ 3 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 apps/backend/src/__tests__/auth.test.ts create mode 100644 apps/backend/src/validations/auth.validation.ts diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..8814af54 --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,210 @@ +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { authRoutes } from '../routes/auth'; + +import type { JWT } from '@fastify/jwt'; +import type { PrismaClient } from '@prisma/client'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { Redis } from 'ioredis'; + + +const MOCK_CLIENT_ID = 'mock-github-client-id'; +const MOCK_GOOGLE_CLIENT_ID = 'mock-google-client-id'; +const MOCK_BACKEND_URL = 'http://localhost:3000'; + + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + await app.register(import('@fastify/cookie')); + + //as not testing this here + app.decorate('authenticate', async (_request: FastifyRequest, reply: FastifyReply) => { + reply.status(401).send({ error: 'Unauthorized' }); + }); + + app.decorate('jwt', { + sign: vi.fn().mockReturnValue('mock-token'), + decode: vi.fn(), + verify: vi.fn(), + } as unknown as JWT); + + app.decorate('prisma', { + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + userIdentity: { findUnique: vi.fn(), create: vi.fn() }, + refreshToken: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, + } as unknown as PrismaClient); + + app.decorate('redis', { + set: vi.fn(), + get: vi.fn(), + getdel: vi.fn(), + } as unknown as Redis); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('Auth API — OAuth initiation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.stubEnv('GITHUB_CLIENT_ID', MOCK_CLIENT_ID); + vi.stubEnv('GOOGLE_CLIENT_ID', MOCK_GOOGLE_CLIENT_ID); + vi.stubEnv('BACKEND_URL', MOCK_BACKEND_URL); + vi.stubEnv('NODE_ENV', 'test'); + app = await buildApp(); //fresh app instance before and after each instance + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await app.close(); //fresh app instance before and after each instance + }); + + // /auth/github + describe('GET /auth/github — OAuth initiation', () => { + it('302 — redirects to GitHub with valid query params', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toContain('github.com/login/oauth/authorize'); + }); + + it('302 — sets oauth_state cookie on redirect', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie']).toMatch(/oauth_state=/); + }); + + it('302 — accepts valid state param', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?state=some-client-state', + }); + + expect(res.statusCode).toBe(302); + }); + + it('302 — accepts valid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=devcard://callback', + }); + + expect(res.statusCode).toBe(302); + }); + + it('400 — rejects invalid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=https://evil.com/callback', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: expect.any(String) }); + }); + + it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=http://localhost/callback', + }); + + expect(res.statusCode).toBe(400); + }); + + it('400 — returns 400 when GITHUB_CLIENT_ID is missing', async () => { + vi.stubEnv('GITHUB_CLIENT_ID', ''); + + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(400); + }); + }); + + // /auth/google + describe('GET /auth/google — OAuth initiation', () => { + it('302 — redirects to Google with valid query params', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toContain('accounts.google.com/o/oauth2/v2/auth'); + }); + + it('302 — sets oauth_state cookie on redirect', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie']).toMatch(/oauth_state=/); + }); + + it('302 — accepts valid state param', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?state=some-client-state', + }); + + expect(res.statusCode).toBe(302); + }); + + it('302 — accepts valid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=devcard://callback', + }); + + expect(res.statusCode).toBe(302); + }); + + it('400 — rejects invalid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=https://evil.com/callback', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: expect.any(String) }); + }); + + it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=http://localhost/callback', + }); + + expect(res.statusCode).toBe(400); + }); + + it('400 — returns 400 when GOOGLE_CLIENT_ID is missing', async () => { + vi.stubEnv('GOOGLE_CLIENT_ID', ''); + + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(400); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 3bc39ad4..56a7618f 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -2,8 +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 type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; +import type { OAuthStartQuery } from '../validations/auth.validation.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; interface GitHubEmailResponse { @@ -24,11 +26,6 @@ interface OAuthCallbackQuery { state?: string; } -type GoogleAuthQuery = { - state?: string; - mobile_redirect_uri?: string; -}; - interface GoogleUser { id: string; email: string; @@ -66,23 +63,18 @@ export async function authRoutes(app: FastifyInstance): Promise { } // GitHub OAuth start - app.get('/github', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => { + app.get('/github', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => { const clientId = process.env.GITHUB_CLIENT_ID; if(!clientId){ return reply.status(400).send() } - //TODO: Add zod validation here - const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query - - if ( - mobileRedirectUri && - !mobileRedirectUri.startsWith('devcard://') - ) { - return reply.status(400).send({ - error: 'Invalid mobile redirect URI', - }); - } const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; + + const parsed = oAuthStartSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.errors[0].message }); + } + const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -288,24 +280,18 @@ export async function authRoutes(app: FastifyInstance): Promise { }); // Google OAuth start - app.get('/google', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => { + app.get('/google', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => { const clientId = process.env.GOOGLE_CLIENT_ID; if(!clientId){ return reply.status(400).send() } const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; - //TODO: Add zod validation here - const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query - if ( - mobileRedirectUri && - !mobileRedirectUri.startsWith('devcard://') - ) { - return reply.status(400).send({ - error: 'Invalid mobile redirect URI', - }); + const parsed = oAuthStartSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.errors[0].message }); } - + const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts new file mode 100644 index 00000000..92e79d9c --- /dev/null +++ b/apps/backend/src/validations/auth.validation.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const oAuthStartSchema = z.object({ + state: z.string().optional().default(''), + mobile_redirect_uri: z + .string() + .optional() + .default('') + .refine( + (val) => !val || val.startsWith('devcard://'), + { message: 'Invalid mobile redirect URI' } + ), +}); + +export type OAuthStartQuery = z.infer; \ No newline at end of file