Skip to content

Commit 18809a8

Browse files
authored
Added Zod Validation for OAuth Authentication Initiation Endpoints (#585)
1 parent c48cf37 commit 18809a8

3 files changed

Lines changed: 239 additions & 28 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import Fastify from 'fastify';
2+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3+
4+
import { authRoutes } from '../routes/auth';
5+
6+
import type { JWT } from '@fastify/jwt';
7+
import type { PrismaClient } from '@prisma/client';
8+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
9+
import type { Redis } from 'ioredis';
10+
11+
12+
const MOCK_CLIENT_ID = 'mock-github-client-id';
13+
const MOCK_GOOGLE_CLIENT_ID = 'mock-google-client-id';
14+
const MOCK_BACKEND_URL = 'http://localhost:3000';
15+
16+
17+
async function buildApp(): Promise<FastifyInstance> {
18+
const app = Fastify({ logger: false });
19+
20+
await app.register(import('@fastify/cookie'));
21+
22+
//as not testing this here
23+
app.decorate('authenticate', async (_request: FastifyRequest, reply: FastifyReply) => {
24+
reply.status(401).send({ error: 'Unauthorized' });
25+
});
26+
27+
app.decorate('jwt', {
28+
sign: vi.fn().mockReturnValue('mock-token'),
29+
decode: vi.fn(),
30+
verify: vi.fn(),
31+
} as unknown as JWT);
32+
33+
app.decorate('prisma', {
34+
user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
35+
userIdentity: { findUnique: vi.fn(), create: vi.fn() },
36+
refreshToken: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
37+
} as unknown as PrismaClient);
38+
39+
app.decorate('redis', {
40+
set: vi.fn(),
41+
get: vi.fn(),
42+
getdel: vi.fn(),
43+
} as unknown as Redis);
44+
45+
await app.register(authRoutes, { prefix: '/auth' });
46+
await app.ready();
47+
return app;
48+
}
49+
50+
describe('Auth API — OAuth initiation', () => {
51+
let app: FastifyInstance;
52+
53+
beforeEach(async () => {
54+
vi.clearAllMocks();
55+
vi.stubEnv('GITHUB_CLIENT_ID', MOCK_CLIENT_ID);
56+
vi.stubEnv('GOOGLE_CLIENT_ID', MOCK_GOOGLE_CLIENT_ID);
57+
vi.stubEnv('BACKEND_URL', MOCK_BACKEND_URL);
58+
vi.stubEnv('NODE_ENV', 'test');
59+
app = await buildApp(); //fresh app instance before and after each instance
60+
});
61+
62+
afterEach(async () => {
63+
vi.unstubAllEnvs();
64+
await app.close(); //fresh app instance before and after each instance
65+
});
66+
67+
// /auth/github
68+
describe('GET /auth/github — OAuth initiation', () => {
69+
it('302 — redirects to GitHub with valid query params', async () => {
70+
const res = await app.inject({
71+
method: 'GET',
72+
url: '/auth/github',
73+
});
74+
75+
expect(res.statusCode).toBe(302);
76+
expect(res.headers.location).toContain('github.com/login/oauth/authorize');
77+
});
78+
79+
it('302 — sets oauth_state cookie on redirect', async () => {
80+
const res = await app.inject({
81+
method: 'GET',
82+
url: '/auth/github',
83+
});
84+
85+
expect(res.statusCode).toBe(302);
86+
expect(res.headers['set-cookie']).toBeDefined();
87+
expect(res.headers['set-cookie']).toMatch(/oauth_state=/);
88+
});
89+
90+
it('302 — accepts valid state param', async () => {
91+
const res = await app.inject({
92+
method: 'GET',
93+
url: '/auth/github?state=some-client-state',
94+
});
95+
96+
expect(res.statusCode).toBe(302);
97+
});
98+
99+
it('302 — accepts valid mobile_redirect_uri', async () => {
100+
const res = await app.inject({
101+
method: 'GET',
102+
url: '/auth/github?mobile_redirect_uri=devcard://callback',
103+
});
104+
105+
expect(res.statusCode).toBe(302);
106+
});
107+
108+
it('400 — rejects invalid mobile_redirect_uri', async () => {
109+
const res = await app.inject({
110+
method: 'GET',
111+
url: '/auth/github?mobile_redirect_uri=https://evil.com/callback',
112+
});
113+
114+
expect(res.statusCode).toBe(400);
115+
expect(res.json()).toMatchObject({ error: expect.any(String) });
116+
});
117+
118+
it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => {
119+
const res = await app.inject({
120+
method: 'GET',
121+
url: '/auth/github?mobile_redirect_uri=http://localhost/callback',
122+
});
123+
124+
expect(res.statusCode).toBe(400);
125+
});
126+
127+
it('400 — returns 400 when GITHUB_CLIENT_ID is missing', async () => {
128+
vi.stubEnv('GITHUB_CLIENT_ID', '');
129+
130+
const res = await app.inject({
131+
method: 'GET',
132+
url: '/auth/github',
133+
});
134+
135+
expect(res.statusCode).toBe(400);
136+
});
137+
});
138+
139+
// /auth/google
140+
describe('GET /auth/google — OAuth initiation', () => {
141+
it('302 — redirects to Google with valid query params', async () => {
142+
const res = await app.inject({
143+
method: 'GET',
144+
url: '/auth/google',
145+
});
146+
147+
expect(res.statusCode).toBe(302);
148+
expect(res.headers.location).toContain('accounts.google.com/o/oauth2/v2/auth');
149+
});
150+
151+
it('302 — sets oauth_state cookie on redirect', async () => {
152+
const res = await app.inject({
153+
method: 'GET',
154+
url: '/auth/google',
155+
});
156+
157+
expect(res.statusCode).toBe(302);
158+
expect(res.headers['set-cookie']).toBeDefined();
159+
expect(res.headers['set-cookie']).toMatch(/oauth_state=/);
160+
});
161+
162+
it('302 — accepts valid state param', async () => {
163+
const res = await app.inject({
164+
method: 'GET',
165+
url: '/auth/google?state=some-client-state',
166+
});
167+
168+
expect(res.statusCode).toBe(302);
169+
});
170+
171+
it('302 — accepts valid mobile_redirect_uri', async () => {
172+
const res = await app.inject({
173+
method: 'GET',
174+
url: '/auth/google?mobile_redirect_uri=devcard://callback',
175+
});
176+
177+
expect(res.statusCode).toBe(302);
178+
});
179+
180+
it('400 — rejects invalid mobile_redirect_uri', async () => {
181+
const res = await app.inject({
182+
method: 'GET',
183+
url: '/auth/google?mobile_redirect_uri=https://evil.com/callback',
184+
});
185+
186+
expect(res.statusCode).toBe(400);
187+
expect(res.json()).toMatchObject({ error: expect.any(String) });
188+
});
189+
190+
it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => {
191+
const res = await app.inject({
192+
method: 'GET',
193+
url: '/auth/google?mobile_redirect_uri=http://localhost/callback',
194+
});
195+
196+
expect(res.statusCode).toBe(400);
197+
});
198+
199+
it('400 — returns 400 when GOOGLE_CLIENT_ID is missing', async () => {
200+
vi.stubEnv('GOOGLE_CLIENT_ID', '');
201+
202+
const res = await app.inject({
203+
method: 'GET',
204+
url: '/auth/google',
205+
});
206+
207+
expect(res.statusCode).toBe(400);
208+
});
209+
});
210+
});

apps/backend/src/routes/auth.ts

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +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';
56

67
import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js';
8+
import type { OAuthStartQuery } from '../validations/auth.validation.js';
79
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
810

911
interface GitHubEmailResponse {
@@ -24,11 +26,6 @@ interface OAuthCallbackQuery {
2426
state?: string;
2527
}
2628

27-
type GoogleAuthQuery = {
28-
state?: string;
29-
mobile_redirect_uri?: string;
30-
};
31-
3229
interface GoogleUser {
3330
id: string;
3431
email: string;
@@ -66,23 +63,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
6663
}
6764

6865
// GitHub OAuth start
69-
app.get('/github', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => {
66+
app.get('/github', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => {
7067
const clientId = process.env.GITHUB_CLIENT_ID;
7168
if(!clientId){
7269
return reply.status(400).send()
7370
}
74-
//TODO: Add zod validation here
75-
const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query
76-
77-
if (
78-
mobileRedirectUri &&
79-
!mobileRedirectUri.startsWith('devcard://')
80-
) {
81-
return reply.status(400).send({
82-
error: 'Invalid mobile redirect URI',
83-
});
84-
}
8571
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
72+
73+
const parsed = oAuthStartSchema.safeParse(request.query);
74+
if (!parsed.success) {
75+
return reply.status(400).send({ error: parsed.error.errors[0].message });
76+
}
77+
const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data;
8678
const state = buildOAuthState(clientState, mobileRedirectUri);
8779

8880
reply.setCookie('oauth_state', state, {
@@ -288,24 +280,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
288280
});
289281

290282
// Google OAuth start
291-
app.get('/google', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => {
283+
app.get('/google', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => {
292284
const clientId = process.env.GOOGLE_CLIENT_ID;
293285
if(!clientId){
294286
return reply.status(400).send()
295287
}
296288
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
297-
//TODO: Add zod validation here
298-
const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query
299289

300-
if (
301-
mobileRedirectUri &&
302-
!mobileRedirectUri.startsWith('devcard://')
303-
) {
304-
return reply.status(400).send({
305-
error: 'Invalid mobile redirect URI',
306-
});
290+
const parsed = oAuthStartSchema.safeParse(request.query);
291+
if (!parsed.success) {
292+
return reply.status(400).send({ error: parsed.error.errors[0].message });
307293
}
308-
294+
const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data;
309295
const state = buildOAuthState(clientState, mobileRedirectUri);
310296

311297
reply.setCookie('oauth_state', state, {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
export const oAuthStartSchema = z.object({
4+
state: z.string().optional().default(''),
5+
mobile_redirect_uri: z
6+
.string()
7+
.optional()
8+
.default('')
9+
.refine(
10+
(val) => !val || val.startsWith('devcard://'),
11+
{ message: 'Invalid mobile redirect URI' }
12+
),
13+
});
14+
15+
export type OAuthStartQuery = z.infer<typeof oAuthStartSchema>;

0 commit comments

Comments
 (0)