Skip to content

Commit 3b5a8e9

Browse files
authored
Merge branch 'main' into feat/uat-configured
Signed-off-by: Prashantkumar Khatri <khatri2105104@st.jmi.ac.in>
2 parents d2b506d + 52e4df1 commit 3b5a8e9

4 files changed

Lines changed: 269 additions & 53 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import cookiePlugin from '@fastify/cookie';
2+
import jwtPlugin from '@fastify/jwt';
3+
import Fastify, { type FastifyInstance } from 'fastify';
4+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5+
6+
import { authRoutes } from '../routes/auth.js';
7+
8+
async function buildTestApp(): Promise<FastifyInstance> {
9+
const app = Fastify({ logger: false });
10+
11+
await app.register(cookiePlugin as any);
12+
await app.register(jwtPlugin as any, {
13+
secret: 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
14+
cookie: { cookieName: 'access_Token', signed: false },
15+
});
16+
17+
app.decorate('prisma', {
18+
user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
19+
userIdentity: { findUnique: vi.fn(), create: vi.fn() },
20+
refreshToken: { create: vi.fn() },
21+
} as any);
22+
23+
app.decorate('redis', {
24+
set: vi.fn(),
25+
getdel: vi.fn(),
26+
} as any);
27+
28+
app.decorate('authenticate', async () => {});
29+
30+
await app.register(authRoutes, { prefix: '/auth' });
31+
await app.ready();
32+
return app;
33+
}
34+
35+
function cookieCleared(res: any): boolean {
36+
const raw = res.headers['set-cookie'] as string | string[] | undefined;
37+
const cookies = Array.isArray(raw) ? raw : raw ? [raw] : [];
38+
return cookies.some((c) => c.startsWith('oauth_state=;') || c.includes('oauth_state=; '));
39+
}
40+
41+
describe('GET /auth/github/callback — Zod validation', () => {
42+
let app: FastifyInstance;
43+
44+
beforeEach(async () => {
45+
vi.clearAllMocks();
46+
app = await buildTestApp();
47+
});
48+
49+
afterEach(async () => {
50+
await app.close();
51+
});
52+
53+
it('400 — missing code rejects with validation error', async () => {
54+
const res = await app.inject({
55+
method: 'GET',
56+
url: '/auth/github/callback?state=somestate',
57+
headers: { Cookie: 'oauth_state=somestate' },
58+
});
59+
60+
expect(res.statusCode).toBe(400);
61+
expect(res.json().error).toBe('Invalid callback parameters');
62+
expect(cookieCleared(res)).toBe(true);
63+
});
64+
65+
it('400 — empty code rejects with validation error', async () => {
66+
const res = await app.inject({
67+
method: 'GET',
68+
url: '/auth/github/callback?code=&state=somestate',
69+
headers: { Cookie: 'oauth_state=somestate' },
70+
});
71+
72+
expect(res.statusCode).toBe(400);
73+
expect(res.json().error).toBe('Invalid callback parameters');
74+
expect(cookieCleared(res)).toBe(true);
75+
});
76+
77+
it('400 — missing state rejects with validation error', async () => {
78+
const res = await app.inject({
79+
method: 'GET',
80+
url: '/auth/github/callback?code=validcode',
81+
});
82+
83+
expect(res.statusCode).toBe(400);
84+
expect(res.json().error).toBe('Invalid callback parameters');
85+
expect(cookieCleared(res)).toBe(true);
86+
});
87+
88+
it('400 — empty state rejects with validation error', async () => {
89+
const res = await app.inject({
90+
method: 'GET',
91+
url: '/auth/github/callback?code=validcode&state=',
92+
});
93+
94+
expect(res.statusCode).toBe(400);
95+
expect(res.json().error).toBe('Invalid callback parameters');
96+
expect(cookieCleared(res)).toBe(true);
97+
});
98+
99+
it('400 — valid code and state but no cookie rejects with state error', async () => {
100+
const res = await app.inject({
101+
method: 'GET',
102+
url: '/auth/github/callback?code=validcode&state=somestate',
103+
});
104+
105+
expect(res.statusCode).toBe(400);
106+
expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack');
107+
expect(cookieCleared(res)).toBe(true);
108+
});
109+
110+
it('400 — valid code and state but mismatched cookie rejects with state error', async () => {
111+
const res = await app.inject({
112+
method: 'GET',
113+
url: '/auth/github/callback?code=validcode&state=somestate',
114+
headers: { Cookie: 'oauth_state=differentstate' },
115+
});
116+
117+
expect(res.statusCode).toBe(400);
118+
expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack');
119+
expect(cookieCleared(res)).toBe(true);
120+
});
121+
});
122+
123+
describe('GET /auth/google/callback — Zod validation', () => {
124+
let app: FastifyInstance;
125+
126+
beforeEach(async () => {
127+
vi.clearAllMocks();
128+
app = await buildTestApp();
129+
});
130+
131+
afterEach(async () => {
132+
await app.close();
133+
});
134+
135+
it('400 — missing code rejects with validation error', async () => {
136+
const res = await app.inject({
137+
method: 'GET',
138+
url: '/auth/google/callback?state=somestate',
139+
headers: { Cookie: 'oauth_state=somestate' },
140+
});
141+
142+
expect(res.statusCode).toBe(400);
143+
expect(res.json().error).toBe('Invalid callback parameters');
144+
expect(cookieCleared(res)).toBe(true);
145+
});
146+
147+
it('400 — empty code rejects with validation error', async () => {
148+
const res = await app.inject({
149+
method: 'GET',
150+
url: '/auth/google/callback?code=&state=somestate',
151+
headers: { Cookie: 'oauth_state=somestate' },
152+
});
153+
154+
expect(res.statusCode).toBe(400);
155+
expect(res.json().error).toBe('Invalid callback parameters');
156+
expect(cookieCleared(res)).toBe(true);
157+
});
158+
159+
it('400 — missing state rejects with validation error', async () => {
160+
const res = await app.inject({
161+
method: 'GET',
162+
url: '/auth/google/callback?code=validcode',
163+
});
164+
165+
expect(res.statusCode).toBe(400);
166+
expect(res.json().error).toBe('Invalid callback parameters');
167+
expect(cookieCleared(res)).toBe(true);
168+
});
169+
170+
it('400 — empty state rejects with validation error', async () => {
171+
const res = await app.inject({
172+
method: 'GET',
173+
url: '/auth/google/callback?code=validcode&state=',
174+
});
175+
176+
expect(res.statusCode).toBe(400);
177+
expect(res.json().error).toBe('Invalid callback parameters');
178+
expect(cookieCleared(res)).toBe(true);
179+
});
180+
181+
it('400 — valid code and state but no cookie rejects with state error', async () => {
182+
const res = await app.inject({
183+
method: 'GET',
184+
url: '/auth/google/callback?code=validcode&state=somestate',
185+
});
186+
187+
expect(res.statusCode).toBe(400);
188+
expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack');
189+
expect(cookieCleared(res)).toBe(true);
190+
});
191+
192+
it('400 — valid code and state but mismatched cookie rejects with state error', async () => {
193+
const res = await app.inject({
194+
method: 'GET',
195+
url: '/auth/google/callback?code=validcode&state=somestate',
196+
headers: { Cookie: 'oauth_state=differentstate' },
197+
});
198+
199+
expect(res.statusCode).toBe(400);
200+
expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack');
201+
expect(cookieCleared(res)).toBe(true);
202+
});
203+
});

apps/backend/src/routes/auth.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/
22
import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js';
33
import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js';
44
import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js';
5-
import { oAuthStartSchema } from '../validations/auth.validation.js';
5+
import { oAuthStartSchema, oAuthCallbackSchema } from '../validations/auth.validation.js';
66

77
import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js';
8-
import type { OAuthStartQuery } from '../validations/auth.validation.js';
8+
import type { OAuthStartQuery, OAuthCallbackQuery } from '../validations/auth.validation.js';
99
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
1010

1111
interface GitHubEmailResponse {
@@ -21,11 +21,6 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
2121
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
2222
const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
2323

24-
interface OAuthCallbackQuery {
25-
code: string;
26-
state?: string;
27-
}
28-
2924
interface GoogleUser {
3025
id: string;
3126
email: string;
@@ -99,18 +94,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
9994

10095
// GitHub OAuth callback
10196
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
102-
//TODO: Add zod validation here
103-
const { code, state } = request.query;
10497
const storedState = request.cookies?.oauth_state;
105-
if (!state || !storedState || state !== storedState) {
98+
const parsed = oAuthCallbackSchema.safeParse(request.query);
99+
if (!parsed.success) {
100+
reply.clearCookie('oauth_state', { path: '/' });
101+
return reply.status(400).send({ error: 'Invalid callback parameters' });
102+
}
103+
const { code, state } = parsed.data;
104+
if (!storedState || state !== storedState) {
105+
reply.clearCookie('oauth_state', { path: '/' });
106106
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
107107
}
108108
reply.clearCookie('oauth_state', { path: '/' });
109109

110-
if (!code) {
111-
return reply.status(400).send({ error: 'Missing authorization code' });
112-
}
113-
114110
try {
115111
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
116112
method: 'POST',
@@ -317,18 +313,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
317313

318314
// Google callback
319315
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
320-
//TODO: Add zod validation here
321-
const { code, state } = request.query;
322-
323316
const storedState = request.cookies?.oauth_state;
324-
if (!state || !storedState || state !== storedState) {
325-
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
317+
const parsed = oAuthCallbackSchema.safeParse(request.query);
318+
if (!parsed.success) {
319+
reply.clearCookie('oauth_state', { path: '/' });
320+
return reply.status(400).send({ error: 'Invalid callback parameters' });
326321
}
327-
reply.clearCookie('oauth_state', { path: '/' });
322+
const { code, state } = parsed.data;
328323

329-
if (!code) {
330-
return reply.status(400).send({ error: 'Missing authorization code' });
324+
if (!storedState || state !== storedState) {
325+
reply.clearCookie('oauth_state', { path: '/' });
326+
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
331327
}
328+
reply.clearCookie('oauth_state', { path: '/' });
332329

333330
try {
334331
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {

0 commit comments

Comments
 (0)