Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,20 +21,36 @@ 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,
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 } = 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' });
}
Expand Down Expand Up @@ -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,
Expand All @@ -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' });
}
Expand Down
15 changes: 12 additions & 3 deletions apps/backend/src/routes/connect.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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',
Expand Down