Skip to content

Commit 89d1caa

Browse files
fix(auth): validate OAuth state cookie to prevent CSRF attacks (#171)
Following PA instructions merge conflicts are fixed.
1 parent 677ac41 commit 89d1caa

1 file changed

Lines changed: 70 additions & 32 deletions

File tree

apps/backend/src/routes/auth.ts

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,49 @@ export async function authRoutes(app: FastifyInstance) {
3232

3333
// ─── GitHub OAuth ───
3434

35+
3536
app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => {
3637
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
3738
const clientState = (request.query as any).state || '';
3839
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
3940
const state = buildOAuthState(clientState, mobileRedirectUri);
4041

41-
const params = new URLSearchParams({
42-
client_id: (process.env.GITHUB_CLIENT_ID || '').trim(),
43-
redirect_uri: redirectUri,
44-
scope: 'read:user user:email',
45-
state,
46-
});
47-
const authUrl = `${GITHUB_AUTH_URL}?${params}`;
48-
console.log('--- GITHUB OAUTH REDIRECT ---');
49-
console.log('URL:', authUrl);
50-
return reply.redirect(authUrl);
42+
// Store state in a short-lived signed cookie before redirecting
43+
reply.setCookie('oauth_state', state, {
44+
httpOnly: true,
45+
secure: process.env.NODE_ENV === 'production',
46+
sameSite: 'lax',
47+
path: '/',
48+
maxAge: 600, // 10 minutes — plenty for a login round-trip
5149
});
5250

53-
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
54-
const { code } = request.query;
55-
if (!code) {
56-
return reply.status(400).send({ error: 'Missing authorization code' });
57-
}
51+
const params = new URLSearchParams({
52+
client_id: (process.env.GITHUB_CLIENT_ID || '').trim(),
53+
redirect_uri: redirectUri,
54+
scope: 'read:user user:email',
55+
state,
56+
});
57+
const authUrl = `${GITHUB_AUTH_URL}?${params}`;
58+
console.log('--- GITHUB OAUTH REDIRECT ---');
59+
console.log('URL:', authUrl);
60+
return reply.redirect(authUrl);
61+
});
62+
63+
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
64+
const { code, state } = request.query;
65+
66+
// ── CSRF check ──────────────────────────────────────────────────────────────
67+
const storedState = (request.cookies as any)?.oauth_state;
68+
if (!state || !storedState || state !== storedState) {
69+
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
70+
}
71+
// Clear the state cookie immediately — prevents replay
72+
reply.clearCookie('oauth_state', { path: '/' });
73+
// ────────────────────────────────────────────────────────────────────────────
74+
75+
if (!code) {
76+
return reply.status(400).send({ error: 'Missing authorization code' });
77+
}
5878

5979
try {
6080
// Exchange code for token
@@ -170,25 +190,43 @@ export async function authRoutes(app: FastifyInstance) {
170190
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
171191
const state = buildOAuthState(clientState, mobileRedirectUri);
172192

173-
const params = new URLSearchParams({
174-
client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(),
175-
redirect_uri: redirectUri,
176-
response_type: 'code',
177-
scope: 'openid email profile',
178-
state,
179-
access_type: 'offline',
180-
});
181-
const authUrl = `${GOOGLE_AUTH_URL}?${params}`;
182-
console.log('--- GOOGLE OAUTH REDIRECT ---');
183-
console.log('URL:', authUrl);
184-
return reply.redirect(authUrl);
193+
// Store state in a short-lived signed cookie before redirecting
194+
reply.setCookie('oauth_state', state, {
195+
httpOnly: true,
196+
secure: process.env.NODE_ENV === 'production',
197+
sameSite: 'lax',
198+
path: '/',
199+
maxAge: 600,
185200
});
186201

187-
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
188-
const { code } = request.query;
189-
if (!code) {
190-
return reply.status(400).send({ error: 'Missing authorization code' });
191-
}
202+
const params = new URLSearchParams({
203+
client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(),
204+
redirect_uri: redirectUri,
205+
response_type: 'code',
206+
scope: 'openid email profile',
207+
state,
208+
access_type: 'offline',
209+
});
210+
const authUrl = `${GOOGLE_AUTH_URL}?${params}`;
211+
console.log('--- GOOGLE OAUTH REDIRECT ---');
212+
console.log('URL:', authUrl);
213+
return reply.redirect(authUrl);
214+
});
215+
216+
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
217+
const { code, state } = request.query;
218+
219+
// ── CSRF check ──────────────────────────────────────────────────────────────
220+
const storedState = (request.cookies as any)?.oauth_state;
221+
if (!state || !storedState || state !== storedState) {
222+
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
223+
}
224+
reply.clearCookie('oauth_state', { path: '/' });
225+
// ────────────────────────────────────────────────────────────────────────────
226+
227+
if (!code) {
228+
return reply.status(400).send({ error: 'Missing authorization code' });
229+
}
192230

193231
try {
194232
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {

0 commit comments

Comments
 (0)