Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Backend Tests

on:
pull_request:
branches: [main, dev]
branches: [foss-main]
types: [opened, synchronize, reopened, ready_for_review]

concurrency:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Code Quality Checks

on:
pull_request:
branches: [main, dev]
branches: [foss-main]
types: [opened, synchronize, reopened, ready_for_review]

concurrency:
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ on:
branches:
- main
- dev
- foss-main
- feat/mpass-proxy-auth
paths:
- 'surfsense_backend/**'
- 'surfsense_web/**'
- '.github/workflows/docker-build.yml'
workflow_dispatch:
inputs:
branch:
Expand All @@ -26,7 +29,11 @@ permissions:
jobs:
tag_release:
runs-on: ubuntu-latest
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch'
if: >
github.ref == format('refs/heads/{0}', github.event.repository.default_branch) ||
github.ref == 'refs/heads/foss-main' ||
github.ref == 'refs/heads/feat/mpass-proxy-auth' ||
github.event_name == 'workflow_dispatch'
Comment on lines +35 to +36

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow now (a) creates/pushes version tags when building feat/mpass-proxy-auth, and (b) marks images from that feature branch as latest. Both are high-risk: feature-branch pushes can overwrite the stable latest tag and pollute version tag history with non-release builds. Please limit tagging and latest publication to the default/release branches only.

Suggested change
github.ref == 'refs/heads/feat/mpass-proxy-auth' ||
github.event_name == 'workflow_dispatch'
(
github.event_name == 'workflow_dispatch' &&
(
github.event.inputs.branch == '' ||
github.event.inputs.branch == github.event.repository.default_branch ||
github.event.inputs.branch == 'foss-main'
)
)

Copilot uses AI. Check for mistakes.
outputs:
new_tag: ${{ steps.tag_version.outputs.next_version }}
steps:
Expand Down Expand Up @@ -248,7 +255,7 @@ jobs:
type=ref,event=branch
type=sha,prefix=git-
flavor: |
latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch }}
latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.ref == 'refs/heads/feat/mpass-proxy-auth' || github.event.inputs.branch == github.event.repository.default_branch }}

- name: Create manifest list and push
working-directory: /tmp/digests
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ node_modules/
.venv
.pnpm-store
.DS_Store
deepagents/
deepagents/.env.local
.env.local
.idea/
7 changes: 5 additions & 2 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ SURFSENSE_VERSION=latest
# REQUIRED: Generate a secret key with: openssl rand -base64 32
SECRET_KEY=replace_me_with_a_random_string

# Auth type: LOCAL (email/password) or GOOGLE (OAuth)
AUTH_TYPE=LOCAL
# Auth — DO NOT change this value. This fork only supports SSO
# (mPass/Cognito via oauth2-proxy ForwardAuth). Setting LOCAL or
# GOOGLE will break authentication — the backend routes for those
# modes are not registered in this fork.
AUTH_TYPE=SSO

# Allow new user registrations (TRUE or FALSE)
# REGISTRATION_ENABLED=TRUE
Expand Down
4 changes: 2 additions & 2 deletions docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ services:
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
- LANGCHAIN_TRACING_V2=false
- LANGSMITH_TRACING=false
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
- AUTH_TYPE=${AUTH_TYPE:-SSO}
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
Expand Down Expand Up @@ -204,7 +204,7 @@ services:
context: ../surfsense_web
args:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-SSO}
NEXT_PUBLIC_ETL_SERVICE: ${NEXT_PUBLIC_ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-4848}}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${NEXT_PUBLIC_DEPLOYMENT_MODE:-self-hosted}
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ services:
environment:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-5929}}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-SSO}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
labels:
Expand Down
10 changes: 8 additions & 2 deletions surfsense_backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,15 @@ STRIPE_RECONCILIATION_BATCH_SIZE=100
# Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS)
# BACKEND_URL=https://api.yourdomain.com

# Auth
AUTH_TYPE=GOOGLE or LOCAL
# Auth — DO NOT change this value. This fork only supports SSO
# (mPass/Cognito via oauth2-proxy ForwardAuth). Setting LOCAL or
# GOOGLE will break authentication — the backend routes for those
# modes are not registered in this fork.
AUTH_TYPE=SSO
REGISTRATION_ENABLED=TRUE or FALSE

# mPass proxy auth bypass paths (oauth2-proxy ForwardAuth integration)
# MPASS_BYPASS_PATHS=/health # comma-separated, defaults to /health
# For Google Auth Only
GOOGLE_OAUTH_CLIENT_ID=924507538m
GOOGLE_OAUTH_CLIENT_SECRET=GOCSV
Expand Down
190 changes: 51 additions & 139 deletions surfsense_backend/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@
initialize_vision_llm_router,
)
from app.db import User, create_db_and_tables, get_async_session
from app.middleware.proxy_auth import ProxyAuthMiddleware
from app.routes import router as crud_router
from app.routes.auth_routes import router as auth_router
from app.schemas import UserCreate, UserRead, UserUpdate
from app.schemas import UserRead, UserUpdate
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
from app.users import (
current_active_user,
fastapi_users,
get_user_manager,
)
from app.utils.perf import get_perf_logger, log_system_snapshot

rate_limit_logger = logging.getLogger("surfsense.rate_limit")
Expand Down Expand Up @@ -315,14 +320,28 @@ async def dispatch(

app.add_middleware(RequestPerfMiddleware)

# Add SlowAPI middleware for automatic rate limiting
# Starlette executes middleware in reverse registration order (last added = first to
# run on the request). Request-path execution order:
#
# CORSMiddleware → ProxyHeadersMiddleware → SlowAPIMiddleware
# → ProxyAuthMiddleware → RequestPerfMiddleware → route handler
#
# SlowAPIMiddleware wraps ProxyAuthMiddleware so rate limiting fires before any DB
# lookup — abusive traffic is shed at the limiter before we touch the database.
# ProxyAuthMiddleware runs after ProxyHeadersMiddleware so the client IP/scheme
# are already normalised when we resolve the user.

# Innermost: reads X-Auth-Request-Email, resolves/creates user, sets request.state.proxy_user.
app.add_middleware(ProxyAuthMiddleware)

Comment on lines +323 to +336

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProxyAuthMiddleware is added unconditionally. In deployments where the backend is reachable without Traefik ForwardAuth header rewriting, this creates an auth bypass via a spoofed X-Auth-Request-Email header. Consider only registering this middleware when proxy-auth is explicitly enabled (e.g. AUTH_TYPE==SSO or a dedicated flag) and otherwise not adding it at all.

Copilot uses AI. Check for mistakes.
# Wraps ProxyAuthMiddleware — rate limiting fires before the DB lookup.
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
# http.response.start on every body chunk, breaking SSE/streaming endpoints.
app.add_middleware(SlowAPIMiddleware)

# Add ProxyHeaders middleware FIRST to trust proxy headers (e.g., from Cloudflare)
# This ensures FastAPI uses HTTPS in redirects when behind a proxy
# Outermost of the inner three: trusts proxy headers (X-Forwarded-For etc.)
# so FastAPI uses HTTPS in redirects when behind Traefik.
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")

# Add CORS middleware
Expand Down Expand Up @@ -362,149 +381,42 @@ async def dispatch(
allow_headers=["*"], # Allows all headers
)

app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
dependencies=[Depends(rate_limit_login)],
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
dependencies=[
Depends(rate_limit_register),
Depends(registration_allowed), # blocks registration when disabled
],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
dependencies=[Depends(rate_limit_password_reset)],
)
app.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)

# Register /users/me BEFORE fastapi_users.get_users_router so our routes take
# precedence (FastAPI first-match wins). fastapi-users' internal /users/me only
# validates JWT — it does not check request.state.proxy_user set by the proxy
# auth middleware, so proxy-auth users would always get 401 from that route.
@app.get("/users/me", response_model=UserRead, tags=["users"])
async def get_current_user_me(user: User = Depends(current_active_user)):
return user


@app.patch("/users/me", response_model=UserRead, tags=["users"])
async def update_current_user_me(
request: Request,
user_update: UserUpdate,
user: User = Depends(current_active_user),
user_manager=Depends(get_user_manager),
):
# Re-fetch in user_manager's session to avoid detached-instance conflict.
# ProxyAuthMiddleware's User object is from a closed session; the JWT
# dependency also loaded the same user in user_manager's session.
# Passing the middleware's object to session.add() conflicts with the
# JWT-loaded one. user_manager.get() returns the session-attached instance.
db_user = await user_manager.get(user.id)
return await user_manager.update(user_update, db_user, safe=True, request=request)


app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)


# Include custom auth routes (refresh token, logout)
app.include_router(auth_router)

Comment on lines 410 to 419

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app no longer includes the fastapi-users auth/register/reset/verify (and Google OAuth) routers, but the frontend still calls /auth/jwt/login and /auth/register (e.g. LocalLoginForm/AuthApiService). This will break LOCAL/GOOGLE authentication flows and likely any existing deployments relying on those endpoints. Please restore these routers conditionally (based on AUTH_TYPE) or update the frontend + docs to remove those flows entirely.

Copilot uses AI. Check for mistakes.
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)
# The CSRF cookie must have secure=False for HTTP (localhost development)
is_secure_context = config.BACKEND_URL and config.BACKEND_URL.startswith("https://")

# For cross-origin OAuth (frontend and backend on different domains):
# - SameSite=None is required to allow cross-origin cookie setting
# - Secure=True is required when SameSite=None
# 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,
auth_backend,
SECRET,
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(
google_oauth_client,
auth_backend,
SECRET,
is_verified_by_default=True,
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"],
dependencies=[
Depends(registration_allowed)
], # 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"])

Expand Down
13 changes: 11 additions & 2 deletions surfsense_backend/app/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,19 @@ def is_cloud(cls) -> bool:
os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100")
)

# Auth
AUTH_TYPE = os.getenv("AUTH_TYPE")
# Auth — this fork is SSO-only. Default to SSO so a missing envvar doesn't
# silently fall through to upstream LOCAL behaviour; app.py asserts on boot.
AUTH_TYPE = os.getenv("AUTH_TYPE", "SSO")
REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE"

# Comma-separated path prefixes that bypass proxy auth (default: /health).
MPASS_BYPASS_PATHS = os.getenv("MPASS_BYPASS_PATHS", None)

# SMB tenant name — used to synthesize {username}@{SMB_NAME}.com email
# addresses when the OIDC provider sends a bare username (e.g. Cognito
# cognito:username claim) instead of an email.
SMB_NAME = os.getenv("SMB_NAME", "")

# Google OAuth
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
Expand Down
Empty file.
Loading
Loading