From 0a1e036b6446dd2c211a8848116ac7010b1d72cf Mon Sep 17 00:00:00 2001 From: boalambo Date: Sun, 30 Aug 2026 19:05:38 +0100 Subject: [PATCH] fix: validate Google OAuth state on callback --- src/app/api/auth/google/callback/route.ts | 9 +++++-- src/lib/google/__tests__/oauth.test.ts | 23 ++++++++++++++++ src/lib/google/oauth.ts | 33 +++++++++++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/lib/google/__tests__/oauth.test.ts diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts index dd3f4c40..4caf6738 100644 --- a/src/app/api/auth/google/callback/route.ts +++ b/src/app/api/auth/google/callback/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; -import { exchangeCodeForToken, getGoogleUser, getGoogleAvatarUrl } from '@/lib/google/oauth'; +import { + exchangeCodeForToken, + getGoogleUser, + getGoogleAvatarUrl, + validateState, +} from '@/lib/google/oauth'; import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; import { edgeLog } from '@/../infra/edge-config'; @@ -40,7 +45,7 @@ export async function GET( // Verify state parameter to prevent CSRF attacks const storedState = request.cookies.get('google_oauth_state')?.value; - if (!state || state !== storedState) { + if (!validateState(storedState, state)) { edgeLog('error', '/api/auth/google/callback', 'Invalid state parameter'); return addHeaders( NextResponse.json({ message: 'Invalid state parameter' }, { status: 400 }), diff --git a/src/lib/google/__tests__/oauth.test.ts b/src/lib/google/__tests__/oauth.test.ts new file mode 100644 index 00000000..72871b75 --- /dev/null +++ b/src/lib/google/__tests__/oauth.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { generateState, validateState } from '../oauth'; + +describe('Google OAuth utilities', () => { + it('generates a cryptographically random state value', () => { + const state1 = generateState(); + const state2 = generateState(); + + expect(state1).toBeTruthy(); + expect(state2).toBeTruthy(); + expect(state1).not.toBe(state2); + expect(state1.length).toBeGreaterThan(32); + }); + + it('validates a matching OAuth state and rejects mismatches', () => { + const validState = generateState(); + + expect(validateState(validState, validState)).toBe(true); + expect(validateState(validState, `${validState}tampered`)).toBe(false); + expect(validateState(undefined, validState)).toBe(false); + expect(validateState(validState, null)).toBe(false); + }); +}); diff --git a/src/lib/google/oauth.ts b/src/lib/google/oauth.ts index af2a3036..796e6978 100644 --- a/src/lib/google/oauth.ts +++ b/src/lib/google/oauth.ts @@ -104,10 +104,39 @@ export async function getGoogleUser(accessToken: string): Promise { } /** - * Generate a random state parameter for OAuth + * Generate a cryptographically secure state parameter for OAuth */ export function generateState(): string { - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + const bytes = new Uint8Array(32); + + if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) { + crypto.getRandomValues(bytes); + } else { + // Fallback for runtimes without Web Crypto support. + const randomValues = new Uint8Array(32); + for (let i = 0; i < randomValues.length; i += 1) { + randomValues[i] = Math.floor(Math.random() * 256); + } + randomValues.forEach((value, index) => { + bytes[index] = value; + }); + } + + return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); +} + +/** + * Validate an OAuth state value from the callback against the one stored in a cookie. + */ +export function validateState( + storedState: string | null | undefined, + callbackState: string | null | undefined, +): boolean { + if (!storedState || !callbackState) { + return false; + } + + return storedState === callbackState; } /**