diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 6e0a6db6d..6a40482d5 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -2,7 +2,7 @@ name: Backend Tests on: pull_request: - branches: [main, dev] + branches: [foss-main] types: [opened, synchronize, reopened, ready_for_review] concurrency: diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0dd2e1809..b5d80b9a0 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -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: @@ -227,8 +227,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: latest - name: Check if frontend files changed id: frontend-changes diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8de55ba91..9b3b3eb51 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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: @@ -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' outputs: new_tag: ${{ steps.tag_version.outputs.next_version }} steps: @@ -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 diff --git a/.gitignore b/.gitignore index fd8fd782b..7a7681681 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules/ .venv .pnpm-store .DS_Store -deepagents/ \ No newline at end of file +deepagents/.env.local +.env.local +.idea/ diff --git a/docker/.env.example b/docker/.env.example index 1a3869773..4394929a7 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c7922e3ef..666b0c642 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 @@ -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} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 549190947..c14cd8b09 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 8c8587cea..3899c99f7 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -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 diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 7b2b421ac..bf9729996 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -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") @@ -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) + +# 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 @@ -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) -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"]) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 575db4c7b..ca7d7d47d 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -307,10 +307,26 @@ 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) + + # Email domain used to synthesize {username}@{DEFAULT_EMAIL_DOMAIN} when + # the OIDC provider sends a bare username (e.g. Cognito cognito:username + # claim) instead of a full email address. + DEFAULT_EMAIL_DOMAIN = os.getenv("DEFAULT_EMAIL_DOMAIN", "askii.ai") + + # Portal hostname prefix (e.g. fossil for fossil.local...). + SMB_NAME = (os.getenv("SMB_NAME") or "").strip() + # Default shared search space name (SSO auto-join). Defaults to SMB_NAME. + SMB_DEFAULT_WORKSPACE_NAME = ( + os.getenv("SMB_DEFAULT_WORKSPACE_NAME") or os.getenv("SMB_NAME") or "" + ).strip() + # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") @@ -442,13 +458,13 @@ def is_cloud(cls) -> bool: # OAuth JWT SECRET_KEY = os.getenv("SECRET_KEY") - # JWT Token Lifetimes + # JWT Token Lifetimes (defaults match FOSS devstack SESSION_* unified envs when unset) ACCESS_TOKEN_LIFETIME_SECONDS = int( - os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day - ) + os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(7 * 24 * 60 * 60)) + ) # 604800s = 7d REFRESH_TOKEN_LIFETIME_SECONDS = int( - os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks - ) + os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) + ) # 1209600s = 14d # ETL Service ETL_SERVICE = os.getenv("ETL_SERVICE") diff --git a/surfsense_backend/app/middleware/__init__.py b/surfsense_backend/app/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/middleware/proxy_auth.py b/surfsense_backend/app/middleware/proxy_auth.py new file mode 100644 index 000000000..3b466eb29 --- /dev/null +++ b/surfsense_backend/app/middleware/proxy_auth.py @@ -0,0 +1,216 @@ +import logging +import secrets +import unicodedata +from datetime import UTC, datetime + +from fastapi_users.db import SQLAlchemyUserDatabase +from fastapi_users.password import PasswordHelper # singleton below +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from app.config import config +from app.db import User, async_session_maker + +logger = logging.getLogger(__name__) +_password_helper = PasswordHelper() + +_DEFAULT_BYPASS_PATHS = ["/health"] +_LAST_LOGIN_THROTTLE_SECONDS = 300 + + +def _normalise_email(email: str) -> str: + # NFKC normalisation collapses Unicode lookalikes before lowercasing, + # preventing homoglyph spoofing (e.g. fullwidth latin chars). + return unicodedata.normalize("NFKC", email).strip().lower() + + +def _coerce_bypass_paths(setting) -> list[str]: + if not setting: + return list(_DEFAULT_BYPASS_PATHS) + if isinstance(setting, str): + return [p.strip() for p in setting.split(",") if p.strip()] + return list(setting) + + +def _is_bypass_path(path: str, bypass_paths: list[str]) -> bool: + # Match exact path OR a true subpath (e.g. /health/ready) but NOT a path that + # merely starts with the same characters (e.g. /healthz must NOT bypass /health). + return any(path == p or path.startswith(p.rstrip("/") + "/") for p in bypass_paths) + + +class ProxyAuthMiddleware(BaseHTTPMiddleware): + """ + Starlette middleware for mPass proxy authentication. + + oauth2-proxy sets X-Auth-Request-Email on every request that has passed + OIDC validation. This middleware reads that header, finds or creates the + corresponding SurfSense user, and injects them into request.state.proxy_user + so the current_active_user dependency sees a fully authenticated user + without requiring a JWT token. + + Security / trust model + ---------------------- + This middleware trusts X-Auth-Request-Email unconditionally. That is safe + because: + 1. Traefik ForwardAuth overwrites X-Auth-Request-* headers on every + request, so they cannot be spoofed by a browser or external client. + 2. In production the app container does not expose its port externally — + only Traefik is public-facing, so there is no direct path to the app + that bypasses header rewriting. + + A shared-secret header (set by oauth2-proxy, forwarded via Traefik + authResponseHeaders, checked here) would add defense-in-depth against a + misconfigured ingress but is not required given the network topology above. + Add it if the threat model ever changes (e.g. the app port becomes reachable + inside a zero-trust network where internal callers could forge headers). + """ + + def __init__(self, app): + super().__init__(app) + self.bypass_paths = _coerce_bypass_paths( + getattr(config, "MPASS_BYPASS_PATHS", None) + ) + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + # Already injected on this request cycle (idempotent) + if getattr(request.state, "proxy_user", None) is not None: + return await call_next(request) + + if _is_bypass_path(request.url.path, self.bypass_paths): + return await call_next(request) + + raw_email = (request.headers.get("x-auth-request-email") or "").strip() + if raw_email and "@" not in raw_email: + # Header holds a bare username (user_id_claim=cognito:username). + domain = getattr(config, "DEFAULT_EMAIL_DOMAIN", "askii.ai") + raw_email = f"{raw_email}@{domain}" + if not raw_email: + raw_username = (request.headers.get("x-auth-request-user") or "").strip() + domain = getattr(config, "DEFAULT_EMAIL_DOMAIN", "askii.ai") + if raw_username: + raw_email = f"{raw_username}@{domain}" + if not raw_email: + logger.debug( + "ProxyAuth: x-auth-request-email missing on %s", request.url.path + ) + return await call_next(request) + + user = await self._resolve_user(_normalise_email(raw_email), request) + + # Respect deactivated accounts — mPass authentication does not + # override an explicit SurfSense account suspension. + if user is None or not user.is_active: + logger.warning("ProxyAuth: user inactive or not found for %r", raw_email) + return await call_next(request) + + logger.debug("ProxyAuth: injected user id=%s for %r", user.id, user.email) + request.state.proxy_user = user + return await call_next(request) + + async def _resolve_user(self, email: str, request: Request) -> User | None: + try: + async with async_session_maker() as session: + result = await session.execute(select(User).where(User.email == email)) + user = result.unique().scalar_one_or_none() + created = False + + if user is None: + hashed_password = _password_helper.hash(secrets.token_urlsafe(32)) + display_name = email.split("@")[0] or None + user = User( + email=email, + hashed_password=hashed_password, + display_name=display_name, + is_active=True, + is_verified=True, + is_superuser=False, + ) + session.add(user) + try: + await session.commit() + await session.refresh(user) + created = True + except IntegrityError as exc: + # Concurrent request raced us to the insert — fall back + # to SELECT by email and re-raise if still not found. + await session.rollback() + result = await session.execute( + select(User).where(User.email == email) + ) + user = result.unique().scalar_one_or_none() + if user is None: + logger.error( + "ProxyAuth: IntegrityError but user still not found " + "for %s: %s", + email, + exc, + ) + return None + + # Update last_login at most once every 5 minutes per user. + # Unlike Plane (Django session — one DB write per login session), + # FastAPI has no server-side session so this middleware runs on + # every request. Writing last_login unconditionally would add an + # UPDATE + COMMIT to every API call; throttling keeps it cheap. + now = datetime.now(UTC) + needs_update = created or ( + user.last_login is None + or (now - user.last_login).total_seconds() + > _LAST_LOGIN_THROTTLE_SECONDS + ) + if needs_update: + try: + await session.execute( + update(User) + .where(User.id == user.id) + .values(last_login=now) + ) + await session.commit() + except Exception: + logger.warning( + "ProxyAuth: failed to update last_login for %s", email + ) + + if created: + # Trigger on_after_register so the default SearchSpace, + # RBAC roles and system prompts are created — same as + # Google OAuth and email/password signup. + # Use a fresh session so UserManager always has a clean connection. + # Re-fetch user in reg_session to avoid DetachedInstanceError — + # the user object from the outer session (or a rolled-back session + # after an IntegrityError race) must not be used across sessions. + try: + from app.users import UserManager + + async with async_session_maker() as reg_session: + reg_result = await reg_session.execute( + select(User).where(User.id == user.id) + ) + reg_user = reg_result.unique().scalar_one_or_none() + if reg_user is None: + raise RuntimeError( + f"ProxyAuth: user {user.id} vanished before on_after_register" + ) + + user_db = SQLAlchemyUserDatabase(reg_session, User) + user_manager = UserManager(user_db) + await user_manager.on_after_register( + reg_user, request=request + ) + except Exception: + logger.exception( + "ProxyAuth: on_after_register failed for %s — " + "user created but default search space may be missing", + email, + ) + + return user + + except Exception: + logger.exception("ProxyAuth: unexpected error resolving user for %s", email) + return None diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py index b1cbaf2a5..8e4ff430b 100644 --- a/surfsense_backend/app/routes/auth_routes.py +++ b/surfsense_backend/app/routes/auth_routes.py @@ -2,9 +2,11 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse from sqlalchemy import select +from app.config import config from app.db import User, async_session_maker from app.schemas.auth import ( LogoutAllResponse, @@ -15,6 +17,7 @@ ) from app.users import current_active_user, get_jwt_strategy from app.utils.refresh_tokens import ( + create_refresh_token, revoke_all_user_tokens, revoke_refresh_token, rotate_refresh_token, @@ -26,6 +29,57 @@ router = APIRouter(prefix="/auth/jwt", tags=["auth"]) +@router.get("/proxy-login") +async def proxy_login(request: Request): + """ + Exchange the oauth2-proxy ForwardAuth session for a SurfSense JWT delivered + via short-lived cookies. + + Flow: + Browser → Traefik ForwardAuth → oauth2-proxy validates session + → sets X-Auth-Request-Email → ProxyAuthMiddleware resolves/creates user + → this endpoint reads request.state.proxy_user → issues JWT + → sets surfsense_sso_token + surfsense_sso_refresh_token cookies (60s TTL) + → redirects to / → page.tsx reads cookies → stores to localStorage → /dashboard + + All user provisioning (including on_after_register side effects — + default SearchSpace, RBAC roles, system prompts) is owned by + ProxyAuthMiddleware. This handler does not touch the User table. + """ + user: User | None = getattr(request.state, "proxy_user", None) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No proxy auth session — request did not pass through oauth2-proxy ForwardAuth", + ) + + # Middleware already filters inactive users; defence-in-depth re-check. + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive", + ) + + strategy = get_jwt_strategy() + access_token = await strategy.write_token(user) + refresh_token = await create_refresh_token(user.id) + + frontend_url = (config.NEXT_FRONTEND_URL or "http://localhost:3000").rstrip("/") + + # Deliver tokens via short-lived cookies so the frontend can pick them up at / + # without needing a dedicated /auth/callback route (avoids Traefik path splitting). + response = RedirectResponse(f"{frontend_url}/", status_code=302) + cookie_opts = dict(httponly=False, secure=True, samesite="lax", max_age=60) # noqa: C408 + response.set_cookie("surfsense_sso_token", access_token, **cookie_opts) + response.set_cookie("surfsense_sso_refresh_token", refresh_token, **cookie_opts) + + logger.info( + "proxy_login: issued JWT for %s → redirecting to frontend via cookie", + user.email, + ) + return response + + @router.post("/refresh", response_model=RefreshTokenResponse) async def refresh_access_token(request: RefreshTokenRequest): """ diff --git a/surfsense_backend/app/routes/incentive_tasks_routes.py b/surfsense_backend/app/routes/incentive_tasks_routes.py index 496b07d06..e177542ca 100644 --- a/surfsense_backend/app/routes/incentive_tasks_routes.py +++ b/surfsense_backend/app/routes/incentive_tasks_routes.py @@ -117,6 +117,10 @@ async def complete_task( ) session.add(new_task) + # The user object may be detached (from ProxyAuthMiddleware's closed + # session). Merge it into this session before modifying + committing. + user = await session.merge(user) + # pages_used can exceed pages_limit when a document's final page count is # determined after processing. Base the new limit on the higher of the two # so the rewarded pages are fully usable above the current high-water mark. diff --git a/surfsense_backend/app/services/smb_auto_join.py b/surfsense_backend/app/services/smb_auto_join.py new file mode 100644 index 000000000..a2ae182f5 --- /dev/null +++ b/surfsense_backend/app/services/smb_auto_join.py @@ -0,0 +1,109 @@ +"""Ensure SSO users are members of the shared SMB SearchSpace (SMB_DEFAULT_WORKSPACE_NAME).""" + +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.config import config +from app.db import ( + SearchSpace, + SearchSpaceMembership, + SearchSpaceRole, + async_session_maker, +) + +logger = logging.getLogger(__name__) + + +async def auto_join_smb_search_space(user_id: uuid.UUID) -> None: + """ + ``_auto_join_workspace``: match the search space whose + name equals ``SMB_DEFAULT_WORKSPACE_NAME`` or ``SMB_NAME``. If none exists, + do nothing (no fallback to another space). + + Uses the default invite role (Editor) when absent. Idempotent. + Safe when auth uses Bearer JWT only: runs from ``current_active_user`` as well. + """ + smb_slug = ( + getattr(config, "SMB_DEFAULT_WORKSPACE_NAME", None) + or getattr(config, "SMB_NAME", "") + or "" + ) + if isinstance(smb_slug, str): + smb_slug = smb_slug.strip() + if not smb_slug: + return + + async with async_session_maker() as session: + not_deleting = ~SearchSpace.name.startswith("[DELETING] ") + space_result = await session.execute( + select(SearchSpace) + .where(SearchSpace.name == smb_slug, not_deleting) + .order_by(SearchSpace.id.asc()) + .limit(1) + ) + space = space_result.scalars().first() + if space is None: + logger.debug( + "SMB auto-join: no search space named %r — skipping", + smb_slug, + ) + return + + existing_member = await session.execute( + select(SearchSpaceMembership.id).where( + SearchSpaceMembership.user_id == user_id, + SearchSpaceMembership.search_space_id == space.id, + ) + ) + if existing_member.scalars().first() is not None: + return + + role_result = await session.execute( + select(SearchSpaceRole).where( + SearchSpaceRole.search_space_id == space.id, + SearchSpaceRole.is_default == True, # noqa: E712 + ) + ) + role = role_result.scalars().first() + if role is None: + role_result = await session.execute( + select(SearchSpaceRole).where( + SearchSpaceRole.search_space_id == space.id, + SearchSpaceRole.name == "Editor", + ) + ) + role = role_result.scalars().first() + if role is None: + logger.warning( + "SMB auto-join: no default or Editor role for search space %s — skipping", + space.id, + ) + return + + membership = SearchSpaceMembership( + user_id=user_id, + search_space_id=space.id, + role_id=role.id, + is_owner=False, + ) + session.add(membership) + try: + await session.commit() + logger.info( + "SMB auto-join: joined user %s to search space %s (role=%s)", + user_id, + space.id, + role.name, + ) + except IntegrityError: + await session.rollback() + logger.debug( + "SMB auto-join: race for user %s / space %s — already joined", + user_id, + space.id, + ) diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 66e0cc8dd..e4d8f420e 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime import httpx -from fastapi import Depends, Request, Response +from fastapi import Depends, HTTPException, Request, Response, status from fastapi.responses import JSONResponse, RedirectResponse from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users.authentication import ( @@ -27,6 +27,7 @@ get_user_db, ) from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS +from app.services.smb_auto_join import auto_join_smb_search_space from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) @@ -211,6 +212,23 @@ async def on_after_register(self, user: User, request: Request | None = None): f"Failed to create default search space for user {user.id}: {e}" ) + # SMB auto-join — runs once at user creation, NOT on every request. + # Per-request auto-join silently re-grants membership to users an + # operator has explicitly removed (the DELETE /searchspaces/{id}/ + # members/{membership_id} endpoint hard-deletes the row, leaving no + # tombstone for the auto-join function to consult). Doing it here + # keeps removed users removed. + # + # Trade-off: users who registered BEFORE the SMB workspace existed + # are not retroactively auto-joined — operators can backfill those + # with a one-time SQL INSERT against `search_space_memberships`. + try: + await auto_join_smb_search_space(user.id) + except Exception: + logger.exception( + "SMB auto-join failed for newly registered user %s", user.id + ) + async def on_after_forgot_password( self, user: User, token: str, request: Request | None = None ): @@ -298,5 +316,40 @@ async def get_login_response(self, token: str) -> Response: fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) -current_active_user = fastapi_users.current_user(active=True) -current_optional_user = fastapi_users.current_user(active=True, optional=True) +_jwt_current_optional_user = fastapi_users.current_user(active=True, optional=True) + + +async def current_active_user( + request: Request, + jwt_user: User | None = Depends(_jwt_current_optional_user), +) -> User: + """ + Returns the authenticated user. + + Checks request.state.proxy_user first (set by ProxyAuthMiddleware when + mPass proxy auth is active). Falls back to JWT Bearer token validation + so existing email/password and Google OAuth flows continue to work when + proxy auth is disabled. + + SMB shared SearchSpace auto-join runs in `on_after_register` (one-shot + at user creation), NOT here on every request. Per-request auto-join + silently re-grants membership to users that operators have explicitly + removed via DELETE /searchspaces/{id}/members/{membership_id}. + """ + proxy_user = getattr(request.state, "proxy_user", None) + if proxy_user is not None: + return proxy_user + if jwt_user is not None: + return jwt_user + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + + +async def current_optional_user( + request: Request, + jwt_user: User | None = Depends(_jwt_current_optional_user), +) -> User | None: + proxy_user = getattr(request.state, "proxy_user", None) + return proxy_user if proxy_user is not None else jwt_user diff --git a/surfsense_backend/scripts/docker/entrypoint.sh b/surfsense_backend/scripts/docker/entrypoint.sh old mode 100644 new mode 100755 diff --git a/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py index 1c8f7f990..9ac4057ca 100644 --- a/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py +++ b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py @@ -1,7 +1,6 @@ from __future__ import annotations from types import SimpleNamespace -from urllib.parse import parse_qs, urlparse import asyncpg import httpx @@ -13,7 +12,7 @@ from app.routes import stripe_routes from app.tasks.celery_tasks import stripe_reconciliation_task from tests.conftest import TEST_DATABASE_URL -from tests.utils.helpers import TEST_EMAIL, TEST_PASSWORD, auth_headers +from tests.utils.helpers import TEST_EMAIL, auth_headers, get_auth_token pytestmark = pytest.mark.integration @@ -48,51 +47,12 @@ async def _get_pages_limit(email: str) -> int: return row["pages_limit"] -def _extract_access_token(response: httpx.Response) -> str | None: - if response.status_code == 200: - return response.json()["access_token"] - - if response.status_code == 302: - location = response.headers.get("location", "") - return parse_qs(urlparse(location).query).get("token", [None])[0] - - return None - - -async def _authenticate_test_user(client: httpx.AsyncClient) -> str: - response = await client.post( - "/auth/jwt/login", - data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - token = _extract_access_token(response) - if token: - return token - - reg_response = await client.post( - "/auth/register", - json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, - ) - assert reg_response.status_code == 201, ( - f"Registration failed ({reg_response.status_code}): {reg_response.text}" - ) - - response = await client.post( - "/auth/jwt/login", - data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - token = _extract_access_token(response) - assert token, f"Login failed ({response.status_code}): {response.text}" - return token - - @pytest_asyncio.fixture(scope="session") async def auth_token(_ensure_tables) -> str: async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", timeout=30.0 ) as client: - return await _authenticate_test_user(client) + return await get_auth_token(client) @pytest.fixture(scope="session") @@ -182,6 +142,7 @@ async def test_create_checkout_session_records_pending_purchase( fake_client = _FakeCreateStripeClient(checkout_session) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: fake_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") monkeypatch.setattr( stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" @@ -269,6 +230,7 @@ async def test_webhook_grants_pages_once( create_client = _FakeCreateStripeClient(checkout_session) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") monkeypatch.setattr( stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" @@ -362,6 +324,7 @@ async def test_reconciliation_fulfills_paid_pending_purchase( create_client = _FakeCreateStripeClient(checkout_session) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") monkeypatch.setattr( stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" @@ -440,6 +403,7 @@ async def test_reconciliation_marks_expired_pending_purchase_failed( create_client = _FakeCreateStripeClient(checkout_session) monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") monkeypatch.setattr( stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" diff --git a/surfsense_backend/tests/unit/middleware/test_proxy_auth.py b/surfsense_backend/tests/unit/middleware/test_proxy_auth.py new file mode 100644 index 000000000..d23074a5e --- /dev/null +++ b/surfsense_backend/tests/unit/middleware/test_proxy_auth.py @@ -0,0 +1,464 @@ +""" +Unit tests for app.middleware.proxy_auth. + +SPEC (GIVEN / WHEN / THEN) +────────────────────────── + 1 GIVEN request.state.proxy_user is already set + WHEN request arrives + THEN call_next is called immediately with no DB access + + 2 GIVEN request path is a bypass path + WHEN request arrives with email header + THEN call_next is called with no DB access, proxy_user not set + + 3 GIVEN no X-Auth-Request-Email header + WHEN request arrives + THEN call_next is called and proxy_user not set + + 4 GIVEN email is not in DB (first seen) + WHEN request arrives with email header + THEN user is inserted, on_after_register is called, proxy_user set + + 5 GIVEN email already in DB + WHEN request arrives with email header + THEN existing user found, no INSERT, proxy_user set + + 6 GIVEN user.is_active is False + WHEN request arrives with email header + THEN proxy_user not set (pass through unauthenticated) + + 7 GIVEN valid email header and active user + WHEN middleware runs + THEN request.state.proxy_user == resolved user + + 8 GIVEN email header with uppercase and leading/trailing whitespace + WHEN request arrives + THEN _normalise_email is called with raw value and normalised email is used + + 9 GIVEN concurrent INSERT raises IntegrityError + WHEN commit raises IntegrityError + THEN fallback SELECT finds user, proxy_user set, no crash +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.exc import IntegrityError +from starlette.requests import Request +from starlette.responses import Response + +from app.middleware.proxy_auth import ( + ProxyAuthMiddleware, + _coerce_bypass_paths, + _is_bypass_path, + _normalise_email, +) + +pytestmark = pytest.mark.unit + +_EMAIL = "alice@example.com" + + +# ── shared test helpers ──────────────────────────────────────────────────────── + + +def _make_request( + path: str = "/api/data", + headers: dict[str, str] | None = None, +) -> Request: + raw_headers = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] + scope = { + "type": "http", + "method": "GET", + "path": path, + "headers": raw_headers, + "query_string": b"", + "root_path": "", + } + return Request(scope) + + +def _make_user( + email: str = _EMAIL, + is_active: bool = True, + last_login: datetime | None = None, +) -> MagicMock: + user = MagicMock() + user.id = uuid.uuid4() + user.email = email + user.is_active = is_active + user.last_login = last_login + return user + + +def _make_middleware( + bypass_paths: list[str] | None = None, +) -> ProxyAuthMiddleware: + """Instantiate ProxyAuthMiddleware without calling __init__ (skips config + ASGI setup).""" + mw = object.__new__(ProxyAuthMiddleware) + mw.bypass_paths = bypass_paths if bypass_paths is not None else ["/health"] + return mw + + +def _make_session_cm(session: AsyncMock) -> MagicMock: + """Wrap an AsyncMock session in an async context manager mock.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=session) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + +async def _ok_call_next(request: Request) -> Response: + return Response("OK") + + +# ── _normalise_email ─────────────────────────────────────────────────────────── + + +class TestNormaliseEmail: + def test_lowercases(self): + assert _normalise_email("ALICE@Example.COM") == "alice@example.com" + + def test_strips_leading_trailing_whitespace(self): + assert _normalise_email(" alice@example.com ") == "alice@example.com" + + def test_nfkc_fullwidth_chars(self): + # Fullwidth 'a' (U+FF41) → ASCII 'a' + assert _normalise_email("\uff41lice@example.com") == "alice@example.com" + + def test_combined(self): + assert _normalise_email(" ALICE@EXAMPLE.COM ") == "alice@example.com" + + +# ── _is_bypass_path ──────────────────────────────────────────────────────────── + + +class TestIsBypassPath: + def test_exact_match(self): + assert _is_bypass_path("/health", ["/health"]) + + def test_sub_path(self): + assert _is_bypass_path("/health/ready", ["/health"]) + + def test_no_prefix_collision(self): + # /healthz must NOT match bypass prefix /health + assert not _is_bypass_path("/healthz", ["/health"]) + + def test_unrelated_path(self): + assert not _is_bypass_path("/api/users", ["/health"]) + + def test_multiple_bypass_paths(self): + assert _is_bypass_path("/docs", ["/health", "/docs"]) + + def test_root_bypass_covers_subpaths(self): + assert _is_bypass_path("/docs/intro", ["/health", "/docs"]) + + +# ── _coerce_bypass_paths ─────────────────────────────────────────────────────── + + +class TestCoerceBypassPaths: + def test_none_returns_default(self): + assert _coerce_bypass_paths(None) == ["/health"] + + def test_empty_string_returns_default(self): + assert _coerce_bypass_paths("") == ["/health"] + + def test_comma_separated_string(self): + assert _coerce_bypass_paths("/health,/docs") == ["/health", "/docs"] + + def test_list_passthrough(self): + assert _coerce_bypass_paths(["/health", "/metrics"]) == [ + "/health", + "/metrics", + ] + + def test_strips_spaces_from_csv(self): + assert _coerce_bypass_paths(" /health , /docs ") == ["/health", "/docs"] + + +# ── ProxyAuthMiddleware.dispatch ─────────────────────────────────────────────── + + +class TestProxyAuthMiddlewareDispatch: + # ── SPEC 1: already authenticated ───────────────────────────────────────── + + async def test_already_authenticated_skips_db(self): + """ + GIVEN request.state.proxy_user is already set + WHEN request arrives + THEN call_next is called immediately, DB never touched, original user preserved + """ + mw = _make_middleware() + existing = _make_user() + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + request.state.proxy_user = existing + call_next = AsyncMock(return_value=Response("OK")) + + with patch("app.middleware.proxy_auth.async_session_maker") as mock_sm: + await mw.dispatch(request, call_next) + + mock_sm.assert_not_called() + assert request.state.proxy_user is existing + + # ── SPEC 2: bypass path ──────────────────────────────────────────────────── + + async def test_bypass_path_skips_db_and_leaves_user_unset(self): + """ + GIVEN path is a configured bypass path (/health) + WHEN request arrives with email header + THEN call_next called, DB never touched, proxy_user not set + """ + mw = _make_middleware(bypass_paths=["/health"]) + request = _make_request( + path="/health", + headers={"x-auth-request-email": _EMAIL}, + ) + call_next = AsyncMock(return_value=Response("OK")) + + with patch("app.middleware.proxy_auth.async_session_maker") as mock_sm: + await mw.dispatch(request, call_next) + + mock_sm.assert_not_called() + assert getattr(request.state, "proxy_user", None) is None + + # ── SPEC 3: no email header ──────────────────────────────────────────────── + + async def test_no_email_header_passes_through_unauthenticated(self): + """ + GIVEN no X-Auth-Request-Email header + WHEN request arrives + THEN call_next called, DB never touched, proxy_user not set + """ + mw = _make_middleware() + request = _make_request() # no email header + call_next = AsyncMock(return_value=Response("OK")) + + with patch("app.middleware.proxy_auth.async_session_maker") as mock_sm: + await mw.dispatch(request, call_next) + + mock_sm.assert_not_called() + assert getattr(request.state, "proxy_user", None) is None + + # ── SPEC 4: new user (first seen) ───────────────────────────────────────── + + async def test_new_user_created_and_on_after_register_called(self): + """ + GIVEN email not in DB + WHEN request arrives with email header + THEN User is INSERTed, on_after_register is called, proxy_user set + """ + mw = _make_middleware() + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + + # Main session: SELECT → None (user absent), then UPDATE last_login + s1 = AsyncMock() + s1.add = MagicMock() # add() is sync; avoid AsyncMock warning + no_user_result = MagicMock() + no_user_result.unique.return_value.scalar_one_or_none.return_value = None + update_result = MagicMock() + s1.execute = AsyncMock(side_effect=[no_user_result, update_result]) + s1_cm = _make_session_cm(s1) + + # Registration session: re-fetch user by id for on_after_register + reg_user = _make_user(email=_EMAIL) + s2 = AsyncMock() + reg_result = MagicMock() + reg_result.unique.return_value.scalar_one_or_none.return_value = reg_user + s2.execute = AsyncMock(return_value=reg_result) + s2_cm = _make_session_cm(s2) + + mock_on_after_register = AsyncMock() + mock_manager = MagicMock() + mock_manager.on_after_register = mock_on_after_register + + with ( + patch( + "app.middleware.proxy_auth.async_session_maker", + side_effect=[s1_cm, s2_cm], + ), + patch("app.users.UserManager", return_value=mock_manager), + patch("app.middleware.proxy_auth.SQLAlchemyUserDatabase"), + ): + await mw.dispatch(request, _ok_call_next) + + s1.add.assert_called_once() + s1.commit.assert_called() + mock_on_after_register.assert_called_once() + assert getattr(request.state, "proxy_user", None) is not None + + # ── SPEC 5: existing user ────────────────────────────────────────────────── + + async def test_existing_user_found_no_insert(self): + """ + GIVEN email already in DB + WHEN request arrives with email header + THEN user found by SELECT, no INSERT (session.add not called), proxy_user set + """ + mw = _make_middleware() + existing = _make_user(email=_EMAIL, last_login=None) + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + + session = AsyncMock() + found_result = MagicMock() + found_result.unique.return_value.scalar_one_or_none.return_value = existing + update_result = MagicMock() + session.execute = AsyncMock(side_effect=[found_result, update_result]) + session_cm = _make_session_cm(session) + + with patch( + "app.middleware.proxy_auth.async_session_maker", return_value=session_cm + ): + await mw.dispatch(request, _ok_call_next) + + session.add.assert_not_called() + assert request.state.proxy_user is existing + + # ── SPEC 6: inactive user ────────────────────────────────────────────────── + + async def test_inactive_user_passes_through_unauthenticated(self): + """ + GIVEN user exists but is_active=False + WHEN request arrives with email header + THEN proxy_user not set (inactive user is not injected) + """ + mw = _make_middleware() + inactive = _make_user(email=_EMAIL, is_active=False) + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + + session = AsyncMock() + found_result = MagicMock() + found_result.unique.return_value.scalar_one_or_none.return_value = inactive + session.execute = AsyncMock(return_value=found_result) + session_cm = _make_session_cm(session) + + with patch( + "app.middleware.proxy_auth.async_session_maker", return_value=session_cm + ): + await mw.dispatch(request, _ok_call_next) + + assert getattr(request.state, "proxy_user", None) is None + + # ── SPEC 7: proxy_user set ───────────────────────────────────────────────── + + async def test_valid_email_sets_proxy_user_to_resolved_user(self): + """ + GIVEN valid email header and active user in DB + WHEN middleware runs + THEN request.state.proxy_user is the resolved user object + """ + mw = _make_middleware() + user = _make_user(email=_EMAIL, last_login=datetime.now(UTC)) + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + + session = AsyncMock() + found_result = MagicMock() + found_result.unique.return_value.scalar_one_or_none.return_value = user + # last_login is recent → needs_update=False → only one execute call + session.execute = AsyncMock(return_value=found_result) + session_cm = _make_session_cm(session) + + with patch( + "app.middleware.proxy_auth.async_session_maker", return_value=session_cm + ): + await mw.dispatch(request, _ok_call_next) + + assert request.state.proxy_user is user + + # ── SPEC 8: email normalisation ──────────────────────────────────────────── + + async def test_email_with_uppercase_and_whitespace_is_normalised(self): + """ + GIVEN email header value " ALICE@EXAMPLE.COM " + WHEN request arrives + THEN _normalise_email is called with the raw value and the lookup uses + the normalised form + """ + mw = _make_middleware() + raw_email = " ALICE@EXAMPLE.COM " + normalised = "alice@example.com" + user = _make_user(email=normalised, last_login=datetime.now(UTC)) + request = _make_request(headers={"x-auth-request-email": raw_email}) + + session = AsyncMock() + found_result = MagicMock() + found_result.unique.return_value.scalar_one_or_none.return_value = user + session.execute = AsyncMock(return_value=found_result) + session_cm = _make_session_cm(session) + + with ( + patch( + "app.middleware.proxy_auth.async_session_maker", return_value=session_cm + ), + patch( + "app.middleware.proxy_auth._normalise_email", + wraps=_normalise_email, + ) as mock_norm, + ): + await mw.dispatch(request, _ok_call_next) + + mock_norm.assert_called_once_with(raw_email.strip()) + assert request.state.proxy_user is user + + # ── SPEC 9: race condition / IntegrityError ────────────────────────────── + + async def test_race_condition_fallback_select_sets_proxy_user(self): + """ + GIVEN two concurrent requests for the same new email + WHEN INSERT raises IntegrityError (the other request won the race) + THEN fallback SELECT finds the user committed by the winner, proxy_user set + """ + mw = _make_middleware() + # race_user was committed by the concurrent winner; it has a recent last_login + # so the throttle check (needs_update) is False, avoiding a third execute call. + race_user = _make_user(email=_EMAIL, last_login=datetime.now(UTC)) + request = _make_request(headers={"x-auth-request-email": _EMAIL}) + + session = AsyncMock() + session.add = MagicMock() # add() is sync; avoid AsyncMock warning + # Call 1: initial SELECT — no user found + no_user_result = MagicMock() + no_user_result.unique.return_value.scalar_one_or_none.return_value = None + # Call 2: fallback SELECT after rollback — race_user found + fallback_result = MagicMock() + fallback_result.unique.return_value.scalar_one_or_none.return_value = race_user + session.execute = AsyncMock(side_effect=[no_user_result, fallback_result]) + # INSERT commit raises IntegrityError; any subsequent commits should succeed + session.commit = AsyncMock(side_effect=[IntegrityError(None, None, None), None]) + session_cm = _make_session_cm(session) + + with patch( + "app.middleware.proxy_auth.async_session_maker", return_value=session_cm + ): + await mw.dispatch(request, _ok_call_next) + + session.rollback.assert_called_once() + assert request.state.proxy_user is race_user + + # ── SPEC 10: bare username → email synthesis ────────────────────────────── + + async def test_bare_username_synthesizes_email_via_default_email_domain(self): + """ + GIVEN X-Auth-Request-Email contains a bare username (no @) + AND config.DEFAULT_EMAIL_DOMAIN is set + WHEN request arrives + THEN middleware synthesizes {username}@{DEFAULT_EMAIL_DOMAIN} + AND passes it to _resolve_user + """ + mw = _make_middleware() + resolved = _make_user(email="testuser@askii.ai") + mw._resolve_user = AsyncMock(return_value=resolved) + request = _make_request(headers={"x-auth-request-email": "testuser"}) + + with patch("app.middleware.proxy_auth.config") as mock_cfg: + mock_cfg.DEFAULT_EMAIL_DOMAIN = "askii.ai" + await mw.dispatch(request, _ok_call_next) + + mw._resolve_user.assert_called_once() + called_email = mw._resolve_user.call_args.args[0] + assert called_email == "testuser@askii.ai" + assert request.state.proxy_user is resolved diff --git a/surfsense_backend/tests/unit/routes/__init__.py b/surfsense_backend/tests/unit/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/routes/test_proxy_login.py b/surfsense_backend/tests/unit/routes/test_proxy_login.py new file mode 100644 index 000000000..a4c7f4d5f --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_proxy_login.py @@ -0,0 +1,201 @@ +""" +Unit tests for the proxy_login endpoint. + +proxy_login does not touch the User table. All provisioning (including +on_after_register side effects — default SearchSpace, RBAC roles, system +prompts) is owned by ProxyAuthMiddleware. This endpoint only reads +request.state.proxy_user (set upstream by the middleware) and issues a JWT +via short-lived cookies. + +SPEC (GIVEN / WHEN / THEN) +────────────────────────── + 1 GIVEN request.state.proxy_user is unset + WHEN GET /auth/jwt/proxy-login is called + THEN 401 Unauthorized is returned + + 2 GIVEN request.state.proxy_user is an active user + WHEN GET /auth/jwt/proxy-login is called + THEN 302 redirect to frontend / with surfsense_sso_token and + surfsense_sso_refresh_token cookies set + + 3 GIVEN request.state.proxy_user is an inactive user + WHEN GET /auth/jwt/proxy-login is called + THEN 401 Unauthorized is returned (defence-in-depth; middleware + normally filters inactive users before this point) +""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from starlette.requests import Request + +pytestmark = pytest.mark.unit + +_EMAIL = "alice@example.com" +_FRONTEND_URL = "https://foss-research.local.moneta.dev" + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +def _make_request( + path: str = "/auth/jwt/proxy-login", + headers: dict[str, str] | None = None, + proxy_user=None, + scheme: str = "https", +) -> Request: + raw_headers = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] + scope = { + "type": "http", + "method": "GET", + "path": path, + "headers": raw_headers, + "query_string": b"", + "root_path": "", + "server": ("localhost", 80), + "scheme": scheme, + } + request = Request(scope) + if proxy_user is not None: + request.state.proxy_user = proxy_user + return request + + +def _make_user(email: str = _EMAIL, is_active: bool = True) -> MagicMock: + user = MagicMock() + user.id = uuid.uuid4() + user.email = email + user.is_active = is_active + return user + + +# ── tests ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestProxyLoginRouteRegistration: + """ + Regression guard for the route being missing from the running image. + + These tests don't exercise behaviour — they assert that the router exposes + the endpoint at the expected path/method. They would have failed loudly when + the baked Docker image lacked the proxy_login function entirely (the bug + that caused the cookie-handoff loop in the devstack on 2026-04-11). + """ + + def test_proxy_login_route_is_registered(self): + from app.routes.auth_routes import router + + paths = [route.path for route in router.routes] + assert "/auth/jwt/proxy-login" in paths, ( + f"proxy-login route missing from auth router. Registered paths: {paths}" + ) + + def test_proxy_login_route_uses_get_method(self): + from app.routes.auth_routes import router + + matching = [r for r in router.routes if r.path == "/auth/jwt/proxy-login"] + assert len(matching) == 1 + assert "GET" in matching[0].methods + + def test_proxy_login_route_calls_proxy_login_function(self): + from app.routes.auth_routes import proxy_login, router + + matching = [r for r in router.routes if r.path == "/auth/jwt/proxy-login"] + assert matching[0].endpoint is proxy_login + + +@pytest.mark.unit +class TestLocalAuthRoutesAreNotRegistered: + """This fork is SSO-only — native fastapi-users routers must never exist.""" + + def test_local_auth_routes_are_not_registered(self): + from app.app import app + + registered = { + (method, route.path) + for route in app.routes + if hasattr(route, "methods") and hasattr(route, "path") + for method in (route.methods or ()) + } + + forbidden = { + ("POST", "/auth/jwt/login"), + ("POST", "/auth/register"), + ("POST", "/auth/forgot-password"), + ("POST", "/auth/reset-password"), + ("POST", "/auth/request-verify-token"), + ("POST", "/auth/verify"), + } + + present = forbidden & registered + assert not present, ( + f"SSO contract violated — these local-auth routes are registered: {sorted(present)}" + ) + + +@pytest.mark.unit +class TestProxyLogin: + @pytest.mark.asyncio + async def test_no_proxy_user_returns_401(self): + """GIVEN request.state.proxy_user unset THEN 401.""" + from fastapi import HTTPException + + from app.routes.auth_routes import proxy_login + + request = _make_request() # no proxy_user + with pytest.raises(HTTPException) as exc_info: + await proxy_login(request) + + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_active_user_gets_redirect_with_cookies(self): + """GIVEN active proxy_user THEN 302 + SSO cookies, DB never touched.""" + from app.routes.auth_routes import proxy_login + + user = _make_user(is_active=True) + request = _make_request(proxy_user=user) + + mock_strategy = AsyncMock() + mock_strategy.write_token = AsyncMock(return_value="mock-access-token") + + with ( + patch( + "app.routes.auth_routes.get_jwt_strategy", return_value=mock_strategy + ), + patch( + "app.routes.auth_routes.create_refresh_token", + AsyncMock(return_value="mock-refresh-token"), + ), + patch("app.routes.auth_routes.config") as mock_config, + patch("app.routes.auth_routes.async_session_maker") as mock_sm, + ): + mock_config.NEXT_FRONTEND_URL = _FRONTEND_URL + response = await proxy_login(request) + + mock_sm.assert_not_called() + assert response.status_code == 302 + assert response.headers["location"] == f"{_FRONTEND_URL}/" + + raw_headers = [(k, v) for k, v in response.raw_headers if k == b"set-cookie"] + cookie_values = [v.decode() for _, v in raw_headers] + assert any("surfsense_sso_token=mock-access-token" in c for c in cookie_values) + assert any( + "surfsense_sso_refresh_token=mock-refresh-token" in c for c in cookie_values + ) + + @pytest.mark.asyncio + async def test_inactive_proxy_user_returns_401(self): + """GIVEN inactive proxy_user THEN 401.""" + from fastapi import HTTPException + + from app.routes.auth_routes import proxy_login + + request = _make_request(proxy_user=_make_user(is_active=False)) + with pytest.raises(HTTPException) as exc_info: + await proxy_login(request) + + assert exc_info.value.status_code == 401 diff --git a/surfsense_backend/tests/unit/test_config_jwt_lifetimes.py b/surfsense_backend/tests/unit/test_config_jwt_lifetimes.py new file mode 100644 index 000000000..8662d2887 --- /dev/null +++ b/surfsense_backend/tests/unit/test_config_jwt_lifetimes.py @@ -0,0 +1,30 @@ +"""JWT access/refresh lifetimes follow ACCESS_TOKEN_* / REFRESH_TOKEN_* env (compose maps unified session vars).""" + +import importlib +import os + +import pytest + + +@pytest.mark.unit +def test_jwt_lifetime_seconds_from_env(monkeypatch): + import app.config as cfg_module + + prev_access = os.environ.get("ACCESS_TOKEN_LIFETIME_SECONDS") + prev_refresh = os.environ.get("REFRESH_TOKEN_LIFETIME_SECONDS") + try: + monkeypatch.setenv("ACCESS_TOKEN_LIFETIME_SECONDS", "111") + monkeypatch.setenv("REFRESH_TOKEN_LIFETIME_SECONDS", "222") + importlib.reload(cfg_module) + assert cfg_module.Config.ACCESS_TOKEN_LIFETIME_SECONDS == 111 + assert cfg_module.Config.REFRESH_TOKEN_LIFETIME_SECONDS == 222 + finally: + if prev_access is not None: + os.environ["ACCESS_TOKEN_LIFETIME_SECONDS"] = prev_access + else: + os.environ.pop("ACCESS_TOKEN_LIFETIME_SECONDS", None) + if prev_refresh is not None: + os.environ["REFRESH_TOKEN_LIFETIME_SECONDS"] = prev_refresh + else: + os.environ.pop("REFRESH_TOKEN_LIFETIME_SECONDS", None) + importlib.reload(cfg_module) diff --git a/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py new file mode 100644 index 000000000..2373a60d6 --- /dev/null +++ b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py @@ -0,0 +1,163 @@ +""" +Regression-guard for SurfSense's architectural immunity to the +stale-session-on-user-switch class of bug. + +SurfSense's `current_active_user` (in app.users) resolves to: + proxy_user (set per-request by ProxyAuthMiddleware from + X-Auth-Request-Email) IF present, ELSE + jwt_user (decoded from Authorization: Bearer JWT) IF present, ELSE + raise 401 Not Authenticated. + +This precedence is what makes SurfSense immune to the cross-app +stale-session-on-user-switch bug class that affected Plane #29 / +Outline #19 / Penpot #18 / Twenty #8. The other apps had to add an +explicit "compare upstream identity vs session identity → flush on +mismatch" middleware. SurfSense doesn't need that because the upstream +identity (proxy_user) ALWAYS wins over the persisted session (jwt_user) +when both are present. + +The cross-app contract: + awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md + +These tests pin the precedence so a future refactor that flips the +priority (or removes the proxy_user check) cannot silently re-introduce +the bug. Without them, a well-intentioned "simplify auth to just use +JWT" change would pass type-checks and ship the regression. +""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException, Request +from starlette.requests import Request as StarletteRequest + +from app.users import current_active_user, current_optional_user + +pytestmark = pytest.mark.unit + + +def _make_request(proxy_user=None) -> Request: + """Build a minimal Request whose .state.proxy_user is settable.""" + scope = {"type": "http", "headers": [], "method": "GET", "path": "/"} + request = StarletteRequest(scope) + if proxy_user is not None: + request.state.proxy_user = proxy_user + return request + + +def _make_user(email: str, *, is_active: bool = True): + user = MagicMock() + user.id = uuid.uuid4() + user.email = email + user.is_active = is_active + return user + + +@pytest.mark.asyncio +async def test_proxy_user_wins_when_both_proxy_and_jwt_present(): + """ + GIVEN proxy_user resolves alice from upstream header + AND jwt_user resolves bob from Bearer JWT (stale) + WHEN current_active_user runs + THEN returns alice (the upstream identity) + + This is the architectural-immunity contract. SurfSense never serves + the stale JWT identity when an upstream identity is asserted on the + same request. If this test fails, the cross-app stale-session bug + class is re-introduced into SurfSense. + """ + alice = _make_user("alice@example.com") + bob = _make_user("bob@example.com") + request = _make_request(proxy_user=alice) + + # Bypass the SMB auto-join side effect; we're testing precedence only. + import app.users as users_module + original = users_module.auto_join_smb_search_space + users_module.auto_join_smb_search_space = AsyncMock(return_value=None) + try: + result = await current_active_user(request, jwt_user=bob) + finally: + users_module.auto_join_smb_search_space = original + + assert result is alice + assert result is not bob + + +@pytest.mark.asyncio +async def test_falls_back_to_jwt_when_proxy_user_absent(): + """ + GIVEN proxy_user is NOT set on the request (no X-Auth-Request-Email) + AND jwt_user resolves alice from Bearer JWT + WHEN current_active_user runs + THEN returns alice (the JWT identity) + + Header absence is NOT a logout signal per the cross-app spec — it + legitimately occurs for internal calls, bypass routes, OPTIONS + preflight, and direct backend hits at 127.0.0.1. SurfSense MUST + serve the JWT identity in this case, not 401. + """ + alice = _make_user("alice@example.com") + request = _make_request(proxy_user=None) + + import app.users as users_module + original = users_module.auto_join_smb_search_space + users_module.auto_join_smb_search_space = AsyncMock(return_value=None) + try: + result = await current_active_user(request, jwt_user=alice) + finally: + users_module.auto_join_smb_search_space = original + + assert result is alice + + +@pytest.mark.asyncio +async def test_raises_401_when_neither_proxy_nor_jwt_present(): + """ + GIVEN proxy_user is NOT set AND jwt_user is None (no Bearer) + WHEN current_active_user runs + THEN raises 401 Not Authenticated + + Sanity: an entirely unauthenticated request must be rejected. + """ + request = _make_request(proxy_user=None) + + with pytest.raises(HTTPException) as exc_info: + await current_active_user(request, jwt_user=None) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_optional_user_returns_proxy_when_both_present(): + """ + GIVEN proxy_user resolves alice AND jwt_user resolves bob + WHEN current_optional_user runs + THEN returns alice + + Same precedence rule, optional variant. Returns None instead of + raising when neither is present. + """ + alice = _make_user("alice@example.com") + bob = _make_user("bob@example.com") + request = _make_request(proxy_user=alice) + + result = await current_optional_user(request, jwt_user=bob) + + assert result is alice + + +@pytest.mark.asyncio +async def test_optional_user_returns_none_when_neither_present(): + """ + GIVEN no proxy_user AND no jwt_user + WHEN current_optional_user runs + THEN returns None (does NOT raise) + """ + request = _make_request(proxy_user=None) + + result = await current_optional_user(request, jwt_user=None) + + assert result is None diff --git a/surfsense_backend/tests/utils/helpers.py b/surfsense_backend/tests/utils/helpers.py index c5719a253..49b0836ab 100644 --- a/surfsense_backend/tests/utils/helpers.py +++ b/surfsense_backend/tests/utils/helpers.py @@ -10,36 +10,30 @@ FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" TEST_EMAIL = "testuser@surfsense.com" -TEST_PASSWORD = "testpassword123" async def get_auth_token(client: httpx.AsyncClient) -> str: - """Log in and return a Bearer JWT token, registering the user first if needed.""" - response = await client.post( - "/auth/jwt/login", - data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code == 200: - return response.json()["access_token"] + """Obtain a Bearer JWT via the SSO proxy-login endpoint. - reg_response = await client.post( - "/auth/register", - json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, - ) - assert reg_response.status_code == 201, ( - f"Registration failed ({reg_response.status_code}): {reg_response.text}" + Local auth routes (/auth/register, /auth/jwt/login) are disabled in SSO + mode, so the test user is provisioned the same way production users are: + ProxyAuthMiddleware reads X-Auth-Request-Email and JIT-creates the user, + then /auth/jwt/proxy-login issues a JWT delivered via the + surfsense_sso_token cookie on a 302 redirect. + """ + response = await client.get( + "/auth/jwt/proxy-login", + headers={"X-Auth-Request-Email": TEST_EMAIL}, + follow_redirects=False, ) - - response = await client.post( - "/auth/jwt/login", - data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, + assert response.status_code == 302, ( + f"proxy-login failed ({response.status_code}): {response.text}" ) - assert response.status_code == 200, ( - f"Login after registration failed ({response.status_code}): {response.text}" + token = response.cookies.get("surfsense_sso_token") + assert token, ( + f"surfsense_sso_token cookie missing from proxy-login response: {response.headers!r}" ) - return response.json()["access_token"] + return token async def get_search_space_id(client: httpx.AsyncClient, token: str) -> int: diff --git a/surfsense_web/.dockerignore b/surfsense_web/.dockerignore index 7d48982c9..d5ebb2f2c 100644 --- a/surfsense_web/.dockerignore +++ b/surfsense_web/.dockerignore @@ -4,6 +4,11 @@ node_modules .next out .DS_Store +# Next.js reads these at build time and inlines NEXT_PUBLIC_* values into the +# JS bundle, bypassing Dockerfile ARG defaults + entrypoint runtime substitution. +# Exclude from build context so the image stays portable across .env.local contents. +.env.local +.env.*.local npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index b448c1f71..2313f3e56 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -1,5 +1,10 @@ NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE + +# mPass proxy auth — set when deployed behind oauth2-proxy + Traefik ForwardAuth +NEXT_PUBLIC_OIDC_LOGOUT_URL=https:///logout +NEXT_PUBLIC_OIDC_CLIENT_ID= +NEXT_PUBLIC_OAUTH2_PROXY_URL=https://auth. NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848 diff --git a/surfsense_web/Dockerfile b/surfsense_web/Dockerfile index da6bc8b7e..aad2bb4f3 100644 --- a/surfsense_web/Dockerfile +++ b/surfsense_web/Dockerfile @@ -9,7 +9,7 @@ RUN apk add --no-cache libc6-compat WORKDIR /app # Install pnpm -RUN corepack enable pnpm +RUN corepack enable pnpm && corepack prepare pnpm@10.33.4 --activate # Copy package files COPY package.json pnpm-lock.yaml* .npmrc* ./ @@ -27,7 +27,7 @@ FROM base AS builder WORKDIR /app # Enable pnpm -RUN corepack enable pnpm +RUN corepack enable pnpm && corepack prepare pnpm@10.33.4 --activate # Build with placeholder values for NEXT_PUBLIC_* variables. # These are replaced at container startup by docker-entrypoint.js @@ -37,12 +37,23 @@ ARG NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYP ARG NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__ ARG NEXT_PUBLIC_ZERO_CACHE_URL=__NEXT_PUBLIC_ZERO_CACHE_URL__ ARG NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__ +ARG NEXT_PUBLIC_OAUTH2_PROXY_URL=__NEXT_PUBLIC_OAUTH2_PROXY_URL__ +# These are baked at build time (not placeholder-substituted). Next.js inlines +# them as literal strings and terser dead-code-eliminates branches based on +# truthiness; placeholder tokens look truthy and defeat that optimization. +ARG NEXT_PUBLIC_LOGOUT_REDIRECT_URL= +ARG NEXT_PUBLIC_OIDC_LOGOUT_URL= +ARG NEXT_PUBLIC_OIDC_CLIENT_ID= ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=$NEXT_PUBLIC_FASTAPI_BACKEND_URL ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=$NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ENV NEXT_PUBLIC_ETL_SERVICE=$NEXT_PUBLIC_ETL_SERVICE ENV NEXT_PUBLIC_ZERO_CACHE_URL=$NEXT_PUBLIC_ZERO_CACHE_URL ENV NEXT_PUBLIC_DEPLOYMENT_MODE=$NEXT_PUBLIC_DEPLOYMENT_MODE +ENV NEXT_PUBLIC_OAUTH2_PROXY_URL=$NEXT_PUBLIC_OAUTH2_PROXY_URL +ENV NEXT_PUBLIC_LOGOUT_REDIRECT_URL=$NEXT_PUBLIC_LOGOUT_REDIRECT_URL +ENV NEXT_PUBLIC_OIDC_LOGOUT_URL=$NEXT_PUBLIC_OIDC_LOGOUT_URL +ENV NEXT_PUBLIC_OIDC_CLIENT_ID=$NEXT_PUBLIC_OIDC_CLIENT_ID COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/surfsense_web/app/(home)/layout.tsx b/surfsense_web/app/(home)/layout.tsx index f1ceffac0..3645d9512 100644 --- a/surfsense_web/app/(home)/layout.tsx +++ b/surfsense_web/app/(home)/layout.tsx @@ -7,12 +7,15 @@ import { Navbar } from "@/components/homepage/navbar"; export default function HomePageLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isAuthPage = pathname === "/login" || pathname === "/register"; + // The home route ("/") is the SSO splash — it only renders during the + // cookie-handoff redirect dance and should not flash any chrome. + const isSplashPage = pathname === "/"; return (
- + {!isSplashPage && } {children} - {!isAuthPage && } + {!isAuthPage && !isSplashPage && }
); } diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index e22fc2798..f71776180 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -2,6 +2,7 @@ import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; +import { useEffect } from "react"; import { Logo } from "@/components/Logo"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { AmbientBackground } from "./AmbientBackground"; @@ -9,16 +10,16 @@ import { AmbientBackground } from "./AmbientBackground"; export function GoogleLoginButton() { const t = useTranslations("auth"); - const handleGoogleLogin = () => { - // Track Google login attempt + // Auto-redirect to proxy-login on mount — user is already authenticated + // via oauth2-proxy/Cognito so no button click is needed. + useEffect(() => { trackLoginAttempt("google"); + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`; + }, []); - // 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`; + const handleGoogleLogin = () => { + trackLoginAttempt("google"); + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`; }; return (
diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 07a4db4d3..2dd203884 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -9,6 +9,7 @@ import { useState } from "react"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; +import { setBearerToken } from "@/lib/auth-utils"; import { AUTH_TYPE } from "@/lib/env-config"; import { ValidationError } from "@/lib/error"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; @@ -46,14 +47,12 @@ export function LocalLoginForm() { // Track successful login trackLoginSuccess("local"); - // Set flag so TokenHandler knows local login was already tracked - if (typeof window !== "undefined") { - sessionStorage.setItem("login_success_tracked", "true"); - } + // Store token directly — no /auth/callback redirect needed + setBearerToken(data.access_token); // Small delay to show success message setTimeout(() => { - router.push(`/auth/callback?token=${data.access_token}`); + router.push("/dashboard"); }, 500); } catch (err) { if (err instanceof ValidationError) { diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 3dbbf21a9..9961df3d8 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -8,7 +8,8 @@ import { toast } from "sonner"; import { Logo } from "@/components/Logo"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; -import { AUTH_TYPE } from "@/lib/env-config"; +import { getBearerToken } from "@/lib/auth-utils"; +import { AUTH_TYPE, isSSOAuth } from "@/lib/env-config"; import { AmbientBackground } from "./AmbientBackground"; import { GoogleLoginButton } from "./GoogleLoginButton"; import { LocalLoginForm } from "./LocalLoginForm"; @@ -22,6 +23,30 @@ function LoginContent() { const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null); const searchParams = useSearchParams(); + // SSO mode: bounce straight to oauth2-proxy /oauth2/sign_in. The dedicated + // auth subdomain handles the OIDC dance with Cognito and returns the user + // to / where the home-route splash + cookie handoff finishes the login + // normally. Same pattern as handleUnauthorized() in lib/auth-utils.ts. + // Falls through to the LOCAL/GOOGLE form below when AUTH_TYPE is not SSO. + // Hook is unconditional (rules-of-hooks); the early-return that skips + // rendering the form lives below, after every other hook has run. + useEffect(() => { + if (typeof window === "undefined") return; + + // Already signed in — never show the login surface. Hand off to the + // home route which will redirect to /dashboard. + if (getBearerToken()) { + window.location.replace("/"); + return; + } + + if (isSSOAuth()) { + const oauthProxyUrl = process.env.NEXT_PUBLIC_OAUTH2_PROXY_URL || window.location.origin; + const rd = `${window.location.origin}/`; + window.location.replace(`${oauthProxyUrl}/oauth2/sign_in?rd=${encodeURIComponent(rd)}`); + } + }, []); + useEffect(() => { // Check for various URL parameters that might indicate success or error states const registered = searchParams.get("registered"); @@ -103,6 +128,17 @@ function LoginContent() { // Use global loading screen for auth type determination - spinner animation won't reset useGlobalLoadingEffect(isLoading); + // SSO mode: render splash while window.location.replace() takes effect. + // isSSOAuth() reads a build-time-inlined env var (NEXT_PUBLIC_*) so it + // works during SSR too — the splash renders on the server, the browser + // receives splash HTML directly, no form flash before hydration. + // All hooks above run unconditionally (rules-of-hooks); only the render + // branches here. In SSO deployments isSSOAuth() is true, so the form + // below is never reached at runtime. + if (isSSOAuth()) { + return
; + } + // Show nothing while loading - the GlobalLoadingProvider handles the loading UI if (isLoading) { return null; diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index 5eeea727f..14ff98be0 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -1,55 +1,55 @@ "use client"; -import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { HeroSection } from "@/components/homepage/hero-section"; -import { getBearerToken } from "@/lib/auth-utils"; - -const WhySurfSense = dynamic( - () => import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })), - { ssr: false } -); - -const FeaturesCards = dynamic( - () => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })), - { ssr: false } -); - -const FeaturesBentoGrid = dynamic( - () => - import("@/components/homepage/features-bento-grid").then((m) => ({ - default: m.FeaturesBentoGrid, - })), - { ssr: false } -); - -const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), { - ssr: false, -}); - -const CTAHomepage = dynamic( - () => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })), - { ssr: false } -); +import { + clearSSOCookies, + getBearerToken, + getSSOCookieTokens, + setBearerToken, + setRefreshToken, +} from "@/lib/auth-utils"; + +/** + * SSO-only home route. + * + * This fork is configured for mPass/Cognito SSO via oauth2-proxy, so the + * marketing landing page (HeroSection / FeaturesCards / etc.) is never the + * intended destination — every visitor either has a session or is about to + * get one. Rendering the marketing JSX here would cause a visible flash + * (~200-500ms) before the redirect chain completes: + * + * / → marketing flash → /auth/jwt/proxy-login → / → /dashboard + * + * Instead we render a neutral splash and let the cookie handoff or proxy + * redirect take the user where they need to go. + */ export default function HomePage() { const router = useRouter(); useEffect(() => { if (getBearerToken()) { router.replace("/dashboard"); + return; } + + // Cookie handoff from /auth/jwt/proxy-login after oauth2-proxy + Cognito login. + // Backend sets short-lived cookies (60s TTL) and redirects here instead of + // to /auth/callback, avoiding any Traefik path-split between frontend and backend. + const { token, refreshToken } = getSSOCookieTokens(); + if (token) { + setBearerToken(token); + if (refreshToken) setRefreshToken(refreshToken); + clearSSOCookies(); + router.replace("/dashboard"); + return; + } + + // No JWT anywhere → start the SSO flow. + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`; }, [router]); - return ( -
- - - - - - -
- ); + // Splash — neutral background, no UI flash during the redirect dance. + return
; } diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index f9e387cf7..b5d9c1000 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -12,7 +12,7 @@ import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getBearerToken } from "@/lib/auth-utils"; -import { AUTH_TYPE } from "@/lib/env-config"; +import { AUTH_TYPE, isSSOAuth } from "@/lib/env-config"; import { AppError, ValidationError } from "@/lib/error"; import { trackRegistrationAttempt, @@ -37,6 +37,20 @@ export default function RegisterPage() { const router = useRouter(); const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom); + // SSO mode: bounce straight to oauth2-proxy /oauth2/sign_in. The dedicated + // auth subdomain handles the OIDC dance with Cognito and returns the user + // to / where the home-route splash + cookie handoff finishes the login + // normally. Same pattern as the /login page and handleUnauthorized() in + // lib/auth-utils.ts. Falls through to the LOCAL form below when AUTH_TYPE + // is not SSO. Hook is unconditional (rules-of-hooks). + useEffect(() => { + if (typeof window !== "undefined" && isSSOAuth()) { + const oauthProxyUrl = process.env.NEXT_PUBLIC_OAUTH2_PROXY_URL || window.location.origin; + const rd = `${window.location.origin}/`; + window.location.replace(`${oauthProxyUrl}/oauth2/sign_in?rd=${encodeURIComponent(rd)}`); + } + }, []); + // Check authentication type and redirect if not LOCAL useEffect(() => { if (getBearerToken()) { @@ -156,6 +170,17 @@ export default function RegisterPage() { } }; + // SSO mode: render splash while window.location.replace() takes effect. + // isSSOAuth() reads a build-time-inlined env var (NEXT_PUBLIC_*) so it + // works during SSR too — the splash renders on the server, the browser + // receives splash HTML directly, no form flash before hydration. + // All hooks above run unconditionally (rules-of-hooks); only the render + // branches here. In SSO deployments isSSOAuth() is true, so the form + // below is never reached at runtime. + if (isSSOAuth()) { + return
; + } + return (
diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts index 3d8ff0d33..3bd00f7c1 100644 --- a/surfsense_web/app/api/zero/query/route.ts +++ b/surfsense_web/app/api/zero/query/route.ts @@ -5,7 +5,15 @@ import type { Context } from "@/types/zero"; import { queries } from "@/zero/queries"; import { schema } from "@/zero/schema"; -const backendURL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; +// This route runs server-side (Next.js API route, called by zero-cache). +// Prefer an internal URL for server-to-server calls — `NEXT_PUBLIC_*` is the +// browser-facing URL, which in reverse-proxy / ForwardAuth deployments goes +// through an auth gateway that rejects server-side fetches without browser +// cookies. Falls back to the public URL for single-host deployments. +const backendURL = + process.env.FASTAPI_BACKEND_INTERNAL_URL || + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || + "http://localhost:8000"; async function authenticateRequest( request: Request diff --git a/surfsense_web/app/auth/callback/loading.tsx b/surfsense_web/app/auth/callback/loading.tsx deleted file mode 100644 index f12b3847d..000000000 --- a/surfsense_web/app/auth/callback/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; - -export default function AuthCallbackLoading() { - // Use global loading - spinner animation won't reset when page transitions - useGlobalLoadingEffect(true); - - // Return null - the GlobalLoadingProvider handles the loading UI - return null; -} diff --git a/surfsense_web/app/auth/callback/page.tsx b/surfsense_web/app/auth/callback/page.tsx deleted file mode 100644 index 4050eefb6..000000000 --- a/surfsense_web/app/auth/callback/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { Suspense } from "react"; -import TokenHandler from "@/components/TokenHandler"; - -export default function AuthCallbackPage() { - // Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI - // TokenHandler uses useGlobalLoadingEffect to show the loading screen - return ( - - - - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 0b1369340..2f8a285c1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -41,7 +41,7 @@ import { Thread } from "@/components/assistant-ui/thread"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesSync } from "@/hooks/use-messages-sync"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { isPodcastGenerating, @@ -663,11 +663,10 @@ export default function NewChatPage() { setSidebarDocuments([]); } - const response = await fetch(`${backendUrl}/api/v1/new_chat`, { + const response = await authenticatedFetch(`${backendUrl}/api/v1/new_chat`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify({ chat_id: currentThreadId, @@ -1028,11 +1027,10 @@ export default function NewChatPage() { try { const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; - const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { + const response = await authenticatedFetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify({ search_space_id: searchSpaceId, @@ -1345,11 +1343,10 @@ export default function NewChatPage() { ]); try { - const response = await fetch(getRegenerateUrl(threadId), { + const response = await authenticatedFetch(getRegenerateUrl(threadId), { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify({ search_space_id: searchSpaceId, diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts index 8e196c9c7..ff1477482 100644 --- a/surfsense_web/atoms/user/user-query.atoms.ts +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -1,6 +1,5 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { userApiService } from "@/lib/apis/user-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; export const USER_QUERY_KEY = ["user", "me"] as const; const userQueryFn = () => userApiService.getMe(); @@ -9,7 +8,7 @@ export const currentUserAtom = atomWithQuery(() => { return { queryKey: USER_QUERY_KEY, staleTime: 5 * 60 * 1000, - enabled: !!getBearerToken(), + enabled: true, queryFn: userQueryFn, }; }); diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index e81a1bf2b..88dd66550 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -1,87 +1,65 @@ -"use client"; +// mPass SSO: This component is disabled because oauth2-proxy ForwardAuth +// handles login via Cognito. The cookie-handoff pattern (proxy_login → +// cookie → frontend reads cookie → JWT in localStorage) replaces the +// native OAuth token extraction flow below. +// +// To restore native OAuth login, uncomment this file and re-register +// the /auth/callback route. -import { useEffect } from "react"; -import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; -import { trackLoginSuccess } from "@/lib/posthog/events"; -interface TokenHandlerProps { - redirectPath?: string; // Default path to redirect after storing token (if no saved path) - tokenParamName?: string; // Name of the URL parameter containing the token - storageKey?: string; // Key to use when storing in localStorage (kept for backwards compatibility) -} - -/** - * Client component that extracts a token from URL parameters and stores it in localStorage - * After storing the token, it redirects the user back to the page they were on before - * being redirected to login (if available), or to the default redirectPath. - * - * @param redirectPath - Default path to redirect after storing token (default: '/dashboard') - * @param tokenParamName - Name of the URL parameter containing the token (default: 'token') - * @param storageKey - Key to use when storing in localStorage (default: 'surfsense_bearer_token') - */ -const TokenHandler = ({ - redirectPath = "/dashboard", - tokenParamName = "token", - storageKey = "surfsense_bearer_token", -}: TokenHandlerProps) => { - // Always show loading for this component - spinner animation won't reset - useGlobalLoadingEffect(true); - - useEffect(() => { - if (typeof window === "undefined") return; - - const run = async () => { - const params = new URLSearchParams(window.location.search); - const token = params.get(tokenParamName); - const refreshToken = params.get("refresh_token"); - - if (token) { - try { - const alreadyTracked = sessionStorage.getItem("login_success_tracked"); - if (!alreadyTracked) { - trackLoginSuccess("google"); - } - sessionStorage.removeItem("login_success_tracked"); - - localStorage.setItem(storageKey, token); - setBearerToken(token); - - if (refreshToken) { - setRefreshToken(refreshToken); - } - - // Auto-set active search space in desktop if not already set - if (window.electronAPI?.getActiveSearchSpace) { - try { - const stored = await window.electronAPI.getActiveSearchSpace(); - if (!stored) { - const spaces = await searchSpacesApiService.getSearchSpaces(); - if (spaces?.length) { - await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id)); - } - } - } catch { - // non-critical - } - } - - const savedRedirectPath = getAndClearRedirectPath(); - const finalRedirectPath = savedRedirectPath || redirectPath; - window.location.href = finalRedirectPath; - } catch (error) { - console.error("Error storing token in localStorage:", error); - window.location.href = redirectPath; - } - } - }; - - run(); - }, [tokenParamName, storageKey, redirectPath]); - - // Return null - the global provider handles the loading UI - return null; -}; - -export default TokenHandler; +// "use client"; +// +// import { useEffect } from "react"; +// import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; +// import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; +// import { trackLoginSuccess } from "@/lib/posthog/events"; +// +// interface TokenHandlerProps { +// redirectPath?: string; +// tokenParamName?: string; +// storageKey?: string; +// } +// +// const TokenHandler = ({ +// redirectPath = "/dashboard", +// tokenParamName = "token", +// storageKey = "surfsense_bearer_token", +// }: TokenHandlerProps) => { +// useGlobalLoadingEffect(true); +// +// useEffect(() => { +// if (typeof window === "undefined") return; +// +// const params = new URLSearchParams(window.location.search); +// const token = params.get(tokenParamName); +// const refreshToken = params.get("refresh_token"); +// +// if (token) { +// try { +// const alreadyTracked = sessionStorage.getItem("login_success_tracked"); +// if (!alreadyTracked) { +// trackLoginSuccess("google"); +// } +// sessionStorage.removeItem("login_success_tracked"); +// +// localStorage.setItem(storageKey, token); +// setBearerToken(token); +// +// if (refreshToken) { +// setRefreshToken(refreshToken); +// } +// +// const savedRedirectPath = getAndClearRedirectPath(); +// const finalRedirectPath = savedRedirectPath || redirectPath; +// window.location.href = finalRedirectPath; +// } catch (error) { +// console.error("Error storing token in localStorage:", error); +// window.location.href = redirectPath; +// } +// } +// }, [tokenParamName, storageKey, redirectPath]); +// +// return null; +// }; +// +// export default TokenHandler; diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 0ffb172bf..ee98baf66 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -38,15 +38,21 @@ export function UserDropdown({ trackLogout(); resetUser(); - await logout(); + // Revoke refresh token on server and clear all tokens from localStorage. + // logout() returns true and sets window.location.href when SSO is + // configured — don't overwrite it with a local redirect. + const ssoRedirected = await logout(); - router.push(getLoginPath()); - router.refresh(); + if (!ssoRedirected && typeof window !== "undefined") { + window.location.href = "/"; + } } catch (error) { console.error("Error during logout:", error); - await logout(); - router.push(getLoginPath()); - router.refresh(); + // Even if there's an error, try to clear tokens and redirect + const ssoRedirected = await logout(); + if (!ssoRedirected && typeof window !== "undefined") { + window.location.href = "/"; + } } }; diff --git a/surfsense_web/components/assistant-ui/image.tsx b/surfsense_web/components/assistant-ui/image.tsx index 59781abcf..b29b6ddce 100644 --- a/surfsense_web/components/assistant-ui/image.tsx +++ b/surfsense_web/components/assistant-ui/image.tsx @@ -279,4 +279,4 @@ Image.Preview = ImagePreview; Image.Filename = ImageFilename; Image.Zoom = ImageZoom; -export { Image, ImageRoot, ImagePreview, ImageFilename, ImageZoom, imageVariants }; +export { Image, ImageFilename, ImagePreview, ImageRoot, ImageZoom, imageVariants }; diff --git a/surfsense_web/components/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx index dd5893deb..b6e4b22a7 100644 --- a/surfsense_web/components/auth/sign-in-button.tsx +++ b/surfsense_web/components/auth/sign-in-button.tsx @@ -2,7 +2,7 @@ import { motion } from "motion/react"; import Link from "next/link"; -import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; +import { AUTH_TYPE, isSSOAuth } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -46,34 +46,39 @@ interface SignInButtonProps { export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => { const isGoogleAuth = AUTH_TYPE === "GOOGLE"; + const isSSOAuthMode = isSSOAuth(); + // Both Google and SSO modes use the proxy-login button (not email/password form) + const isProxyLogin = isGoogleAuth || isSSOAuthMode; - const handleGoogleLogin = () => { - trackLoginAttempt("google"); - window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + const handleProxyLogin = () => { + trackLoginAttempt(isSSOAuthMode ? "sso" : "google"); + // Redirect to proxy-login — Traefik ForwardAuth triggers Cognito if needed, + // then the endpoint issues a JWT and redirects to /auth/callback. + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`; }; const getClassName = () => { if (variant === "desktop") { - return isGoogleAuth + return isProxyLogin ? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" : "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"; } if (variant === "compact") { - return isGoogleAuth + return isProxyLogin ? "rounded-full bg-white px-4 py-1.5 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" : "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black"; } // mobile - return isGoogleAuth + return isProxyLogin ? "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation" : "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"; }; - if (isGoogleAuth) { + if (isProxyLogin) { return ( { getClassName() )} > - + {isGoogleAuth && } Sign In ); diff --git a/surfsense_web/components/homepage/footer-new.tsx b/surfsense_web/components/homepage/footer-new.tsx index 4bbff0cbd..aedcfac8f 100644 --- a/surfsense_web/components/homepage/footer-new.tsx +++ b/surfsense_web/components/homepage/footer-new.tsx @@ -1,95 +1,6 @@ -import { - IconBrandDiscord, - IconBrandGithub, - IconBrandLinkedin, - IconBrandTwitter, -} from "@tabler/icons-react"; -import Link from "next/link"; import { Logo } from "@/components/Logo"; export function FooterNew() { - const pages = [ - // { - // title: "All Products", - // href: "#", - // }, - // { - // title: "Studio", - // href: "#", - // }, - // { - // title: "Clients", - // href: "#", - // }, - { - title: "Pricing", - href: "/pricing", - }, - { - title: "Docs", - href: "/docs", - }, - { - title: "Contact Us", - href: "/contact", - }, - { - title: "Announcements", - href: "/announcements", - }, - ]; - - const socials = [ - { - title: "Twitter", - href: "https://x.com/mod_setter", - icon: IconBrandTwitter, - }, - { - title: "LinkedIn", - href: "https://www.linkedin.com/company/surfsense/", - icon: IconBrandLinkedin, - }, - { - title: "GitHub", - href: "https://github.com/MODSetter", - icon: IconBrandGithub, - }, - { - title: "Discord", - href: "https://discord.gg/ejRNvftDp9", - icon: IconBrandDiscord, - }, - ]; - const legals = [ - { - title: "Privacy Policy", - href: "/privacy", - }, - { - title: "Terms of Service", - href: "/terms", - }, - // { - // title: "Cookie Policy", - // href: "#", - // }, - ]; - - const signups = [ - { - title: "Sign In", - href: "/login", - }, - // { - // title: "Login", - // href: "#", - // }, - // { - // title: "Forgot Password", - // href: "#", - // }, - ]; return (
@@ -103,78 +14,6 @@ export function FooterNew() { © SurfSense {new Date().getFullYear()}. All rights reserved.
-
-
-

- Pages -

-
    - {pages.map((page, idx) => ( -
  • - - {page.title} - -
  • - ))} -
-
- -
-

- Socials -

-
    - {socials.map((social, idx) => { - const Icon = social.icon; - return ( -
  • - - - {social.title} - -
  • - ); - })} -
-
- -
-

- Legal -

-
    - {legals.map((legal, idx) => ( -
  • - - {legal.title} - -
  • - ))} -
-
-
-

- Register -

-
    - {signups.map((auth, idx) => ( -
  • - - {auth.title} - -
  • - ))} -
-
-

SurfSense diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index ba5a9d9ac..23c17aa3e 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/dropdown-menu"; import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; +import { AUTH_TYPE, isSSOAuth } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -171,22 +171,54 @@ export function HeroSection() { function GetStartedButton() { const isGoogleAuth = AUTH_TYPE === "GOOGLE"; - - const handleGoogleLogin = () => { - trackLoginAttempt("google"); - window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + const isSSOAuthMode = isSSOAuth(); + const isProxyLogin = isGoogleAuth || isSSOAuthMode; + + const handleProxyLogin = () => { + trackLoginAttempt(isSSOAuthMode ? "sso" : "google"); + // Redirect to proxy-login — Traefik ForwardAuth triggers Cognito if needed, + // then the endpoint issues a JWT and redirects to /auth/callback. + window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`; }; - if (isGoogleAuth) { + if (isProxyLogin) { return ( + {/* Animated gradient background on hover */} + + {/* Show Google logo only for native Google OAuth, not SSO */} + {isGoogleAuth && ( + + + + )} + {isGoogleAuth ? "Continue with Google" : "Sign In"} + ); } diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index 4961199aa..c62df226e 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,5 +1,5 @@ "use client"; -import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; +import { IconMenu2, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; @@ -17,12 +17,7 @@ interface NavbarProps { export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => { const [isScrolled, setIsScrolled] = useState(false); - const navItems = [ - { name: "Pricing", link: "/pricing" }, - { name: "Changelog", link: "/changelog" }, - { name: "Docs", link: "/docs" }, - { name: "Contact\u00A0Us", link: "/contact" }, - ]; + const navItems = [{ name: "Docs", link: "/docs" }]; useEffect(() => { if (typeof window === "undefined") return; @@ -94,22 +89,6 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => { ))}

- - - - - - @@ -193,22 +172,6 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => { ))}
- - - - - -
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7e9c33a1a..6184f164f 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -599,16 +599,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid trackLogout(); resetUser(); - // Revoke refresh token on server and clear all tokens from localStorage - await logout(); + // Revoke refresh token on server and clear all tokens from localStorage. + // logout() returns true when SSO redirect is in progress — don't overwrite. + const ssoRedirected = await logout(); - if (typeof window !== "undefined") { - router.push(getLoginPath()); + if (!ssoRedirected && typeof window !== "undefined") { + router.push("/"); } } catch (error) { console.error("Error during logout:", error); - await logout(); - router.push(getLoginPath()); + const ssoRedirected = await logout(); + if (!ssoRedirected) router.push("/"); } }, [router]); diff --git a/surfsense_web/docker-entrypoint.js b/surfsense_web/docker-entrypoint.js index 8323f5652..b71264550 100644 --- a/surfsense_web/docker-entrypoint.js +++ b/surfsense_web/docker-entrypoint.js @@ -27,6 +27,7 @@ const replacements = [ process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848", ], ["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"], + ["__NEXT_PUBLIC_OAUTH2_PROXY_URL__", process.env.NEXT_PUBLIC_OAUTH2_PROXY_URL || ""], ]; let filesProcessed = 0; diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index bc9e6c1d8..a0e2cda67 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -66,9 +66,11 @@ class BaseApiService { * ---------- */ const defaultOptions: RequestOptions = { - headers: { - Authorization: `Bearer ${this.bearerToken || ""}`, - }, + headers: this.bearerToken + ? { + Authorization: `Bearer ${this.bearerToken}`, + } + : {}, method: "GET", responseType: ResponseType.JSON, }; @@ -87,14 +89,13 @@ class BaseApiService { throw new AppError("Base URL is not set."); } - // Validate the bearer token + // Validate the bearer token — skip check when no token is present; + // the backend proxy-auth middleware handles authentication via + // the X-Auth-Request-Email header set by oauth2-proxy/Traefik. const isNoAuthEndpoint = this.noAuthEndpoints.includes(url) || this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); - if (!this.bearerToken && !isNoAuthEndpoint) { - throw new AuthenticationError("You are not authenticated. Please login again."); - } // Construct the full URL const fullUrl = new URL(url, this.baseUrl).toString(); diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index d66934c3b..10bd31bdf 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -41,9 +41,18 @@ export function getLoginPath(): string { } /** - * Clears tokens and optionally redirects to login. - * Call this when a 401 response is received. - * Only redirects when the current route is protected; on public routes we just clear tokens. + * Clears tokens and redirects through oauth2-proxy on 401. + * + * This fork is SSO-only (mPass/Cognito via oauth2-proxy ForwardAuth), so the + * only valid recovery from a 401 is bouncing the user through the OIDC flow + * at the dedicated auth subdomain. The previous /login redirect was a dead + * page in SSO mode and resulted in a blank screen. + * + * Moving this responsibility to the frontend lets the devstack repo drop + * its SurfSense-specific Traefik `mpass-signin@file` middleware and + * `auth-redirect.yml` dynamic config — those exist purely to convert API + * 401s into redirects at the network layer. With this in place the app + * handles its own 401s before Traefik ever needs to. */ export function handleUnauthorized(): void { if (typeof window === "undefined") return; @@ -54,15 +63,20 @@ export function handleUnauthorized(): void { localStorage.removeItem(BEARER_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); - // Only redirect on protected routes; stay on public pages (e.g. /docs) - if (!isPublicRoute(pathname)) { - const currentPath = pathname + window.location.search + window.location.hash; - const excludedPaths = ["/auth", "/auth/callback", "/"]; - if (!excludedPaths.includes(pathname)) { - localStorage.setItem(REDIRECT_PATH_KEY, currentPath); - } - window.location.href = getLoginPath(); + // Public routes (e.g. /docs) don't need auth — don't redirect, just clear. + if (isPublicRoute(pathname)) return; + + const currentPath = pathname + window.location.search + window.location.hash; + const excludedPaths = ["/auth", "/"]; + if (!excludedPaths.includes(pathname)) { + localStorage.setItem(REDIRECT_PATH_KEY, currentPath); } + + // Redirect through oauth2-proxy /oauth2/sign_in. The dedicated auth subdomain + // handles the OIDC dance with Cognito and returns the user to `rd=` on success. + const oauthProxyUrl = process.env.NEXT_PUBLIC_OAUTH2_PROXY_URL || window.location.origin; + const rd = window.location.href; + window.location.href = `${oauthProxyUrl}/oauth2/sign_in?rd=${encodeURIComponent(rd)}`; } /** @@ -176,36 +190,63 @@ export async function ensureTokensFromElectron(): Promise { } /** - * Logout the current user by revoking the refresh token and clearing localStorage. - * Returns true if logout was successful (or tokens were cleared), false otherwise. + * Reads the short-lived SSO handoff cookies set by /auth/jwt/proxy-login. + * Returns null for each if not present. + */ +export function getSSOCookieTokens(): { token: string | null; refreshToken: string | null } { + if (typeof document === "undefined") return { token: null, refreshToken: null }; + const get = (name: string): string | null => { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + }; + return { token: get("surfsense_sso_token"), refreshToken: get("surfsense_sso_refresh_token") }; +} + +/** + * Clears the SSO handoff cookies after tokens have been transferred to localStorage. + */ +export function clearSSOCookies(): void { + if (typeof document === "undefined") return; + const expire = "expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"; + document.cookie = `surfsense_sso_token=; ${expire}`; + document.cookie = `surfsense_sso_refresh_token=; ${expire}`; +} + +/** + * Logout the current user: revoke JWT refresh token server-side, clear local + * tokens, navigate to home. Returns true when the browser is already navigating + * so callers can skip their own redirect. */ export async function logout(): Promise { const refreshToken = getRefreshToken(); - // Call backend to revoke the refresh token if (refreshToken) { try { const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; const response = await fetch(`${backendUrl}/auth/jwt/revoke`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); - if (!response.ok) { console.warn("Failed to revoke refresh token:", response.status, await response.text()); } } catch (error) { console.warn("Failed to revoke refresh token on server:", error); - // Continue to clear local tokens even if server call fails } } - // Clear all tokens from localStorage clearAllTokens(); - return true; + + if (typeof window !== "undefined") { + // Strip the first subdomain so we land on the portal (outside ForwardAuth) + // instead of SurfSense's own root, which would silently re-auth. + const portalHost = window.location.hostname.replace(/^[^.]+\.(?=[^.]*\.[^.]*\.)/, ""); + window.location.href = `${window.location.protocol}//${portalHost}`; + return true; + } + + return false; } /** @@ -227,7 +268,7 @@ export function redirectToLogin(): void { const currentPath = window.location.pathname + window.location.search + window.location.hash; // Don't save auth-related paths or home page - const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"]; + const excludedPaths = ["/auth", "/", "/login", "/register"]; if (!excludedPaths.includes(window.location.pathname)) { localStorage.setItem(REDIRECT_PATH_KEY, currentPath); } diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 80db395c6..0bb9c9993 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -11,15 +11,18 @@ import packageJson from "../package.json"; -// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth +// Auth type: +// "LOCAL" — email/password login form +// "GOOGLE" — Google OAuth (native fastapi-users Google flow) +// "SSO" — Cognito/oauth2-proxy ForwardAuth (our devstack pattern) // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ -export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; +export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "SSO"; // Backend API URL // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__ export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; -// ETL Service: "DOCLING", "UNSTRUCTURED", or "LLAMACLOUD" +// ETL Service: "DOCLING" or "UNSTRUCTURED" // Placeholder: __NEXT_PUBLIC_ETL_SERVICE__ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; @@ -40,6 +43,9 @@ export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; // Helper to check if Google auth is enabled export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE"; +// Helper to check if SSO (Cognito/oauth2-proxy) auth is enabled +export const isSSOAuth = () => AUTH_TYPE === "SSO"; + // Helper to check if running in self-hosted mode export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted";