11import type { FastifyInstance , FastifyRequest , FastifyReply } from 'fastify' ;
2+ import { randomBytes } from 'crypto' ;
3+ import { encrypt } from '../utils/encryption.js' ;
24
35const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize' ;
46const 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';
79const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' ;
810const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo' ;
911
12+ const NONCE_TTL = 600 ; // 10 minutes
13+
1014interface OAuthCallbackQuery {
1115 code : string ;
1216 state ?: string ;
1317}
1418
19+ interface StoredNonce {
20+ mobileRedirectUri ?: string ;
21+ }
22+
1523export 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 - z A - Z 0 - 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 - 9 a - f ] { 64 } $ / . test ( nonce ) ) return null ;
404+
405+ return { nonce, isMobile } ;
291406}
0 commit comments