From 1c4c0ee68400a0ea8717b79ebfe111c3dbb4c4cf Mon Sep 17 00:00:00 2001 From: ramnnnn2006 Date: Tue, 16 Jun 2026 10:46:55 +0530 Subject: [PATCH 1/4] feat(auth): add Zod validation for OAuth callback endpoints Validates code and state query params in /auth/github/callback and /auth/google/callback before any token exchange or DB work happens. Adds oauthCallbackSchema to validators.ts and tests covering missing/ empty code, missing/empty state, and state cookie mismatch scenarios. --- .../src/__tests__/auth-callback.test.ts | 210 ++++++++++++++++++ apps/backend/src/routes/auth.ts | 28 ++- .../src/validations/auth.validation.ts | 7 +- 3 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/__tests__/auth-callback.test.ts diff --git a/apps/backend/src/__tests__/auth-callback.test.ts b/apps/backend/src/__tests__/auth-callback.test.ts new file mode 100644 index 00000000..7d94176f --- /dev/null +++ b/apps/backend/src/__tests__/auth-callback.test.ts @@ -0,0 +1,210 @@ +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 { + 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; +} + +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 () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + it('validation error response includes field-level details', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body.details).toBeDefined(); + expect(body.details.fieldErrors).toHaveProperty('code'); + }); +}); + +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'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + 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'); + }); + + it('validation error response includes field-level details', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode', + }); + + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body.details).toBeDefined(); + expect(body.details.fieldErrors).toHaveProperty('state'); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 56a7618f..6b403b93 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -2,7 +2,7 @@ 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'; @@ -99,18 +99,17 @@ export async function authRoutes(app: FastifyInstance): Promise { // 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 parsed = oauthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); + } + const { code, state } = parsed.data; const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + if (!storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } - try { const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', @@ -317,19 +316,18 @@ export async function authRoutes(app: FastifyInstance): Promise { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - //TODO: Add zod validation here - const { code, state } = request.query; + const parsed = oauthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); + } + const { code, state } = parsed.data; const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + if (!storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } - try { const tokenRes = await fetch(GOOGLE_TOKEN_URL, { method: 'POST', diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts index 92e79d9c..6ae6a5bb 100644 --- a/apps/backend/src/validations/auth.validation.ts +++ b/apps/backend/src/validations/auth.validation.ts @@ -12,4 +12,9 @@ export const oAuthStartSchema = z.object({ ), }); -export type OAuthStartQuery = z.infer; \ No newline at end of file +export type OAuthStartQuery = z.infer; + +export const oauthCallbackSchema = z.object({ + code: z.string().min(1, 'Authorization code is required'), + state: z.string().min(1, 'State parameter is required'), +}); \ No newline at end of file From 243404a3d9a957ba88917dd94f7fde77b8c4e4a6 Mon Sep 17 00:00:00 2001 From: ramnnnn2006 Date: Wed, 17 Jun 2026 15:11:38 +0530 Subject: [PATCH 2/4] fix(auth): address review feedback on OAuth callback validation - rename oauthCallbackSchema to oAuthCallbackSchema to match naming convention - add .trim() to code and state fields to reject whitespace-only values - export OAuthCallbackQuery type from auth.validation.ts and remove duplicate local interface --- apps/backend/src/routes/auth.ts | 13 ++++--------- apps/backend/src/validations/auth.validation.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 6b403b93..d0a5f25f 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -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, oauthCallbackSchema } 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 { @@ -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; @@ -99,7 +94,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const parsed = oauthCallbackSchema.safeParse(request.query); + const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); } @@ -316,7 +311,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const parsed = oauthCallbackSchema.safeParse(request.query); + const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); } diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts index 6ae6a5bb..57280991 100644 --- a/apps/backend/src/validations/auth.validation.ts +++ b/apps/backend/src/validations/auth.validation.ts @@ -14,7 +14,9 @@ export const oAuthStartSchema = z.object({ export type OAuthStartQuery = z.infer; -export const oauthCallbackSchema = z.object({ - code: z.string().min(1, 'Authorization code is required'), - state: z.string().min(1, 'State parameter is required'), -}); \ No newline at end of file +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; \ No newline at end of file From 86f4348c1806cc5a31c9b8670fabfceb5e983d35 Mon Sep 17 00:00:00 2001 From: ramnnnn2006 Date: Wed, 17 Jun 2026 17:14:11 +0530 Subject: [PATCH 3/4] fix(auth): clear oauth_state cookie on validation failure and add trailing newline --- apps/backend/src/routes/auth.ts | 6 ++++-- apps/backend/src/validations/auth.validation.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index d0a5f25f..3c3946b1 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -94,12 +94,13 @@ export async function authRoutes(app: FastifyInstance): Promise { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const storedState = request.cookies?.oauth_state; const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); } const { code, state } = parsed.data; - const storedState = request.cookies?.oauth_state; if (!storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } @@ -311,13 +312,14 @@ export async function authRoutes(app: FastifyInstance): Promise { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const storedState = request.cookies?.oauth_state; const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); } const { code, state } = parsed.data; - const storedState = request.cookies?.oauth_state; if (!storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts index 57280991..b6db0dc8 100644 --- a/apps/backend/src/validations/auth.validation.ts +++ b/apps/backend/src/validations/auth.validation.ts @@ -19,4 +19,4 @@ export const oAuthCallbackSchema = z.object({ state: z.string().trim().min(1, 'State parameter is required'), }); -export type OAuthCallbackQuery = z.infer; \ No newline at end of file +export type OAuthCallbackQuery = z.infer; From 57595a5d7a43024e81ecb2357af15ab0d3b8d936 Mon Sep 17 00:00:00 2001 From: ramnnnn2006 Date: Wed, 17 Jun 2026 17:19:31 +0530 Subject: [PATCH 4/4] fix(auth): clear oauth_state cookie on all failure paths and drop details from 400 response --- .../src/__tests__/auth-callback.test.ts | 43 ++++++++----------- apps/backend/src/routes/auth.ts | 6 ++- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/__tests__/auth-callback.test.ts b/apps/backend/src/__tests__/auth-callback.test.ts index 7d94176f..ed6bb4cb 100644 --- a/apps/backend/src/__tests__/auth-callback.test.ts +++ b/apps/backend/src/__tests__/auth-callback.test.ts @@ -32,6 +32,12 @@ async function buildTestApp(): Promise { 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; @@ -53,6 +59,7 @@ describe('GET /auth/github/callback — Zod validation', () => { 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 () => { @@ -64,6 +71,7 @@ describe('GET /auth/github/callback — Zod validation', () => { 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 () => { @@ -74,6 +82,7 @@ describe('GET /auth/github/callback — Zod validation', () => { 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 () => { @@ -84,6 +93,7 @@ describe('GET /auth/github/callback — Zod validation', () => { 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 () => { @@ -94,6 +104,7 @@ describe('GET /auth/github/callback — Zod validation', () => { 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 () => { @@ -105,19 +116,7 @@ describe('GET /auth/github/callback — Zod validation', () => { expect(res.statusCode).toBe(400); expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); - }); - - it('validation error response includes field-level details', async () => { - const res = await app.inject({ - method: 'GET', - url: '/auth/github/callback?state=somestate', - headers: { Cookie: 'oauth_state=somestate' }, - }); - - expect(res.statusCode).toBe(400); - const body = res.json(); - expect(body.details).toBeDefined(); - expect(body.details.fieldErrors).toHaveProperty('code'); + expect(cookieCleared(res)).toBe(true); }); }); @@ -142,6 +141,7 @@ describe('GET /auth/google/callback — Zod validation', () => { 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 () => { @@ -153,6 +153,7 @@ describe('GET /auth/google/callback — Zod validation', () => { 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 () => { @@ -163,6 +164,7 @@ describe('GET /auth/google/callback — Zod validation', () => { 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 () => { @@ -173,6 +175,7 @@ describe('GET /auth/google/callback — Zod validation', () => { 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 () => { @@ -183,6 +186,7 @@ describe('GET /auth/google/callback — Zod validation', () => { 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 () => { @@ -194,17 +198,6 @@ describe('GET /auth/google/callback — Zod validation', () => { expect(res.statusCode).toBe(400); expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); - }); - - it('validation error response includes field-level details', async () => { - const res = await app.inject({ - method: 'GET', - url: '/auth/google/callback?code=validcode', - }); - - expect(res.statusCode).toBe(400); - const body = res.json(); - expect(body.details).toBeDefined(); - expect(body.details.fieldErrors).toHaveProperty('state'); + expect(cookieCleared(res)).toBe(true); }); }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 3c3946b1..7bfa7075 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -98,10 +98,11 @@ export async function authRoutes(app: FastifyInstance): Promise { const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { reply.clearCookie('oauth_state', { path: '/' }); - return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); + 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: '/' }); @@ -316,11 +317,12 @@ export async function authRoutes(app: FastifyInstance): Promise { const parsed = oAuthCallbackSchema.safeParse(request.query); if (!parsed.success) { reply.clearCookie('oauth_state', { path: '/' }); - return reply.status(400).send({ error: 'Invalid callback parameters', details: parsed.error.flatten() }); + 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: '/' });