Skip to content

Commit 491beb1

Browse files
author
chrisyangxiaoqi
committed
feat(auth): add Zod validation for authentication utility endpoints
Add request validation for /auth/mobile/exchange and /auth/refresh using Zod schemas, as described in #540. - New file: apps/backend/src/validations/auth.validation.ts - mobileExchangeSchema: validates UUID exchange code - refreshTokenSchema: validates optional refresh_token in body - Updated apps/backend/src/routes/auth.ts: - /auth/mobile/exchange now validates request body with mobileExchangeSchema - /auth/refresh now validates request body with refreshTokenSchema - New test file: apps/backend/src/__tests__/auth.validation.test.ts Part of #540
1 parent f6e043e commit 491beb1

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js';
3+
4+
describe('auth.validation', () => {
5+
describe('mobileExchangeSchema', () => {
6+
it('accepts a valid UUID code', () => {
7+
const result = mobileExchangeSchema.safeParse({
8+
code: '550e8400-e29b-41d4-a716-446655440000',
9+
});
10+
expect(result.success).toBe(true);
11+
if (result.success) {
12+
expect(result.data.code).toBe('550e8400-e29b-41d4-a716-446655440000');
13+
}
14+
});
15+
16+
it('rejects a non-UUID string', () => {
17+
const result = mobileExchangeSchema.safeParse({
18+
code: 'not-a-uuid',
19+
});
20+
expect(result.success).toBe(false);
21+
if (!result.success) {
22+
expect(result.error.flatten().fieldErrors.code).toBeDefined();
23+
}
24+
});
25+
26+
it('rejects missing code', () => {
27+
const result = mobileExchangeSchema.safeParse({});
28+
expect(result.success).toBe(false);
29+
});
30+
});
31+
32+
describe('refreshTokenSchema', () => {
33+
it('accepts a valid refresh_token string', () => {
34+
const result = refreshTokenSchema.safeParse({
35+
refresh_token: 'some-refresh-token',
36+
});
37+
expect(result.success).toBe(true);
38+
});
39+
40+
it('accepts an empty body (refresh_token is optional)', () => {
41+
const result = refreshTokenSchema.safeParse({});
42+
expect(result.success).toBe(true);
43+
if (result.success) {
44+
expect(result.data.refresh_token).toBeUndefined();
45+
}
46+
});
47+
48+
it('rejects a non-string refresh_token', () => {
49+
const result = refreshTokenSchema.safeParse({
50+
refresh_token: 123,
51+
});
52+
expect(result.success).toBe(false);
53+
});
54+
});
55+
});

apps/backend/src/routes/auth.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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';
55

6+
import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js';
67
import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js';
78
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
89

@@ -495,7 +496,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
495496
});
496497

497498
app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => {
498-
const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token;
499+
// Validate request body with Zod
500+
const bodyParsed = refreshTokenSchema.safeParse(request.body);
501+
if (!bodyParsed.success) {
502+
return reply.status(400).send({
503+
error: 'Invalid request body',
504+
issues: bodyParsed.error.flatten().fieldErrors,
505+
});
506+
}
507+
const refreshToken = request.cookies.refresh_token ?? bodyParsed.data.refresh_token;
499508

500509
if (!refreshToken) {
501510
return reply.status(401).send({
@@ -599,8 +608,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
599608

600609
})
601610

602-
app.post('/mobile/exchange', async (request: FastifyRequest<{Body: {code: string}}>, reply: FastifyReply) => {
603-
const { code } = request.body;
611+
app.post('/mobile/exchange', async (request: FastifyRequest, reply: FastifyReply) => {
612+
const parsed = mobileExchangeSchema.safeParse(request.body);
613+
if (!parsed.success) {
614+
return reply.status(400).send({
615+
error: 'Invalid request body',
616+
issues: parsed.error.flatten().fieldErrors,
617+
});
618+
}
619+
const { code } = parsed.data;
604620
const raw = await app.redis.getdel(`mobile_exchange:${code}`);
605621
if (!raw) {return reply.status(400).send({ error: 'Invalid or expired exchange code' });}
606622

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { z } from 'zod';
2+
3+
export const mobileExchangeSchema = z.object({
4+
code: z
5+
.string({ message: 'Exchange code is required' })
6+
.uuid({ message: 'Exchange code must be a valid UUID' }),
7+
});
8+
9+
export const refreshTokenSchema = z.object({
10+
refresh_token: z
11+
.string({ message: 'Refresh token must be a string' })
12+
.min(1, 'Refresh token cannot be empty')
13+
.optional(),
14+
});

0 commit comments

Comments
 (0)