diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 7d7e88a28..993961148 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -1,6 +1,6 @@ from contextlib import asynccontextmanager -from fastapi import Depends, FastAPI, HTTPException, status +from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.ext.asyncio import AsyncSession from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware @@ -107,6 +107,8 @@ app.include_router( ) if config.AUTH_TYPE == "GOOGLE": + from fastapi.responses import RedirectResponse + from app.users import google_oauth_client # Determine if we're in a secure context (HTTPS) or local development (HTTP) @@ -119,6 +121,15 @@ if config.AUTH_TYPE == "GOOGLE": # For same-origin or local development, use SameSite=Lax (default) csrf_cookie_samesite = "none" if is_secure_context else "lax" + # Extract the domain from BACKEND_URL for cookie domain setting + # This helps with cross-site cookie issues in Firefox/Safari + csrf_cookie_domain = None + if config.BACKEND_URL: + from urllib.parse import urlparse + + parsed_url = urlparse(config.BACKEND_URL) + csrf_cookie_domain = parsed_url.hostname + app.include_router( fastapi_users.get_oauth_router( google_oauth_client, @@ -127,6 +138,7 @@ if config.AUTH_TYPE == "GOOGLE": is_verified_by_default=True, csrf_token_cookie_secure=is_secure_context, csrf_token_cookie_samesite=csrf_cookie_samesite, + csrf_token_cookie_httponly=False, # Required for cross-site OAuth in Firefox/Safari ) if not config.BACKEND_URL else fastapi_users.get_oauth_router( @@ -137,6 +149,8 @@ if config.AUTH_TYPE == "GOOGLE": redirect_url=f"{config.BACKEND_URL}/auth/google/callback", csrf_token_cookie_secure=is_secure_context, csrf_token_cookie_samesite=csrf_cookie_samesite, + csrf_token_cookie_httponly=False, # Required for cross-site OAuth in Firefox/Safari + csrf_token_cookie_domain=csrf_cookie_domain, # Explicitly set cookie domain ), prefix="/auth/google", tags=["auth"], @@ -145,6 +159,62 @@ if config.AUTH_TYPE == "GOOGLE": ], # blocks OAuth registration when disabled ) + # Add a redirect-based authorize endpoint for Firefox/Safari compatibility + # This endpoint performs a server-side redirect instead of returning JSON + # which fixes cross-site cookie issues where browsers don't send cookies + # set via cross-origin fetch requests on subsequent redirects + @app.get("/auth/google/authorize-redirect", tags=["auth"]) + async def google_authorize_redirect( + request: Request, + ): + """ + Redirect-based OAuth authorization endpoint. + + Unlike the standard /auth/google/authorize endpoint that returns JSON, + this endpoint directly redirects the browser to Google's OAuth page. + This fixes CSRF cookie issues in Firefox and Safari where cookies set + via cross-origin fetch requests are not sent on subsequent redirects. + """ + import secrets + + from fastapi_users.router.oauth import generate_state_token + + # Generate CSRF token + csrf_token = secrets.token_urlsafe(32) + + # Build state token + state_data = {"csrftoken": csrf_token} + state = generate_state_token(state_data, SECRET, lifetime_seconds=3600) + + # Get the callback URL + if config.BACKEND_URL: + redirect_url = f"{config.BACKEND_URL}/auth/google/callback" + else: + redirect_url = str(request.url_for("oauth:google.jwt.callback")) + + # Get authorization URL from Google + authorization_url = await google_oauth_client.get_authorization_url( + redirect_url, + state, + scope=["openid", "email", "profile"], + ) + + # Create redirect response and set CSRF cookie + response = RedirectResponse(url=authorization_url, status_code=302) + response.set_cookie( + key="fastapiusersoauthcsrf", + value=csrf_token, + max_age=3600, + path="/", + domain=csrf_cookie_domain, + secure=is_secure_context, + httponly=False, # Required for cross-site OAuth in Firefox/Safari + samesite=csrf_cookie_samesite, + ) + + return response + + app.include_router(crud_router, prefix="/api/v1", tags=["crud"]) diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index a13f95cc4..1e7f7c4f8 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -3,7 +3,7 @@ import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Logo } from "@/components/Logo"; -import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events"; +import { trackLoginAttempt } from "@/lib/posthog/events"; import { AmbientBackground } from "./AmbientBackground"; export function GoogleLoginButton() { @@ -13,29 +13,12 @@ export function GoogleLoginButton() { // Track Google login attempt trackLoginAttempt("google"); - // Redirect to Google OAuth authorization URL - // credentials: 'include' is required to accept the CSRF cookie from cross-origin response - fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`, { - credentials: "include", - }) - .then((response) => { - if (!response.ok) { - throw new Error("Failed to get authorization URL"); - } - return response.json(); - }) - .then((data) => { - if (data.authorization_url) { - window.location.href = data.authorization_url; - } else { - trackLoginFailure("google", "No authorization URL received"); - console.error("No authorization URL received"); - } - }) - .catch((error) => { - trackLoginFailure("google", error?.message || "Unknown error"); - console.error("Error during Google login:", error); - }); + // IMPORTANT: Use the redirect-based authorize endpoint for cross-origin OAuth + // This fixes CSRF cookie issues in Firefox/Safari where cookies set via + // cross-origin fetch requests may not be sent on subsequent redirects. + // The authorize-redirect endpoint does a server-side redirect to Google + // and sets the CSRF cookie properly for same-site context. + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize-redirect`; }; return (