Skip to content

Commit 47763f7

Browse files
committed
fix(auth): validate OAuth state nonce server-side for login flow (closes #223)
Apply server-side nonce validation to the GitHub and Google login OAuth flows to prevent CSRF attacks, matching the connect flow fix. - Generate 32-byte cryptographically secure nonce on OAuth initiation - Store nonce in Redis (oauth:auth-nonce:<nonce>) with 10-minute TTL - State param carries nonce only; no user-controlled data trusted from callback - On callback: reject malformed state, unknown/expired nonces, corrupt data - Consume nonce on first use — prevents replay attacks - Fix mobile token delivery to use fragment (#token=) instead of query string - Remove debug console.log statements from OAuth redirect handlers - Drop Math.random()-based state generation in favour of randomBytes(32)
1 parent c52744a commit 47763f7

1 file changed

Lines changed: 162 additions & 47 deletions

File tree

apps/backend/src/routes/auth.ts

Lines changed: 162 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2+
import { randomBytes } from 'crypto';
3+
import { encrypt } from '../utils/encryption.js';
24

35
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
46
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
@@ -7,39 +9,101 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
79
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
810
const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
911

12+
const NONCE_TTL = 600; // 10 minutes
13+
1014
interface OAuthCallbackQuery {
1115
code: string;
1216
state?: string;
1317
}
1418

19+
interface StoredNonce {
20+
mobileRedirectUri?: string;
21+
}
22+
1523
export async function authRoutes(app: FastifyInstance) {
24+
// ─── Developer Login Bypass (development only) ───
25+
// Intentionally disabled in production.
26+
if (process.env.NODE_ENV !== 'production') {
27+
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
28+
const user = await app.prisma.user.findUnique({
29+
where: { username: 'devcard-demo' },
30+
});
31+
if (!user) {
32+
return reply.status(404).send({ error: 'Demo user not seeded' });
33+
}
34+
const token = app.jwt.sign(
35+
{ id: user.id, username: user.username },
36+
{ expiresIn: '30d' }
37+
);
38+
return { token };
39+
});
40+
}
41+
1642
// ─── GitHub OAuth ───
1743

1844
app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => {
19-
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
20-
const clientState = (request.query as any).state || '';
21-
const state = clientState ? `${clientState}_${generateState()}` : generateState();
45+
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
46+
const isMobile = !!mobileRedirectUri;
47+
48+
const nonce = generateNonce();
49+
const nonceData: StoredNonce = isMobile ? { mobileRedirectUri } : {};
50+
51+
await app.redis.set(
52+
`oauth:auth-nonce:${nonce}`,
53+
JSON.stringify(nonceData),
54+
'EX',
55+
NONCE_TTL
56+
);
2257

58+
const state = isMobile ? `mobile_${nonce}` : nonce;
59+
60+
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
2361
const params = new URLSearchParams({
2462
client_id: (process.env.GITHUB_CLIENT_ID || '').trim(),
2563
redirect_uri: redirectUri,
2664
scope: 'read:user user:email',
2765
state,
2866
});
29-
const authUrl = `${GITHUB_AUTH_URL}?${params}`;
30-
console.log('--- GITHUB OAUTH REDIRECT ---');
31-
console.log('URL:', authUrl);
32-
return reply.redirect(authUrl);
67+
68+
app.log.debug({ provider: 'github' }, 'OAuth redirect initiated');
69+
return reply.redirect(`${GITHUB_AUTH_URL}?${params}`);
3370
});
3471

3572
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
36-
const { code } = request.query;
37-
if (!code) {
38-
return reply.status(400).send({ error: 'Missing authorization code' });
73+
const { code, state } = request.query;
74+
75+
if (!code || !state) {
76+
return reply.status(400).send({ error: 'Missing required parameters' });
77+
}
78+
79+
const parsed = parseState(state);
80+
81+
if (!parsed) {
82+
app.log.warn({}, 'OAuth state validation failed: malformed state');
83+
return reply.status(400).send({ error: 'Invalid OAuth state' });
84+
}
85+
86+
const { nonce, isMobile } = parsed;
87+
88+
const storedRaw = await app.redis.get(`oauth:auth-nonce:${nonce}`);
89+
90+
if (!storedRaw) {
91+
app.log.warn({}, 'OAuth state validation failed: unknown or expired nonce');
92+
return reply.status(400).send({ error: 'Invalid OAuth state' });
93+
}
94+
95+
// Consume nonce immediately — one-time use, prevents replay
96+
await app.redis.del(`oauth:auth-nonce:${nonce}`);
97+
98+
let storedNonce: StoredNonce;
99+
try {
100+
storedNonce = JSON.parse(storedRaw);
101+
} catch {
102+
app.log.warn({}, 'OAuth state validation failed: corrupt nonce data');
103+
return reply.status(400).send({ error: 'Invalid OAuth state' });
39104
}
40105

41106
try {
42-
// Exchange code for token
43107
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
44108
method: 'POST',
45109
headers: {
@@ -60,13 +124,11 @@ export async function authRoutes(app: FastifyInstance) {
60124
return reply.status(400).send({ error: 'Failed to authenticate with GitHub' });
61125
}
62126

63-
// Fetch user profile
64127
const userRes = await fetch(GITHUB_USER_URL, {
65128
headers: { Authorization: `Bearer ${tokenData.access_token}` },
66129
});
67130
const githubUser = (await userRes.json()) as any;
68131

69-
// Fetch email if not public
70132
let email = githubUser.email;
71133
if (!email) {
72134
const emailsRes = await fetch('https://api.github.com/user/emails', {
@@ -77,7 +139,6 @@ export async function authRoutes(app: FastifyInstance) {
77139
email = primary?.email || emails[0]?.email;
78140
}
79141

80-
// Upsert user
81142
const user = await app.prisma.user.upsert({
82143
where: {
83144
provider_providerId: {
@@ -102,50 +163,63 @@ export async function authRoutes(app: FastifyInstance) {
102163
},
103164
});
104165

105-
// Save the authentication token for 'user:email read:user' so we have a basic platform connection
106-
const encryptedToken = (app as any).encryption ? (app as any).encryption.encrypt(tokenData.access_token) : tokenData.access_token;
107-
108-
await app.prisma.oAuthToken.upsert({
109-
where: { userId_platform: { userId: user.id, platform: 'github' } },
110-
update: { accessToken: encryptedToken, scopes: 'read:user user:email' },
111-
create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' },
112-
});
166+
// Persist auth token — non-fatal if it fails
167+
try {
168+
const encryptedToken = encrypt(tokenData.access_token);
169+
await app.prisma.oAuthToken.upsert({
170+
where: { userId_platform: { userId: user.id, platform: 'github' } },
171+
update: { accessToken: encryptedToken, scopes: 'read:user user:email' },
172+
create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' },
173+
});
174+
} catch (err) {
175+
app.log.error({ err, userId: user.id }, 'Failed to persist GitHub OAuth token — authentication proceeds');
176+
}
113177

114-
// Generate JWT
115178
const token = app.jwt.sign(
116179
{ id: user.id, username: user.username },
117180
{ expiresIn: '30d' }
118181
);
119182

120-
// For mobile app: redirect with token as query param
121-
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
122-
if (request.query.state?.startsWith('mobile_')) {
123-
return reply.redirect(`${mobileRedirect}?token=${token}`);
183+
if (isMobile) {
184+
const mobileRedirect = storedNonce.mobileRedirectUri || process.env.MOBILE_REDIRECT_URI;
185+
return reply.redirect(`${mobileRedirect}#token=${token}`);
124186
}
125187

126-
// For web: set cookie and redirect
127188
reply.setCookie('token', token, {
128189
httpOnly: true,
129190
secure: process.env.NODE_ENV === 'production',
130191
sameSite: 'lax',
131192
path: '/',
132-
maxAge: 30 * 24 * 60 * 60, // 30 days
193+
maxAge: 30 * 24 * 60 * 60,
133194
});
134195

135196
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
136197
} catch (err) {
137-
app.log.error('GitHub auth error:', err);
198+
const message = err instanceof Error ? err.message : String(err);
199+
app.log.error({ err, message }, 'GitHub auth error');
138200
return reply.status(500).send({ error: 'Authentication failed' });
139201
}
140202
});
141203

142204
// ─── Google OAuth ───
143205

144206
app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => {
145-
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
146-
const clientState = (request.query as any).state || '';
147-
const state = clientState ? `${clientState}_${generateState()}` : generateState();
207+
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
208+
const isMobile = !!mobileRedirectUri;
209+
210+
const nonce = generateNonce();
211+
const nonceData: StoredNonce = isMobile ? { mobileRedirectUri } : {};
148212

213+
await app.redis.set(
214+
`oauth:auth-nonce:${nonce}`,
215+
JSON.stringify(nonceData),
216+
'EX',
217+
NONCE_TTL
218+
);
219+
220+
const state = isMobile ? `mobile_${nonce}` : nonce;
221+
222+
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
149223
const params = new URLSearchParams({
150224
client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(),
151225
redirect_uri: redirectUri,
@@ -154,16 +228,43 @@ export async function authRoutes(app: FastifyInstance) {
154228
state,
155229
access_type: 'offline',
156230
});
157-
const authUrl = `${GOOGLE_AUTH_URL}?${params}`;
158-
console.log('--- GOOGLE OAUTH REDIRECT ---');
159-
console.log('URL:', authUrl);
160-
return reply.redirect(authUrl);
231+
232+
app.log.debug({ provider: 'google' }, 'OAuth redirect initiated');
233+
return reply.redirect(`${GOOGLE_AUTH_URL}?${params}`);
161234
});
162235

163236
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
164-
const { code } = request.query;
165-
if (!code) {
166-
return reply.status(400).send({ error: 'Missing authorization code' });
237+
const { code, state } = request.query;
238+
239+
if (!code || !state) {
240+
return reply.status(400).send({ error: 'Missing required parameters' });
241+
}
242+
243+
const parsed = parseState(state);
244+
245+
if (!parsed) {
246+
app.log.warn({}, 'OAuth state validation failed: malformed state');
247+
return reply.status(400).send({ error: 'Invalid OAuth state' });
248+
}
249+
250+
const { nonce, isMobile } = parsed;
251+
252+
const storedRaw = await app.redis.get(`oauth:auth-nonce:${nonce}`);
253+
254+
if (!storedRaw) {
255+
app.log.warn({}, 'OAuth state validation failed: unknown or expired nonce');
256+
return reply.status(400).send({ error: 'Invalid OAuth state' });
257+
}
258+
259+
// Consume nonce immediately — one-time use, prevents replay
260+
await app.redis.del(`oauth:auth-nonce:${nonce}`);
261+
262+
let storedNonce: StoredNonce;
263+
try {
264+
storedNonce = JSON.parse(storedRaw);
265+
} catch {
266+
app.log.warn({}, 'OAuth state validation failed: corrupt nonce data');
267+
return reply.status(400).send({ error: 'Invalid OAuth state' });
167268
}
168269

169270
try {
@@ -190,7 +291,6 @@ export async function authRoutes(app: FastifyInstance) {
190291
});
191292
const googleUser = (await userRes.json()) as any;
192293

193-
// Generate username from email
194294
const baseUsername = googleUser.email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '');
195295

196296
const user = await app.prisma.user.upsert({
@@ -220,9 +320,9 @@ export async function authRoutes(app: FastifyInstance) {
220320
{ expiresIn: '30d' }
221321
);
222322

223-
if (request.query.state?.startsWith('mobile_')) {
224-
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
225-
return reply.redirect(`${mobileRedirect}?token=${token}`);
323+
if (isMobile) {
324+
const mobileRedirect = storedNonce.mobileRedirectUri || process.env.MOBILE_REDIRECT_URI;
325+
return reply.redirect(`${mobileRedirect}#token=${token}`);
226326
}
227327

228328
reply.setCookie('token', token, {
@@ -235,7 +335,8 @@ export async function authRoutes(app: FastifyInstance) {
235335

236336
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
237337
} catch (err) {
238-
app.log.error('Google auth error:', err);
338+
const message = err instanceof Error ? err.message : String(err);
339+
app.log.error({ err, message }, 'Google auth error');
239340
return reply.status(500).send({ error: 'Authentication failed' });
240341
}
241342
});
@@ -286,6 +387,20 @@ export async function authRoutes(app: FastifyInstance) {
286387
});
287388
}
288389

289-
function generateState(): string {
290-
return Math.random().toString(36).substring(2, 15);
390+
function generateNonce(): string {
391+
return randomBytes(32).toString('hex');
392+
}
393+
394+
// State is `<64-hex-nonce>` (web) or `mobile_<64-hex-nonce>` (mobile).
395+
// Returns null for any malformed input — caller must reject.
396+
function parseState(state: string): { nonce: string; isMobile: boolean } | null {
397+
if (!state) return null;
398+
399+
const isMobile = state.startsWith('mobile_');
400+
const nonce = isMobile ? state.slice('mobile_'.length) : state;
401+
402+
// Must be exactly 64 lowercase hex chars (32 random bytes)
403+
if (!/^[0-9a-f]{64}$/.test(nonce)) return null;
404+
405+
return { nonce, isMobile };
291406
}

0 commit comments

Comments
 (0)