diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index febc41d..00ebb51 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -15,28 +15,47 @@ interface OAuthCallbackQuery { export async function authRoutes(app: FastifyInstance) { // ─── GitHub OAuth ─── - app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { - const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; - const clientState = (request.query as any).state || ''; - const state = clientState ? `${clientState}_${generateState()}` : generateState(); - - const params = new URLSearchParams({ - client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), - redirect_uri: redirectUri, - scope: 'read:user user:email', - state, - }); - const authUrl = `${GITHUB_AUTH_URL}?${params}`; - console.log('--- GITHUB OAUTH REDIRECT ---'); - console.log('URL:', authUrl); - return reply.redirect(authUrl); +app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { + const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; + const clientState = (request.query as any).state || ''; + const state = clientState ? `${clientState}_${generateState()}` : generateState(); + + // Store state in a short-lived signed cookie before redirecting + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 600, // 10 minutes — plenty for a login round-trip }); - app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code } = request.query; - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } + const params = new URLSearchParams({ + client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), + redirect_uri: redirectUri, + scope: 'read:user user:email', + state, + }); + const authUrl = `${GITHUB_AUTH_URL}?${params}`; + console.log('--- GITHUB OAUTH REDIRECT ---'); + console.log('URL:', authUrl); + return reply.redirect(authUrl); +}); + +app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const { code, state } = request.query; + + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; + if (!state || !storedState || state !== storedState) { + return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); + } + // Clear the state cookie immediately — prevents replay + reply.clearCookie('oauth_state', { path: '/' }); + // ──────────────────────────────────────────────────────────────────────────── + + if (!code) { + return reply.status(400).send({ error: 'Missing authorization code' }); + } try { // Exchange code for token @@ -141,30 +160,48 @@ export async function authRoutes(app: FastifyInstance) { // ─── Google OAuth ─── - app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { - const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; - const clientState = (request.query as any).state || ''; - const state = clientState ? `${clientState}_${generateState()}` : generateState(); - - const params = new URLSearchParams({ - client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), - redirect_uri: redirectUri, - response_type: 'code', - scope: 'openid email profile', - state, - access_type: 'offline', - }); - const authUrl = `${GOOGLE_AUTH_URL}?${params}`; - console.log('--- GOOGLE OAUTH REDIRECT ---'); - console.log('URL:', authUrl); - return reply.redirect(authUrl); + app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { + const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; + const clientState = (request.query as any).state || ''; + const state = clientState ? `${clientState}_${generateState()}` : generateState(); + + // Store state in a short-lived signed cookie before redirecting + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 600, }); - app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code } = request.query; - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } + const params = new URLSearchParams({ + client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + }); + const authUrl = `${GOOGLE_AUTH_URL}?${params}`; + console.log('--- GOOGLE OAUTH REDIRECT ---'); + console.log('URL:', authUrl); + return reply.redirect(authUrl); +}); + + app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const { code, state } = request.query; + + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; + if (!state || !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, {