Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions apps/backend/src/__tests__/auth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyInstance> {
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 () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejection coverage is strong and nicely symmetric across both providers (missing/empty code, missing/empty state, no-cookie, mismatched-cookie, field-level details).

Gap: there's no happy-path assertion — valid code + matching state proceeding past validation (e.g. mocking fetch to assert clearCookie fires / token exchange is attempted). Without it the suite can't catch a regression that wrongly rejects valid callbacks. buildTestApp already wires the prisma/redis mocks, so it's set up for this. Optional for this PR's scope.

const res = await app.inject({
method: 'GET',
url: '/auth/github/callback?state=somestate',
headers: { Cookie: 'oauth_state=somestate' },
});
Comment thread
Harxhit marked this conversation as resolved.

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);
});
});
41 changes: 19 additions & 22 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -99,18 +94,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {

// 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: '/' });
Comment thread
Harxhit marked this conversation as resolved.

if (!code) {
return reply.status(400).send({ error: 'Missing authorization code' });
}

try {
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
Expand Down Expand Up @@ -317,18 +313,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {

// 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: '/' });
Comment on lines +324 to +328

try {
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/src/validations/auth.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ export const oAuthStartSchema = z.object({
),
});

export type OAuthStartQuery = z.infer<typeof oAuthStartSchema>;
export type OAuthStartQuery = z.infer<typeof oAuthStartSchema>;

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<typeof oAuthCallbackSchema>;