@@ -162,17 +162,10 @@ function sanitizeReturnTo(raw, mode = "login", req, enforceAllowedOrigins = true
162162 if ( ! [ "http:" , "https:" ] . includes ( parsed . protocol ) ) return fallback ;
163163 if ( enforceAllowedOrigins ) {
164164 const allowedOrigins = new Set ( env . clientOrigins || [ ] ) ;
165- const originHeader = String ( req ?. headers ?. origin || "" ) . trim ( ) ;
166- const refererHeader = String ( req ?. headers ?. referer || "" ) . trim ( ) ;
167- if ( originHeader ) allowedOrigins . add ( originHeader ) ;
168- if ( refererHeader ) {
169- try {
170- allowedOrigins . add ( new URL ( refererHeader ) . origin ) ;
171- } catch {
172- // ignore invalid referer
173- }
165+ const allowLoopback = isLoopbackHost ( parsed . hostname ) ;
166+ if ( ! allowLoopback && ( allowedOrigins . size === 0 || ! allowedOrigins . has ( parsed . origin ) ) ) {
167+ return fallback ;
174168 }
175- if ( allowedOrigins . size > 0 && ! allowedOrigins . has ( parsed . origin ) ) return fallback ;
176169 }
177170 return parsed . toString ( ) ;
178171 } catch {
@@ -189,12 +182,60 @@ function appendUrlParams(url, params = {}) {
189182 return target . toString ( ) ;
190183}
191184
185+ function appendUrlHashParams ( url , params = { } ) {
186+ const target = new URL ( url ) ;
187+ const hashParams = new URLSearchParams ( target . hash . startsWith ( "#" ) ? target . hash . slice ( 1 ) : "" ) ;
188+ for ( const [ key , value ] of Object . entries ( params ) ) {
189+ if ( value === undefined || value === null || value === "" ) continue ;
190+ hashParams . set ( key , String ( value ) ) ;
191+ }
192+ target . hash = hashParams . toString ( ) ;
193+ return target . toString ( ) ;
194+ }
195+
196+ function signOauthStatePayload ( payloadB64 ) {
197+ return crypto . createHmac ( "sha256" , env . jwtSecret ) . update ( payloadB64 ) . digest ( "base64url" ) ;
198+ }
199+
200+ function createOauthState ( { nonce, mode, returnTo } ) {
201+ const payload = Buffer . from (
202+ JSON . stringify ( {
203+ nonce : String ( nonce || "" ) ,
204+ mode : mode === "register" ? "register" : "login" ,
205+ returnTo : String ( returnTo || "" ) ,
206+ } ) ,
207+ "utf8"
208+ ) . toString ( "base64url" ) ;
209+ const sig = signOauthStatePayload ( payload ) ;
210+ return `${ payload } .${ sig } ` ;
211+ }
212+
213+ function parseOauthState ( rawState ) {
214+ const raw = String ( rawState || "" ) ;
215+ if ( ! raw ) return null ;
216+ const [ payload , sig ] = raw . split ( "." ) ;
217+ if ( ! payload || ! sig ) return null ;
218+
219+ const expectedSig = signOauthStatePayload ( payload ) ;
220+ const sigBuf = Buffer . from ( sig , "utf8" ) ;
221+ const expectedBuf = Buffer . from ( expectedSig , "utf8" ) ;
222+ if ( sigBuf . length !== expectedBuf . length ) return null ;
223+ if ( ! crypto . timingSafeEqual ( sigBuf , expectedBuf ) ) return null ;
224+
225+ try {
226+ return JSON . parse ( Buffer . from ( payload , "base64url" ) . toString ( "utf8" ) ) ;
227+ } catch {
228+ return null ;
229+ }
230+ }
231+
192232function setOauthStateCookie ( res , value ) {
193233 const isProd = env . nodeEnv === "production" ;
194234 res . cookie ( "oauth_state" , value , {
195235 httpOnly : true ,
196236 secure : isProd ,
197237 sameSite : "lax" ,
238+ path : "/api/auth/google" ,
198239 maxAge : 10 * 60 * 1000 ,
199240 } ) ;
200241}
@@ -205,6 +246,7 @@ function clearOauthStateCookie(res) {
205246 httpOnly : true ,
206247 secure : isProd ,
207248 sameSite : "lax" ,
249+ path : "/api/auth/google" ,
208250 expires : new Date ( 0 ) ,
209251 } ) ;
210252}
@@ -340,7 +382,7 @@ export const googleStart = asyncHandler(async (req, res) => {
340382 const mode = req . query ?. mode === "register" ? "register" : "login" ;
341383 const returnTo = sanitizeReturnTo ( req . query ?. returnTo , mode , req , true ) ;
342384 const nonce = crypto . randomUUID ( ) ;
343- const state = Buffer . from ( JSON . stringify ( { nonce, mode, returnTo } ) , "utf8" ) . toString ( "base64url" ) ;
385+ const state = createOauthState ( { nonce, mode, returnTo } ) ;
344386
345387 setOauthStateCookie ( res , nonce ) ;
346388
@@ -358,20 +400,15 @@ export const googleStart = asyncHandler(async (req, res) => {
358400} ) ;
359401
360402export const googleCallback = asyncHandler ( async ( req , res ) => {
361- const rawState = String ( req . query ?. state || "" ) ;
362-
363- let decodedState = { nonce : "" , mode : "login" , returnTo : getDefaultFrontendAuthUrl ( "login" ) } ;
364- if ( rawState ) {
365- try {
366- decodedState = JSON . parse ( Buffer . from ( rawState , "base64url" ) . toString ( "utf8" ) ) ;
367- } catch {
368- // keep fallback defaults
369- }
370- }
403+ const decodedState = parseOauthState ( req . query ?. state ) || {
404+ nonce : "" ,
405+ mode : "login" ,
406+ returnTo : getDefaultFrontendAuthUrl ( "login" ) ,
407+ } ;
371408
372409 const mode = decodedState . mode === "register" ? "register" : "login" ;
373- const returnTo = sanitizeReturnTo ( decodedState . returnTo , mode , req , false ) ;
374- const failRedirect = ( message ) => res . redirect ( appendUrlParams ( returnTo , { auth_error : message } ) ) ;
410+ const returnTo = sanitizeReturnTo ( decodedState . returnTo , mode , req , true ) ;
411+ const failRedirect = ( message ) => res . redirect ( appendUrlHashParams ( returnTo , { auth_error : message } ) ) ;
375412 const googleRedirectUri = getGoogleRedirectUri ( req ) ;
376413
377414 if ( ! isGoogleAuthConfigured ( req ) ) {
@@ -419,13 +456,11 @@ export const googleCallback = asyncHandler(async (req, res) => {
419456 req,
420457 } ) ;
421458
422- return res . redirect (
423- appendUrlParams ( returnTo , {
424- auth_success : "1" ,
425- auth_mode : mode ,
426- auth_token : token ,
427- } )
428- ) ;
459+ return res . redirect ( appendUrlHashParams ( returnTo , {
460+ auth_success : "1" ,
461+ auth_mode : mode ,
462+ auth_token : token ,
463+ } ) ) ;
429464 } catch ( err ) {
430465 console . error ( "Google auth callback failed:" , err ) ;
431466 return failRedirect ( err ?. message || "Google authentication failed" ) ;
0 commit comments