From 491beb1a4e5837ebb0e4104dae57ca444dad2a7a Mon Sep 17 00:00:00 2001 From: chrisyangxiaoqi Date: Mon, 15 Jun 2026 03:27:19 +0800 Subject: [PATCH 1/3] 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 --- .../src/__tests__/auth.validation.test.ts | 55 +++++++++++++++++++ apps/backend/src/routes/auth.ts | 22 +++++++- .../src/validations/auth.validation.ts | 14 +++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/__tests__/auth.validation.test.ts create mode 100644 apps/backend/src/validations/auth.validation.ts diff --git a/apps/backend/src/__tests__/auth.validation.test.ts b/apps/backend/src/__tests__/auth.validation.test.ts new file mode 100644 index 00000000..8d165ea5 --- /dev/null +++ b/apps/backend/src/__tests__/auth.validation.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js'; + +describe('auth.validation', () => { + describe('mobileExchangeSchema', () => { + it('accepts a valid UUID code', () => { + const result = mobileExchangeSchema.safeParse({ + code: '550e8400-e29b-41d4-a716-446655440000', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.code).toBe('550e8400-e29b-41d4-a716-446655440000'); + } + }); + + it('rejects a non-UUID string', () => { + const result = mobileExchangeSchema.safeParse({ + code: 'not-a-uuid', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.code).toBeDefined(); + } + }); + + it('rejects missing code', () => { + const result = mobileExchangeSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); + + describe('refreshTokenSchema', () => { + it('accepts a valid refresh_token string', () => { + const result = refreshTokenSchema.safeParse({ + refresh_token: 'some-refresh-token', + }); + expect(result.success).toBe(true); + }); + + it('accepts an empty body (refresh_token is optional)', () => { + const result = refreshTokenSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.refresh_token).toBeUndefined(); + } + }); + + it('rejects a non-string refresh_token', () => { + const result = refreshTokenSchema.safeParse({ + refresh_token: 123, + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 3bc39ad4..2ed20bf8 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -3,6 +3,7 @@ import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; +import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js'; import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -495,7 +496,15 @@ export async function authRoutes(app: FastifyInstance): Promise { }); app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => { - const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + // Validate request body with Zod + const bodyParsed = refreshTokenSchema.safeParse(request.body); + if (!bodyParsed.success) { + return reply.status(400).send({ + error: 'Invalid request body', + issues: bodyParsed.error.flatten().fieldErrors, + }); + } + const refreshToken = request.cookies.refresh_token ?? bodyParsed.data.refresh_token; if (!refreshToken) { return reply.status(401).send({ @@ -599,8 +608,15 @@ export async function authRoutes(app: FastifyInstance): Promise { }) - app.post('/mobile/exchange', async (request: FastifyRequest<{Body: {code: string}}>, reply: FastifyReply) => { - const { code } = request.body; + app.post('/mobile/exchange', async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = mobileExchangeSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: 'Invalid request body', + issues: parsed.error.flatten().fieldErrors, + }); + } + const { code } = parsed.data; const raw = await app.redis.getdel(`mobile_exchange:${code}`); if (!raw) {return reply.status(400).send({ error: 'Invalid or expired exchange code' });} diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts new file mode 100644 index 00000000..c10294c5 --- /dev/null +++ b/apps/backend/src/validations/auth.validation.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const mobileExchangeSchema = z.object({ + code: z + .string({ message: 'Exchange code is required' }) + .uuid({ message: 'Exchange code must be a valid UUID' }), +}); + +export const refreshTokenSchema = z.object({ + refresh_token: z + .string({ message: 'Refresh token must be a string' }) + .min(1, 'Refresh token cannot be empty') + .optional(), +}); From 388ba141b23303f3b09828c55acc79a03391cbcf Mon Sep 17 00:00:00 2001 From: chrisyangxiaoqi Date: Mon, 15 Jun 2026 03:55:01 +0800 Subject: [PATCH 2/3] fix(auth): remove .js extension from import path Remove .js extension from TypeScript import to fix lint error. Part of #540 --- apps/backend/src/routes/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 2ed20bf8..26c05f2d 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -3,7 +3,7 @@ import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; -import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js'; +import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation'; import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; From 64406af677a9a586b8f29d316124b63c82202c26 Mon Sep 17 00:00:00 2001 From: chrisyangxiaoqi Date: Mon, 15 Jun 2026 04:11:06 +0800 Subject: [PATCH 3/3] fix(auth): fix import order for ESLint import-x/order Group all parent (../) imports together with no empty lines, add empty line before type imports group. Fixes #540 --- apps/backend/src/__tests__/auth.validation.test.ts | 3 ++- apps/backend/src/routes/auth.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/__tests__/auth.validation.test.ts b/apps/backend/src/__tests__/auth.validation.test.ts index 8d165ea5..03e3de01 100644 --- a/apps/backend/src/__tests__/auth.validation.test.ts +++ b/apps/backend/src/__tests__/auth.validation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation.js'; + +import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation'; describe('auth.validation', () => { describe('mobileExchangeSchema', () => { diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 26c05f2d..b86c448d 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,12 +1,13 @@ import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/error.util.js'; -import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; +import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; - import { mobileExchangeSchema, refreshTokenSchema } from '../validations/auth.validation'; + import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + interface GitHubEmailResponse { email: string; primary: boolean;