diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index febc41d..a6b2518 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { randomBytes } from 'crypto'; + const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; const GITHUB_USER_URL = 'https://api.github.com/user'; @@ -20,6 +21,15 @@ export async function authRoutes(app: FastifyInstance) { const clientState = (request.query as any).state || ''; const state = clientState ? `${clientState}_${generateState()}` : generateState(); + // Store state in a short-lived cookie so the callback can verify it + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 10 * 60, + }); + const params = new URLSearchParams({ client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), redirect_uri: redirectUri, @@ -27,13 +37,20 @@ export async function authRoutes(app: FastifyInstance) { 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 } = request.query; + const { code, state } = request.query; + + // Validate state to prevent CSRF attacks + const storedState = (request.cookies as any).oauth_state; + reply.clearCookie('oauth_state', { path: '/' }); + + if (!storedState || !state || state !== storedState) { + return reply.status(400).send({ error: 'Invalid OAuth state parameter.' }); + } + if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } @@ -146,6 +163,15 @@ export async function authRoutes(app: FastifyInstance) { const clientState = (request.query as any).state || ''; const state = clientState ? `${clientState}_${generateState()}` : generateState(); + // Store state in a short-lived cookie so the callback can verify it + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 10 * 60, + }); + const params = new URLSearchParams({ client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), redirect_uri: redirectUri, @@ -155,13 +181,20 @@ export async function authRoutes(app: FastifyInstance) { 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 } = request.query; + const { code, state } = request.query; + + // Validate state to prevent CSRF attacks + const storedState = (request.cookies as any).oauth_state; + reply.clearCookie('oauth_state', { path: '/' }); + + if (!storedState || !state || state !== storedState) { + return reply.status(400).send({ error: 'Invalid OAuth state parameter.' }); + } + if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 68f8671..96ab5f6 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { randomBytes } from 'crypto'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -52,7 +53,9 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${GITHUB_AUTH_URL}?${params}`); }); - app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + app.get('/github/callback', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; if (!code || !state) { @@ -66,12 +69,18 @@ export async function connectRoutes(app: FastifyInstance) { if (!decodedState) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - const userId = decodedState.userId; - if (!userId) { + // Verify the userId embedded in the state matches the authenticated session. + // Without this check an attacker who obtains any valid GitHub OAuth code can + // craft an arbitrary state payload (base64-encoded JSON) pointing to a victim's + // userId and have the server store the attacker's token under the victim's account. + const sessionUserId = (request.user as any).id; + if (!decodedState.userId || decodedState.userId !== sessionUserId) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`); } + const userId = decodedState.userId; + // Exchange code for token const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST',