@@ -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