diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 7336fa9bd..3ad529671 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -113,6 +113,7 @@ jobs: env: HOSTED_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }} HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }} + GOOGLE_DESKTOP_CLIENT_ID: ${{ vars.GOOGLE_DESKTOP_CLIENT_ID }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} @@ -143,6 +144,7 @@ jobs: working-directory: surfsense_desktop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOGLE_DESKTOP_CLIENT_ID: ${{ vars.GOOGLE_DESKTOP_CLIENT_ID }} WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }} AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }} AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }} diff --git a/.gitignore b/.gitignore index d086673db..507709dca 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ surfsense_web/blob-report/ content_research/ automation-design-plan.md automation-frontend-builder-plan.md +surfsense_desktop/.env diff --git a/docker/.env.example b/docker/.env.example index 63308bc9e..d2f713492 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -30,6 +30,11 @@ SECRET_KEY=replace_me_with_a_random_string # Auth type: LOCAL (email/password) or GOOGLE (OAuth) AUTH_TYPE=LOCAL +# Cloud only: set COOKIE_DOMAIN=.surfsense.com so api., zero., and app +# subdomains all receive the same first-party session cookie. Leave empty for +# self-hosted Docker where Caddy serves a single origin. +# COOKIE_DOMAIN= + # Deployment mode: self-hosted enables local filesystem connectors; cloud hides them. DEPLOYMENT_MODE=self-hosted @@ -135,6 +140,19 @@ CERT_EMAIL= # ZERO_MUTATE_URL=https://surf.example.com/api/zero/mutate # ZERO_QUERY_URL=http://frontend:3000/api/zero/query # ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate +# +# Forward browser session cookies from zero-cache to the query route. Keep this +# enabled before switching the web app to cookie-only auth. +# ZERO_QUERY_FORWARD_COOKIES=true +# +# Optional shared secret for the zero-cache -> /api/zero/query hop. Set the same +# value on zero-cache and the frontend. When unset, the query route accepts the +# request for backward-compatible rollout. +# ZERO_QUERY_API_KEY= +# +# Bounds for auth revocation and RBAC membership changes on already-open sockets. +# ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=60 +# ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=60 # ------------------------------------------------------------------------------ # Database (defaults work out of the box, change for security) diff --git a/docker/docker-compose.deps-only.yml b/docker/docker-compose.deps-only.yml index ad4cc3127..e70e126bb 100644 --- a/docker/docker-compose.deps-only.yml +++ b/docker/docker-compose.deps-only.yml @@ -99,7 +99,7 @@ services: # container to run migrations, so you must run `uv run alembic upgrade head` # from `surfsense_backend/` on the host BEFORE `docker compose up -d`. zero-cache: - image: rocicorp/zero:1.4.0 + image: rocicorp/zero:1.6.0 ports: - "${ZERO_CACHE_PORT:-4848}:4848" extra_hosts: @@ -120,6 +120,10 @@ services: - ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30} - ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://host.docker.internal:3000/api/zero/query} - ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://host.docker.internal:3000/api/zero/mutate} + - ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true} + - ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-} + - ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60} + - ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60} volumes: - zero_cache_data:/data restart: unless-stopped diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5b86ea888..9660690ea 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -220,7 +220,7 @@ services: condition: service_started zero-cache: - image: rocicorp/zero:1.4.0 + image: rocicorp/zero:1.6.0 ports: - "${ZERO_CACHE_PORT:-4848}:4848" extra_hosts: @@ -243,6 +243,10 @@ services: - ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30} - ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query} - ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} + - ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true} + - ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-} + - ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60} + - ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60} volumes: - zero_cache_data:/data restart: unless-stopped diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1ee7ae0ed..3b47d6670 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -250,7 +250,7 @@ services: restart: unless-stopped zero-cache: - image: rocicorp/zero:1.4.0 + image: rocicorp/zero:1.6.0 expose: - "4848" extra_hosts: @@ -268,6 +268,10 @@ services: ZERO_CVR_MAX_CONNS: ${ZERO_CVR_MAX_CONNS:-30} ZERO_QUERY_URL: ${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query} ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} + ZERO_QUERY_FORWARD_COOKIES: ${ZERO_QUERY_FORWARD_COOKIES:-true} + ZERO_QUERY_API_KEY: ${ZERO_QUERY_API_KEY:-} + ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS: ${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60} + ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS: ${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60} volumes: - zero_cache_data:/data restart: unless-stopped diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 6a8f991e4..aee79c09f 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -81,9 +81,24 @@ STRIPE_RECONCILIATION_INTERVAL=10m SECRET_KEY=SECRET -# JWT Token Lifetimes (optional, defaults shown) -# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day -# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks +# JWT/session lifetimes (optional, defaults shown) +# ACCESS_TOKEN_LIFETIME_SECONDS=1800 # 30 minutes +# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 14-day inactivity window +# REFRESH_ROTATION_GRACE_SECONDS=45 +# REFRESH_ABSOLUTE_LIFETIME_SECONDS=2592000 # 30-day absolute cap +# +# Web session cookies. Leave COOKIE_DOMAIN empty for self-hosted same-origin +# Docker. In cloud, use .surfsense.com so api., zero., and the app share the +# first-party session cookie. +# SESSION_COOKIE_NAME=surfsense_session +# REFRESH_COOKIE_NAME=surfsense_refresh +# SESSION_COOKIE_SECURE_POLICY=auto +# SESSION_COOKIE_SAMESITE=lax +# COOKIE_DOMAIN= +# +# Comma-separated allow-list for cookie-session unsafe requests. Defaults also +# include NEXT_FRONTEND_URL and SURFSENSE_PUBLIC_URL when set. +# CSRF_ALLOWED_ORIGINS=http://localhost:3000 # Personal Access Tokens (PATs). Empty/unset = no maximum; users may create # never-expiring PATs. When set, PAT creation requires an expiry <= this many days. # PAT_MAX_EXPIRY_DAYS= @@ -115,6 +130,8 @@ REGISTRATION_ENABLED=TRUE or FALSE # For Google Auth Only GOOGLE_OAUTH_CLIENT_ID=924507538m GOOGLE_OAUTH_CLIENT_SECRET=GOCSV +GOOGLE_DESKTOP_CLIENT_ID=your_google_desktop_client_id +GOOGLE_DESKTOP_CLIENT_SECRET=your_google_desktop_client_secret GOOGLE_PICKER_API_KEY=your-google-picker-api-key # Google Connector Specific Configurations diff --git a/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py b/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py index b49b099a6..fc2526492 100644 --- a/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py +++ b/surfsense_backend/alembic/versions/166_add_pat_and_api_access.py @@ -77,7 +77,5 @@ def upgrade() -> None: def downgrade() -> None: - op.execute( - "ALTER TABLE searchspaces DROP COLUMN IF EXISTS api_access_enabled" - ) + op.execute("ALTER TABLE searchspaces DROP COLUMN IF EXISTS api_access_enabled") op.execute("DROP TABLE IF EXISTS personal_access_tokens") diff --git a/surfsense_backend/alembic/versions/167_publish_zero_authz_parent_tables.py b/surfsense_backend/alembic/versions/167_publish_zero_authz_parent_tables.py new file mode 100644 index 000000000..5137cac44 --- /dev/null +++ b/surfsense_backend/alembic/versions/167_publish_zero_authz_parent_tables.py @@ -0,0 +1,23 @@ +"""publish Zero authz parent tables + +Revision ID: 167 +Revises: 166 +""" + +from collections.abc import Sequence + +from alembic import op +from app.zero_publication import apply_publication + +revision: str = "167" +down_revision: str | None = "166" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + apply_publication(op.get_bind()) + + +def downgrade() -> None: + """No-op. Historical publication shapes are immutable.""" diff --git a/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py new file mode 100644 index 000000000..fc14c8d73 --- /dev/null +++ b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py @@ -0,0 +1,66 @@ +"""harden refresh token schema + +Revision ID: 168 +Revises: 167 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "168" +down_revision: str | None = "167" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "refresh_tokens", + sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + op.add_column( + "refresh_tokens", + sa.Column("absolute_expiry", sa.TIMESTAMP(timezone=True), nullable=True), + ) + op.execute( + """ + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE is_revoked = TRUE + """ + ) + op.alter_column( + "refresh_tokens", + "token_hash", + existing_type=sa.String(length=256), + type_=sa.String(length=64), + existing_nullable=False, + ) + op.drop_column("refresh_tokens", "is_revoked") + + +def downgrade() -> None: + op.add_column( + "refresh_tokens", + sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false"), + ) + op.execute( + """ + UPDATE refresh_tokens + SET is_revoked = TRUE + WHERE revoked_at IS NOT NULL + """ + ) + op.alter_column("refresh_tokens", "is_revoked", server_default=None) + op.alter_column( + "refresh_tokens", + "token_hash", + existing_type=sa.String(length=64), + type_=sa.String(length=256), + existing_nullable=False, + ) + op.drop_column("refresh_tokens", "absolute_expiry") + op.drop_column("refresh_tokens", "revoked_at") diff --git a/surfsense_backend/alembic/versions/169_migrate_google_oauth_account_ids_to_sub.py b/surfsense_backend/alembic/versions/169_migrate_google_oauth_account_ids_to_sub.py new file mode 100644 index 000000000..65e29c422 --- /dev/null +++ b/surfsense_backend/alembic/versions/169_migrate_google_oauth_account_ids_to_sub.py @@ -0,0 +1,74 @@ +"""migrate Google OAuth account IDs to sub + +Revision ID: 169 +Revises: 168 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "169" +down_revision: str | None = "168" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _oauth_account_table_exists() -> bool: + bind = op.get_bind() + return bool( + bind.execute( + sa.text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'oauth_account' + ) + """ + ) + ).scalar() + ) + + +def upgrade() -> None: + if not _oauth_account_table_exists(): + return + + op.execute( + """ + UPDATE oauth_account AS legacy + SET account_id = regexp_replace(legacy.account_id, '^people/', '') + WHERE legacy.oauth_name = 'google' + AND legacy.account_id LIKE 'people/%' + AND NOT EXISTS ( + SELECT 1 + FROM oauth_account AS canonical + WHERE canonical.oauth_name = 'google' + AND canonical.account_id = regexp_replace(legacy.account_id, '^people/', '') + ) + """ + ) + + +def downgrade() -> None: + if not _oauth_account_table_exists(): + return + + op.execute( + """ + UPDATE oauth_account AS canonical + SET account_id = 'people/' || canonical.account_id + WHERE canonical.oauth_name = 'google' + AND canonical.account_id NOT LIKE 'people/%' + AND NOT EXISTS ( + SELECT 1 + FROM oauth_account AS legacy + WHERE legacy.oauth_name = 'google' + AND legacy.account_id = 'people/' || canonical.account_id + ) + """ + ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py index fe42410ed..c1122b681 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py @@ -58,6 +58,7 @@ def create_create_automation_tool( ``AsyncSession`` is opened per call to avoid stale sessions on compiled-agent cache hits (same pattern as the Notion / memory tools). """ + @tool async def create_automation(intent: str, runtime: ToolRuntime) -> dict[str, Any]: """Draft + save an automation from a natural-language intent. diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py index ae7e33428..c481c6c3d 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py @@ -242,6 +242,7 @@ def create_generate_image_tool( # Update all image URLs in response_dict to be absolute (for the serving endpoint) from urllib.parse import urlparse + for image in images: if image.get("url"): raw_url: str = image["url"] diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index e6aa2fa3e..1c81c8c29 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -10,7 +10,7 @@ from datetime import UTC, datetime from threading import Lock import redis -from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -28,6 +28,7 @@ from app.agents.chat.runtime.checkpointer import ( setup_checkpointer_tables, ) from app.auth.context import AuthContext +from app.auth.csrf import CsrfOriginMiddleware from app.config import ( config, initialize_image_gen_router, @@ -53,8 +54,14 @@ from app.observability import metrics as ot_metrics from app.observability.bootstrap import init_otel, shutdown_otel from app.rate_limiter import get_real_client_ip, limiter 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.routes.auth_routes import ( + resolve_google_user, + router as auth_router, + session_router, +) +from app.routes.users_routes import router as users_router +from app.routes.zero_context_routes import router as zero_context_router +from app.schemas import UserCreate, UserRead from app.session_events import register_session_hooks from app.users import SECRET, allow_any_principal, auth_backend, fastapi_users from app.utils.perf import log_system_snapshot @@ -803,6 +810,7 @@ allowed_origins.extend( ] ) +app.add_middleware(CsrfOriginMiddleware) app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, @@ -855,16 +863,14 @@ if config.AUTH_TYPE != "GOOGLE": tags=["auth"], ) -# /users/me (read/update profile) is needed in every auth mode, so it stays -# mounted unconditionally. -app.include_router( - fastapi_users.get_users_router(UserRead, UserUpdate), - prefix="/users", - tags=["users"], -) +# /users/me uses the unified auth resolver so web cookie sessions, desktop bearer +# sessions, and PAT principals all resolve through the same authority. +app.include_router(users_router) # Include custom auth routes (refresh token, logout) app.include_router(auth_router) +app.include_router(session_router) +app.include_router(zero_context_router) if config.AUTH_TYPE == "GOOGLE": from fastapi.responses import RedirectResponse @@ -890,36 +896,183 @@ if config.AUTH_TYPE == "GOOGLE": 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 + from fastapi_users.jwt import decode_jwt + from fastapi_users.router.oauth import ( + CSRF_TOKEN_COOKIE_NAME, + CSRF_TOKEN_KEY, + STATE_TOKEN_AUDIENCE, + generate_state_token, + ) + from google.auth.transport import requests as google_requests + from google.oauth2 import id_token as google_id_token + + from app.users import get_user_manager + + def _google_callback_url(request: Request) -> str: + if config.BACKEND_URL: + return f"{config.BACKEND_URL}/auth/google/callback" + return str(request.url_for("google_oauth_callback")) + + def _set_google_oauth_csrf_cookie(response: Response, csrf_token: str) -> None: + response.set_cookie( + key=CSRF_TOKEN_COOKIE_NAME, + 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, ) - if not config.BACKEND_URL - else fastapi_users.get_oauth_router( - google_oauth_client, - auth_backend, + + async def _google_authorization_url(request: Request, response: Response) -> str: + import secrets + + csrf_token = secrets.token_urlsafe(32) + state = generate_state_token( + {CSRF_TOKEN_KEY: csrf_token}, 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", + lifetime_seconds=3600, + ) + authorization_url = await google_oauth_client.get_authorization_url( + _google_callback_url(request), + state, + scope=["openid", "email", "profile"], + ) + _set_google_oauth_csrf_cookie(response, csrf_token) + return authorization_url + + @app.get( + "/auth/google/authorize", tags=["auth"], - # REGISTRATION_ENABLED is a master auth kill switch: when set to FALSE - # it blocks BOTH new OAuth signups AND login of existing OAuth users - # (the fastapi-users OAuth router shares one callback for create+login, - # so this dependency closes both paths together). dependencies=[Depends(registration_allowed)], ) + async def google_authorize(request: Request, response: Response): + """Return Google's authorization URL, matching fastapi-users' shape.""" + return {"authorization_url": await _google_authorization_url(request, response)} + + @app.get( + "/auth/google/callback", + name="google_oauth_callback", + tags=["auth"], + dependencies=[Depends(registration_allowed)], + ) + async def google_oauth_callback( + request: Request, + user_manager=Depends(get_user_manager), + ): + """Handle web Google OAuth with the same verified-email policy as desktop.""" + import secrets + + import httpx + import jwt as pyjwt + + state = request.query_params.get("state") + code = request.query_params.get("code") + if not state or not code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OAuth callback missing code or state", + ) + + try: + state_data = decode_jwt(state, SECRET, [STATE_TOKEN_AUDIENCE]) + except pyjwt.DecodeError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ACCESS_TOKEN_DECODE_ERROR", + ) from exc + except pyjwt.ExpiredSignatureError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ACCESS_TOKEN_ALREADY_EXPIRED", + ) from exc + + cookie_csrf_token = request.cookies.get(CSRF_TOKEN_COOKIE_NAME) + state_csrf_token = state_data.get(CSRF_TOKEN_KEY) + if ( + not cookie_csrf_token + or not state_csrf_token + or not secrets.compare_digest(cookie_csrf_token, state_csrf_token) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OAUTH_INVALID_STATE", + ) + + token_payload = { + "client_id": config.GOOGLE_OAUTH_CLIENT_ID, + "client_secret": config.GOOGLE_OAUTH_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": _google_callback_url(request), + } + async with httpx.AsyncClient(timeout=10) as client: + token_response = await client.post( + "https://oauth2.googleapis.com/token", + data=token_payload, + ) + if token_response.status_code >= 400: + _error_logger.warning( + "Web Google OAuth exchange failed: %s", token_response.text + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="OAuth exchange failed", + ) + + token_data = token_response.json() + google_access_token = token_data.get("access_token") + google_id_token_value = token_data.get("id_token") + if not google_access_token or not google_id_token_value: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="OAuth exchange failed", + ) + + try: + claims = google_id_token.verify_oauth2_token( + google_id_token_value, + google_requests.Request(), + config.GOOGLE_OAUTH_CLIENT_ID, + ) + except Exception as exc: + _error_logger.warning("Web Google id_token verification failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google identity token", + ) from exc + + expires_at = ( + int(datetime.now(UTC).timestamp()) + int(token_data["expires_in"]) + if token_data.get("expires_in") + else None + ) + user = await resolve_google_user( + user_manager=user_manager, + request=request, + google_access_token=google_access_token, + claims=claims, + expires_at=expires_at, + google_refresh_token=token_data.get("refresh_token"), + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LOGIN_BAD_CREDENTIALS", + ) + + response = await auth_backend.login(auth_backend.get_strategy(), user) + await user_manager.on_after_login(user, request, response) + response.delete_cookie( + key=CSRF_TOKEN_COOKIE_NAME, + path="/", + domain=csrf_cookie_domain, + secure=is_secure_context, + samesite=csrf_cookie_samesite, + httponly=False, + ) + return response # Add a redirect-based authorize endpoint for Firefox/Safari compatibility # This endpoint performs a server-side redirect instead of returning JSON @@ -944,43 +1097,9 @@ if config.AUTH_TYPE == "GOOGLE": 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, - ) - + response = RedirectResponse(url="", status_code=302) + authorization_url = await _google_authorization_url(request, response) + response.headers["location"] = authorization_url return response diff --git a/surfsense_backend/app/auth/csrf.py b/surfsense_backend/app/auth/csrf.py new file mode 100644 index 000000000..4f1b6db4a --- /dev/null +++ b/surfsense_backend/app/auth/csrf.py @@ -0,0 +1,61 @@ +"""CSRF protection for ambient cookie-authenticated requests.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from fastapi import status +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from app.config import config + +UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} + + +def _origin_from_url(url: str | None) -> str | None: + if not url: + return None + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}" + + +def _allowed_origins() -> set[str]: + origins = set(config.CSRF_ALLOWED_ORIGINS) + for url in (config.NEXT_FRONTEND_URL, config.SURFSENSE_PUBLIC_URL): + origin = _origin_from_url(url) + if origin: + origins.add(origin) + return origins + + +class CsrfOriginMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + if request.method not in UNSAFE_METHODS: + return await call_next(request) + + # PAT/Bearer credentials are not ambient browser credentials and are not + # CSRF-able. Enforce only when the web session cookie is the credential. + if ( + request.headers.get("Authorization") + or config.SESSION_COOKIE_NAME not in request.cookies + ): + return await call_next(request) + + origin = request.headers.get("Origin") or _origin_from_url( + request.headers.get("Referer") + ) + if origin not in _allowed_origins(): + return JSONResponse( + {"detail": "CSRF origin check failed"}, + status_code=status.HTTP_403_FORBIDDEN, + ) + + return await call_next(request) diff --git a/surfsense_backend/app/auth/session_cookies.py b/surfsense_backend/app/auth/session_cookies.py new file mode 100644 index 000000000..835db0ac1 --- /dev/null +++ b/surfsense_backend/app/auth/session_cookies.py @@ -0,0 +1,130 @@ +"""Centralized session-cookie I/O for web authentication.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from enum import Enum +from typing import Any + +import jwt +from fastapi import Request, Response + +from app.config import config + + +class TransportMode(Enum): + COOKIE = "cookie" + HEADER = "header" + + +def _cookie_secure(request: Request | None = None) -> bool: + policy = config.SESSION_COOKIE_SECURE_POLICY + if policy == "always": + return True + if policy == "never": + return False + if request is not None: + proto = request.headers.get("x-forwarded-proto") + if proto: + return proto.split(",", 1)[0].strip().lower() == "https" + return request.url.scheme == "https" + return bool(config.BACKEND_URL and config.BACKEND_URL.startswith("https://")) + + +def _set_persistent_cookie( + response: Response, + *, + key: str, + value: str, + max_age: int, + request: Request | None, +) -> None: + expires = datetime.now(UTC) + timedelta(seconds=max_age) + response.set_cookie( + key=key, + value=value, + max_age=max_age, + expires=expires, + httponly=True, + secure=_cookie_secure(request), + samesite=config.SESSION_COOKIE_SAMESITE, + domain=config.COOKIE_DOMAIN, + path="/", + ) + + +def write_session( + response: Response, + access: str, + refresh: str | None = None, + request: Request | None = None, +) -> None: + _set_persistent_cookie( + response, + key=config.SESSION_COOKIE_NAME, + value=access, + max_age=config.ACCESS_TOKEN_LIFETIME_SECONDS, + request=request, + ) + if refresh is not None: + _set_persistent_cookie( + response, + key=config.REFRESH_COOKIE_NAME, + value=refresh, + max_age=config.REFRESH_TOKEN_LIFETIME_SECONDS, + request=request, + ) + + +def clear_session(response: Response, request: Request | None = None) -> None: + for key in (config.SESSION_COOKIE_NAME, config.REFRESH_COOKIE_NAME): + response.delete_cookie( + key=key, + path="/", + domain=config.COOKIE_DOMAIN, + secure=_cookie_secure(request), + samesite=config.SESSION_COOKIE_SAMESITE, + httponly=True, + ) + + +def read_refresh( + request: Request, body: Any | None = None +) -> tuple[str | None, TransportMode]: + cookie = request.cookies.get(config.REFRESH_COOKIE_NAME) + if cookie: + return cookie, TransportMode.COOKIE + if body is None: + return None, TransportMode.HEADER + return getattr(body, "refresh_token", None), TransportMode.HEADER + + +def access_expires_at(access_token: str) -> int: + payload = jwt.decode( + access_token, + config.SECRET_KEY, + algorithms=["HS256"], + options={"verify_aud": False}, + ) + return int(payload["exp"]) + + +def issue( + response: Response, + mode: TransportMode, + *, + access: str, + refresh: str | None, + access_expires_at: int, + request: Request | None = None, +) -> dict: + if mode is TransportMode.COOKIE: + write_session(response, access, refresh, request) + return {"authenticated": True, "access_expires_at": access_expires_at} + + return { + "access_token": access, + "refresh_token": refresh, + "token_type": "bearer", + "access_expires_at": access_expires_at, + } diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py index 261d41bfc..ed748fb7c 100644 --- a/surfsense_backend/app/automations/services/automation.py +++ b/surfsense_backend/app/automations/services/automation.py @@ -10,6 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.auth.context import AuthContext from app.automations.persistence.enums.trigger_type import TriggerType from app.automations.persistence.models.automation import Automation from app.automations.persistence.models.trigger import AutomationTrigger @@ -27,7 +28,6 @@ from app.automations.services.model_policy import ( ) from app.automations.triggers import get_trigger from app.automations.triggers.builtin.schedule import compute_next_fire_at -from app.auth.context import AuthContext from app.db import Permission, SearchSpace, get_async_session from app.users import get_auth_context from app.utils.rbac import check_permission diff --git a/surfsense_backend/app/automations/services/run.py b/surfsense_backend/app/automations/services/run.py index 8ef763e5e..9bcd1393e 100644 --- a/surfsense_backend/app/automations/services/run.py +++ b/surfsense_backend/app/automations/services/run.py @@ -6,9 +6,9 @@ from fastapi import Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.context import AuthContext from app.automations.persistence.models.automation import Automation from app.automations.persistence.models.run import AutomationRun -from app.auth.context import AuthContext from app.db import Permission, get_async_session from app.users import get_auth_context from app.utils.rbac import check_permission diff --git a/surfsense_backend/app/automations/services/trigger.py b/surfsense_backend/app/automations/services/trigger.py index 7ff6e56fa..52c827c67 100644 --- a/surfsense_backend/app/automations/services/trigger.py +++ b/surfsense_backend/app/automations/services/trigger.py @@ -8,13 +8,13 @@ from fastapi import Depends, HTTPException from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.context import AuthContext from app.automations.persistence.enums.trigger_type import TriggerType from app.automations.persistence.models.automation import Automation from app.automations.persistence.models.trigger import AutomationTrigger from app.automations.schemas.api import TriggerCreate, TriggerUpdate from app.automations.triggers import get_trigger from app.automations.triggers.builtin.schedule import compute_next_fire_at -from app.auth.context import AuthContext from app.db import Permission, get_async_session from app.users import get_auth_context from app.utils.rbac import check_permission diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py index 704c9cf9b..331ed0f40 100644 --- a/surfsense_backend/app/celery_app.py +++ b/surfsense_backend/app/celery_app.py @@ -188,6 +188,7 @@ celery_app = Celery( "app.tasks.celery_tasks.document_reindex_tasks", "app.tasks.celery_tasks.stale_notification_cleanup_task", "app.tasks.celery_tasks.stripe_reconciliation_task", + "app.tasks.celery_tasks.refresh_token_cleanup_task", "app.tasks.celery_tasks.auto_reload_task", "app.tasks.celery_tasks.gateway_tasks", "app.etl_pipeline.cache.eviction.task", @@ -306,6 +307,11 @@ celery_app.conf.beat_schedule = { "schedule": crontab(hour="3", minute="17"), "options": {"expires": 600}, }, + "purge-refresh-tokens": { + "task": "purge_refresh_tokens", + "schedule": crontab(hour="3", minute="41"), + "options": {"expires": 600}, + }, # Prune the ETL parse cache (TTL + size budget) once daily, off-peak. "evict-etl-cache": { "task": "evict_etl_cache", diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index b998f05cf..47e529741 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -768,6 +768,8 @@ class Config: # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + GOOGLE_DESKTOP_CLIENT_ID = os.getenv("GOOGLE_DESKTOP_CLIENT_ID") + GOOGLE_DESKTOP_CLIENT_SECRET = os.getenv("GOOGLE_DESKTOP_CLIENT_SECRET") GOOGLE_PICKER_API_KEY = os.getenv("GOOGLE_PICKER_API_KEY") # Google Calendar redirect URI @@ -914,15 +916,39 @@ class Config: # JWT Token Lifetimes ACCESS_TOKEN_LIFETIME_SECONDS = int( - os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day + os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(30 * 60)) # 30 minutes ) + MIN_ISSUED_AT = int(os.getenv("MIN_ISSUED_AT", "0")) REFRESH_TOKEN_LIFETIME_SECONDS = int( os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks ) - _PAT_MAX_EXPIRY_DAYS = os.getenv("PAT_MAX_EXPIRY_DAYS", "").strip() - PAT_MAX_EXPIRY_DAYS = ( - int(_PAT_MAX_EXPIRY_DAYS) if _PAT_MAX_EXPIRY_DAYS else None + REFRESH_ROTATION_GRACE_SECONDS = int( + os.getenv("REFRESH_ROTATION_GRACE_SECONDS", "45") ) + REFRESH_ABSOLUTE_LIFETIME_SECONDS = int( + os.getenv("REFRESH_ABSOLUTE_LIFETIME_SECONDS", str(30 * 24 * 60 * 60)) + ) + if REFRESH_ABSOLUTE_LIFETIME_SECONDS <= REFRESH_TOKEN_LIFETIME_SECONDS: + raise ValueError( + "REFRESH_ABSOLUTE_LIFETIME_SECONDS must be greater than " + "REFRESH_TOKEN_LIFETIME_SECONDS so the sliding inactivity window works." + ) + SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "surfsense_session") + REFRESH_COOKIE_NAME = os.getenv("REFRESH_COOKIE_NAME", "surfsense_refresh") + SESSION_COOKIE_SECURE_POLICY = os.getenv( + "SESSION_COOKIE_SECURE_POLICY", "auto" + ).lower() + SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower() + if SESSION_COOKIE_SAMESITE == "none": + raise ValueError("SESSION_COOKIE_SAMESITE=none is not supported") + COOKIE_DOMAIN = os.getenv("COOKIE_DOMAIN") or None + CSRF_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv("CSRF_ALLOWED_ORIGINS", "").split(",") + if origin.strip() + ] + _PAT_MAX_EXPIRY_DAYS = os.getenv("PAT_MAX_EXPIRY_DAYS", "").strip() + PAT_MAX_EXPIRY_DAYS = int(_PAT_MAX_EXPIRY_DAYS) if _PAT_MAX_EXPIRY_DAYS else None # ETL Service ETL_SERVICE = os.getenv("ETL_SERVICE") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index a65a964fd..2c9d28b58 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -2714,9 +2714,10 @@ class RefreshToken(Base, TimestampMixin): index=True, ) user = relationship("User", back_populates="refresh_tokens") - token_hash = Column(String(256), unique=True, nullable=False, index=True) + token_hash = Column(String(64), unique=True, nullable=False, index=True) expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True) - is_revoked = Column(Boolean, default=False, nullable=False) + revoked_at = Column(TIMESTAMP(timezone=True), nullable=True) + absolute_expiry = Column(TIMESTAMP(timezone=True), nullable=True) family_id = Column(UUID(as_uuid=True), nullable=False, index=True) @property @@ -2725,7 +2726,7 @@ class RefreshToken(Base, TimestampMixin): @property def is_valid(self) -> bool: - return not self.is_expired and not self.is_revoked + return not self.is_expired and self.revoked_at is None class PersonalAccessToken(BaseModel, TimestampMixin): diff --git a/surfsense_backend/app/routes/agent_action_log_route.py b/surfsense_backend/app/routes/agent_action_log_route.py index bf94ae3b4..72086b8ae 100644 --- a/surfsense_backend/app/routes/agent_action_log_route.py +++ b/surfsense_backend/app/routes/agent_action_log_route.py @@ -28,13 +28,12 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.context import AuthContext from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags +from app.auth.context import AuthContext from app.db import ( AgentActionLog, NewChatThread, Permission, - User, get_async_session, ) from app.users import get_auth_context @@ -114,7 +113,6 @@ async def list_thread_actions( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ) -> AgentActionListResponse: - user = auth.user """List agent actions for a thread, newest first. Authorization: diff --git a/surfsense_backend/app/routes/agent_permissions_route.py b/surfsense_backend/app/routes/agent_permissions_route.py index 521adfb03..1eb8b1a37 100644 --- a/surfsense_backend/app/routes/agent_permissions_route.py +++ b/surfsense_backend/app/routes/agent_permissions_route.py @@ -30,14 +30,13 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.context import AuthContext from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags +from app.auth.context import AuthContext from app.db import ( AgentPermissionRule, NewChatThread, Permission, SearchSpace, - User, get_async_session, ) from app.users import get_auth_context @@ -136,7 +135,6 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead: async def _ensure_search_space_membership_admin( session: AsyncSession, auth: AuthContext, search_space_id: int ) -> None: - user = auth.user """Curating agent rules == "settings" administration on the space.""" space = await session.get(SearchSpace, search_space_id) if space is None: diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py index be1506a9f..5674f4d12 100644 --- a/surfsense_backend/app/routes/auth_routes.py +++ b/surfsense_backend/app/routes/auth_routes.py @@ -1,21 +1,46 @@ """Authentication routes for refresh token management.""" import logging +from datetime import UTC, datetime +from types import SimpleNamespace +from urllib.parse import urlparse -from fastapi import APIRouter, Depends, HTTPException, status +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi_users import exceptions as fastapi_users_exceptions +from google.auth.transport import requests as google_requests +from google.oauth2 import id_token as google_id_token from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext -from app.db import User, async_session_maker +from app.auth.session_cookies import ( + access_expires_at, + clear_session, + issue, + read_refresh, +) +from app.config import config +from app.db import User, async_session_maker, get_async_session +from app.rate_limiter import limiter from app.schemas.auth import ( + DesktopLoginRequest, + DesktopSessionRequest, LogoutAllResponse, LogoutRequest, LogoutResponse, RefreshTokenRequest, RefreshTokenResponse, + SessionResponse, +) +from app.users import ( + UserManager, + get_auth_context, + get_jwt_strategy, + get_user_manager, ) -from app.users import get_jwt_strategy, require_session_context from app.utils.refresh_tokens import ( + create_refresh_token, revoke_all_user_tokens, revoke_refresh_token, rotate_refresh_token, @@ -25,57 +50,140 @@ from app.utils.refresh_tokens import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth/jwt", tags=["auth"]) +session_router = APIRouter(prefix="/auth", tags=["auth"]) -@router.post("/refresh", response_model=RefreshTokenResponse) -async def refresh_access_token(request: RefreshTokenRequest): +async def _load_user(user_id) -> User | None: + async with async_session_maker() as session: + result = await session.execute(select(User).where(User.id == user_id)) + return result.scalars().first() + + +async def resolve_google_user( + *, + user_manager: UserManager, + request: Request, + google_access_token: str, + claims: dict, + expires_at: int | None = None, + google_refresh_token: str | None = None, +) -> User: + """Resolve a Google identity with one policy for web and desktop OAuth. + + Email-based account linking is only allowed when Google asserts that the + email is verified. Existing OAuth accounts continue to resolve by provider + account id regardless of the current email claim. + """ + if not claims.get("sub") or not claims.get("email"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google identity token", + ) + + sub = claims["sub"] + email_verified = bool(claims.get("email_verified")) + + canonical_user = await user_manager.user_db.get_by_oauth_account("google", sub) + if canonical_user is None: + legacy_account_id = f"people/{sub}" + legacy_user = await user_manager.user_db.get_by_oauth_account( + "google", legacy_account_id + ) + if legacy_user is not None: + # Fallback for pre-sub Google OAuth rows created by the old web flow. + # TODO: Remove after oauth_account is fully backfilled to bare Google + # sub and production has zero google rows with account_id LIKE 'people/%'. + for oauth_account in legacy_user.oauth_accounts: + if ( + oauth_account.oauth_name == "google" + and oauth_account.account_id == legacy_account_id + ): + await user_manager.user_db.update_oauth_account( + legacy_user, + oauth_account, + {"account_id": sub}, + ) + break + + try: + return await user_manager.oauth_callback( + "google", + google_access_token, + sub, + claims["email"], + expires_at=expires_at, + refresh_token=google_refresh_token, + request=request, + associate_by_email=email_verified, + is_verified_by_default=email_verified, + ) + except fastapi_users_exceptions.UserAlreadyExists as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OAUTH_USER_ALREADY_EXISTS", + ) from exc + + +@router.post("/refresh", response_model=None) +@limiter.limit("30/minute") +async def refresh_access_token( + request: Request, + response: Response, + body: RefreshTokenRequest | None = None, +): """ Exchange a valid refresh token for a new access token and refresh token. Implements token rotation for security. """ - token_record = await validate_refresh_token(request.refresh_token) - - if not token_record: + refresh_token, mode = read_refresh(request, body) + if not refresh_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", ) - # Get user from token record - async with async_session_maker() as session: - result = await session.execute( - select(User).where(User.id == token_record.user_id) + rotation = await rotate_refresh_token(refresh_token) + if not rotation: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", ) - user = result.scalars().first() + user = await _load_user(rotation.user_id) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", ) - # Generate new access token strategy = get_jwt_strategy() access_token = await strategy.write_token(user) - # Rotate refresh token - new_refresh_token = await rotate_refresh_token(token_record) - logger.info(f"Refreshed token for user {user.id}") - return RefreshTokenResponse( - access_token=access_token, - refresh_token=new_refresh_token, + return issue( + response, + mode, + access=access_token, + refresh=rotation.refresh_token, + access_expires_at=access_expires_at(access_token), + request=request, ) @router.post("/revoke", response_model=LogoutResponse) -async def revoke_token(request: LogoutRequest): +async def revoke_token( + request: Request, + response: Response, + body: LogoutRequest | None = None, +): """ Logout current device by revoking the provided refresh token. Does not require authentication - just the refresh token. """ - revoked = await revoke_refresh_token(request.refresh_token) + refresh_token, _mode = read_refresh(request, body) + revoked = await revoke_refresh_token(refresh_token) if refresh_token else False + clear_session(response, request) if revoked: logger.info("User logged out from current device - token revoked") else: @@ -85,13 +193,185 @@ async def revoke_token(request: LogoutRequest): @router.post("/logout-all", response_model=LogoutAllResponse) async def logout_all_devices( - auth: AuthContext = Depends(require_session_context), + request: Request, + response: Response, + body: LogoutRequest | None = None, + session: AsyncSession = Depends(get_async_session), + user_manager: UserManager = Depends(get_user_manager), ): """ Logout from all devices by revoking all refresh tokens for the user. Requires valid access token. """ - user = auth.user + user: User | None = None + try: + auth = await get_auth_context( + request, session=session, user_manager=user_manager + ) + if auth.is_session: + user = auth.user + except HTTPException: + user = None + + if user is None: + refresh_token, _mode = read_refresh(request, body) + token_record = ( + await validate_refresh_token(refresh_token) if refresh_token else None + ) + if token_record: + user = await _load_user(token_record.user_id) + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + await revoke_all_user_tokens(user.id) + clear_session(response, request) logger.info(f"User {user.id} logged out from all devices") return LogoutAllResponse() + + +@session_router.get("/session", response_model=SessionResponse) +async def get_session( + request: Request, + auth: AuthContext = Depends(get_auth_context), +): + if auth.method == "pat": + return SessionResponse(access_expires_at=None) + + access_token = request.cookies.get(config.SESSION_COOKIE_NAME) + if access_token is None: + auth_header = request.headers.get("Authorization") + if auth_header: + scheme, _, token = auth_header.partition(" ") + if scheme.lower() == "bearer" and token: + access_token = token + + if access_token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized" + ) + return SessionResponse(access_expires_at=access_expires_at(access_token)) + + +@session_router.post("/desktop/login", response_model=RefreshTokenResponse) +@limiter.limit("5/minute") +async def desktop_password_login( + request: Request, + body: DesktopLoginRequest, + user_manager: UserManager = Depends(get_user_manager), +): + if config.AUTH_TYPE == "GOOGLE": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + if not config.REGISTRATION_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Registration is disabled", + ) + + credentials = SimpleNamespace(username=body.email, password=body.password) + user = await user_manager.authenticate(credentials) + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LOGIN_BAD_CREDENTIALS", + ) + + app_access_token = await get_jwt_strategy().write_token(user) + app_refresh_token = await create_refresh_token(user.id) + await user_manager.on_after_login(user, request, None) + return RefreshTokenResponse( + access_token=app_access_token, + refresh_token=app_refresh_token, + access_expires_at=access_expires_at(app_access_token), + ) + + +@session_router.post("/desktop/session", response_model=RefreshTokenResponse) +@limiter.limit("20/minute") +async def create_desktop_session( + request: Request, + body: DesktopSessionRequest, + user_manager: UserManager = Depends(get_user_manager), +): + parsed_redirect = urlparse(body.redirect_uri) + try: + redirect_port = parsed_redirect.port + except ValueError: + redirect_port = None + if not ( + parsed_redirect.scheme == "http" + and parsed_redirect.hostname in {"127.0.0.1", "::1"} + and redirect_port is not None + and parsed_redirect.path == "/callback" + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid redirect URI" + ) + if not config.GOOGLE_DESKTOP_CLIENT_ID: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Desktop OAuth is not configured", + ) + + token_payload = { + "client_id": config.GOOGLE_DESKTOP_CLIENT_ID, + "code": body.code, + "code_verifier": body.code_verifier, + "grant_type": "authorization_code", + "redirect_uri": body.redirect_uri, + } + if config.GOOGLE_DESKTOP_CLIENT_SECRET: + token_payload["client_secret"] = config.GOOGLE_DESKTOP_CLIENT_SECRET + + async with httpx.AsyncClient(timeout=10) as client: + token_response = await client.post( + "https://oauth2.googleapis.com/token", data=token_payload + ) + if token_response.status_code >= 400: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="OAuth exchange failed" + ) + token_data = token_response.json() + + id_token = token_data.get("id_token") + access_token = token_data.get("access_token") + if not id_token or not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="OAuth exchange failed" + ) + + try: + claims = google_id_token.verify_oauth2_token( + id_token, + google_requests.Request(), + config.GOOGLE_DESKTOP_CLIENT_ID, + ) + except Exception as exc: + logger.warning("Desktop Google id_token verification failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google identity token", + ) from exc + + user = await resolve_google_user( + user_manager=user_manager, + request=request, + google_access_token=access_token, + claims=claims, + expires_at=( + int(datetime.now(UTC).timestamp()) + int(token_data["expires_in"]) + if token_data.get("expires_in") + else None + ), + google_refresh_token=token_data.get("refresh_token"), + ) + app_access_token = await get_jwt_strategy().write_token(user) + app_refresh_token = await create_refresh_token(user.id) + return RefreshTokenResponse( + access_token=app_access_token, + refresh_token=app_refresh_token, + access_expires_at=access_expires_at(app_access_token), + ) diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index 9d908f4a1..accf3b18f 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.auth.context import AuthContext from app.agents.chat.runtime.path_resolver import virtual_path_to_doc +from app.auth.context import AuthContext from app.db import ( Chunk, Document, @@ -18,7 +18,6 @@ from app.db import ( Permission, SearchSpace, SearchSpaceMembership, - User, get_async_session, ) from app.schemas import ( @@ -684,7 +683,6 @@ async def search_document_titles( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Lightweight document title search optimized for mention picker (@mentions). @@ -789,7 +787,6 @@ async def get_document_by_virtual_path( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Resolve a knowledge-base document by its agent-facing virtual path. The agent renders every document under ``/documents/...`` with a @@ -847,7 +844,6 @@ async def get_documents_status( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Batch status endpoint for documents in a search space. @@ -1071,7 +1067,6 @@ async def get_watched_folders( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Return root folders that are marked as watched (metadata->>'watched' = 'true').""" await check_permission( session, @@ -1113,7 +1108,6 @@ async def get_document_chunks_paginated( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Paginated chunk loading for a document. Supports both page-based and offset-based access. @@ -1175,7 +1169,6 @@ async def read_document( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific document by ID. Requires DOCUMENTS_READ permission for the search space. @@ -1230,7 +1223,6 @@ async def update_document( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update a document. Requires DOCUMENTS_UPDATE permission for the search space. @@ -1290,7 +1282,6 @@ async def delete_document( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a document. Requires DOCUMENTS_DELETE permission for the search space. @@ -1536,7 +1527,6 @@ async def folder_mtime_check( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Pre-upload optimization: check which files need uploading based on mtime. Returns the subset of relative paths where the file is new or has a @@ -1754,7 +1744,6 @@ async def folder_unlink( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Handle file deletion events from the desktop watcher. For each relative path, find the matching document and delete it. @@ -1809,7 +1798,6 @@ async def folder_sync_finalize( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Finalize a full folder scan by deleting orphaned documents. The client sends the complete list of relative paths currently in the diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index fe00995ea..0bc1dd45f 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -19,7 +19,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext -from app.db import Chunk, Document, DocumentType, Permission, User, get_async_session +from app.db import Chunk, Document, DocumentType, Permission, get_async_session from app.routes.reports_routes import ( _FILE_EXTENSIONS, _MEDIA_TYPES, @@ -50,7 +50,6 @@ async def get_editor_content( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get document content for editing. @@ -182,7 +181,6 @@ async def download_document_markdown( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Download the full document content as a .md file. Reconstructs markdown from source_markdown or chunks. @@ -337,7 +335,6 @@ async def export_document( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Export a document in the requested format (reuses the report export pipeline).""" await check_permission( session, diff --git a/surfsense_backend/app/routes/export_routes.py b/surfsense_backend/app/routes/export_routes.py index 8e419157f..70df33b2e 100644 --- a/surfsense_backend/app/routes/export_routes.py +++ b/surfsense_backend/app/routes/export_routes.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext -from app.db import Permission, User, get_async_session +from app.db import Permission, get_async_session from app.services.export_service import build_export_zip from app.users import get_auth_context from app.utils.rbac import check_permission @@ -27,7 +27,6 @@ async def export_knowledge_base( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Export documents as a ZIP of markdown files preserving folder structure.""" await check_permission( session, diff --git a/surfsense_backend/app/routes/folders_routes.py b/surfsense_backend/app/routes/folders_routes.py index 8a5dfcb73..1da0c9b0e 100644 --- a/surfsense_backend/app/routes/folders_routes.py +++ b/surfsense_backend/app/routes/folders_routes.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.auth.context import AuthContext -from app.db import Document, Folder, Permission, User, get_async_session +from app.db import Document, Folder, Permission, get_async_session from app.schemas import ( BulkDocumentMove, DocumentMove, @@ -95,7 +95,6 @@ async def list_folders( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """List all folders in a search space (flat). Requires DOCUMENTS_READ permission.""" try: await check_permission( @@ -127,7 +126,6 @@ async def get_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Get a single folder. Requires DOCUMENTS_READ permission.""" try: folder = await session.get(Folder, folder_id) @@ -158,7 +156,6 @@ async def get_folder_breadcrumb( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Get ancestor chain for breadcrumb display. Requires DOCUMENTS_READ permission.""" try: folder = await session.get(Folder, folder_id) @@ -203,7 +200,6 @@ async def stop_watching_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Clear the watched flag from a folder's metadata.""" folder = await session.get(Folder, folder_id) if not folder: @@ -232,7 +228,6 @@ async def update_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Rename a folder. Requires DOCUMENTS_UPDATE permission.""" try: folder = await session.get(Folder, folder_id) @@ -273,7 +268,6 @@ async def move_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Move a folder to a new parent. Requires DOCUMENTS_UPDATE permission.""" try: folder = await session.get(Folder, folder_id) @@ -334,7 +328,6 @@ async def reorder_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Reorder a folder among its siblings via fractional indexing. Requires DOCUMENTS_UPDATE.""" try: folder = await session.get(Folder, folder_id) @@ -376,7 +369,6 @@ async def delete_folder( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Mark documents for deletion and dispatch Celery to delete docs first, then folders.""" try: folder = await session.get(Folder, folder_id) @@ -451,7 +443,6 @@ async def move_document( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Move a document to a folder (or root). Requires DOCUMENTS_UPDATE permission.""" try: result = await session.execute( @@ -498,7 +489,6 @@ async def bulk_move_documents( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Move multiple documents to a folder (or root). Requires DOCUMENTS_UPDATE permission.""" try: if not request.document_ids: diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py index 0d05f4baf..931794059 100644 --- a/surfsense_backend/app/routes/gateway_webhook_routes.py +++ b/surfsense_backend/app/routes/gateway_webhook_routes.py @@ -30,7 +30,6 @@ from app.db import ( ExternalChatHealthStatus, ExternalChatPeerKind, ExternalChatPlatform, - User, get_async_session, ) from app.gateway.accounts import ( @@ -979,7 +978,6 @@ async def list_platforms( async def get_gateway_config( auth: AuthContext = Depends(get_auth_context), ) -> dict[str, bool | str]: - user = auth.user if not config.GATEWAY_ENABLED: return { "enabled": False, diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py index 370b1cc8d..95c8fe12b 100644 --- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py +++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py @@ -101,7 +101,6 @@ async def request_pairing_code( async def bridge_health( auth: AuthContext = Depends(get_auth_context), ) -> dict[str, Any]: - user = auth.user _ensure_baileys_enabled() adapter = WhatsAppBaileysAdapter() try: diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py index 9376c8f0f..96cb3825c 100644 --- a/surfsense_backend/app/routes/image_generation_routes.py +++ b/surfsense_backend/app/routes/image_generation_routes.py @@ -24,7 +24,6 @@ from app.db import ( Permission, SearchSpace, SearchSpaceMembership, - User, get_async_session, ) from app.schemas import ( @@ -224,6 +223,7 @@ async def _execute_image_generation( # Fix relative URLs in response data (for the serving endpoint) from urllib.parse import urlparse + images = response_dict.get("data", []) provider_base_url = resolved_kwargs.get("api_base") for image in images: @@ -422,7 +422,6 @@ async def get_image_generation( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Get a specific image generation by ID.""" try: result = await session.execute( @@ -455,7 +454,6 @@ async def delete_image_generation( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Delete an image generation record.""" try: result = await session.execute( diff --git a/surfsense_backend/app/routes/logs_routes.py b/surfsense_backend/app/routes/logs_routes.py index 16400ef0b..28c3e4fd1 100644 --- a/surfsense_backend/app/routes/logs_routes.py +++ b/surfsense_backend/app/routes/logs_routes.py @@ -13,7 +13,6 @@ from app.db import ( Permission, SearchSpace, SearchSpaceMembership, - User, get_async_session, ) from app.schemas import LogCreate, LogRead, LogUpdate @@ -29,7 +28,6 @@ async def create_log( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Create a new log entry. Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated). @@ -141,7 +139,6 @@ async def read_log( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific log by ID. Requires LOGS_READ permission for the search space. @@ -178,7 +175,6 @@ async def update_log( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update a log entry. Requires LOGS_READ permission (logs are typically updated by system). @@ -222,7 +218,6 @@ async def delete_log( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a log entry. Requires LOGS_DELETE permission for the search space. @@ -262,7 +257,6 @@ async def get_logs_summary( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a summary of logs for a search space in the last X hours. Requires LOGS_READ permission for the search space. diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py index d75e1de79..84e9b830d 100644 --- a/surfsense_backend/app/routes/model_connections_routes.py +++ b/surfsense_backend/app/routes/model_connections_routes.py @@ -325,7 +325,9 @@ async def _assert_connection_access( @router.get("/global-llm-config-status") -async def global_llm_config_status(auth: AuthContext = Depends(require_session_context)): +async def global_llm_config_status( + auth: AuthContext = Depends(require_session_context), +): del auth return {"exists": config.GLOBAL_LLM_CONFIG_FILE_EXISTS} diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py index e5cca8700..eb3c66b5f 100644 --- a/surfsense_backend/app/routes/notes_routes.py +++ b/surfsense_backend/app/routes/notes_routes.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext -from app.db import Document, DocumentType, Permission, User, get_async_session +from app.db import Document, DocumentType, Permission, get_async_session from app.schemas import DocumentRead, PaginatedResponse from app.users import get_auth_context from app.utils.rbac import check_permission @@ -102,7 +102,6 @@ async def list_notes( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all notes in a search space. @@ -196,7 +195,6 @@ async def delete_note( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a note. diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 3d50d589d..e1122b2bb 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -125,7 +125,6 @@ PERMISSION_DESCRIPTIONS = { async def list_all_permissions( auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all available permissions that can be assigned to roles. """ @@ -162,7 +161,6 @@ async def create_role( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Create a new custom role in a search space. Requires ROLES_CREATE permission. @@ -244,7 +242,6 @@ async def list_roles( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all roles in a search space. Requires ROLES_READ permission. @@ -283,7 +280,6 @@ async def get_role( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific role by ID. Requires ROLES_READ permission. @@ -329,7 +325,6 @@ async def update_role( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update a role. Requires ROLES_UPDATE permission. @@ -427,7 +422,6 @@ async def delete_role( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a custom role. Requires ROLES_DELETE permission. @@ -485,7 +479,6 @@ async def list_members( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all members of a search space. Requires MEMBERS_VIEW permission. @@ -551,7 +544,6 @@ async def update_member_role( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update a member's role. Requires MEMBERS_MANAGE_ROLES permission. @@ -689,7 +681,6 @@ async def remove_member( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Remove a member from a search space. Requires MEMBERS_REMOVE permission. @@ -814,7 +805,6 @@ async def list_invites( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all invites for a search space. Requires MEMBERS_INVITE permission. @@ -854,7 +844,6 @@ async def update_invite( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update an invite. Requires MEMBERS_INVITE permission. @@ -921,7 +910,6 @@ async def revoke_invite( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Revoke (delete) an invite. Requires MEMBERS_INVITE permission. diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index d5996485e..bdcf8a874 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -33,7 +33,6 @@ from app.db import ( Report, SearchSpace, SearchSpaceMembership, - User, get_async_session, ) from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead @@ -161,7 +160,6 @@ async def _get_report_with_access( session: AsyncSession, auth: AuthContext, ) -> Report: - user = auth.user """Fetch a report and verify the user belongs to its search space. Raises HTTPException(404) if not found, HTTPException(403) if no access. diff --git a/surfsense_backend/app/routes/sandbox_routes.py b/surfsense_backend/app/routes/sandbox_routes.py index e7974b993..c04abe9ee 100644 --- a/surfsense_backend/app/routes/sandbox_routes.py +++ b/surfsense_backend/app/routes/sandbox_routes.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.auth.context import AuthContext -from app.db import NewChatThread, Permission, User, get_async_session +from app.db import NewChatThread, Permission, get_async_session from app.users import get_auth_context from app.utils.rbac import check_permission @@ -50,7 +50,6 @@ async def download_sandbox_file( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Download a file from the Daytona sandbox associated with a chat thread.""" from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.sandbox import ( diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index fab79ab49..718b4b907 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -40,7 +40,6 @@ from app.db import ( Permission, SearchSourceConnector, SearchSourceConnectorType, - User, async_session_maker, get_async_session, ) @@ -286,7 +285,6 @@ async def read_search_source_connectors( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all search source connectors for a search space. Requires CONNECTORS_READ permission. @@ -330,7 +328,6 @@ async def read_search_source_connector( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific search source connector by ID. Requires CONNECTORS_READ permission. @@ -565,7 +562,6 @@ async def delete_search_source_connector( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a search source connector and all its associated documents. @@ -2735,7 +2731,6 @@ async def list_mcp_connectors( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ List all MCP connectors for a search space. @@ -2787,7 +2782,6 @@ async def get_mcp_connector( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific MCP connector by ID. @@ -2841,7 +2835,6 @@ async def update_mcp_connector( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update an MCP connector. @@ -2918,7 +2911,6 @@ async def delete_mcp_connector( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete an MCP connector. @@ -2977,7 +2969,6 @@ async def test_mcp_server_connection( server_config: dict = Body(...), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Test connection to an MCP server and fetch available tools. @@ -3058,7 +3049,6 @@ async def get_drive_picker_token( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """Return an OAuth access token + client ID for the Google Picker API.""" result = await session.execute( select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id) diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index e92f7dfc1..6eebaf201 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -279,7 +279,9 @@ async def update_search_space( ) from e -@router.put("/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead) +@router.put( + "/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead +) async def update_search_space_api_access( search_space_id: int, body: SearchSpaceApiAccessUpdate, diff --git a/surfsense_backend/app/routes/team_memory_routes.py b/surfsense_backend/app/routes/team_memory_routes.py index 3ded87d36..76d934cb2 100644 --- a/surfsense_backend/app/routes/team_memory_routes.py +++ b/surfsense_backend/app/routes/team_memory_routes.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext -from app.db import User, get_async_session +from app.db import get_async_session from app.services.memory import ( MemoryRead, MemoryScope, @@ -32,7 +32,6 @@ async def get_team_memory( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user await check_search_space_access(session, auth, search_space_id) memory_md = await read_memory( scope=MemoryScope.TEAM, @@ -49,7 +48,6 @@ async def update_team_memory( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user await check_search_space_access(session, auth, search_space_id) result = await save_memory( scope=MemoryScope.TEAM, @@ -68,7 +66,6 @@ async def reset_team_memory( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user await check_search_space_access(session, auth, search_space_id) result = await reset_memory( scope=MemoryScope.TEAM, diff --git a/surfsense_backend/app/routes/users_routes.py b/surfsense_backend/app/routes/users_routes.py new file mode 100644 index 000000000..dad8847af --- /dev/null +++ b/surfsense_backend/app/routes/users_routes.py @@ -0,0 +1,34 @@ +"""Cookie-aware user profile routes.""" + +from fastapi import APIRouter, Depends, Request + +from app.auth.context import AuthContext +from app.schemas import UserRead, UserUpdate +from app.users import ( + UserManager, + get_auth_context, + get_user_manager, + require_session_context, +) + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserRead) +async def get_current_user_profile( + auth: AuthContext = Depends(get_auth_context), +): + return auth.user + + +@router.patch("/me", response_model=UserRead) +async def update_current_user_profile( + update: UserUpdate, + request: Request, + auth: AuthContext = Depends(require_session_context), + user_manager: UserManager = Depends(get_user_manager), +): + updated_user = await user_manager.update( + update, auth.user, safe=True, request=request + ) + return updated_user diff --git a/surfsense_backend/app/routes/video_presentations_routes.py b/surfsense_backend/app/routes/video_presentations_routes.py index 189a050e4..e40ccb2f9 100644 --- a/surfsense_backend/app/routes/video_presentations_routes.py +++ b/surfsense_backend/app/routes/video_presentations_routes.py @@ -21,7 +21,6 @@ from app.db import ( Permission, SearchSpace, SearchSpaceMembership, - User, VideoPresentation, get_async_session, ) @@ -93,7 +92,6 @@ async def read_video_presentation( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific video presentation by ID. Requires authentication with VIDEO_PRESENTATIONS_READ permission. @@ -137,7 +135,6 @@ async def delete_video_presentation( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a video presentation. Requires VIDEO_PRESENTATIONS_DELETE permission for the search space. @@ -181,7 +178,6 @@ async def stream_slide_audio( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Stream the audio file for a specific slide in a video presentation. The slide_number is 1-based. Audio path is read from the slides JSONB. diff --git a/surfsense_backend/app/routes/zero_context_routes.py b/surfsense_backend/app/routes/zero_context_routes.py new file mode 100644 index 000000000..0277883d8 --- /dev/null +++ b/surfsense_backend/app/routes/zero_context_routes.py @@ -0,0 +1,31 @@ +"""Zero sync authentication context routes.""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.context import AuthContext +from app.db import get_async_session +from app.users import get_auth_context +from app.utils.rbac import get_allowed_read_space_ids + +router = APIRouter(prefix="/zero", tags=["zero"]) + + +class ZeroContextResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + user_id: str = Field(alias="userId") + allowed_space_ids: list[int] = Field(alias="allowedSpaceIds") + + +@router.get("/context", response_model=ZeroContextResponse) +async def get_zero_context( + auth: AuthContext = Depends(get_auth_context), + session: AsyncSession = Depends(get_async_session), +) -> ZeroContextResponse: + allowed_space_ids = await get_allowed_read_space_ids(session, auth) + return ZeroContextResponse( + user_id=str(auth.user.id), + allowed_space_ids=allowed_space_ids, + ) diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 1566310e1..f111f0226 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -242,9 +242,9 @@ __all__ = [ "SearchSourceConnectorCreate", "SearchSourceConnectorRead", "SearchSourceConnectorUpdate", + "SearchSpaceApiAccessUpdate", # Search space schemas "SearchSpaceBase", - "SearchSpaceApiAccessUpdate", "SearchSpaceCreate", "SearchSpaceRead", "SearchSpaceUpdate", diff --git a/surfsense_backend/app/schemas/auth.py b/surfsense_backend/app/schemas/auth.py index 0d958a6d2..bdc009109 100644 --- a/surfsense_backend/app/schemas/auth.py +++ b/surfsense_backend/app/schemas/auth.py @@ -6,21 +6,22 @@ from pydantic import BaseModel class RefreshTokenRequest(BaseModel): """Request body for token refresh endpoint.""" - refresh_token: str + refresh_token: str | None = None class RefreshTokenResponse(BaseModel): """Response from token refresh endpoint.""" access_token: str - refresh_token: str + refresh_token: str | None = None token_type: str = "bearer" + access_expires_at: int class LogoutRequest(BaseModel): """Request body for logout endpoint (current device).""" - refresh_token: str + refresh_token: str | None = None class LogoutResponse(BaseModel): @@ -33,3 +34,19 @@ class LogoutAllResponse(BaseModel): """Response from logout all devices endpoint.""" detail: str = "Successfully logged out from all devices" + + +class SessionResponse(BaseModel): + authenticated: bool = True + access_expires_at: int | None = None + + +class DesktopSessionRequest(BaseModel): + code: str + code_verifier: str + redirect_uri: str + + +class DesktopLoginRequest(BaseModel): + email: str + password: str diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 0df69de09..11c57e969 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -435,7 +435,6 @@ async def list_snapshots_for_thread( thread_id: int, auth: AuthContext, ) -> list[dict]: - user = auth.user """List all public snapshots for a thread.""" from app.config import config @@ -482,7 +481,6 @@ async def list_snapshots_for_search_space( search_space_id: int, auth: AuthContext, ) -> list[dict]: - user = auth.user """List all public snapshots for a search space.""" from app.config import config @@ -540,7 +538,6 @@ async def delete_snapshot( snapshot_id: int, auth: AuthContext, ) -> bool: - user = auth.user """Delete a specific snapshot. Only thread owner can delete.""" # Get snapshot with thread result = await session.execute( diff --git a/surfsense_backend/app/tasks/celery_tasks/refresh_token_cleanup_task.py b/surfsense_backend/app/tasks/celery_tasks/refresh_token_cleanup_task.py new file mode 100644 index 000000000..7a17f1963 --- /dev/null +++ b/surfsense_backend/app/tasks/celery_tasks/refresh_token_cleanup_task.py @@ -0,0 +1,34 @@ +"""Celery task for pruning expired refresh-token rows.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, or_ + +from app.celery_app import celery_app +from app.config import config +from app.db import RefreshToken, async_session_maker + + +@celery_app.task(name="purge_refresh_tokens") +def purge_refresh_tokens() -> int: + return asyncio.run(_purge_refresh_tokens()) + + +async def _purge_refresh_tokens() -> int: + now = datetime.now(UTC) + revoked_cutoff = now - timedelta(seconds=config.REFRESH_ROTATION_GRACE_SECONDS) + + async with async_session_maker() as session: + result = await session.execute( + delete(RefreshToken).where( + or_( + RefreshToken.expires_at < now, + RefreshToken.revoked_at < revoked_cutoff, + ) + ) + ) + await session.commit() + return result.rowcount or 0 diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index d668dba45..bf9ec74d1 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -3,6 +3,7 @@ import uuid from datetime import UTC, datetime import httpx +import jwt from fastapi import Depends, HTTPException, Request, Response, status from fastapi.responses import JSONResponse, RedirectResponse from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models @@ -12,11 +13,12 @@ from fastapi_users.authentication import ( JWTStrategy, ) from fastapi_users.db import SQLAlchemyUserDatabase -from pydantic import BaseModel +from fastapi_users.jwt import generate_jwt from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext +from app.auth.session_cookies import access_expires_at, write_session from app.config import config from app.db import ( Prompt, @@ -36,12 +38,6 @@ from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) -class BearerResponse(BaseModel): - access_token: str - refresh_token: str - token_type: str - - SECRET = config.SECRET_KEY @@ -230,8 +226,23 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db yield UserManager(user_db) +class IatJWTStrategy(JWTStrategy[models.UP, models.ID]): + async def write_token(self, user: models.UP) -> str: + data = { + "sub": str(user.id), + "aud": self.token_audience, + "iat": int(datetime.now(UTC).timestamp()), + } + return generate_jwt( + data, + self.encode_key, + self.lifetime_seconds, + algorithm=self.algorithm, + ) + + def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: - return JWTStrategy( + return IatJWTStrategy( secret=SECRET, lifetime_seconds=config.ACCESS_TOKEN_LIFETIME_SECONDS, ) @@ -260,9 +271,6 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: # BEARER AUTH CODE. class CustomBearerTransport(BearerTransport): async def get_login_response(self, token: str) -> Response: - import jwt - - # Decode JWT to get user_id for refresh token creation try: payload = jwt.decode( token, SECRET, algorithms=["HS256"], options={"verify_aud": False} @@ -271,24 +279,26 @@ class CustomBearerTransport(BearerTransport): refresh_token = await create_refresh_token(user_id) except Exception as e: logger.error(f"Failed to create refresh token: {e}") - # Fall back to response without refresh token - refresh_token = "" - - bearer_response = BearerResponse( - access_token=token, - refresh_token=refresh_token, - token_type="bearer", - ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create session", + ) from e if config.AUTH_TYPE == "GOOGLE": - redirect_url = ( - f"{config.NEXT_FRONTEND_URL}/auth/callback" - f"?token={bearer_response.access_token}" - f"&refresh_token={bearer_response.refresh_token}" + response = RedirectResponse( + f"{config.NEXT_FRONTEND_URL}/dashboard", + status_code=302, ) - return RedirectResponse(redirect_url, status_code=302) else: - return JSONResponse(bearer_response.model_dump()) + response = JSONResponse( + { + "authenticated": True, + "access_expires_at": access_expires_at(token), + } + ) + + write_session(response, token, refresh_token) + return response bearer_transport = CustomBearerTransport(tokenUrl="auth/jwt/login") @@ -303,6 +313,22 @@ auth_backend = AuthenticationBackend( fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) +def _token_meets_epoch(token: str) -> bool: + min_issued_at = config.MIN_ISSUED_AT + if min_issued_at <= 0: + return True + + try: + payload = jwt.decode( + token, SECRET, algorithms=["HS256"], options={"verify_aud": False} + ) + except jwt.PyJWTError: + return False + + issued_at = payload.get("iat") + return isinstance(issued_at, (int, float)) and int(issued_at) >= min_issued_at + + async def get_auth_context( request: Request, session: AsyncSession = Depends(get_async_session), @@ -315,38 +341,42 @@ async def get_auth_context( receives the full SurfSense principal instead of a bare User. """ auth_header = request.headers.get("Authorization") - if not auth_header: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) + if auth_header: + scheme, _, credential = auth_header.partition(" ") + is_bearer = scheme.lower() == "bearer" and bool(credential) + token = credential if is_bearer else auth_header.strip() - scheme, _, token = auth_header.partition(" ") - if scheme.lower() != "bearer" or not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) + if token.startswith(PAT_PREFIX): + pat = await resolve_pat(session, token) + if pat and pat.user and pat.user.is_active: + maybe_touch_last_used(pat) + return AuthContext.pat_auth(pat.user, pat) - if token.startswith(PAT_PREFIX): - pat = await resolve_pat(session, token) - if pat and pat.user and pat.user.is_active: - maybe_touch_last_used(pat) - return AuthContext.pat_auth(pat.user, pat) + if is_bearer and _token_meets_epoch(token): + try: + user = await get_jwt_strategy().read_token(token, user_manager) + except Exception: + logger.exception("Failed to read bearer access token") + user = None - try: - user = await get_jwt_strategy().read_token(token, user_manager) - except Exception: - logger.exception("Failed to read access token") - user = None + if user and user.is_active: + return AuthContext.session(user) - if not user or not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) + cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME) + if cookie_token and _token_meets_epoch(cookie_token): + try: + user = await get_jwt_strategy().read_token(cookie_token, user_manager) + except Exception: + logger.exception("Failed to read session cookie access token") + user = None - return AuthContext.session(user) + if user and user.is_active: + return AuthContext.session(user) + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + ) async def allow_any_principal( @@ -371,6 +401,3 @@ async def require_session_context( detail="This action requires an interactive session", ) return auth - - -current_optional_user = fastapi_users.current_user(active=True, optional=True) diff --git a/surfsense_backend/app/utils/blocknote_to_markdown.py b/surfsense_backend/app/utils/blocknote_to_markdown.py index 3731b4b3c..e26a9f4ee 100644 --- a/surfsense_backend/app/utils/blocknote_to_markdown.py +++ b/surfsense_backend/app/utils/blocknote_to_markdown.py @@ -23,11 +23,15 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- -def _render_inline_content(content: list[dict[str, Any]] | None) -> str: +def _render_inline_content( + content: list[dict[str, Any]] | None, + inherited_styles: dict[str, Any] | None = None, +) -> str: """Convert BlockNote inline content array to a markdown string.""" if not content: return "" + inherited_styles = inherited_styles or {} parts: list[str] = [] for item in content: if not isinstance(item, dict): @@ -37,7 +41,10 @@ def _render_inline_content(content: list[dict[str, Any]] | None) -> str: if item_type == "text": text = item.get("text", "") - styles: dict[str, Any] = item.get("styles", {}) + styles: dict[str, Any] = { + **inherited_styles, + **item.get("styles", {}), + } # Apply inline styles (order: code first so nested marks don't break it) if styles.get("code"): @@ -56,7 +63,11 @@ def _render_inline_content(content: list[dict[str, Any]] | None) -> str: elif item_type == "link": href = item.get("href", "") link_content = item.get("content", []) - link_text = _render_inline_content(link_content) if link_content else href + link_text = ( + _render_inline_content(link_content, inherited_styles) + if link_content + else href + ) parts.append(f"[{link_text}]({href})") else: @@ -89,6 +100,7 @@ def _render_block( """ block_type = block.get("type", "paragraph") props: dict[str, Any] = block.get("props", {}) + styles: dict[str, Any] = block.get("styles", {}) content = block.get("content") children: list[dict[str, Any]] = block.get("children", []) prefix = " " * indent # 2-space indent per nesting level @@ -98,17 +110,17 @@ def _render_block( # --- Block type handlers --- if block_type == "paragraph": - text = _render_inline_content(content) if content else "" + text = _render_inline_content(content, styles) if content else "" lines.append(f"{prefix}{text}") elif block_type == "heading": level = props.get("level", 1) hashes = "#" * min(max(level, 1), 6) - text = _render_inline_content(content) if content else "" + text = _render_inline_content(content, styles) if content else "" lines.append(f"{prefix}{hashes} {text}") elif block_type == "bulletListItem": - text = _render_inline_content(content) if content else "" + text = _render_inline_content(content, styles) if content else "" lines.append(f"{prefix}- {text}") elif block_type == "numberedListItem": @@ -118,13 +130,13 @@ def _render_block( numbered_list_counter = int(start) else: numbered_list_counter += 1 - text = _render_inline_content(content) if content else "" + text = _render_inline_content(content, styles) if content else "" lines.append(f"{prefix}{numbered_list_counter}. {text}") elif block_type == "checkListItem": checked = props.get("checked", False) marker = "[x]" if checked else "[ ]" - text = _render_inline_content(content) if content else "" + text = _render_inline_content(content, styles) if content else "" lines.append(f"{prefix}- {marker} {text}") elif block_type == "codeBlock": diff --git a/surfsense_backend/app/utils/pat.py b/surfsense_backend/app/utils/pat.py index 46e3d4d08..e4b13d480 100644 --- a/surfsense_backend/app/utils/pat.py +++ b/surfsense_backend/app/utils/pat.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) PAT_PREFIX = "ss_pat_" PAT_TOKEN_BYTES = 32 LAST_USED_THROTTLE = timedelta(minutes=10) +_last_used_tasks: set[asyncio.Task[None]] = set() def generate_pat() -> str: @@ -70,4 +71,6 @@ def maybe_touch_last_used(pat: PersonalAccessToken) -> None: if last_used_at is not None and now - last_used_at < LAST_USED_THROTTLE: return - asyncio.create_task(_touch_last_used(pat.id)) + task = asyncio.create_task(_touch_last_used(pat.id)) + _last_used_tasks.add(task) + task.add_done_callback(_last_used_tasks.discard) diff --git a/surfsense_backend/app/utils/rbac.py b/surfsense_backend/app/utils/rbac.py index 8777f09f6..c82c94344 100644 --- a/surfsense_backend/app/utils/rbac.py +++ b/surfsense_backend/app/utils/rbac.py @@ -80,6 +80,28 @@ async def get_user_permissions( return [] +async def get_allowed_read_space_ids( + session: AsyncSession, + auth: AuthContext, +) -> list[int]: + """Return search spaces the principal may read through sync transports. + + This mirrors the basic REST search-space access rule: membership is required, + and PAT principals are additionally constrained by the per-space API gate. + """ + stmt = ( + select(SearchSpaceMembership.search_space_id) + .join(SearchSpace, SearchSpace.id == SearchSpaceMembership.search_space_id) + .filter(SearchSpaceMembership.user_id == auth.user.id) + .order_by(SearchSpaceMembership.search_space_id) + ) + if auth.is_gated: + stmt = stmt.filter(SearchSpace.api_access_enabled == True) # noqa: E712 + + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def _enforce_api_access_gate( session: AsyncSession, auth: AuthContext, diff --git a/surfsense_backend/app/utils/refresh_tokens.py b/surfsense_backend/app/utils/refresh_tokens.py index 8c0312ba8..6a96dd803 100644 --- a/surfsense_backend/app/utils/refresh_tokens.py +++ b/surfsense_backend/app/utils/refresh_tokens.py @@ -4,6 +4,7 @@ import hashlib import logging import secrets import uuid +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from sqlalchemy import select, update @@ -14,6 +15,13 @@ from app.db import RefreshToken, async_session_maker logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class RefreshRotationResult: + user_id: uuid.UUID + refresh_token: str | None + access_only: bool = False + + def generate_refresh_token() -> str: """Generate a cryptographically secure refresh token.""" return secrets.token_urlsafe(32) @@ -27,6 +35,7 @@ def hash_token(token: str) -> str: async def create_refresh_token( user_id: uuid.UUID, family_id: uuid.UUID | None = None, + absolute_expiry: datetime | None = None, ) -> str: """ Create and store a new refresh token for a user. @@ -40,8 +49,14 @@ async def create_refresh_token( """ token = generate_refresh_token() token_hash = hash_token(token) - expires_at = datetime.now(UTC) + timedelta( - seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS + now = datetime.now(UTC) + if absolute_expiry is None: + absolute_expiry = now + timedelta( + seconds=config.REFRESH_ABSOLUTE_LIFETIME_SECONDS + ) + expires_at = min( + now + timedelta(seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS), + absolute_expiry, ) if family_id is None: @@ -53,6 +68,7 @@ async def create_refresh_token( token_hash=token_hash, expires_at=expires_at, family_id=family_id, + absolute_expiry=absolute_expiry, ) session.add(refresh_token) await session.commit() @@ -61,15 +77,7 @@ async def create_refresh_token( async def validate_refresh_token(token: str) -> RefreshToken | None: - """ - Validate a refresh token. Handles reuse detection. - - Args: - token: The plaintext refresh token - - Returns: - RefreshToken if valid, None otherwise - """ + """Validate an active refresh token without rotating it.""" token_hash = hash_token(token) async with async_session_maker() as session: @@ -81,43 +89,87 @@ async def validate_refresh_token(token: str) -> RefreshToken | None: if not refresh_token: return None - # Reuse detection: revoked token used while family has active tokens - if refresh_token.is_revoked: - active = await session.execute( - select(RefreshToken).where( - RefreshToken.family_id == refresh_token.family_id, - RefreshToken.is_revoked == False, # noqa: E712 - RefreshToken.expires_at > datetime.now(UTC), - ) + now = datetime.now(UTC) + if ( + refresh_token.revoked_at is not None + or now >= refresh_token.expires_at + or ( + refresh_token.absolute_expiry is not None + and now >= refresh_token.absolute_expiry ) - if active.scalars().first(): - # Revoke entire family - await session.execute( - update(RefreshToken) - .where(RefreshToken.family_id == refresh_token.family_id) - .values(is_revoked=True) - ) - await session.commit() - logger.warning(f"Token reuse detected for user {refresh_token.user_id}") - return None - - if refresh_token.is_expired: + ): return None return refresh_token -async def rotate_refresh_token(old_token: RefreshToken) -> str: - """Revoke old token and create new one in same family.""" - async with async_session_maker() as session: - await session.execute( - update(RefreshToken) - .where(RefreshToken.id == old_token.id) - .values(is_revoked=True) - ) - await session.commit() +async def rotate_refresh_token(token: str) -> RefreshRotationResult | None: + """Atomically rotate a refresh token with access-only grace.""" + token_hash = hash_token(token) + now = datetime.now(UTC) + grace_window = timedelta(seconds=config.REFRESH_ROTATION_GRACE_SECONDS) - return await create_refresh_token(old_token.user_id, old_token.family_id) + async with async_session_maker() as session: + async with session.begin(): + result = await session.execute( + select(RefreshToken) + .where(RefreshToken.token_hash == token_hash) + .with_for_update() + ) + refresh_token = result.scalars().first() + + if not refresh_token: + return None + user_id = refresh_token.user_id + + if refresh_token.revoked_at is not None: + if ( + now - refresh_token.revoked_at <= grace_window + and now < refresh_token.expires_at + ): + return RefreshRotationResult( + user_id=user_id, + refresh_token=None, + access_only=True, + ) + + await session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == refresh_token.family_id) + .values(revoked_at=now, expires_at=now) + ) + logger.warning(f"Token reuse detected for user {user_id}") + return None + + if now >= refresh_token.expires_at: + return None + + family_cap = refresh_token.absolute_expiry or ( + now + timedelta(seconds=config.REFRESH_ABSOLUTE_LIFETIME_SECONDS) + ) + if now >= family_cap: + return None + + new_plaintext = generate_refresh_token() + child = RefreshToken( + user_id=user_id, + token_hash=hash_token(new_plaintext), + expires_at=min( + now + timedelta(seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS), + family_cap, + ), + family_id=refresh_token.family_id, + absolute_expiry=family_cap, + ) + session.add(child) + refresh_token.revoked_at = now + refresh_token.absolute_expiry = family_cap + + return RefreshRotationResult( + user_id=user_id, + refresh_token=new_plaintext, + access_only=False, + ) async def revoke_refresh_token(token: str) -> bool: @@ -131,12 +183,13 @@ async def revoke_refresh_token(token: str) -> bool: True if token was found and revoked, False otherwise """ token_hash = hash_token(token) + now = datetime.now(UTC) async with async_session_maker() as session: result = await session.execute( update(RefreshToken) .where(RefreshToken.token_hash == token_hash) - .values(is_revoked=True) + .values(revoked_at=now, expires_at=now) ) await session.commit() return result.rowcount > 0 @@ -144,10 +197,11 @@ async def revoke_refresh_token(token: str) -> bool: async def revoke_all_user_tokens(user_id: uuid.UUID) -> None: """Revoke all refresh tokens for a user (logout all devices).""" + now = datetime.now(UTC) async with async_session_maker() as session: await session.execute( update(RefreshToken) .where(RefreshToken.user_id == user_id) - .values(is_revoked=True) + .values(revoked_at=now, expires_at=now) ) await session.commit() diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index b14ee14d1..c16f27087 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -52,6 +52,16 @@ AUTOMATION_RUN_COLS = [ "created_at", ] +AUTOMATION_COLS = [ + "id", + "search_space_id", +] + +NEW_CHAT_THREAD_COLS = [ + "id", + "search_space_id", +] + # Enough to drive the lifecycle UI by push: status, the reviewable brief, and # its version. The bulky source_content and transcript are deliberately excluded # and fetched over REST when a gate opens. @@ -73,10 +83,12 @@ ZERO_PUBLICATION: Mapping[str, Sequence[str] | None] = { "documents": DOCUMENT_COLS, "folders": None, "search_source_connectors": None, + "new_chat_threads": NEW_CHAT_THREAD_COLS, "new_chat_messages": None, "chat_comments": None, "chat_session_state": None, "user": USER_COLS, + "automations": AUTOMATION_COLS, "automation_runs": AUTOMATION_RUN_COLS, "podcasts": PODCAST_COLS, } diff --git a/surfsense_backend/scripts/revoke_refresh_tokens_cutover.py b/surfsense_backend/scripts/revoke_refresh_tokens_cutover.py new file mode 100644 index 000000000..449d4a3e9 --- /dev/null +++ b/surfsense_backend/scripts/revoke_refresh_tokens_cutover.py @@ -0,0 +1,69 @@ +"""One-shot cutover helper to revoke every refresh token. + +Run with --yes during the auth-hardening cutover, alongside setting +MIN_ISSUED_AT to the deploy epoch. +""" + +from __future__ import annotations + +import argparse +import asyncio + +from sqlalchemy import text + +from app.db import async_session_maker + + +async def _count_active_tokens() -> int: + async with async_session_maker() as session: + result = await session.execute( + text( + """ + SELECT count(*) + FROM refresh_tokens + WHERE revoked_at IS NULL + AND expires_at > NOW() + """ + ) + ) + return int(result.scalar_one()) + + +async def _revoke_all_tokens() -> int: + async with async_session_maker() as session: + result = await session.execute( + text( + """ + UPDATE refresh_tokens + SET revoked_at = NOW(), + expires_at = NOW() + WHERE revoked_at IS NULL + OR expires_at > NOW() + """ + ) + ) + await session.commit() + return int(result.rowcount or 0) + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--yes", + action="store_true", + help="Actually revoke tokens. Without this flag the command is a dry run.", + ) + args = parser.parse_args() + + active_count = await _count_active_tokens() + if not args.yes: + print(f"Dry run: {active_count} active refresh token(s) would be revoked.") + print("Re-run with --yes during the auth-hardening cutover to revoke them.") + return + + updated_count = await _revoke_all_tokens() + print(f"Revoked {updated_count} refresh token row(s).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py b/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py index e1955494d..dcd4d1d2f 100644 --- a/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py +++ b/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py @@ -8,7 +8,6 @@ webhook fulfillment (idempotent), and the reconciliation fallback. from __future__ import annotations from types import SimpleNamespace -from urllib.parse import parse_qs, urlparse import asyncpg import httpx @@ -63,18 +62,13 @@ 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"}, + "/auth/desktop/login", + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) token = _extract_access_token(response) if token: @@ -89,9 +83,8 @@ 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"}, + "/auth/desktop/login", + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) token = _extract_access_token(response) assert token, f"Login failed ({response.status_code}): {response.text}" diff --git a/surfsense_backend/tests/integration/test_auth_transport_invariant.py b/surfsense_backend/tests/integration/test_auth_transport_invariant.py new file mode 100644 index 000000000..386411d3b --- /dev/null +++ b/surfsense_backend/tests/integration/test_auth_transport_invariant.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import Request, Response + +from app.auth.session_cookies import TransportMode, issue, read_refresh +from app.config import config + + +def _request_with_refresh_cookie(token: str) -> Request: + scope = { + "type": "http", + "method": "POST", + "path": "/auth/jwt/refresh", + "headers": [(b"cookie", f"{config.REFRESH_COOKIE_NAME}={token}".encode())], + "scheme": "https", + "server": ("testserver", 443), + } + return Request(scope) + + +def test_cookie_transport_sets_cookies_without_body_tokens(): + response = Response() + + body = issue( + response, + TransportMode.COOKIE, + access="access-token", + refresh="refresh-token", + access_expires_at=123, + ) + + assert "access_token" not in body + assert "refresh_token" not in body + assert body == {"authenticated": True, "access_expires_at": 123} + + set_cookie_headers = response.headers.getlist("set-cookie") + assert any(config.SESSION_COOKIE_NAME in header for header in set_cookie_headers) + assert any(config.REFRESH_COOKIE_NAME in header for header in set_cookie_headers) + + +def test_cookie_transport_re_stamps_access_without_refresh_body_or_cookie(): + response = Response() + + body = issue( + response, + TransportMode.COOKIE, + access="access-token", + refresh=None, + access_expires_at=123, + ) + + assert "access_token" not in body + assert "refresh_token" not in body + + set_cookie_headers = response.headers.getlist("set-cookie") + assert any(config.SESSION_COOKIE_NAME in header for header in set_cookie_headers) + assert not any( + config.REFRESH_COOKIE_NAME in header for header in set_cookie_headers + ) + + +def test_header_transport_returns_body_tokens_without_cookies(): + response = Response() + + body = issue( + response, + TransportMode.HEADER, + access="access-token", + refresh="refresh-token", + access_expires_at=123, + ) + + assert body == { + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "bearer", + "access_expires_at": 123, + } + assert "set-cookie" not in response.headers + + +def test_read_refresh_cookie_source_wins_over_body_source(): + request = _request_with_refresh_cookie("cookie-token") + + refresh, mode = read_refresh(request, SimpleNamespace(refresh_token="body-token")) + + assert refresh == "cookie-token" + assert mode is TransportMode.COOKIE diff --git a/surfsense_backend/tests/integration/test_zero_authz_context.py b/surfsense_backend/tests/integration/test_zero_authz_context.py new file mode 100644 index 000000000..dcb0fe34a --- /dev/null +++ b/surfsense_backend/tests/integration/test_zero_authz_context.py @@ -0,0 +1,85 @@ +"""Regression tests for Zero's backend-computed authorization context.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.context import AuthContext +from app.db import PersonalAccessToken, SearchSpace, User +from app.routes.search_spaces_routes import create_default_roles_and_membership +from app.utils.rbac import check_search_space_access, get_allowed_read_space_ids + +pytestmark = pytest.mark.integration + + +def _pat_auth(user: User) -> AuthContext: + pat = PersonalAccessToken( + user_id=user.id, + user=user, + token_hash="1" * 64, + token_prefix="ss_pat_zero", + label="Zero PAT", + ) + return AuthContext.pat_auth(user, pat) + + +async def _space_with_membership( + db_session: AsyncSession, + user: User, + *, + api_access_enabled: bool, +) -> SearchSpace: + space = SearchSpace( + name="Zero Authz Space", + user_id=user.id, + api_access_enabled=api_access_enabled, + ) + db_session.add(space) + await db_session.flush() + await create_default_roles_and_membership(db_session, space.id, user.id) + await db_session.flush() + return space + + +async def test_zero_read_set_matches_session_search_space_access( + db_session: AsyncSession, + db_user: User, + db_search_space: SearchSpace, +): + disabled_space = await _space_with_membership( + db_session, + db_user, + api_access_enabled=False, + ) + session_auth = AuthContext.session(db_user) + + allowed_ids = set(await get_allowed_read_space_ids(db_session, session_auth)) + + for space in (db_search_space, disabled_space): + membership = await check_search_space_access(db_session, session_auth, space.id) + assert membership.search_space_id in allowed_ids + + +async def test_zero_read_set_applies_pat_api_access_gate( + db_session: AsyncSession, + db_user: User, + db_search_space: SearchSpace, +): + db_search_space.api_access_enabled = True + disabled_space = await _space_with_membership( + db_session, + db_user, + api_access_enabled=False, + ) + await db_session.flush() + pat_auth = _pat_auth(db_user) + + allowed_ids = set(await get_allowed_read_space_ids(db_session, pat_auth)) + + assert db_search_space.id in allowed_ids + assert disabled_space.id not in allowed_ids + with pytest.raises(HTTPException) as exc_info: + await check_search_space_access(db_session, pat_auth, disabled_space.id) + assert exc_info.value.status_code == 403 diff --git a/surfsense_backend/tests/unit/gateway/test_byo_long_poll_lifespan.py b/surfsense_backend/tests/unit/gateway/test_byo_long_poll_lifespan.py index c184af601..38fde8a06 100644 --- a/surfsense_backend/tests/unit/gateway/test_byo_long_poll_lifespan.py +++ b/surfsense_backend/tests/unit/gateway/test_byo_long_poll_lifespan.py @@ -40,7 +40,9 @@ async def cleanup_supervisors(): async def test_start_byo_long_poll_noops_when_mode_is_webhook(monkeypatch): monkeypatch.setattr(byo_long_poll.config, "GATEWAY_ENABLED", True) monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "webhook") - monkeypatch.setattr(byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled") + monkeypatch.setattr( + byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled" + ) await byo_long_poll.start_byo_long_poll_supervisors() @@ -53,7 +55,9 @@ async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatc monkeypatch.setattr( byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll" ) - monkeypatch.setattr(byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled") + monkeypatch.setattr( + byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled" + ) session = mocker.AsyncMock() session.execute.return_value = ScalarResult([]) monkeypatch.setattr( @@ -75,7 +79,9 @@ async def test_start_byo_long_poll_spawns_one_supervisor_per_account( monkeypatch.setattr( byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll" ) - monkeypatch.setattr(byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled") + monkeypatch.setattr( + byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled" + ) accounts = [mocker.Mock(id=1), mocker.Mock(id=2)] session = mocker.AsyncMock() session.execute.return_value = ScalarResult(accounts) @@ -125,7 +131,9 @@ async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch): monkeypatch.setattr( byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll" ) - monkeypatch.setattr(byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled") + monkeypatch.setattr( + byo_long_poll.config, "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled" + ) session = mocker.AsyncMock() session.execute.return_value = ScalarResult([mocker.Mock(id=1)]) monkeypatch.setattr( diff --git a/surfsense_backend/tests/unit/routes/test_revert_turn_route.py b/surfsense_backend/tests/unit/routes/test_revert_turn_route.py index 44fcfe042..09d913b9c 100644 --- a/surfsense_backend/tests/unit/routes/test_revert_turn_route.py +++ b/surfsense_backend/tests/unit/routes/test_revert_turn_route.py @@ -450,7 +450,9 @@ class TestRevertTurnDispatch: thread_id=1, chat_turn_id="ct-mixed-all", session=session, - auth=AuthContext.session(_FakeUser()), # only id=7 has a different user_id + auth=AuthContext.session( + _FakeUser() + ), # only id=7 has a different user_id ) assert response.total == len(rows) == 6 diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py index cecf8be5d..7bb169496 100644 --- a/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py +++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py @@ -32,7 +32,9 @@ def _patch_common_bundle_dependencies(monkeypatch: pytest.MonkeyPatch): _CapturedChatLiteLLM.calls = [] - async def _fake_search_space(_session: Any, _search_space_id: int) -> SimpleNamespace: + async def _fake_search_space( + _session: Any, _search_space_id: int + ) -> SimpleNamespace: return SimpleNamespace(id=42, user_id="user-1") monkeypatch.setattr(llm_bundle, "_load_search_space", _fake_search_space) diff --git a/surfsense_backend/tests/unit/test_pat_fail_closed_static.py b/surfsense_backend/tests/unit/test_pat_fail_closed_static.py index 01ecd918f..88b8f9151 100644 --- a/surfsense_backend/tests/unit/test_pat_fail_closed_static.py +++ b/surfsense_backend/tests/unit/test_pat_fail_closed_static.py @@ -32,11 +32,7 @@ CONNECTOR_LISTERS = [ def _python_files() -> list[Path]: - return [ - path - for path in APP_ROOT.rglob("*.py") - if "__pycache__" not in path.parts - ] + return [path for path in APP_ROOT.rglob("*.py") if "__pycache__" not in path.parts] def test_current_active_user_is_removed_from_app_tree() -> None: diff --git a/surfsense_backend/tests/unit/test_zero_authz_static.py b/surfsense_backend/tests/unit/test_zero_authz_static.py new file mode 100644 index 000000000..d61204f24 --- /dev/null +++ b/surfsense_backend/tests/unit/test_zero_authz_static.py @@ -0,0 +1,22 @@ +"""Static guards for Zero authorization wiring.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[3] +WEB_ROOT = REPO_ROOT / "surfsense_web" + + +def test_zero_query_route_uses_authoritative_backend_context() -> None: + route = WEB_ROOT / "app/api/zero/query/route.ts" + text = route.read_text() + + assert "/zero/context" in text + assert "/users/me" not in text + assert "userID: auth.ctx.userId" in text + assert "handleQueryRequest({" in text diff --git a/surfsense_backend/tests/unit/utils/test_content_utils.py b/surfsense_backend/tests/unit/utils/test_content_utils.py index db898f294..a8ad57714 100644 --- a/surfsense_backend/tests/unit/utils/test_content_utils.py +++ b/surfsense_backend/tests/unit/utils/test_content_utils.py @@ -290,4 +290,4 @@ class TestExtractTextContent: def test_boolean_returns_empty_string(self): from app.utils.content_utils import extract_text_content - assert extract_text_content(True) == "" \ No newline at end of file + assert extract_text_content(True) == "" diff --git a/surfsense_backend/tests/utils/helpers.py b/surfsense_backend/tests/utils/helpers.py index c5719a253..fc77c6e6b 100644 --- a/surfsense_backend/tests/utils/helpers.py +++ b/surfsense_backend/tests/utils/helpers.py @@ -16,9 +16,8 @@ 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"}, + "/auth/desktop/login", + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) if response.status_code == 200: return response.json()["access_token"] @@ -32,9 +31,8 @@ async def get_auth_token(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"}, + "/auth/desktop/login", + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, ) assert response.status_code == 200, ( f"Login after registration failed ({response.status_code}): {response.text}" diff --git a/surfsense_desktop/.env b/surfsense_desktop/.env deleted file mode 100644 index 40e151c10..000000000 --- a/surfsense_desktop/.env +++ /dev/null @@ -1,10 +0,0 @@ -# Electron-specific build-time configuration. -# Set before running pnpm dist:mac / dist:win / dist:linux. - -# The hosted web frontend URL. Used to intercept OAuth redirects and keep them -# inside the desktop app. Set to your production frontend domain. -HOSTED_FRONTEND_URL=https://surfsense.com - -# PostHog analytics (leave empty to disable) -POSTHOG_KEY= -POSTHOG_HOST=https://assets.surfsense.com diff --git a/surfsense_desktop/.env.example b/surfsense_desktop/.env.example index f4e797250..42de081af 100644 --- a/surfsense_desktop/.env.example +++ b/surfsense_desktop/.env.example @@ -3,7 +3,15 @@ # The hosted web frontend URL. Used to intercept OAuth redirects and keep them # inside the desktop app. Set to your production frontend domain. -HOSTED_FRONTEND_URL=https://surfsense.com +HOSTED_FRONTEND_URL=http://localhost:3000 + +# The backend API URL used by desktop auth and refresh flows. +HOSTED_BACKEND_URL=http://localhost:8000 + +# Public Google OAuth Desktop app client ID. Required for packaged desktop +# Google login using loopback + PKCE. This is safe to ship in the desktop app; +# the PKCE code verifier, not a client secret, protects the token exchange. +GOOGLE_DESKTOP_CLIENT_ID=your_google_desktop_client_id.apps.googleusercontent.com # Runtime override for the above (read at app start, no rebuild required). # Useful for self-hosters whose backend NEXT_FRONTEND_URL differs from the diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 5b663de00..4f269ecb8 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -28,7 +28,7 @@ "@types/node": "^25.5.0", "concurrently": "^9.2.1", "dotenv": "^17.3.1", - "electron": "^41.0.2", + "electron": "^42.4.0", "electron-builder": "^26.8.1", "esbuild": "^0.27.4", "typescript": "^5.9.3", diff --git a/surfsense_desktop/pnpm-lock.yaml b/surfsense_desktop/pnpm-lock.yaml index e7b84cc01..5a5284412 100644 --- a/surfsense_desktop/pnpm-lock.yaml +++ b/surfsense_desktop/pnpm-lock.yaml @@ -46,8 +46,8 @@ importers: specifier: ^17.3.1 version: 17.3.1 electron: - specifier: ^41.0.2 - version: 41.0.2 + specifier: ^42.4.0 + version: 42.4.0 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -70,6 +70,10 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@electron-internal/extract-zip@1.0.3': + resolution: {integrity: sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -79,14 +83,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -346,8 +350,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@24.12.0': - resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -361,9 +365,6 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -483,9 +484,6 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -714,9 +712,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.0.2: - resolution: {integrity: sha512-raotm/aO8kOs1jD8SI8ssJ7EKciQOY295AOOprl1TxW7B0At8m5Ae7qNU1xdMxofiHMR8cNEGi9PKD3U+yT/mA==} - engines: {node: '>= 12.20.55'} + electron@42.4.0: + resolution: {integrity: sha512-OXXqh9LD9KxXPv2Fe25EfU9N9AvWTuV6V81sfhQaNvTAXCd9ONA+Q4OWvMe+CmYD6xIwjFxGGtG/ZphDYYC5OQ==} + engines: {node: '>= 22.12.0'} hasBin: true emoji-regex@8.0.0: @@ -777,11 +775,6 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - extsprintf@1.4.1: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} @@ -795,9 +788,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -838,6 +828,10 @@ packages: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1045,6 +1039,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1261,9 +1258,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1397,6 +1391,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -1554,12 +1553,13 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unique-filename@4.0.0: resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1644,9 +1644,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1660,6 +1657,8 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@electron-internal/extract-zip@1.0.3': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -1672,7 +1671,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -1686,17 +1685,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.5 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.28.0 transitivePeerDependencies: - supports-color @@ -1753,7 +1751,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -1930,9 +1928,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@24.12.0': + '@types/node@24.13.2': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/node@25.5.0': dependencies: @@ -1951,11 +1949,6 @@ snapshots: '@types/verror@1.10.11': optional: true - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.5.0 - optional: true - '@xmldom/xmldom@0.8.11': {} abbrev@3.0.1: {} @@ -2100,8 +2093,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} buffer@5.7.1: @@ -2428,11 +2419,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.0.2: + electron@42.4.0: dependencies: - '@electron/get': 2.0.3 - '@types/node': 24.12.0 - extract-zip: 2.0.1 + '@electron-internal/extract-zip': 1.0.3 + '@electron/get': 5.0.0 + '@types/node': 24.13.2 transitivePeerDependencies: - supports-color @@ -2509,16 +2500,6 @@ snapshots: exponential-backoff@3.1.3: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - extsprintf@1.4.1: optional: true @@ -2528,10 +2509,6 @@ snapshots: fast-uri@3.1.0: {} - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2569,6 +2546,13 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2804,6 +2788,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3015,8 +3006,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3136,6 +3125,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -3295,10 +3286,11 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@7.16.0: {} - undici-types@7.18.2: {} + undici@7.28.0: + optional: true + unique-filename@4.0.0: dependencies: unique-slug: 5.0.0 @@ -3384,9 +3376,4 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 17daab9a6..436e0e064 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -40,8 +40,12 @@ export const IPC_CHANNELS = { READ_AGENT_LOCAL_FILE_TEXT: 'agent-filesystem:read-local-file-text', WRITE_AGENT_LOCAL_FILE_TEXT: 'agent-filesystem:write-local-file-text', // Auth token sync across windows - GET_AUTH_TOKENS: 'auth:get-tokens', - SET_AUTH_TOKENS: 'auth:set-tokens', + GET_ACCESS_TOKEN: 'auth:get-access-token', + REFRESH_ACCESS_TOKEN: 'auth:refresh-access-token', + LOGOUT: 'auth:logout', + AUTH_CHANGED: 'auth:changed', + AUTH_START_GOOGLE: 'auth:start-google', + AUTH_LOGIN_PASSWORD: 'auth:login-password', // Keyboard shortcut configuration GET_SHORTCUTS: 'shortcuts:get', SET_SHORTCUTS: 'shortcuts:set', diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index ed7eaac66..ab4ba0d92 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, shell } from 'electron'; import { IPC_CHANNELS } from './channels'; import { getPermissionsStatus, @@ -52,8 +52,64 @@ import { type AgentFilesystemTreeWatchOptions, } from '../modules/agent-filesystem-tree-watcher'; import { installDownloadedUpdate } from '../modules/auto-updater'; +import { secretStore } from '../modules/secret-store'; +import { startGoogleOAuth } from '../modules/oauth'; -let authTokens: { bearer: string; refresh: string } | null = null; +const REFRESH_TOKEN_KEY = 'surfsense_refresh_token'; +let accessToken: string | null = null; +let refreshInFlight: Promise | null = null; + +type DesktopAuthResponse = { + access_token?: string; + refresh_token?: string | null; +}; + +function getBackendUrl(): string { + return (process.env.HOSTED_BACKEND_URL || process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || '').replace( + /\/+$/, + '' + ); +} + +function broadcastAuthChanged(): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC_CHANNELS.AUTH_CHANGED, { authed: !!accessToken, accessToken }); + } +} + +async function storeTokens(tokens: { bearer: string; refresh?: string | null }): Promise { + accessToken = tokens.bearer || null; + if (tokens.refresh) { + await secretStore.set(REFRESH_TOKEN_KEY, tokens.refresh); + } + broadcastAuthChanged(); +} + +async function refreshAccessToken(): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + const refresh = await secretStore.get(REFRESH_TOKEN_KEY); + const backendUrl = getBackendUrl(); + if (!refresh || !backendUrl) return null; + + const response = await fetch(`${backendUrl}/auth/jwt/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + if (!response.ok) return null; + + const data = (await response.json()) as { access_token?: string; refresh_token?: string | null }; + if (!data.access_token) return null; + await storeTokens({ bearer: data.access_token, refresh: data.refresh_token }); + return data.access_token; + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; +} export function registerIpcHandlers(): void { ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { @@ -173,14 +229,81 @@ export function registerIpcHandlers(): void { } ); - ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, (_event, tokens: { bearer: string; refresh: string }) => { - authTokens = tokens; + ipcMain.handle(IPC_CHANNELS.GET_ACCESS_TOKEN, async () => { + if (!accessToken) { + await refreshAccessToken(); + } + return accessToken; }); - ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => { - return authTokens; + ipcMain.handle(IPC_CHANNELS.REFRESH_ACCESS_TOKEN, () => { + return refreshAccessToken(); }); + ipcMain.handle(IPC_CHANNELS.LOGOUT, async () => { + const backendUrl = getBackendUrl(); + const refresh = await secretStore.get(REFRESH_TOKEN_KEY); + if (backendUrl && refresh) { + try { + await fetch(`${backendUrl}/auth/jwt/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + } catch { + // Local logout is fail-closed even if the server revoke call fails. + } + } + accessToken = null; + await secretStore.clear(REFRESH_TOKEN_KEY); + broadcastAuthChanged(); + }); + + ipcMain.handle(IPC_CHANNELS.AUTH_START_GOOGLE, async () => { + const backendUrl = getBackendUrl(); + if (!backendUrl) { + throw new Error('Backend URL is not configured'); + } + const tokens = await startGoogleOAuth(backendUrl); + await storeTokens({ bearer: tokens.access_token, refresh: tokens.refresh_token }); + return { ok: true }; + }); + + ipcMain.handle( + IPC_CHANNELS.AUTH_LOGIN_PASSWORD, + async (_event, payload: { email: string; password: string }) => { + const backendUrl = getBackendUrl(); + if (!backendUrl) { + throw new Error('Backend URL is not configured'); + } + + const response = await fetch(`${backendUrl}/auth/desktop/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + let detail = 'Password login failed'; + try { + const error = (await response.json()) as { detail?: string }; + detail = error.detail || detail; + } catch { + // Keep the generic error if the backend did not return JSON. + } + throw new Error(detail); + } + + const tokens = (await response.json()) as DesktopAuthResponse; + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error('Password login did not return desktop tokens'); + } + + await storeTokens({ bearer: tokens.access_token, refresh: tokens.refresh_token }); + return { ok: true }; + } + ); + ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); ipcMain.handle(IPC_CHANNELS.GET_AUTO_LAUNCH, () => getAutoLaunchState()); diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 632758ba8..b2c5436f3 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -17,6 +17,7 @@ import { syncAutoLaunchOnStartup, wasLaunchedAtLogin, } from './modules/auto-launch'; +import { purgeLegacyAuthCutover } from './modules/auth-cutover'; registerGlobalErrorHandlers(); app.setName('SurfSense'); @@ -29,6 +30,7 @@ registerIpcHandlers(); app.whenReady().then(async () => { initAnalytics(); + await purgeLegacyAuthCutover(); const launchedAtLogin = wasLaunchedAtLogin(); const startedHidden = shouldStartHidden(); trackEvent('desktop_app_launched', { diff --git a/surfsense_desktop/src/modules/auth-cutover.ts b/surfsense_desktop/src/modules/auth-cutover.ts new file mode 100644 index 000000000..373865dbe --- /dev/null +++ b/surfsense_desktop/src/modules/auth-cutover.ts @@ -0,0 +1,30 @@ +import { app } from 'electron'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { secretStore } from './secret-store'; + +const CUTOVER_FLAG_FILE = 'auth-cutover-v1.json'; +const REFRESH_TOKEN_KEY = 'surfsense_refresh_token'; + +async function hasCompletedCutover(flagPath: string): Promise { + try { + const raw = await readFile(flagPath, 'utf8'); + return JSON.parse(raw)?.complete === true; + } catch { + return false; + } +} + +export async function purgeLegacyAuthCutover(): Promise { + const userDataPath = app.getPath('userData'); + const flagPath = path.join(userDataPath, CUTOVER_FLAG_FILE); + if (await hasCompletedCutover(flagPath)) return; + + await secretStore.clear(REFRESH_TOKEN_KEY); + await mkdir(userDataPath, { recursive: true }); + await writeFile( + flagPath, + JSON.stringify({ complete: true, completedAt: new Date().toISOString() }), + { mode: 0o600 } + ); +} diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index d4c0da467..296cf6a48 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -22,8 +22,7 @@ function handleDeepLink(url: string) { path: parsed.pathname, }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { - const params = parsed.searchParams.toString(); - win.loadURL(`${getServerOrigin()}/auth/callback?${params}`); + win.loadURL(`${getServerOrigin()}/dashboard`); } win.show(); diff --git a/surfsense_desktop/src/modules/oauth-page.ts b/surfsense_desktop/src/modules/oauth-page.ts new file mode 100644 index 000000000..749429587 --- /dev/null +++ b/surfsense_desktop/src/modules/oauth-page.ts @@ -0,0 +1,72 @@ +import http from 'node:http'; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderOAuthPage(title: string, message: string): string { + return ` + + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+ +`; +} + +export function writeOAuthPage( + res: http.ServerResponse, + statusCode: number, + title: string, + message: string, + _tone?: 'success' | 'error' | 'neutral', +): void { + res + .writeHead(statusCode, { 'content-type': 'text/html; charset=utf-8' }) + .end(renderOAuthPage(title, message)); +} diff --git a/surfsense_desktop/src/modules/oauth.ts b/surfsense_desktop/src/modules/oauth.ts new file mode 100644 index 000000000..65b1b207b --- /dev/null +++ b/surfsense_desktop/src/modules/oauth.ts @@ -0,0 +1,155 @@ +import { shell } from 'electron'; +import crypto from 'node:crypto'; +import http from 'node:http'; +import { writeOAuthPage } from './oauth-page'; + +export interface DesktopAuthTokens { + access_token: string; + refresh_token: string; +} + +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const OAUTH_CALLBACK_PATH = '/callback'; + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function randomUrlSafe(bytes = 32): string { + return base64Url(crypto.randomBytes(bytes)); +} + +function sha256(value: string): string { + return base64Url(crypto.createHash('sha256').update(value).digest()); +} + +function getGoogleDesktopClientId(): string { + const clientId = (process.env.GOOGLE_DESKTOP_CLIENT_ID || '').trim(); + if (!clientId) { + throw new Error('Google desktop OAuth client ID is not configured'); + } + return clientId; +} + +export async function startGoogleOAuth(backendUrl: string): Promise { + const clientId = getGoogleDesktopClientId(); + const state = randomUrlSafe(); + const codeVerifier = randomUrlSafe(64); + const codeChallenge = sha256(codeVerifier); + + return new Promise((resolve, reject) => { + let settled = false; + let port: number | null = null; + let timeout: NodeJS.Timeout | null = null; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (server.listening) { + server.close(); + } + }; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const succeed = (tokens: DesktopAuthTokens) => { + if (settled) return; + settled = true; + cleanup(); + resolve(tokens); + }; + + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (url.pathname !== OAUTH_CALLBACK_PATH) { + writeOAuthPage(res, 404, 'Not found', 'This OAuth callback endpoint is only used by SurfSense.'); + return; + } + + const oauthError = url.searchParams.get('error'); + if (oauthError) { + const description = url.searchParams.get('error_description'); + writeOAuthPage(res, 400, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error(description || `Google OAuth failed: ${oauthError}`)); + return; + } + + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + if (!code || returnedState !== state) { + writeOAuthPage(res, 400, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error('Invalid OAuth callback')); + return; + } + + if (!port) { + writeOAuthPage(res, 500, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error('OAuth loopback server was not ready')); + return; + } + + const redirectUri = `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`; + const response = await fetch(`${backendUrl}/auth/desktop/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }), + }); + if (!response.ok) { + let detail = 'Desktop session exchange failed'; + try { + const error = (await response.json()) as { detail?: string }; + detail = error.detail || detail; + } catch { + // Keep the generic exchange error if the backend did not return JSON. + } + writeOAuthPage(res, 401, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error(detail)); + return; + } + const tokens = (await response.json()) as DesktopAuthTokens; + writeOAuthPage(res, 200, 'Authentication complete', 'You can close this window and return to SurfSense.', 'success'); + succeed(tokens); + } catch (error) { + fail(error instanceof Error ? error : new Error('Google OAuth failed')); + } + }); + + server.listen(0, '127.0.0.1', () => { + const addressInfo = server.address(); + if (!addressInfo || typeof addressInfo === 'string') { + fail(new Error('Unable to bind loopback OAuth server')); + return; + } + port = addressInfo.port; + timeout = setTimeout(() => { + fail(new Error('Google OAuth timed out')); + }, OAUTH_TIMEOUT_MS); + + const redirectUri = `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`; + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid email profile'); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + shell.openExternal(authUrl.toString()).catch((error) => { + fail(error instanceof Error ? error : new Error('Unable to open browser for Google OAuth')); + }); + }); + + server.on('error', (error) => { + fail(error); + }); + }); +} diff --git a/surfsense_desktop/src/modules/secret-store.ts b/surfsense_desktop/src/modules/secret-store.ts new file mode 100644 index 000000000..28a1cfc4b --- /dev/null +++ b/surfsense_desktop/src/modules/secret-store.ts @@ -0,0 +1,86 @@ +import { app, safeStorage } from 'electron'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface SecretStore { + set(key: string, value: string): Promise; + get(key: string): Promise; + clear(key: string): Promise; + isHardwareBacked(): Promise; +} + +const memoryStore = new Map(); +const storePath = path.join(app.getPath('userData'), 'secrets.enc.json'); + +async function readDiskStore(): Promise> { + try { + const raw = await fs.readFile(storePath, 'utf8'); + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +async function writeDiskStore(data: Record): Promise { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 }); +} + +async function canPersistEncryptedSecrets(): Promise { + try { + if (safeStorage.getSelectedStorageBackend?.() === 'basic_text') { + return false; + } + return await safeStorage.isAsyncEncryptionAvailable(); + } catch { + return false; + } +} + +export const secretStore: SecretStore = { + async set(key, value) { + if (!(await canPersistEncryptedSecrets())) { + memoryStore.set(key, value); + return; + } + + const encrypted = await safeStorage.encryptStringAsync(value); + const data = await readDiskStore(); + data[key] = encrypted.toString('base64'); + await writeDiskStore(data); + }, + + async get(key) { + if (!(await canPersistEncryptedSecrets())) { + return memoryStore.get(key) ?? null; + } + + const data = await readDiskStore(); + const encoded = data[key]; + if (!encoded) return null; + + try { + const decrypted = await safeStorage.decryptStringAsync(Buffer.from(encoded, 'base64')); + if (decrypted.shouldReEncrypt) { + await this.set(key, decrypted.result); + } + return decrypted.result; + } catch { + await this.clear(key); + return null; + } + }, + + async clear(key) { + memoryStore.delete(key); + const data = await readDiskStore(); + if (key in data) { + delete data[key]; + await writeDiskStore(data); + } + }, + + async isHardwareBacked() { + return canPersistEncryptedSecrets(); + }, +}; diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 42011d089..bfcd9b512 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -94,6 +94,10 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => { try { const u = new URL(details.url); + if (!u.pathname.includes('/connectors/callback')) { + callback({}); + return; + } const originalHost = u.host; const local = new URL(getServerOrigin()); u.protocol = local.protocol; diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 97232179c..07f363a59 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -80,9 +80,18 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId), // Auth token sync across windows - getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS), - setAuthTokens: (bearer: string, refresh: string) => - ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }), + getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN), + refreshAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.REFRESH_ACCESS_TOKEN), + logout: () => ipcRenderer.invoke(IPC_CHANNELS.LOGOUT), + startGoogleOAuth: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_START_GOOGLE), + loginPassword: (email: string, password: string) => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGIN_PASSWORD, { email, password }), + onAuthChanged: (callback: (payload: { authed: boolean; accessToken: string | null }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { authed: boolean; accessToken: string | null }) => + callback(payload); + ipcRenderer.on(IPC_CHANNELS.AUTH_CHANGED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.AUTH_CHANGED, listener); + }, // Keyboard shortcut configuration getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS), diff --git a/surfsense_evals/src/surfsense_evals/core/auth.py b/surfsense_evals/src/surfsense_evals/core/auth.py index 1e7cc5b3e..a87e757c2 100644 --- a/surfsense_evals/src/surfsense_evals/core/auth.py +++ b/surfsense_evals/src/surfsense_evals/core/auth.py @@ -5,8 +5,8 @@ SurfSense supports ``AUTH_TYPE=LOCAL`` (email + password) and There is no headless equivalent of the Google flow, so the harness handles both modes by treating the JWT as the universal credential: -* **LOCAL**: harness POSTs form-encoded ``username`` + ``password`` to - ``/auth/jwt/login``, reads ``{access_token, refresh_token}``. +* **LOCAL**: harness POSTs JSON ``email`` + ``password`` to + ``/auth/desktop/login``, reads ``{access_token, refresh_token}``. * **GOOGLE / pre-issued JWT**: operator pastes their existing JWT (and optionally refresh token) into ``SURFSENSE_JWT`` / ``SURFSENSE_REFRESH_TOKEN``; harness skips login. @@ -22,7 +22,7 @@ MIRAGE runs. from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any import httpx @@ -40,9 +40,8 @@ _NO_CREDENTIALS_MESSAGE = ( "No SurfSense credentials configured. Set ONE of:\n" " (LOCAL) SURFSENSE_USER_EMAIL + SURFSENSE_USER_PASSWORD\n" " (GOOGLE) SURFSENSE_JWT (and optionally SURFSENSE_REFRESH_TOKEN)\n" - "For GOOGLE: log in to SurfSense in your browser, open DevTools → " - "Application → Local Storage → copy `surfsense_bearer_token` and " - "`surfsense_refresh_token` into those env vars." + "For GOOGLE: use a PAT or operator-issued bearer token and set " + "SURFSENSE_JWT (plus SURFSENSE_REFRESH_TOKEN if available)." ) @@ -69,7 +68,7 @@ async def acquire_token(config: Config, *, http: httpx.AsyncClient | None = None 1. ``SURFSENSE_JWT`` set → use it directly. Refresh token captured if supplied. 2. ``SURFSENSE_USER_EMAIL`` + ``SURFSENSE_USER_PASSWORD`` set → - form-encoded POST to ``/auth/jwt/login``. + JSON POST to ``/auth/desktop/login``. 3. Neither → raise ``CredentialError``. The optional ``http`` argument lets tests inject a mocked client; if @@ -86,9 +85,9 @@ async def acquire_token(config: Config, *, http: httpx.AsyncClient | None = None if config.has_local_mode(): async def _login(client: httpx.AsyncClient) -> TokenBundle: response = await client.post( - f"{config.surfsense_api_base}/auth/jwt/login", - data={ - "username": config.surfsense_user_email, + f"{config.surfsense_api_base}/auth/desktop/login", + json={ + "email": config.surfsense_user_email, "password": config.surfsense_user_password, }, headers={"Accept": "application/json"}, diff --git a/surfsense_evals/tests/core/test_auth.py b/surfsense_evals/tests/core/test_auth.py index 43ec94b93..181d8e632 100644 --- a/surfsense_evals/tests/core/test_auth.py +++ b/surfsense_evals/tests/core/test_auth.py @@ -46,8 +46,8 @@ async def test_acquire_token_jwt_mode_short_circuits(): @pytest.mark.asyncio @respx.mock -async def test_acquire_token_local_mode_posts_form(): - respx.post("http://test/auth/jwt/login").mock( +async def test_acquire_token_local_mode_posts_desktop_login_json(): + respx.post("http://test/auth/desktop/login").mock( return_value=httpx.Response( 200, json={"access_token": "T", "refresh_token": "R", "token_type": "bearer"} ) diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index 7d03cf498..cf75b4756 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -41,6 +41,10 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # "/zero" endpoint behind Caddy. Set it for local dev or packaged clients. # ───────────────────────────────────────────────────────────────────────────── # NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848 +# Server-only shared secret that authorizes zero-cache when it calls +# /api/zero/query. Leave unset during the compatibility rollout, then set it +# once every zero-cache instance sends X-Api-Key. +# ZERO_QUERY_API_KEY= # ───────────────────────────────────────────────────────────────────────────── # Cloudflare Turnstile CAPTCHA for anonymous chat abuse prevention diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 108151512..dd415e10f 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -11,6 +11,7 @@ import { useRuntimeConfig } from "@/components/providers/runtime-config"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; +import { getPostLoginRedirectPath } from "@/lib/auth-utils"; import { ValidationError } from "@/lib/error"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; @@ -38,7 +39,7 @@ export function LocalLoginForm() { trackLoginAttempt("local"); try { - const data = await login({ + await login({ username, password, grant_type: "password", @@ -47,14 +48,9 @@ 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"); - } - // Small delay to show success message setTimeout(() => { - router.push(`/auth/callback?token=${data.access_token}`); + router.push(getPostLoginRedirectPath()); }, 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 8f146f815..31e1ee26d 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -30,8 +30,7 @@ function LoginContent() { const logout = searchParams.get("logout"); const returnUrl = searchParams.get("returnUrl"); - // Save returnUrl to localStorage so it persists through OAuth flows (e.g., Google) - // This is read by TokenHandler after successful authentication + // Save returnUrl for client-side login flows that can redirect directly after success. if (returnUrl) { setRedirectPath(decodeURIComponent(returnUrl)); } diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 9421a0156..571103e79 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -12,8 +12,8 @@ import { Logo } from "@/components/Logo"; import { useRuntimeConfig } from "@/components/providers/runtime-config"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/hooks/use-session"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; -import { getBearerToken } from "@/lib/auth-utils"; import { AppError, ValidationError } from "@/lib/error"; import { trackRegistrationAttempt, @@ -37,18 +37,19 @@ export default function RegisterPage() { message: null, }); const router = useRouter(); + const session = useSession(); const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom); // Check authentication type and redirect if not LOCAL useEffect(() => { - if (getBearerToken()) { + if (session.status === "authenticated") { router.replace("/dashboard"); return; } if (authType !== "LOCAL") { router.push("/login"); } - }, [authType, router]); + }, [authType, router, session.status]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts index f08b012e7..736647c96 100644 --- a/surfsense_web/app/api/zero/query/route.ts +++ b/surfsense_web/app/api/zero/query/route.ts @@ -12,45 +12,66 @@ import { schema } from "@/zero/schema"; // (e.g. http://localhost:8929) does NOT resolve from inside the frontend // container and would make every authenticated Zero query fail with a 503. const backendURL = SERVER_BACKEND_URL.replace(/\/$/, ""); +const zeroQueryApiKey = process.env.ZERO_QUERY_API_KEY; + +function validateZeroCacheRequest(request: Request): NextResponse | null { + if (!zeroQueryApiKey) return null; + if (request.headers.get("X-Api-Key") === zeroQueryApiKey) return null; + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); +} async function authenticateRequest( request: Request -): Promise<{ ctx: Context; error?: never } | { ctx?: never; error: NextResponse }> { +): Promise< + { ctx: Exclude; error?: never } | { ctx?: never; error: NextResponse } +> { const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return { ctx: undefined }; + const cookieHeader = request.headers.get("Cookie"); + const headers: HeadersInit = {}; + if (authHeader?.startsWith("Bearer ")) { + headers.Authorization = authHeader; + } else if (cookieHeader) { + headers.Cookie = cookieHeader; + } else { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; } try { - const res = await fetch(`${backendURL}/users/me`, { - headers: { Authorization: authHeader }, + const res = await fetch(`${backendURL}/zero/context`, { + headers, }); if (!res.ok) { return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; } - const user = await res.json(); - return { ctx: { userId: String(user.id) } }; + const ctx = (await res.json()) as Exclude; + return { ctx }; } catch { return { error: NextResponse.json({ error: "Auth service unavailable" }, { status: 503 }) }; } } export async function POST(request: Request) { + const forbidden = validateZeroCacheRequest(request); + if (forbidden) { + return forbidden; + } + const auth = await authenticateRequest(request); if (auth.error) { return auth.error; } - const result = await handleQueryRequest( - (name, args) => { + const result = await handleQueryRequest({ + handler: (name, args) => { const query = mustGetQuery(queries, name); return query.fn({ args, ctx: auth.ctx }); }, schema, - request - ); + request, + userID: auth.ctx.userId, + }); return NextResponse.json(result); } diff --git a/surfsense_web/app/auth/callback/page.tsx b/surfsense_web/app/auth/callback/page.tsx deleted file mode 100644 index da1755835..000000000 --- a/surfsense_web/app/auth/callback/page.tsx +++ /dev/null @@ -1,14 +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 3594e15eb..70d276264 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 @@ -69,7 +69,7 @@ import { useMessagesSync } from "@/hooks/use-messages-sync"; import { useThreadDetail, useThreadMessages } from "@/hooks/use-thread-queries"; import { getAgentFilesystemSelection } from "@/lib/agent-filesystem"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { getDesktopAccessToken } from "@/lib/auth-fetch"; import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier"; import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; @@ -917,29 +917,26 @@ export default function NewChatPage() { // Cancel ongoing request const cancelRun = useCallback(async () => { if (threadId) { - const token = getBearerToken(); - if (token) { - try { - const response = await fetch( - buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`), - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - if (response.ok) { - const payload = (await response.json()) as { - error_code?: string; - }; - if (payload.error_code === "TURN_CANCELLING") { - recentCancelRequestedAtRef.current = Date.now(); - } + const token = await getDesktopAccessToken(); + try { + const response = await fetch( + buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`), + { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: "include", + } + ); + if (response.ok) { + const payload = (await response.json()) as { + error_code?: string; + }; + if (payload.error_code === "TURN_CANCELLING") { + recentCancelRequestedAtRef.current = Date.now(); } - } catch (error) { - console.warn("[NewChatPage] Failed to signal cancel-active-turn:", error); } + } catch (error) { + console.warn("[NewChatPage] Failed to signal cancel-active-turn:", error); } } if (abortControllerRef.current) { @@ -964,11 +961,7 @@ export default function NewChatPage() { if (!userQuery.trim() && userImages.length === 0) return; - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - return; - } + const token = await getDesktopAccessToken(); // Lazy thread creation: create thread on first message if it doesn't exist let currentThreadId = threadId; @@ -1149,8 +1142,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify({ chat_id: currentThreadId, user_query: userQuery.trim(), @@ -1537,12 +1531,7 @@ export default function NewChatPage() { stagedDecisionsByInterruptIdRef.current.clear(); setIsRunning(true); - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - setIsRunning(false); - return; - } + const token = await getDesktopAccessToken(); const controller = new AbortController(); abortControllerRef.current = controller; @@ -1648,8 +1637,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify({ search_space_id: searchSpaceId, decisions, @@ -1981,11 +1971,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - return; - } + const token = await getDesktopAccessToken(); // Extract the original user query BEFORE removing messages (for reload mode) let userQueryToDisplay: string | undefined; @@ -2104,8 +2090,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify(requestBody), signal: controller.signal, }) diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 8efe81cce..4c32d0b0e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -13,13 +13,15 @@ import { Logo } from "@/components/Logo"; import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel"; import { Button } from "@/components/ui/button"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { useSession } from "@/hooks/use-session"; +import { redirectToLogin } from "@/lib/auth-utils"; import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding"; export default function OnboardPage() { const router = useRouter(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); + const session = useSession(); const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue( globalModelConnectionsAtom ); @@ -29,8 +31,8 @@ export default function OnboardPage() { useAtomValue(globalLlmConfigStatusAtom); useEffect(() => { - if (!getBearerToken()) redirectToLogin(); - }, []); + if (session.status === "unauthenticated") redirectToLogin(); + }, [session.status]); const hasUsableChatModel = useMemo( () => hasEnabledChatModel([...globalConnections, ...connections]), @@ -43,7 +45,8 @@ export default function OnboardPage() { connections ); - const isLoading = globalLoading || rolesLoading || globalConfigStatusLoading; + const isLoading = + session.status === "loading" || globalLoading || rolesLoading || globalConfigStatusLoading; // Onboarding only applies when no global_llm_config.yaml exists. If a global // config is present (or onboarding is already complete), leave this page. diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx index 5ac7e83b8..9946f244f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx @@ -1,10 +1,20 @@ "use client"; -import { Check, Copy, Info, Plus, Trash2 } from "lucide-react"; +import { Check, Copy, Info, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -16,6 +26,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; import { usePats } from "@/hooks/use-pats"; import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; @@ -26,6 +37,7 @@ export function ApiKeyContent() { const [label, setLabel] = useState(""); const [expiresInDays, setExpiresInDays] = useState(""); const [copiedToken, setCopiedToken] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); const sortedTokens = useMemo(() => tokens, [tokens]); @@ -51,95 +63,112 @@ export function ApiKeyContent() { } }, [createdToken]); - const handleDelete = useCallback( - async (id: number, tokenLabel: string) => { - if (!window.confirm(`Delete personal access token "${tokenLabel}"? This cannot be undone.`)) { - return; - } - await deleteToken(id); - }, - [deleteToken] - ); + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) return; + + await deleteToken(deleteTarget.id); + setDeleteTarget(null); + }, [deleteTarget, deleteToken]); return ( -
+
- Personal access tokens are long-lived credentials for extensions, Obsidian, and - programmatic API clients. Copy a token when you create it; it is shown only once. + API keys let extensions, Obsidian, and other apps connect to SurfSense.
-

Personal access tokens

+

API keys

- Expired tokens stay listed until you delete them. + Expired API keys stay listed until you delete them.

-
- {isLoading ? ( -
- - -
- ) : sortedTokens.length > 0 ? ( -
- {sortedTokens.map((token) => { - const expiresAt = token.expires_at ? new Date(token.expires_at) : null; - const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false; - return ( -
+ {isLoading ? ( +
+ {["skeleton-a", "skeleton-b"].map((key) => ( + + + + + + + + ))} +
+ ) : sortedTokens.length > 0 ? ( +
+ {sortedTokens.map((token) => { + const expiresAt = token.expires_at ? new Date(token.expires_at) : null; + const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false; + return ( + +
-
-

{token.label}

- {isExpired ? Expired : null} +
+
+

+ {token.label} +

+ {isExpired ? ( + + Expired + + ) : null} +
+

+ {token.prefix}... +

+

+ Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "} + {token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"} +

-

{token.prefix}...

-

- Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "} - {token.last_used_at - ? new Date(token.last_used_at).toLocaleString() - : "Never"} -

-
- ); - })} -
- ) : ( -

- No personal access tokens yet. -

- )} -
+ + + ); + })} +
+ ) : ( +

+ No API keys yet. +

+ )} - Create personal access token + Create API key - Name this token so you can recognize where it is used later. + Name this API key so you can recognize where it is used later.
- +
- - @@ -173,17 +215,21 @@ export function ApiKeyContent() { !open && setCreatedToken(null)}> - Copy your token now + Copy your API key now - This token is shown only once. Store it somewhere secure before closing this - dialog. + This API key is shown only once. Store it somewhere secure before closing this dialog.
{createdToken?.token} -
@@ -192,6 +238,41 @@ export function ApiKeyContent() {
+ + !open && setDeleteTarget(null)} + > + + + Delete API key? + + {deleteTarget?.label} will be + permanently removed. This cannot be undone. + + + + Cancel + { + event.preventDefault(); + void handleConfirmDelete(); + }} + > + {isMutating ? ( + + + Deleting... + + ) : ( + "Delete" + )} + + + +
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx index 56044de5b..f4454f343 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx @@ -38,13 +38,13 @@ export function CommunityPromptsContent() { const list = prompts ?? []; return ( -
+

Prompts shared by other users. Add any to your collection with one click.

{isLoading && ( -
+
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( @@ -76,7 +76,7 @@ export function CommunityPromptsContent() { )} {!isLoading && !isError && list.length > 0 && ( -
+
{list.map((prompt) => ( +

Create prompt templates triggered with in @@ -276,7 +276,7 @@ export function PromptsContent() {

{isLoading && ( -
+
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( @@ -308,7 +308,7 @@ export function PromptsContent() { )} {!isLoading && !isError && list.length > 0 && ( -
+
{list.map((prompt) => (
{ async function checkAuth() { - let token = getBearerToken(); - if (!token) { - const synced = await ensureTokensFromElectron(); - if (synced) token = getBearerToken(); - } - if (!token) { + if (session.status === "loading") return; + if (session.status === "unauthenticated") { redirectToLogin(); return; } queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] }); setIsCheckingAuth(false); } - checkAuth(); - }, []); + void checkAuth(); + }, [session.status]); // Return null while loading - the global provider handles the loading UI if (isCheckingAuth) { diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 2b3463bb4..c31f0384a 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -9,13 +9,7 @@ import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { CreateSearchSpaceDialog } from "@/components/layout"; import { Button } from "@/components/ui/button"; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; function ErrorScreen({ message }: { message: string }) { diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx index 0d91588e1..5f7f6ade2 100644 --- a/surfsense_web/app/desktop/login/page.tsx +++ b/surfsense_web/app/desktop/login/page.tsx @@ -1,12 +1,10 @@ "use client"; -import { useAtom } from "jotai"; import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder"; import { useIsGoogleAuth } from "@/components/providers/runtime-config"; import { Button } from "@/components/ui/button"; @@ -17,8 +15,7 @@ import { ShortcutKbd } from "@/components/ui/shortcut-kbd"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { setBearerToken } from "@/lib/auth-utils"; -import { buildBackendUrl } from "@/lib/env-config"; +import { getPostLoginRedirectPath } from "@/lib/auth-utils"; type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist"; type ShortcutMap = typeof DEFAULT_SHORTCUTS; @@ -190,12 +187,12 @@ export default function DesktopLoginPage() { const router = useRouter(); const api = useElectronAPI(); const isGoogleAuth = useIsGoogleAuth(); - const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loginError, setLoginError] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(false); const [isGoogleRedirecting, setIsGoogleRedirecting] = useState(false); const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); @@ -237,10 +234,17 @@ export default function DesktopLoginPage() { [updateShortcut] ); - const handleGoogleLogin = () => { + const handleGoogleLogin = async () => { if (isGoogleRedirecting) return; setIsGoogleRedirecting(true); - window.location.href = buildBackendUrl("/auth/google/authorize-redirect"); + try { + await api?.startGoogleOAuth?.(); + await autoSetSearchSpace(); + router.push(getPostLoginRedirectPath()); + } catch (error) { + setIsGoogleRedirecting(false); + toast.error(error instanceof Error ? error.message : "Google sign-in failed"); + } }; const autoSetSearchSpace = async () => { @@ -259,23 +263,19 @@ export default function DesktopLoginPage() { const handleLocalLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoginError(null); + if (isLoggingIn) return; + setIsLoggingIn(true); try { - const data = await login({ - username: email, - password, - grant_type: "password", - }); - - if (typeof window !== "undefined") { - sessionStorage.setItem("login_success_tracked", "true"); + if (!api?.loginPassword) { + throw new Error("Desktop password login is not available"); } + await api.loginPassword(email, password); - setBearerToken(data.access_token); await autoSetSearchSpace(); setTimeout(() => { - router.push(`/auth/callback?token=${data.access_token}`); + router.push(getPostLoginRedirectPath()); }, 300); } catch (err) { if (err instanceof Error) { @@ -283,6 +283,8 @@ export default function DesktopLoginPage() { } else { setLoginError("Login failed. Please check your credentials."); } + } finally { + setIsLoggingIn(false); } }; diff --git a/surfsense_web/app/invite/[invite_code]/page.tsx b/surfsense_web/app/invite/[invite_code]/page.tsx index 959a6d6d1..fee3f4647 100644 --- a/surfsense_web/app/invite/[invite_code]/page.tsx +++ b/surfsense_web/app/invite/[invite_code]/page.tsx @@ -30,8 +30,9 @@ import { } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import type { AcceptInviteResponse } from "@/contracts/types/invites.types"; +import { useSession } from "@/hooks/use-session"; import { invitesApiService } from "@/lib/apis/invites-api.service"; -import { getBearerToken, setRedirectPath } from "@/lib/auth-utils"; +import { setRedirectPath } from "@/lib/auth-utils"; import { trackSearchSpaceInviteAccepted, trackSearchSpaceInviteDeclined, @@ -43,6 +44,7 @@ export default function InviteAcceptPage() { const params = useParams(); const router = useRouter(); const inviteCode = params.invite_code as string; + const session = useSession(); const { data: inviteInfo = null, isLoading: loading } = useQuery({ queryKey: cacheKeys.invites.info(inviteCode), @@ -81,11 +83,9 @@ export default function InviteAcceptPage() { // Check if user is logged in useEffect(() => { - if (typeof window !== "undefined") { - const token = getBearerToken(); - setIsLoggedIn(!!token); - } - }, []); + if (session.status === "loading") return; + setIsLoggedIn(session.status === "authenticated"); + }, [session.status]); const handleAccept = async () => { setAccepting(true); diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 46182f40e..22125665b 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -5,6 +5,7 @@ import { Roboto } from "next/font/google"; import Script from "next/script"; import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider"; import { DesktopUpdateToast } from "@/components/desktop/desktop-update-toast"; +import { AuthCutoverPurge } from "@/components/providers/AuthCutoverPurge"; import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider"; import { I18nProvider } from "@/components/providers/I18nProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; @@ -17,13 +18,10 @@ import { import { ThemeProvider } from "@/components/theme/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { LocaleProvider } from "@/contexts/LocaleContext"; -import { BUILD_TIME_AUTH_TYPE } from "@/lib/env-config"; import { PlatformProvider } from "@/contexts/platform-context"; +import { BUILD_TIME_AUTH_TYPE } from "@/lib/env-config"; import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider"; -import { - getRuntimeAuthInitScript, - resolveRuntimeAuthUiMode, -} from "@/lib/runtime-auth-config"; +import { getRuntimeAuthInitScript, resolveRuntimeAuthUiMode } from "@/lib/runtime-auth-config"; import { cn } from "@/lib/utils"; const roboto = Roboto({ @@ -164,6 +162,7 @@ export default function RootLayout({ + {children} diff --git a/surfsense_web/app/verify-token/route.ts b/surfsense_web/app/verify-token/route.ts index 9df460779..4016600b7 100644 --- a/surfsense_web/app/verify-token/route.ts +++ b/surfsense_web/app/verify-token/route.ts @@ -15,6 +15,7 @@ export async function GET(request: NextRequest) { headers: { Authorization: request.headers.get("authorization") || "", "X-API-Key": request.headers.get("x-api-key") || "", + Cookie: request.headers.get("cookie") || "", }, cache: "no-store", }); diff --git a/surfsense_web/atoms/agent/agent-flags-query.atom.ts b/surfsense_web/atoms/agent/agent-flags-query.atom.ts index 30158deaa..0b1798e51 100644 --- a/surfsense_web/atoms/agent/agent-flags-query.atom.ts +++ b/surfsense_web/atoms/agent/agent-flags-query.atom.ts @@ -1,6 +1,6 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { agentFlagsApiService } from "@/lib/apis/agent-flags-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { isAuthenticated } from "@/lib/auth-utils"; export const AGENT_FLAGS_QUERY_KEY = ["agent", "flags"] as const; @@ -12,6 +12,6 @@ export const AGENT_FLAGS_QUERY_KEY = ["agent", "flags"] as const; export const agentFlagsAtom = atomWithQuery(() => ({ queryKey: AGENT_FLAGS_QUERY_KEY, staleTime: 10 * 60 * 1000, - enabled: !!getBearerToken(), + enabled: isAuthenticated(), queryFn: () => agentFlagsApiService.get(), })); diff --git a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts index 04dad9b21..709b51966 100644 --- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts @@ -1,26 +1,26 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { isAuthenticated } from "@/lib/auth-utils"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; export const globalModelConnectionsAtom = atomWithQuery(() => ({ queryKey: cacheKeys.modelConnections.global(), - enabled: !!getBearerToken(), + enabled: isAuthenticated(), staleTime: 10 * 60 * 1000, queryFn: () => modelConnectionsApiService.getGlobalConnections(), })); export const globalLlmConfigStatusAtom = atomWithQuery(() => ({ queryKey: cacheKeys.modelConnections.globalConfigStatus(), - enabled: !!getBearerToken(), + enabled: isAuthenticated(), staleTime: 60 * 60 * 1000, queryFn: () => modelConnectionsApiService.getGlobalLlmConfigStatus(), })); export const modelProvidersAtom = atomWithQuery(() => ({ queryKey: cacheKeys.modelConnections.providers(), - enabled: !!getBearerToken(), + enabled: isAuthenticated(), staleTime: 60 * 60 * 1000, queryFn: () => modelConnectionsApiService.getModelProviders(), })); diff --git a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts index e4c60b809..255d458be 100644 --- a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts +++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts @@ -49,7 +49,7 @@ export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({ toast.success("Public link deleted"); }, onError: (error: Error) => { - console.error("Failed to delete public chat link:", error); + console.error("Failed to delete public chat:", error); toast.error("Failed to delete public link"); }, })); diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts index 4b6717440..68ec329be 100644 --- a/surfsense_web/atoms/user/user-query.atoms.ts +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -1,6 +1,6 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { userApiService } from "@/lib/apis/user-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { isAuthenticated } from "@/lib/auth-utils"; export const USER_QUERY_KEY = ["user", "me"] as const; const userQueryFn = () => userApiService.getMe(); @@ -12,7 +12,8 @@ export const currentUserAtom = atomWithQuery(() => { // are now pushed via Zero (queries.user.me()), so /users/me only // needs to fire once per session for the static profile fields. staleTime: Infinity, - enabled: !!getBearerToken(), + enabled: isAuthenticated(), + retry: false, queryFn: userQueryFn, }; }); diff --git a/surfsense_web/changelog/content/2026-02-09.mdx b/surfsense_web/changelog/content/2026-02-09.mdx index 3bbc6f45e..7ffef2b4a 100644 --- a/surfsense_web/changelog/content/2026-02-09.mdx +++ b/surfsense_web/changelog/content/2026-02-09.mdx @@ -15,9 +15,9 @@ This update brings **public sharing, image generation**, a redesigned Documents #### Public Sharing -- **Public Chat Links**: Share snapshots of chats via public links. +- **Public Chats**: Share snapshots of chats via public links. - **Sharing Permissions**: Search Space owners control who can create and manage public links. -- **Link Management Page**: View and revoke all public chat links from Search Space Settings. +- **Link Management Page**: View and revoke all public chats from Search Space Settings. #### Auto (Load Balanced) Mode diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx deleted file mode 100644 index 97e937526..000000000 --- a/surfsense_web/components/TokenHandler.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -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 -} - -/** - * 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') - */ -const TokenHandler = ({ - redirectPath = "/dashboard", - tokenParamName = "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"); - - 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, redirectPath]); - - // Return null - the global provider handles the loading UI - return null; -}; - -export default TokenHandler; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx index 7ec39803b..7aa414a30 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx @@ -100,9 +100,7 @@ export const ObsidianConnectForm: FC = ({ onBack }) => {
2
-

- Create a personal access token -

+

Create a personal access token

Create a token and paste it into the plugin's{" "} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx index 283c052cb..f62778180 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx @@ -8,7 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import type { ConnectorConfigProps } from "../index"; export interface CirclebackConfigProps extends ConnectorConfigProps { diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 1fc555471..f44587bd8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -11,7 +11,7 @@ import { Spinner } from "@/components/ui/spinner"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { getReauthEndpoint } from "@/lib/connector-telemetry"; import { buildBackendUrl } from "@/lib/env-config"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 2f10152b8..9b8149ad1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -15,7 +15,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { searchSourceConnector } from "@/contracts/types/connector.types"; import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import { trackConnectorConnected, diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index f53537cdc..cc04af859 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -10,7 +10,7 @@ import { Spinner } from "@/components/ui/spinner"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { getReauthEndpoint } from "@/lib/connector-telemetry"; import { buildBackendUrl } from "@/lib/env-config"; import { formatRelativeDate } from "@/lib/format-date"; diff --git a/surfsense_web/components/assistant-ui/mermaid-diagram.tsx b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx index 50f1dc6de..ddf6e7e56 100644 --- a/surfsense_web/components/assistant-ui/mermaid-diagram.tsx +++ b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx @@ -28,11 +28,7 @@ function initializeMermaid() { mermaidInitialized = true; } -function MermaidDiagramComponent({ - source, - isDarkMode, - fallback, -}: MermaidDiagramProps) { +function MermaidDiagramComponent({ source, isDarkMode, fallback }: MermaidDiagramProps) { const id = useId(); const [svg, setSvg] = useState(null); const [hasError, setHasError] = useState(false); @@ -107,11 +103,7 @@ function MermaidDiagramComponent({ aria-label={hasCopied ? "Copied Mermaid source" : "Copy Mermaid source"} > Copy Source - {hasCopied ? ( - - ) : ( - - )} + {hasCopied ? : }

@@ -131,4 +123,4 @@ function MermaidDiagramComponent({ ); } -export const MermaidDiagram = memo(MermaidDiagramComponent); \ No newline at end of file +export const MermaidDiagram = memo(MermaidDiagramComponent); diff --git a/surfsense_web/components/documents/download-original-button.tsx b/surfsense_web/components/documents/download-original-button.tsx index e04ead89a..6c6f32013 100644 --- a/surfsense_web/components/documents/download-original-button.tsx +++ b/surfsense_web/components/documents/download-original-button.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; interface DownloadOriginalButtonProps { diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 75283c81f..1e29a261a 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -33,7 +33,7 @@ import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; -import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { inferMonacoLanguageFromPath } from "@/lib/editor-language"; import { buildBackendUrl } from "@/lib/env-config"; @@ -274,12 +274,6 @@ export function EditorPanelContent({ if (!documentId || !searchSpaceId) { throw new Error("Missing document context"); } - const token = getBearerToken(); - if (!token) { - redirectToLogin(); - return; - } - const response = await authenticatedFetch( buildBackendUrl( `/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` @@ -417,12 +411,6 @@ export function EditorPanelContent({ if (!searchSpaceId || !documentId) { throw new Error("Missing document context"); } - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } const response = await authenticatedFetch( buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`), { diff --git a/surfsense_web/components/editor-panel/memory.ts b/surfsense_web/components/editor-panel/memory.ts index 1beb977a6..8c4dfc035 100644 --- a/surfsense_web/components/editor-panel/memory.ts +++ b/surfsense_web/components/editor-panel/memory.ts @@ -1,6 +1,6 @@ "use client"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; export type MemoryScope = "user" | "team"; diff --git a/surfsense_web/components/homepage/auth-redirect.tsx b/surfsense_web/components/homepage/auth-redirect.tsx index 6697ab744..43073cd7d 100644 --- a/surfsense_web/components/homepage/auth-redirect.tsx +++ b/surfsense_web/components/homepage/auth-redirect.tsx @@ -2,16 +2,17 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { getBearerToken } from "@/lib/auth-utils"; +import { useSession } from "@/hooks/use-session"; export function AuthRedirect() { const router = useRouter(); + const session = useSession(); useEffect(() => { - if (getBearerToken()) { + if (session.status === "authenticated") { router.replace("/dashboard"); } - }, [router]); + }, [router, session.status]); return null; } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 44cc56ab0..e70a9fec9 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -77,7 +77,7 @@ import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { buildBackendUrl } from "@/lib/env-config"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx index d50d28a3c..6c3d37dd7 100644 --- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx +++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx @@ -10,7 +10,7 @@ import { MarkdownViewer } from "@/components/markdown-viewer"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB @@ -101,12 +101,6 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen changeCountRef.current = 0; const doFetch = async () => { - const token = getBearerToken(); - if (!token) { - redirectToLogin(); - return; - } - try { const response = await authenticatedFetch( buildBackendUrl( @@ -157,13 +151,6 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen }, []); const handleSave = useCallback(async () => { - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } - setSaving(true); try { const response = await authenticatedFetch( diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx index 5cd898afc..11de54f77 100644 --- a/surfsense_web/components/new-chat/image-model-selector.tsx +++ b/surfsense_web/components/new-chat/image-model-selector.tsx @@ -274,9 +274,9 @@ export function ImageModelSelector({ )} {showIconOnlyTrigger ? null : ( - - {selected ? modelName(selected) : "Auto"} - + + {selected ? modelName(selected) : "Auto"} + )} diff --git a/surfsense_web/components/providers/AuthCutoverPurge.tsx b/surfsense_web/components/providers/AuthCutoverPurge.tsx new file mode 100644 index 000000000..db028cb39 --- /dev/null +++ b/surfsense_web/components/providers/AuthCutoverPurge.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; + +const CUTOVER_FLAG_KEY = "surfsense_auth_cutover_v1_complete"; +const LEGACY_BEARER_TOKEN_KEY = "surfsense_bearer_token"; +const LEGACY_REFRESH_TOKEN_KEY = "surfsense_refresh_token"; + +export function AuthCutoverPurge() { + useEffect(() => { + try { + if (localStorage.getItem(CUTOVER_FLAG_KEY) === "true") return; + localStorage.removeItem(LEGACY_BEARER_TOKEN_KEY); + localStorage.removeItem(LEGACY_REFRESH_TOKEN_KEY); + localStorage.setItem(CUTOVER_FLAG_KEY, "true"); + } catch { + // Storage can be unavailable in private mode; cookie auth still works. + } + }, []); + + return null; +} diff --git a/surfsense_web/components/providers/PostHogIdentify.tsx b/surfsense_web/components/providers/PostHogIdentify.tsx index 57a7766b8..f85a5052a 100644 --- a/surfsense_web/components/providers/PostHogIdentify.tsx +++ b/surfsense_web/components/providers/PostHogIdentify.tsx @@ -1,8 +1,11 @@ "use client"; import { useAtomValue } from "jotai"; +import { usePathname } from "next/navigation"; import { useEffect, useRef } from "react"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { useSession } from "@/hooks/use-session"; +import { isPublicRoute } from "@/lib/auth-utils"; import { identifyUser, resetUser } from "@/lib/posthog/events"; /** @@ -12,7 +15,15 @@ import { identifyUser, resetUser } from "@/lib/posthog/events"; * * This should be rendered inside the PostHogProvider. */ -export function PostHogIdentify() { +function PostHogReset() { + useEffect(() => { + resetUser(); + }, []); + + return null; +} + +function PostHogUserIdentify() { const { data: user, isSuccess, isError } = useAtomValue(currentUserAtom); const previousUserIdRef = useRef(null); @@ -47,3 +58,27 @@ export function PostHogIdentify() { // This component doesn't render anything return null; } + +function SessionGatedPostHogIdentify() { + const session = useSession(); + + if (session.status === "loading") { + return null; + } + + if (session.status === "unauthenticated") { + return ; + } + + return ; +} + +export function PostHogIdentify() { + const pathname = usePathname(); + + if (isPublicRoute(pathname)) { + return ; + } + + return ; +} diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index 35d51311a..530e9c958 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -5,14 +5,22 @@ import { useZero, ZeroProvider as ZeroReactProvider, } from "@rocicorp/zero/react"; -import { useAtomValue } from "jotai"; -import { useEffect, useMemo, useRef } from "react"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { getBearerToken, handleUnauthorized, refreshAccessToken } from "@/lib/auth-utils"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useSession } from "@/hooks/use-session"; +import { getDesktopAccessToken } from "@/lib/auth-fetch"; +import { handleUnauthorized, isPublicRoute, refreshSession } from "@/lib/auth-utils"; +import { buildBackendUrl } from "@/lib/env-config"; +import type { Context } from "@/types/zero"; import { queries } from "@/zero/queries"; import { schema } from "@/zero/schema"; const configuredCacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL; +type ZeroContext = Exclude; +type LoadedZeroContext = { + context: ZeroContext; + desktopAuth?: string; +}; function getCacheURL() { if (configuredCacheURL) return configuredCacheURL; @@ -22,48 +30,155 @@ function getCacheURL() { return "http://localhost:4848"; } -function ZeroAuthSync() { +async function fetchZeroContext(isDesktop: boolean): Promise { + const headers: HeadersInit = {}; + let desktopAuth: string | undefined; + + if (isDesktop) { + const token = await getDesktopAccessToken(); + if (!token) return null; + desktopAuth = token; + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(buildBackendUrl("/zero/context"), { + credentials: "include", + headers, + }); + + if (!response.ok) return null; + + return { + context: (await response.json()) as ZeroContext, + desktopAuth, + }; +} + +function ZeroAuthSync({ isDesktop }: { isDesktop: boolean }) { const zero = useZero(); const connectionState = useConnectionState(); - const isRefreshingRef = useRef(false); useEffect(() => { - if (connectionState.name !== "needs-auth" || isRefreshingRef.current) return; + if (connectionState.name !== "needs-auth") return; - isRefreshingRef.current = true; + refreshSession().then(async (refreshed) => { + if (!refreshed) { + handleUnauthorized(); + return; + } - refreshAccessToken() - .then((newToken) => { - if (newToken) { - zero.connection.connect({ auth: newToken }); - } else { + if (isDesktop) { + const newToken = await getDesktopAccessToken(); + if (!newToken) { handleUnauthorized(); + return; } - }) - .finally(() => { - isRefreshingRef.current = false; - }); - }, [connectionState, zero]); + zero.connection.connect({ auth: newToken }); + } else { + zero.connection.connect(); + } + }); + }, [connectionState.name, isDesktop, zero]); + + useEffect(() => { + if (typeof window === "undefined" || !window.electronAPI?.onAuthChanged) return; + return window.electronAPI.onAuthChanged(({ accessToken }) => { + if (accessToken) { + zero.connection.connect({ auth: accessToken }); + } + }); + }, [zero]); return null; } -export function ZeroProvider({ children }: { children: React.ReactNode }) { - const { data: user } = useAtomValue(currentUserAtom); - const cacheURL = useMemo(() => getCacheURL(), []); +function AuthenticatedZeroProvider({ + children, + isDesktop, +}: { + children: React.ReactNode; + isDesktop: boolean; +}) { + const [loadedContext, setLoadedContext] = useState(null); - const userId = user?.id; - const hasUser = !!userId; - const userID = hasUser ? String(userId) : "anon"; - // getBearerToken() returns a string (a primitive), so it's safe to read - // on every render — reference equality holds as long as the token is - // unchanged, which keeps the memoized `opts` below stable. - const auth = hasUser ? getBearerToken() || undefined : undefined; + useEffect(() => { + let isMounted = true; - const context = useMemo( - () => (hasUser ? { userId: String(userId) } : undefined), - [hasUser, userId] + const load = async () => { + const nextContext = await fetchZeroContext(isDesktop); + if (isMounted) { + setLoadedContext(nextContext); + } + }; + + void load(); + + if (!isDesktop || typeof window === "undefined" || !window.electronAPI?.onAuthChanged) { + return () => { + isMounted = false; + }; + } + + const unsubscribe = window.electronAPI.onAuthChanged(({ accessToken }) => { + if (!accessToken) { + setLoadedContext(null); + return; + } + void load(); + }); + + return () => { + isMounted = false; + unsubscribe(); + }; + }, [isDesktop]); + + if (!loadedContext) { + return <>{children}; + } + + return ( + + {children} + ); +} + +function ZeroClientProvider({ + children, + userID, + context, + isDesktop, + initialDesktopAuth, +}: { + children: React.ReactNode; + userID: string; + context: ZeroContext; + isDesktop: boolean; + initialDesktopAuth?: string; +}) { + const cacheURL = useMemo(() => getCacheURL(), []); + const [desktopAuth, setDesktopAuth] = useState(initialDesktopAuth); + + useEffect(() => { + setDesktopAuth(initialDesktopAuth); + }, [initialDesktopAuth]); + + useEffect(() => { + if (!isDesktop) return; + let isMounted = true; + getDesktopAccessToken().then((token) => { + if (isMounted) setDesktopAuth(token || undefined); + }); + return () => { + isMounted = false; + }; + }, [isDesktop]); const opts = useMemo( () => ({ @@ -72,15 +187,44 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) { queries, context, cacheURL, - auth, + auth: isDesktop ? desktopAuth : undefined, }), - [userID, context, cacheURL, auth] + [userID, context, cacheURL, isDesktop, desktopAuth] ); return ( - {hasUser && } + {children} ); } + +function WebZeroProvider({ children }: { children: React.ReactNode }) { + const session = useSession(); + + if (session.status !== "authenticated") { + return <>{children}; + } + + return {children}; +} + +function DesktopZeroProvider({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function ZeroProvider({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isDesktop = typeof window !== "undefined" && !!window.electronAPI; + + if (!isDesktop && isPublicRoute(pathname)) { + return <>{children}; + } + + if (isDesktop) { + return {children}; + } + + return {children}; +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx index 4e8ec5bb6..e8e8b6b12 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx @@ -1,12 +1,10 @@ -import { Link2Off } from "lucide-react"; - interface PublicChatSnapshotsEmptyStateProps { title?: string; description?: string; } export function PublicChatSnapshotsEmptyState({ - title = "No public chat links", + title = "No public chats", description = "When you create public links to share chats, they will appear here.", }: PublicChatSnapshotsEmptyStateProps) { return ( diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx index 3cf07c27a..f18a0f705 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -115,7 +115,7 @@ export function PublicChatSnapshotsManager({ - Failed to load public chat links. Please try again later. + Failed to load public chats. Please try again later. ); @@ -127,7 +127,7 @@ export function PublicChatSnapshotsManager({ - You don't have permission to view public chat links in this search space. + You don't have permission to view public chats in this search space. ); @@ -140,8 +140,8 @@ export function PublicChatSnapshotsManager({ - Public chat links allow anyone with the URL to view a snapshot of a chat. These links do - not update when the original chat changes. + Public chats allow anyone with the URL to view a snapshot of a chat. They do not update + when the original chat changes. diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index 7d3263341..5c775a2a1 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -6,8 +6,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/hooks/use-session"; import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; interface PublicChatFooterProps { shareToken: string; @@ -15,6 +15,7 @@ interface PublicChatFooterProps { export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { const router = useRouter(); + const session = useSession(); const [isCloning, setIsCloning] = useState(false); const hasAutoCloned = useRef(false); @@ -40,19 +41,16 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { // this is a one-time post-login check. (Vercel Best Practice: rerender-defer-reads 5.2) useEffect(() => { const action = new URLSearchParams(window.location.search).get("action"); - const token = getBearerToken(); // Only auto-clone once, if authenticated and action=clone is present - if (action === "clone" && token && !hasAutoCloned.current && !isCloning) { + if (action === "clone" && session.authenticated && !hasAutoCloned.current && !isCloning) { hasAutoCloned.current = true; triggerClone(); } - }, [isCloning, triggerClone]); + }, [isCloning, session.authenticated, triggerClone]); const handleCopyAndContinue = async () => { - const token = getBearerToken(); - - if (!token) { + if (!session.authenticated) { // Include action=clone in the returnUrl so it persists after login const returnUrl = encodeURIComponent(`/public/${shareToken}?action=clone`); router.push(`/login?returnUrl=${returnUrl}`); diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 77d0f83a6..bc385eb53 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -6,7 +6,7 @@ import * as pdfjsLib from "pdfjs-dist"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import { getAuthHeaders } from "@/lib/auth-utils"; +import { getAuthHeaders } from "@/lib/auth-fetch"; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 53b0c9867..1fce9848c 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -21,7 +21,7 @@ import { import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; function ReportPanelSkeleton() { diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx index cfbcedbbf..d0c08d881 100644 --- a/surfsense_web/components/settings/general-settings-manager.tsx +++ b/surfsense_web/components/settings/general-settings-manager.tsx @@ -15,7 +15,7 @@ import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { Spinner } from "../ui/spinner"; @@ -208,10 +208,9 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
- +

- Allow personal access tokens to use this search space. Web and desktop sessions are - not affected. + Allow API keys to access this search space.

{ + if (typeof window === "undefined" || !window.electronAPI?.getAccessToken) { + return {}; + } + + const token = await window.electronAPI.getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export function useSession() { + const [state, setState] = useState({ + status: "loading", + authenticated: false, + accessExpiresAt: null, + }); + + const refresh = useCallback(async () => { + try { + const response = await fetch(buildBackendUrl("/auth/session"), { + credentials: "include", + headers: await getSessionHeaders(), + }); + if (!response.ok) { + setState({ + status: "unauthenticated", + authenticated: false, + accessExpiresAt: null, + }); + return; + } + const data = (await response.json()) as { + authenticated: boolean; + access_expires_at: number | null; + }; + setState({ + status: "authenticated", + authenticated: true, + accessExpiresAt: data.access_expires_at, + }); + } catch { + setState({ + status: "unauthenticated", + authenticated: false, + accessExpiresAt: null, + }); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { ...state, refresh }; +} diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 678293d8e..5afb291ba 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,7 +1,7 @@ import type { ZodType } from "zod"; import { buildBackendUrl } from "@/lib/env-config"; import { getClientPlatform } from "../agent-filesystem"; -import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils"; +import { handleUnauthorized, refreshSession } from "../auth-utils"; import { AbortedError, AppError, @@ -19,6 +19,25 @@ enum ResponseType { // Add more response types as needed } +const REFRESH_RETRY_BLOCK_MS = 30_000; +const refreshRetryBlockedUntil = new Map(); + +function getRefreshRetryKey(method: RequestOptions["method"], url: string): string { + return `${method}:${url}`; +} + +function isRefreshRetryBlocked(key: string): boolean { + const blockedUntil = refreshRetryBlockedUntil.get(key); + if (!blockedUntil) return false; + if (Date.now() < blockedUntil) return true; + refreshRetryBlockedUntil.delete(key); + return false; +} + +function blockRefreshRetry(key: string): void { + refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS); +} + export type RequestOptions = { method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; @@ -31,21 +50,18 @@ export type RequestOptions = { }; class BaseApiService { - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/jwt/refresh"]; // Prefixes that don't require auth (checked with startsWith) noAuthPrefixes: string[] = ["/api/v1/public/"]; - // Use a getter to always read fresh token from localStorage - // This ensures the token is always up-to-date after login/logout - get bearerToken(): string { - return typeof window !== "undefined" ? getBearerToken() || "" : ""; + get isDesktopClient(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; } - // Keep for backward compatibility, but token is now always read from localStorage - setBearerToken(_bearerToken: string) { - void _bearerToken; - // No-op: token is now always read fresh from localStorage via the getter + private async getDesktopAccessToken(): Promise { + if (!this.isDesktopClient) return ""; + return (await window.electronAPI?.getAccessToken?.()) || ""; } async request( @@ -69,9 +85,15 @@ class BaseApiService { * REQUEST * ---------- */ + const isNoAuthEndpoint = + this.noAuthEndpoints.includes(url) || + this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || + /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); + const desktopAccessToken = + this.isDesktopClient && !isNoAuthEndpoint ? await this.getDesktopAccessToken() : ""; const defaultOptions: RequestOptions = { headers: { - Authorization: `Bearer ${this.bearerToken || ""}`, + ...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}), "X-SurfSense-Client-Platform": typeof window === "undefined" ? "web" : getClientPlatform(), }, @@ -88,12 +110,8 @@ class BaseApiService { }, }; - // Validate the bearer token - const isNoAuthEndpoint = - this.noAuthEndpoints.includes(url) || - this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || - /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); - if (!this.bearerToken && !isNoAuthEndpoint) { + const refreshRetryKey = getRefreshRetryKey(mergedOptions.method, url); + if (this.isDesktopClient && !desktopAccessToken && !isNoAuthEndpoint) { throw new AuthenticationError("You are not authenticated. Please login again."); } @@ -104,6 +122,7 @@ class BaseApiService { method: mergedOptions.method, headers: mergedOptions.headers, signal: mergedOptions.signal, + credentials: "include", }; // Automatically stringify body if Content-Type is application/json and body is an object @@ -150,18 +169,22 @@ class BaseApiService { // Handle 401 - try to refresh token first (only once) if (response.status === 401) { - if (!options?._isRetry) { - const newToken = await refreshAccessToken(); - if (newToken) { + if (options?._isRetry) { + blockRefreshRetry(refreshRetryKey); + } else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) { + const refreshed = await refreshSession(); + if (refreshed) { + const newToken = this.isDesktopClient ? await this.getDesktopAccessToken() : ""; return this.request(url, responseSchema, { ...mergedOptions, headers: { ...mergedOptions.headers, - Authorization: `Bearer ${newToken}`, + ...(this.isDesktopClient ? { Authorization: `Bearer ${newToken}` } : {}), }, _isRetry: true, } as RequestOptions & { responseType?: R }); } + blockRefreshRetry(refreshRetryKey); } handleUnauthorized(); throw new AuthenticationError( @@ -196,6 +219,7 @@ class BaseApiService { ); } } + refreshRetryBlockedUntil.delete(getRefreshRetryKey(mergedOptions.method, url)); // biome-ignore lint/suspicious: Unknown let data; @@ -381,7 +405,6 @@ class BaseApiService { ...options, headers: { // Don't set Content-Type - let browser set it with multipart boundary - Authorization: `Bearer ${this.bearerToken}`, ...headersWithoutContentType, }, responseType: ResponseType.JSON, diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index a3966634b..7f98399bd 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -13,11 +13,11 @@ import { getSearchSpacesRequest, getSearchSpacesResponse, leaveSearchSpaceResponse, - type UpdateSearchSpaceRequest, type UpdateSearchSpaceApiAccessRequest, - updateSearchSpaceRequest, + type UpdateSearchSpaceRequest, updateSearchSpaceApiAccessRequest, updateSearchSpaceApiAccessResponse, + updateSearchSpaceRequest, updateSearchSpaceResponse, } from "@/contracts/types/search-space.types"; import { ValidationError } from "../error"; diff --git a/surfsense_web/lib/auth-fetch.ts b/surfsense_web/lib/auth-fetch.ts new file mode 100644 index 000000000..20b236854 --- /dev/null +++ b/surfsense_web/lib/auth-fetch.ts @@ -0,0 +1,75 @@ +import { handleUnauthorized, isDesktopClient, refreshSession } from "@/lib/auth-utils"; + +let desktopAccessToken: string | null = null; +let didSubscribeToDesktopAuth = false; + +function subscribeToDesktopAuth(): void { + if (didSubscribeToDesktopAuth || typeof window === "undefined" || !window.electronAPI) { + return; + } + didSubscribeToDesktopAuth = true; + + window.electronAPI.onAuthChanged?.(({ accessToken }) => { + desktopAccessToken = accessToken; + }); + void window.electronAPI.getAccessToken?.().then((token) => { + if (token) desktopAccessToken = token; + }); +} + +export async function getDesktopAccessToken(): Promise { + if (!isDesktopClient()) return null; + subscribeToDesktopAuth(); + if (desktopAccessToken) return desktopAccessToken; + const token = (await window.electronAPI?.getAccessToken?.()) || null; + desktopAccessToken = token; + return token; +} + +export function getAuthHeaders(additionalHeaders?: Record): Record { + subscribeToDesktopAuth(); + return { + ...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}), + ...additionalHeaders, + }; +} + +export async function authenticatedFetch( + url: string, + options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } +): Promise { + const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; + const token = await getDesktopAccessToken(); + const headers = { + ...(fetchOptions.headers as Record), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const response = await fetch(url, { + ...fetchOptions, + headers, + credentials: "include", + }); + + if (response.status === 401 && !skipAuthRedirect) { + if (!skipRefresh) { + const refreshed = await refreshSession(); + if (refreshed) { + const newToken = await getDesktopAccessToken(); + return fetch(url, { + ...fetchOptions, + headers: { + ...(fetchOptions.headers as Record), + ...(newToken ? { Authorization: `Bearer ${newToken}` } : {}), + }, + credentials: "include", + }); + } + } + + handleUnauthorized(); + throw new Error("Unauthorized: Redirecting to login page"); + } + + return response; +} diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 8ad10308b..47b2f043f 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -1,15 +1,21 @@ /** - * Authentication utilities for handling token expiration and redirects + * Authentication utilities for handling session expiration and redirects. */ import { buildBackendUrl } from "@/lib/env-config"; const REDIRECT_PATH_KEY = "surfsense_redirect_path"; -const BEARER_TOKEN_KEY = "surfsense_bearer_token"; -const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; +const LEGACY_BEARER_TOKEN_KEY = "surfsense_bearer_token"; +const LEGACY_REFRESH_TOKEN_KEY = "surfsense_refresh_token"; -// Flag to prevent multiple simultaneous refresh attempts -let isRefreshing = false; -let refreshPromise: Promise | null = null; +export function isDesktopClient(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function purgeLegacyStoredTokens(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(LEGACY_BEARER_TOKEN_KEY); + localStorage.removeItem(LEGACY_REFRESH_TOKEN_KEY); +} /** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */ const PUBLIC_ROUTE_PREFIXES = [ @@ -43,23 +49,20 @@ export function getLoginPath(): string { } /** - * Clears tokens and optionally redirects to login. + * Clears auth state 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. + * Only redirects when the current route is protected; on public routes we just clear state. */ export function handleUnauthorized(): void { if (typeof window === "undefined") return; const pathname = window.location.pathname; - - // Always clear tokens - localStorage.removeItem(BEARER_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); + purgeLegacyStoredTokens(); // 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", "/"]; + const excludedPaths = ["/auth", "/"]; if (!excludedPaths.includes(pathname)) { setRedirectPath(currentPath); } @@ -89,100 +92,8 @@ export function getAndClearRedirectPath(): string | null { return redirectPath; } -/** - * Gets the bearer token from localStorage - */ -export function getBearerToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(BEARER_TOKEN_KEY); -} - -/** - * Sets the bearer token in localStorage - */ -export function setBearerToken(token: string): void { - if (typeof window === "undefined") return; - localStorage.setItem(BEARER_TOKEN_KEY, token); - syncTokensToElectron(); -} - -/** - * Clears the bearer token from localStorage - */ -export function clearBearerToken(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(BEARER_TOKEN_KEY); -} - -/** - * Gets the refresh token from localStorage - */ -export function getRefreshToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(REFRESH_TOKEN_KEY); -} - -/** - * Sets the refresh token in localStorage - */ -export function setRefreshToken(token: string): void { - if (typeof window === "undefined") return; - localStorage.setItem(REFRESH_TOKEN_KEY, token); - syncTokensToElectron(); -} - -/** - * Clears the refresh token from localStorage - */ -export function clearRefreshToken(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(REFRESH_TOKEN_KEY); -} - -/** - * Clears all auth tokens from localStorage - */ -export function clearAllTokens(): void { - clearBearerToken(); - clearRefreshToken(); -} - -/** - * Pushes the current localStorage tokens into the Electron main process - * so that other BrowserWindows (Quick Ask, Autocomplete) can access them. - */ -function syncTokensToElectron(): void { - if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return; - const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || ""; - const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || ""; - if (bearer) { - window.electronAPI.setAuthTokens(bearer, refresh); - } -} - -/** - * Attempts to pull auth tokens from the Electron main process into localStorage. - * Useful for popup windows (Quick Ask, Autocomplete) on platforms where - * localStorage is not reliably shared across BrowserWindow instances. - * Returns true if tokens were found and written to localStorage. - */ -export async function ensureTokensFromElectron(): Promise { - if (typeof window === "undefined" || !window.electronAPI?.getAuthTokens) return false; - if (getBearerToken()) return true; - - try { - const tokens = await window.electronAPI.getAuthTokens(); - if (tokens?.bearer) { - localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer); - if (tokens.refresh) { - localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh); - } - return true; - } - } catch { - // IPC failure — fall through - } - return false; +export function getPostLoginRedirectPath(defaultPath = "/dashboard"): string { + return getAndClearRedirectPath() || defaultPath; } /** @@ -190,38 +101,45 @@ export async function ensureTokensFromElectron(): Promise { * Returns true if logout was successful (or tokens were cleared), false otherwise. */ export async function logout(): Promise { - const refreshToken = getRefreshToken(); + const isDesktop = isDesktopClient(); - // Call backend to revoke the refresh token - if (refreshToken) { - try { - const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { - method: "POST", - 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 - } + if (isDesktop && window.electronAPI?.logout) { + await window.electronAPI.logout(); + purgeLegacyStoredTokens(); + return true; } - // Clear all tokens from localStorage - clearAllTokens(); + try { + const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + 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 state even if server revoke fails. + } + + purgeLegacyStoredTokens(); return true; } /** - * Checks if the user is authenticated (has a token) + * Compatibility helper for legacy query gates. + * + * Web auth is cookie-backed, so the client cannot synchronously prove whether a + * session exists. Return true and let `/auth/session` or API 401s settle it. + * Desktop can synchronously check for the Electron bridge, while the access + * token itself is resolved asynchronously by auth-fetch. */ export function isAuthenticated(): boolean { - return !!getBearerToken(); + return true; } /** @@ -236,7 +154,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", "/desktop/login"]; if (!excludedPaths.includes(window.location.pathname)) { setRedirectPath(currentPath); } @@ -244,107 +162,35 @@ export function redirectToLogin(): void { window.location.href = getLoginPath(); } -/** - * Creates headers with authorization bearer token - */ -export function getAuthHeaders(additionalHeaders?: Record): Record { - const token = getBearerToken(); - return { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...additionalHeaders, - }; -} - -/** - * Attempts to refresh the access token using the stored refresh token. - * Returns the new access token if successful, null otherwise. - */ -export async function refreshAccessToken(): Promise { - // If already refreshing, wait for that request to complete - if (isRefreshing && refreshPromise) { - return refreshPromise; +async function doRefreshSession(): Promise { + if (isDesktopClient()) { + const token = await window.electronAPI?.refreshAccessToken?.(); + return !!token; } - const currentRefreshToken = getRefreshToken(); - if (!currentRefreshToken) { - return null; - } + try { + const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); - isRefreshing = true; - refreshPromise = (async () => { - try { - const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ refresh_token: currentRefreshToken }), - }); - - if (!response.ok) { - // Refresh failed, clear tokens - clearAllTokens(); - return null; - } - - const data = await response.json(); - if (data.access_token && data.refresh_token) { - setBearerToken(data.access_token); - setRefreshToken(data.refresh_token); - return data.access_token; - } - return null; - } catch { - return null; - } finally { - isRefreshing = false; - refreshPromise = null; - } - })(); - - return refreshPromise; -} - -/** - * Authenticated fetch wrapper that handles 401 responses uniformly. - * On 401, attempts to refresh the token and retry the request. - * If refresh fails, redirects to login and saves the current path. - */ -export async function authenticatedFetch( - url: string, - options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } -): Promise { - const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; - - const headers = getAuthHeaders(fetchOptions.headers as Record); - - const response = await fetch(url, { - ...fetchOptions, - headers, - }); - - // Handle 401 Unauthorized - if (response.status === 401 && !skipAuthRedirect) { - // Try to refresh the token (unless skipRefresh is set to prevent infinite loops) - if (!skipRefresh) { - const newToken = await refreshAccessToken(); - if (newToken) { - // Retry the original request with the new token - const retryHeaders = { - ...(fetchOptions.headers as Record), - Authorization: `Bearer ${newToken}`, - }; - return fetch(url, { - ...fetchOptions, - headers: retryHeaders, - }); - } + if (!response.ok) { + purgeLegacyStoredTokens(); + return false; } - // Refresh failed or was skipped, redirect to login - handleUnauthorized(); - throw new Error("Unauthorized: Redirecting to login page"); + return true; + } catch { + return false; } - - return response; +} + +export async function refreshSession(): Promise { + if (typeof navigator !== "undefined" && "locks" in navigator) { + return navigator.locks.request("ss-token-refresh", () => doRefreshSession()); + } + return doRefreshSession(); } diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 6cfee4edf..b8c50c701 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -746,8 +746,8 @@ "nav_agent_models_desc": "Models with prompts & citations", "nav_system_instructions": "System Instructions", "nav_system_instructions_desc": "SearchSpace-wide AI instructions", - "nav_public_links": "Public Chat Links", - "nav_public_links_desc": "Manage publicly shared chat links", + "nav_public_links": "Public Chats", + "nav_public_links_desc": "Manage publicly shared chats", "nav_team_roles": "Team Roles", "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Name", diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 0f4d2ca33..9f877c337 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -82,7 +82,7 @@ "@remotion/media": "^4.0.438", "@remotion/player": "^4.0.438", "@remotion/web-renderer": "^4.0.438", - "@rocicorp/zero": "1.4.0", + "@rocicorp/zero": "1.6.0", "@slate-serializers/html": "^2.2.3", "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", @@ -116,6 +116,7 @@ "lenis": "^1.3.17", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "motion": "^12.23.22", "next": "^16.1.0", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 4a5b0b5d0..4284d944d 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -168,8 +168,8 @@ importers: specifier: ^4.0.438 version: 4.0.438(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@rocicorp/zero': - specifier: 1.4.0 - version: 1.4.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0)) + specifier: 1.6.0 + version: 1.6.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0)) '@slate-serializers/html': specifier: ^2.2.3 version: 2.2.3 @@ -269,6 +269,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) + mermaid: + specifier: ^11.15.0 + version: 11.15.0 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -483,6 +486,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@ariakit/core@0.4.18': resolution: {integrity: sha512-9urEa+GbZTSyredq3B/3thQjTcSZSUC68XctwCkJNH/xNfKN5O+VThiem2rcJxpsGw8sRUQenhagZi0yB4foyg==} @@ -1181,6 +1187,12 @@ packages: cpu: [x64] os: [win32] + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@databases/escape-identifier@1.0.3': resolution: {integrity: sha512-Su36iSVzaHxpVdISVMViUX/32sLvzxVgjZpYhzhotxZUuLo11GVWsiHwqkvUZijTLUxcDmUqEwGJO3O/soLuZA==} @@ -1813,6 +1825,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -2012,6 +2030,9 @@ packages: peerDependencies: mediabunny: ^1.0.0 + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@microlink/react-json-view@1.31.20': resolution: {integrity: sha512-gNLkGvjFDeAqVGvK3H7lfoDqetn/9lW2ugiYiJhchc7jQU1ZaKsZnt97ANluXWFfd/wifoA9TrVOTsUXwXCJwA==} engines: {node: '>=17'} @@ -2742,10 +2763,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} - engines: {node: '>=14'} - '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -3140,9 +3157,6 @@ packages: '@protobufjs/base64@1.1.2': resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} @@ -3155,9 +3169,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - '@protobufjs/inquire@1.1.1': resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} @@ -3167,9 +3178,6 @@ packages: '@protobufjs/pool@1.1.0': resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} @@ -4219,14 +4227,15 @@ packages: '@rocicorp/resolver@1.0.2': resolution: {integrity: sha512-TfjMTQp9cNNqNtHFfa+XHEGdA7NnmDRu+ZJH4YF3dso0Xk/b9DMhg/sl+b6CR4ThFZArXXDsG1j8Mwl34wcOZQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + deprecated: Use Promise.withResolvers instead '@rocicorp/zero-sqlite3@1.0.18': resolution: {integrity: sha512-JwHcCijxKj94NDij5UDCJsGHo/D8z4j5De/5zphQ+NctQ4TWr9Zx7L+Q1JBfie4ewVS82Ingu+QKbIwWvdNFXg==} engines: {bun: '>=1.1.0', node: 20.x || 22.x || 23.x || 24.x || 25.x} hasBin: true - '@rocicorp/zero@1.4.0': - resolution: {integrity: sha512-BRgdF64JWNgIsHG4Fajgjr5ms0HBTdmZUWoJy09KE3TNwMo0Rmz1r1fte1MMH1zY4witcUJsFhGj4aHLsZAfTA==} + '@rocicorp/zero@1.6.0': + resolution: {integrity: sha512-Rjr9fyrH1FMo3WJkL0kPx1GaIgTWmtQ73PtHEB9n4Ev0+fBJtO5miYzVUWH1Js0Uaj2+Tqc14WcXKrgnvpGNBA==} engines: {node: '>=22'} hasBin: true peerDependencies: @@ -4767,6 +4776,99 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4788,6 +4890,9 @@ packages: '@types/gapi@0.0.47': resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/google.picker@0.0.52': resolution: {integrity: sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==} @@ -5056,6 +5161,9 @@ packages: cpu: [x64] os: [win32] + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -5482,6 +5590,12 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -5533,6 +5647,162 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -5554,6 +5824,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -5603,6 +5876,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -5832,6 +6108,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.47.1: + resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -6362,6 +6641,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -6487,6 +6769,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + icu-minify@4.8.3: resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} @@ -6524,6 +6810,9 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -6541,6 +6830,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intl-messageformat@11.1.2: resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} @@ -6875,6 +7171,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -6882,6 +7181,12 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lenis@1.3.17: resolution: {integrity: sha512-k9T9rgcxne49ggJOvXCraWn5dt7u2mO+BNkhyu6yxuEnm9c092kAW5Bus5SO211zUvx7aCCEtzy9UWr0RB+oJw==} peerDependencies: @@ -7057,6 +7362,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marked@17.0.3: resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} @@ -7133,6 +7443,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7312,11 +7625,6 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanoid@5.1.7: - resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} - engines: {node: ^18 || >=20} - hasBin: true - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -7500,6 +7808,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -7526,6 +7837,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -7636,6 +7950,12 @@ packages: po-parser@2.1.1: resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -7733,10 +8053,6 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.5.5: - resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} - engines: {node: '>=12.0.0'} - protobufjs@7.5.7: resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==} engines: {node: '>=12.0.0'} @@ -8112,14 +8428,23 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -8149,6 +8474,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -8165,11 +8493,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -8400,6 +8723,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -8516,6 +8842,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-essentials@10.1.0: resolution: {integrity: sha512-LirrVzbhIpFQ9BdGfqLnM9r7aP9rnyfeoxbP5ZEkdr531IaY21+KdebRSsbvqu28VDJtcDDn+AlGn95t0c52zQ==} peerDependencies: @@ -8736,6 +9066,10 @@ packages: utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -8984,6 +9318,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@ariakit/core@0.4.18': {} '@ariakit/react-core@0.4.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -9148,7 +9487,7 @@ snapshots: '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 lodash.debounce: 4.0.8 - resolve: 1.22.11 + resolve: 1.22.12 transitivePeerDependencies: - supports-color @@ -9844,6 +10183,10 @@ snapshots: '@biomejs/cli-win32-x64@2.4.6': optional: true + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@databases/escape-identifier@1.0.3': dependencies: '@databases/validate-unicode': 1.0.0 @@ -10297,6 +10640,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.0.0': optional: true @@ -10464,6 +10815,10 @@ snapshots: dependencies: mediabunny: 1.39.2 + '@mermaid-js/parser@1.1.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@microlink/react-json-view@1.31.20(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: react: 19.2.4 @@ -10605,7 +10960,7 @@ snapshots: '@opentelemetry/api-logs@0.208.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api@1.9.0': {} @@ -10683,12 +11038,12 @@ snapshots: '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0)': dependencies: @@ -11203,7 +11558,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.5.5 + protobufjs: 7.5.7 '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.1)': dependencies: @@ -11264,13 +11619,13 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)': dependencies: @@ -11350,7 +11705,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)': dependencies: @@ -11373,8 +11728,6 @@ snapshots: '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions@1.39.0': {} - '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': @@ -11485,7 +11838,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -11552,7 +11905,7 @@ snapshots: jotai-optics: 0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1) jotai-x: 2.3.3(@types/react@19.2.14)(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) lodash: 4.17.23 - nanoid: 5.1.7 + nanoid: 5.1.11 optics-ts: 2.4.1 react: 19.2.4 react-compiler-runtime: 1.0.0(react@19.2.4) @@ -11731,8 +12084,6 @@ snapshots: '@protobufjs/base64@1.1.2': {} - '@protobufjs/codegen@2.0.4': {} - '@protobufjs/codegen@2.0.5': {} '@protobufjs/eventemitter@1.1.0': {} @@ -11740,20 +12091,16 @@ snapshots: '@protobufjs/fetch@1.1.0': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.0': {} - '@protobufjs/inquire@1.1.1': {} '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} - '@protobufjs/utf8@1.1.0': {} - '@protobufjs/utf8@1.1.1': {} '@radix-ui/number@1.1.1': {} @@ -12870,7 +13217,7 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 - '@rocicorp/zero@1.4.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0))': + '@rocicorp/zero@1.6.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0))': dependencies: '@badrap/valita': 0.3.11 '@databases/escape-identifier': 1.0.3 @@ -13405,6 +13752,123 @@ snapshots: dependencies: '@types/node': 20.19.33 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -13425,6 +13889,8 @@ snapshots: '@types/gapi@0.0.47': {} + '@types/geojson@7946.0.16': {} + '@types/google.picker@0.0.52': {} '@types/hast@2.3.10': @@ -13582,7 +14048,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.6 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -13696,6 +14162,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@xmldom/xmldom@0.8.11': {} abstract-logging@2.0.1: {} @@ -14124,6 +14595,14 @@ snapshots: core-util-is@1.0.3: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 @@ -14183,6 +14662,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -14207,6 +14870,8 @@ snapshots: date-fns@4.1.0: {} + dayjs@1.11.21: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -14245,6 +14910,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -14411,7 +15080,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -14476,11 +15145,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-to-primitive@1.3.0: dependencies: @@ -14488,6 +15157,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.47.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -15098,7 +15769,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -15145,7 +15816,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -15192,6 +15863,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -15418,6 +16091,10 @@ snapshots: human-signals@2.1.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + icu-minify@4.8.3: dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 @@ -15450,6 +16127,8 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -15461,9 +16140,13 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 + internmap@1.0.1: {} + + internmap@2.0.3: {} + intl-messageformat@11.1.2: dependencies: '@formatjs/ecma402-abstract': 3.1.1 @@ -15521,7 +16204,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 is-callable@1.2.7: {} @@ -15596,7 +16279,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-set@2.0.3: {} @@ -15757,12 +16440,18 @@ snapshots: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: dependencies: language-subtag-registry: 0.3.23 + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lenis@1.3.17(react@19.2.4): optionalDependencies: react: 19.2.4 @@ -15896,6 +16585,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -16088,6 +16779,30 @@ snapshots: merge2@1.4.1: {} + mermaid@11.15.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.3.1 + es-toolkit: 1.47.1 + katex: 0.16.32 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.0 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -16416,8 +17131,6 @@ snapshots: nanoid@5.1.11: {} - nanoid@5.1.7: {} - napi-build-utils@2.0.0: {} napi-postinstall@0.3.4: {} @@ -16635,6 +17348,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.6.0: {} + pako@1.0.11: {} pako@2.1.0: {} @@ -16677,6 +17392,8 @@ snapshots: dependencies: entities: 6.0.1 + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -16789,6 +17506,13 @@ snapshots: po-parser@2.1.1: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: @@ -16897,21 +17621,6 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.5: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 20.19.33 - long: 5.3.2 - protobufjs@7.5.7: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -17421,7 +18130,7 @@ snapshots: resolve@1.22.11: dependencies: - is-core-module: 2.16.1 + is-core-module: 2.16.2 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -17435,7 +18144,7 @@ snapshots: resolve@2.0.0-next.6: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 + is-core-module: 2.16.2 node-exports-info: 1.6.0 object-keys: 1.1.1 path-parse: 1.0.7 @@ -17447,6 +18156,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -17478,10 +18189,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -17513,6 +18233,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -17525,8 +18247,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.0: {} server-only@0.0.1: {} @@ -17563,7 +18283,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -17854,6 +18574,8 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + stylis@4.4.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -17959,6 +18681,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-dedent@2.3.0: {} + ts-essentials@10.1.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -18213,6 +18937,8 @@ snapshots: dependencies: base64-arraybuffer: 1.0.2 + uuid@14.0.0: {} + uuid@8.3.2: {} uuid@9.0.1: {} diff --git a/surfsense_web/proxy.ts b/surfsense_web/proxy.ts index b53ce68a7..5218926b4 100644 --- a/surfsense_web/proxy.ts +++ b/surfsense_web/proxy.ts @@ -1,9 +1,6 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { BUILD_TIME_AUTH_TYPE } from "@/lib/env-config"; -import { - RUNTIME_AUTH_TYPE_COOKIE_NAME, - resolveRuntimeAuthUiMode, -} from "@/lib/runtime-auth-config"; +import { RUNTIME_AUTH_TYPE_COOKIE_NAME, resolveRuntimeAuthUiMode } from "@/lib/runtime-auth-config"; export function proxy(request: NextRequest) { const response = NextResponse.next(); diff --git a/surfsense_web/tests/auth.setup.ts b/surfsense_web/tests/auth.setup.ts index 7c1e37a39..c7c8bce4f 100644 --- a/surfsense_web/tests/auth.setup.ts +++ b/surfsense_web/tests/auth.setup.ts @@ -4,9 +4,9 @@ import { announcements } from "../lib/announcements/announcements-data"; import { acquireTestToken } from "./helpers/api/auth"; /** - * One-time authentication setup. Acquires a bearer token for the seeded - * e2e user (rate-limit-free /__e2e__/auth/token first, /auth/jwt/login - * fallback) and persists it via localStorage so every test in the + * One-time authentication setup. Acquires an access token for the seeded + * e2e user (rate-limit-free /__e2e__/auth/token first, desktop login + * fallback) and persists it as the session cookie so every test in the * chromium project starts already authenticated. * * Also pre-seeds the localStorage flags that gate the two new-user UI @@ -18,7 +18,9 @@ import { acquireTestToken } from "./helpers/api/auth"; const authFile = path.join(__dirname, "..", "playwright", ".auth", "user.json"); -const STORAGE_KEY = "surfsense_bearer_token"; +const PORT = process.env.PORT || "3000"; +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "surfsense_session"; const ANNOUNCEMENTS_KEY = "surfsense_announcements_state"; /** Decode the user id (`sub`) from a JWT without verifying the signature. */ @@ -45,17 +47,24 @@ setup("authenticate", async ({ page, request }) => { const announcementIds = announcements.map((a) => a.id); const announcementState = { readIds: announcementIds, toastedIds: announcementIds }; + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: access_token, + url: BASE_URL, + httpOnly: true, + sameSite: "Lax", + }, + ]); + await page.addInitScript( - ({ key, token, announcementsKey, state, uid }) => { - localStorage.setItem(key, token); + ({ announcementsKey, state, uid }) => { localStorage.setItem(announcementsKey, JSON.stringify(state)); if (uid) { localStorage.setItem(`surfsense-tour-${uid}`, "true"); } }, { - key: STORAGE_KEY, - token: access_token, announcementsKey: ANNOUNCEMENTS_KEY, state: announcementState, uid: userId, diff --git a/surfsense_web/tests/fixtures/search-space.fixture.ts b/surfsense_web/tests/fixtures/search-space.fixture.ts index 62958caf4..e68ff6dce 100644 --- a/surfsense_web/tests/fixtures/search-space.fixture.ts +++ b/surfsense_web/tests/fixtures/search-space.fixture.ts @@ -22,26 +22,21 @@ export type SearchSpaceFixtures = { searchSpace: SearchSpaceRow; }; -const STORAGE_KEY = "surfsense_bearer_token"; +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "surfsense_session"; -// Reuse the token written by tests/auth.setup.ts; on cache miss we +// Reuse the session cookie written by tests/auth.setup.ts; on cache miss we // mint a fresh one via /__e2e__/auth/token (rate-limit-free). const AUTH_STATE_PATH = path.join(__dirname, "..", "..", "playwright", ".auth", "user.json"); -function loadCachedBearerToken(): string | null { +function loadCachedSessionToken(): string | null { try { const raw = fs.readFileSync(AUTH_STATE_PATH, "utf8"); const parsed = JSON.parse(raw) as { - origins?: Array<{ - origin?: string; - localStorage?: Array<{ name?: string; value?: string }>; - }>; + cookies?: Array<{ name?: string; value?: string }>; }; - for (const origin of parsed.origins ?? []) { - for (const entry of origin.localStorage ?? []) { - if (entry.name === STORAGE_KEY && entry.value) { - return entry.value; - } + for (const cookie of parsed.cookies ?? []) { + if (cookie.name === SESSION_COOKIE_NAME && cookie.value) { + return cookie.value; } } } catch { @@ -53,7 +48,7 @@ function loadCachedBearerToken(): string | null { export const searchSpaceFixtures = base.extend({ apiTokenWorker: [ async ({ playwright }, use) => { - const cached = loadCachedBearerToken(); + const cached = loadCachedSessionToken(); if (cached) { await use(cached); return; diff --git a/surfsense_web/tests/helpers/api/auth.ts b/surfsense_web/tests/helpers/api/auth.ts index 6492b09ba..845e868f1 100644 --- a/surfsense_web/tests/helpers/api/auth.ts +++ b/surfsense_web/tests/helpers/api/auth.ts @@ -1,11 +1,11 @@ import type { APIRequestContext } from "@playwright/test"; /** - * Direct backend auth helper. Uses the same /auth/jwt/login endpoint the - * UI uses; mirrors lib/apis/auth-api.service.ts. + * Direct backend auth helper. Uses the desktop login endpoint when the + * rate-limit-free e2e mint endpoint is unavailable. * * Returns a bearer token specs can attach to API calls when they don't - * want to go through the browser. The browser-side auth (localStorage) + * want to go through the browser. The browser-side auth (cookie storage) * is set up separately by tests/auth.setup.ts. */ @@ -18,7 +18,7 @@ const E2E_MINT_SECRET = process.env.E2E_MINT_SECRET || "local-e2e-mint-secret-no /** * Mints a JWT for the seeded e2e user via the test-only endpoint mounted * by surfsense_backend/tests/e2e/run_backend.py. Bypasses the production - * /auth/jwt/login rate limit (5/min/IP), so it's safe to call from any + * desktop login rate limit, so it's safe to call from any * worker / retry. Returns 404 from the backend when the endpoint isn't * mounted (i.e. someone is pointing the suite at a non-e2e backend). */ @@ -46,18 +46,17 @@ export async function mintTestToken( } export async function loginAsTestUser(request: APIRequestContext): Promise { - const response = await request.post(`${BACKEND_URL}/auth/jwt/login`, { - form: { - username: TEST_USER_EMAIL, + const response = await request.post(`${BACKEND_URL}/auth/desktop/login`, { + data: { + email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD, - grant_type: "password", }, - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { "Content-Type": "application/json" }, }); if (!response.ok()) { throw new Error( - `Login to ${BACKEND_URL}/auth/jwt/login failed (${response.status()}): ${await response.text()}` + `Login to ${BACKEND_URL}/auth/desktop/login failed (${response.status()}): ${await response.text()}` ); } @@ -70,7 +69,7 @@ export async function loginAsTestUser(request: APIRequestContext): Promise { diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 2d12169b1..3359adcc9 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -141,8 +141,14 @@ interface ElectronAPI { searchSpaceId?: number | null ) => Promise; // Auth token sync across windows - getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; - setAuthTokens: (bearer: string, refresh: string) => Promise; + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + logout: () => Promise; + startGoogleOAuth: () => Promise<{ ok: true }>; + loginPassword: (email: string, password: string) => Promise<{ ok: true }>; + onAuthChanged: ( + callback: (payload: { authed: boolean; accessToken: string | null }) => void + ) => () => void; // Keyboard shortcut configuration getShortcuts: () => Promise<{ generalAssist: string; diff --git a/surfsense_web/types/zero.d.ts b/surfsense_web/types/zero.d.ts index 69c9e2402..56914b265 100644 --- a/surfsense_web/types/zero.d.ts +++ b/surfsense_web/types/zero.d.ts @@ -3,6 +3,7 @@ import type { Schema } from "@/zero/schema/index"; export type Context = | { userId: string; + allowedSpaceIds?: number[]; } | undefined; diff --git a/surfsense_web/zero/queries/authz.ts b/surfsense_web/zero/queries/authz.ts new file mode 100644 index 000000000..3c395a688 --- /dev/null +++ b/surfsense_web/zero/queries/authz.ts @@ -0,0 +1,32 @@ +import type { Context } from "@/types/zero"; + +type SpaceScopedQuery = { + where: (...args: unknown[]) => SpaceScopedQuery; +}; + +export function canReadSpace(ctx: Context, searchSpaceId: number): boolean { + return !!ctx?.allowedSpaceIds?.includes(searchSpaceId); +} + +export function denySpace(query: T): T { + return query.where(({ or }: { or: (...args: unknown[]) => unknown }) => or()) as T; +} + +export function constrainToAllowedSpaces(query: T, ctx: Context): T { + const allowedSpaceIds = ctx?.allowedSpaceIds ?? []; + if (allowedSpaceIds.length === 0) { + return denySpace(query); + } + if (allowedSpaceIds.length === 1) { + return query.where("searchSpaceId", allowedSpaceIds[0]) as T; + } + return query.where( + ({ + cmp, + or, + }: { + cmp: (column: string, value: number) => unknown; + or: (...args: unknown[]) => unknown; + }) => or(...allowedSpaceIds.map((id) => cmp("searchSpaceId", id))) + ) as T; +} diff --git a/surfsense_web/zero/queries/automations.ts b/surfsense_web/zero/queries/automations.ts index 79772eb1f..4f3bd451c 100644 --- a/surfsense_web/zero/queries/automations.ts +++ b/surfsense_web/zero/queries/automations.ts @@ -1,12 +1,18 @@ import { defineQuery } from "@rocicorp/zero"; import { z } from "zod"; import { zql } from "../schema/index"; +import { constrainToAllowedSpaces } from "./authz"; // Mirrors chat byThread: client passes the parent id, the REST route still // authorizes via `automation_id -> search_space`. No search_space_id on the // table by design. export const automationRunQueries = { - byAutomation: defineQuery(z.object({ automationId: z.number() }), ({ args: { automationId } }) => - zql.automation_runs.where("automationId", automationId).orderBy("createdAt", "desc") + byAutomation: defineQuery( + z.object({ automationId: z.number() }), + ({ args: { automationId }, ctx }) => + zql.automation_runs + .where("automationId", automationId) + .whereExists("automation", (q) => constrainToAllowedSpaces(q, ctx)) + .orderBy("createdAt", "desc") ), }; diff --git a/surfsense_web/zero/queries/chat.ts b/surfsense_web/zero/queries/chat.ts index de8b13f8a..40e09a6ee 100644 --- a/surfsense_web/zero/queries/chat.ts +++ b/surfsense_web/zero/queries/chat.ts @@ -1,21 +1,31 @@ import { defineQuery } from "@rocicorp/zero"; import { z } from "zod"; import { zql } from "../schema/index"; +import { constrainToAllowedSpaces } from "./authz"; export const messageQueries = { - byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId } }) => - zql.new_chat_messages.where("threadId", threadId).orderBy("createdAt", "asc") + byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId }, ctx }) => + zql.new_chat_messages + .where("threadId", threadId) + .whereExists("thread", (q) => constrainToAllowedSpaces(q, ctx)) + .orderBy("createdAt", "asc") ), }; export const commentQueries = { - byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId } }) => - zql.chat_comments.where("threadId", threadId).orderBy("createdAt", "asc") + byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId }, ctx }) => + zql.chat_comments + .where("threadId", threadId) + .whereExists("thread", (q) => constrainToAllowedSpaces(q, ctx)) + .orderBy("createdAt", "asc") ), }; export const chatSessionQueries = { - byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId } }) => - zql.chat_session_state.where("threadId", threadId).one() + byThread: defineQuery(z.object({ threadId: z.number() }), ({ args: { threadId }, ctx }) => + zql.chat_session_state + .where("threadId", threadId) + .whereExists("thread", (q) => constrainToAllowedSpaces(q, ctx)) + .one() ), }; diff --git a/surfsense_web/zero/queries/documents.ts b/surfsense_web/zero/queries/documents.ts index 97088945f..4e81a0491 100644 --- a/surfsense_web/zero/queries/documents.ts +++ b/surfsense_web/zero/queries/documents.ts @@ -1,15 +1,26 @@ import { defineQuery } from "@rocicorp/zero"; import { z } from "zod"; import { zql } from "../schema/index"; +import { canReadSpace, constrainToAllowedSpaces, denySpace } from "./authz"; export const documentQueries = { - bySpace: defineQuery(z.object({ searchSpaceId: z.number() }), ({ args: { searchSpaceId } }) => - zql.documents.where("searchSpaceId", searchSpaceId).orderBy("createdAt", "desc") + bySpace: defineQuery( + z.object({ searchSpaceId: z.number() }), + ({ args: { searchSpaceId }, ctx }) => { + const query = zql.documents.where("searchSpaceId", searchSpaceId); + if (!canReadSpace(ctx, searchSpaceId)) return denySpace(query).orderBy("createdAt", "desc"); + return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); + } ), }; export const connectorQueries = { - bySpace: defineQuery(z.object({ searchSpaceId: z.number() }), ({ args: { searchSpaceId } }) => - zql.search_source_connectors.where("searchSpaceId", searchSpaceId).orderBy("createdAt", "desc") + bySpace: defineQuery( + z.object({ searchSpaceId: z.number() }), + ({ args: { searchSpaceId }, ctx }) => { + const query = zql.search_source_connectors.where("searchSpaceId", searchSpaceId); + if (!canReadSpace(ctx, searchSpaceId)) return denySpace(query).orderBy("createdAt", "desc"); + return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); + } ), }; diff --git a/surfsense_web/zero/queries/folders.ts b/surfsense_web/zero/queries/folders.ts index 50c246f60..5cf712cda 100644 --- a/surfsense_web/zero/queries/folders.ts +++ b/surfsense_web/zero/queries/folders.ts @@ -1,9 +1,15 @@ import { defineQuery } from "@rocicorp/zero"; import { z } from "zod"; import { zql } from "../schema/index"; +import { canReadSpace, constrainToAllowedSpaces, denySpace } from "./authz"; export const folderQueries = { - bySpace: defineQuery(z.object({ searchSpaceId: z.number() }), ({ args: { searchSpaceId } }) => - zql.folders.where("searchSpaceId", searchSpaceId).orderBy("position", "asc") + bySpace: defineQuery( + z.object({ searchSpaceId: z.number() }), + ({ args: { searchSpaceId }, ctx }) => { + const query = zql.folders.where("searchSpaceId", searchSpaceId); + if (!canReadSpace(ctx, searchSpaceId)) return denySpace(query).orderBy("position", "asc"); + return constrainToAllowedSpaces(query, ctx).orderBy("position", "asc"); + } ), }; diff --git a/surfsense_web/zero/queries/inbox.ts b/surfsense_web/zero/queries/inbox.ts index d85b7212f..8b02824fd 100644 --- a/surfsense_web/zero/queries/inbox.ts +++ b/surfsense_web/zero/queries/inbox.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { zql } from "../schema/index"; export const notificationQueries = { - byUser: defineQuery(z.object({ userId: z.string() }), ({ args: { userId } }) => - zql.notifications.where("userId", userId).orderBy("createdAt", "desc") - ), + byUser: defineQuery(z.object({ userId: z.string() }), ({ args: { userId }, ctx }) => { + if (!ctx?.userId || userId !== ctx.userId) { + return zql.notifications.where("userId", "__none__").orderBy("createdAt", "desc"); + } + return zql.notifications.where("userId", ctx.userId).orderBy("createdAt", "desc"); + }), }; diff --git a/surfsense_web/zero/queries/podcasts.ts b/surfsense_web/zero/queries/podcasts.ts index 5298534dd..0384c260a 100644 --- a/surfsense_web/zero/queries/podcasts.ts +++ b/surfsense_web/zero/queries/podcasts.ts @@ -1,12 +1,18 @@ import { defineQuery } from "@rocicorp/zero"; import { z } from "zod"; import { zql } from "../schema/index"; +import { canReadSpace, constrainToAllowedSpaces, denySpace } from "./authz"; export const podcastQueries = { - bySpace: defineQuery(z.object({ searchSpaceId: z.number() }), ({ args: { searchSpaceId } }) => - zql.podcasts.where("searchSpaceId", searchSpaceId).orderBy("createdAt", "desc") + bySpace: defineQuery( + z.object({ searchSpaceId: z.number() }), + ({ args: { searchSpaceId }, ctx }) => { + const query = zql.podcasts.where("searchSpaceId", searchSpaceId); + if (!canReadSpace(ctx, searchSpaceId)) return denySpace(query).orderBy("createdAt", "desc"); + return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); + } ), - byId: defineQuery(z.object({ podcastId: z.number() }), ({ args: { podcastId } }) => - zql.podcasts.where("id", podcastId).one() + byId: defineQuery(z.object({ podcastId: z.number() }), ({ args: { podcastId }, ctx }) => + constrainToAllowedSpaces(zql.podcasts.where("id", podcastId), ctx).one() ), }; diff --git a/surfsense_web/zero/schema/automations.ts b/surfsense_web/zero/schema/automations.ts index 4d6ebfac7..f9b89c533 100644 --- a/surfsense_web/zero/schema/automations.ts +++ b/surfsense_web/zero/schema/automations.ts @@ -1,5 +1,12 @@ import { json, number, string, table } from "@rocicorp/zero"; +export const automationTable = table("automations") + .columns({ + id: number(), + searchSpaceId: number().from("search_space_id"), + }) + .primaryKey("id"); + // Thin live row: status + per-step progress only. Heavy fields // (definition_snapshot, inputs, output, artifacts, error) stay on REST // (`GET /automations/{id}/runs/{run_id}`) and load on detail expand. diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts index 8da41ee45..07229ac94 100644 --- a/surfsense_web/zero/schema/chat.ts +++ b/surfsense_web/zero/schema/chat.ts @@ -20,6 +20,13 @@ export const newChatMessageTable = table("new_chat_messages") }) .primaryKey("id"); +export const newChatThreadTable = table("new_chat_threads") + .columns({ + id: number(), + searchSpaceId: number().from("search_space_id"), + }) + .primaryKey("id"); + export const chatCommentTable = table("chat_comments") .columns({ id: number(), diff --git a/surfsense_web/zero/schema/index.ts b/surfsense_web/zero/schema/index.ts index d1187ddab..915135c19 100644 --- a/surfsense_web/zero/schema/index.ts +++ b/surfsense_web/zero/schema/index.ts @@ -1,6 +1,11 @@ import { createBuilder, createSchema, relationships } from "@rocicorp/zero"; -import { automationRunTable } from "./automations"; -import { chatCommentTable, chatSessionStateTable, newChatMessageTable } from "./chat"; +import { automationRunTable, automationTable } from "./automations"; +import { + chatCommentTable, + chatSessionStateTable, + newChatMessageTable, + newChatThreadTable, +} from "./chat"; import { documentTable, searchSourceConnectorTable } from "./documents"; import { folderTable } from "./folders"; import { notificationTable } from "./inbox"; @@ -18,14 +23,40 @@ const chatCommentRelationships = relationships(chatCommentTable, ({ one }) => ({ destSchema: chatCommentTable, destField: ["id"], }), + thread: one({ + sourceField: ["threadId"], + destSchema: newChatThreadTable, + destField: ["id"], + }), })); -const newChatMessageRelationships = relationships(newChatMessageTable, ({ many }) => ({ +const newChatMessageRelationships = relationships(newChatMessageTable, ({ one, many }) => ({ comments: many({ sourceField: ["id"], destSchema: chatCommentTable, destField: ["messageId"], }), + thread: one({ + sourceField: ["threadId"], + destSchema: newChatThreadTable, + destField: ["id"], + }), +})); + +const chatSessionStateThreadRelationships = relationships(chatSessionStateTable, ({ one }) => ({ + thread: one({ + sourceField: ["threadId"], + destSchema: newChatThreadTable, + destField: ["id"], + }), +})); + +const automationRunRelationships = relationships(automationRunTable, ({ one }) => ({ + automation: one({ + sourceField: ["automationId"], + destSchema: automationTable, + destField: ["id"], + }), })); export const schema = createSchema({ @@ -34,14 +65,21 @@ export const schema = createSchema({ documentTable, folderTable, searchSourceConnectorTable, + newChatThreadTable, newChatMessageTable, chatCommentTable, chatSessionStateTable, userTable, + automationTable, automationRunTable, podcastTable, ], - relationships: [chatCommentRelationships, newChatMessageRelationships], + relationships: [ + chatCommentRelationships, + newChatMessageRelationships, + chatSessionStateThreadRelationships, + automationRunRelationships, + ], }); export type Schema = typeof schema;