From ecd0985523b381a37a9cb8222afb08a0aa050810 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 14:39:50 +0200 Subject: [PATCH 01/10] Add access token pre-validation to OAuth connectors --- .../app/connectors/airtable_history.py | 16 ++++++++++++++++ .../app/connectors/confluence_history.py | 16 ++++++++++++++++ surfsense_backend/app/connectors/jira_history.py | 16 ++++++++++++++++ .../app/connectors/linear_connector.py | 16 ++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/surfsense_backend/app/connectors/airtable_history.py b/surfsense_backend/app/connectors/airtable_history.py index 64f6465fe..092485f77 100644 --- a/surfsense_backend/app/connectors/airtable_history.py +++ b/surfsense_backend/app/connectors/airtable_history.py @@ -71,6 +71,14 @@ class AirtableHistoryConnector: config_data = connector.config.copy() + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Airtable access token not found. " + "Please reconnect your Airtable account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -98,6 +106,14 @@ class AirtableHistoryConnector: f"Failed to decrypt Airtable credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Airtable access token is invalid or empty. " + "Please reconnect your Airtable account." + ) + try: self._credentials = AirtableAuthCredentialsBase.from_dict(config_data) except Exception as e: diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py index 9e10ffcf1..908f532db 100644 --- a/surfsense_backend/app/connectors/confluence_history.py +++ b/surfsense_backend/app/connectors/confluence_history.py @@ -87,6 +87,14 @@ class ConfluenceHistoryConnector: if is_oauth: # OAuth 2.0 authentication + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Confluence access token not found. " + "Please reconnect your Confluence account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -118,6 +126,14 @@ class ConfluenceHistoryConnector: f"Failed to decrypt Confluence credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Confluence access token is invalid or empty. " + "Please reconnect your Confluence account." + ) + try: self._credentials = AtlassianAuthCredentialsBase.from_dict( config_data diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py index 6e04ec2a4..46a28324d 100644 --- a/surfsense_backend/app/connectors/jira_history.py +++ b/surfsense_backend/app/connectors/jira_history.py @@ -86,6 +86,14 @@ class JiraHistoryConnector: if is_oauth: # OAuth 2.0 authentication + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Jira access token not found. " + "Please reconnect your Jira account." + ) + if not config.SECRET_KEY: raise ValueError( "SECRET_KEY not configured but tokens are marked as encrypted" @@ -119,6 +127,14 @@ class JiraHistoryConnector: f"Failed to decrypt Jira credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Jira access token is invalid or empty. " + "Please reconnect your Jira account." + ) + try: self._credentials = AtlassianAuthCredentialsBase.from_dict( config_data diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index b8206a40d..6500b9027 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -116,6 +116,14 @@ class LinearConnector: config_data = connector.config.copy() + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Linear access token not found. " + "Please reconnect your Linear account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -143,6 +151,14 @@ class LinearConnector: f"Failed to decrypt Linear credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Linear access token is invalid or empty. " + "Please reconnect your Linear account." + ) + try: self._credentials = LinearAuthCredentialsBase.from_dict(config_data) except Exception as e: From 04aac379ed3a250b3e5235c6540e633ee5b5d36b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 16:18:45 +0200 Subject: [PATCH 02/10] Add RefreshToken model for user session management --- surfsense_backend/app/db.py | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 5cdb712db..a4da1a575 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1361,6 +1361,13 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + # Refresh tokens for this user + refresh_tokens = relationship( + "RefreshToken", + back_populates="user", + cascade="all, delete-orphan", + ) + else: class User(SQLAlchemyBaseUserTableUUID, Base): @@ -1426,6 +1433,57 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + # Refresh tokens for this user + refresh_tokens = relationship( + "RefreshToken", + back_populates="user", + cascade="all, delete-orphan", + ) + + +class RefreshToken(Base, TimestampMixin): + """ + Stores refresh tokens for user session management. + + Refresh tokens are long-lived tokens (2 weeks) used to obtain new + access tokens without requiring re-authentication. + """ + + __tablename__ = "refresh_tokens" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # User relationship + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user = relationship("User", back_populates="refresh_tokens") + + # Token hash (stored hashed, not plaintext) + token_hash = Column(String(256), unique=True, nullable=False, index=True) + + # Token expiration + expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True) + + # Revocation flag + is_revoked = Column(Boolean, default=False, nullable=False) + + # Token family for rotation tracking (detect reuse attacks) + family_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + @property + def is_expired(self) -> bool: + """Check if the token has expired.""" + return datetime.now(UTC) >= self.expires_at + + @property + def is_valid(self) -> bool: + """Check if the token is valid (not expired and not revoked).""" + return not self.is_expired and not self.is_revoked + engine = create_async_engine(DATABASE_URL) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) From 048ef7024f6ccc08873dd6bbcdcb08f4275eba1f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 16:21:02 +0200 Subject: [PATCH 03/10] Add token lifetime config options --- surfsense_backend/app/config/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 149fedd39..121e5d3b2 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -255,6 +255,14 @@ class Config: # OAuth JWT SECRET_KEY = os.getenv("SECRET_KEY") + # JWT Token Lifetimes + ACCESS_TOKEN_LIFETIME_SECONDS = int( + os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day + ) + REFRESH_TOKEN_LIFETIME_SECONDS = int( + os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks + ) + # ETL Service ETL_SERVICE = os.getenv("ETL_SERVICE") From 9bd7d7475574cb21c37803da9c2dce33167465a1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 16:49:37 +0200 Subject: [PATCH 04/10] Add RefreshToken model and multi-session refresh token logic --- surfsense_backend/app/db.py | 16 +--- surfsense_backend/app/users.py | 154 ++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 18 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index a4da1a575..2298e7438 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1444,16 +1444,12 @@ else: class RefreshToken(Base, TimestampMixin): """ Stores refresh tokens for user session management. - - Refresh tokens are long-lived tokens (2 weeks) used to obtain new - access tokens without requiring re-authentication. + Each row represents one device/session. """ __tablename__ = "refresh_tokens" id = Column(Integer, primary_key=True, autoincrement=True) - - # User relationship user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), @@ -1461,27 +1457,17 @@ class RefreshToken(Base, TimestampMixin): index=True, ) user = relationship("User", back_populates="refresh_tokens") - - # Token hash (stored hashed, not plaintext) token_hash = Column(String(256), unique=True, nullable=False, index=True) - - # Token expiration expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True) - - # Revocation flag is_revoked = Column(Boolean, default=False, nullable=False) - - # Token family for rotation tracking (detect reuse attacks) family_id = Column(UUID(as_uuid=True), nullable=False, index=True) @property def is_expired(self) -> bool: - """Check if the token has expired.""" return datetime.now(UTC) >= self.expires_at @property def is_valid(self) -> bool: - """Check if the token is valid (not expired and not revoked).""" return not self.is_expired and not self.is_revoked diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 4be2fe525..cbffd359d 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,5 +1,8 @@ +import hashlib import logging +import secrets import uuid +from datetime import UTC, datetime, timedelta import httpx from fastapi import Depends, Request, Response @@ -12,9 +15,11 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel +from sqlalchemy import select, update from app.config import config from app.db import ( + RefreshToken, SearchSpace, SearchSpaceMembership, SearchSpaceRole, @@ -29,11 +34,130 @@ logger = logging.getLogger(__name__) class BearerResponse(BaseModel): access_token: str + refresh_token: str token_type: str SECRET = config.SECRET_KEY + +# Refresh token utilities (multi-session) +def generate_refresh_token() -> str: + """Generate a cryptographically secure refresh token.""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """Hash a token for secure storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + +async def create_refresh_token( + user_id: uuid.UUID, + family_id: uuid.UUID | None = None, +) -> str: + """ + Create and store a new refresh token for a user. + + Args: + user_id: The user's ID + family_id: Optional family ID for token rotation + + Returns: + The plaintext refresh token + """ + token = generate_refresh_token() + token_hash = hash_token(token) + expires_at = datetime.now(UTC) + timedelta( + seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS + ) + + if family_id is None: + family_id = uuid.uuid4() + + async with async_session_maker() as session: + refresh_token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + family_id=family_id, + ) + session.add(refresh_token) + await session.commit() + + return 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 + """ + token_hash = hash_token(token) + + async with async_session_maker() as session: + result = await session.execute( + select(RefreshToken).where(RefreshToken.token_hash == token_hash) + ) + refresh_token = result.scalars().first() + + 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), + ) + ) + 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() + + return await create_refresh_token(old_token.user_id, old_token.family_id) + + +async def revoke_all_user_tokens(user_id: uuid.UUID) -> None: + """Revoke all refresh tokens for a user (logout all devices).""" + async with async_session_maker() as session: + await session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id) + .values(is_revoked=True) + ) + await session.commit() + if config.AUTH_TYPE == "GOOGLE": from httpx_oauth.clients.google import GoogleOAuth2 @@ -183,7 +307,10 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: - return JWTStrategy(secret=SECRET, lifetime_seconds=3600 * 24) + return JWTStrategy( + secret=SECRET, + lifetime_seconds=config.ACCESS_TOKEN_LIFETIME_SECONDS, + ) # # COOKIE AUTH | Uncomment if you want to use cookie auth. @@ -209,9 +336,30 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: # BEARER AUTH CODE. class CustomBearerTransport(BearerTransport): async def get_login_response(self, token: str) -> Response: - bearer_response = BearerResponse(access_token=token, token_type="bearer") - redirect_url = f"{config.NEXT_FRONTEND_URL}/auth/callback?token={bearer_response.access_token}" + import jwt + + # Decode JWT to get user_id for refresh token creation + try: + payload = jwt.decode(token, SECRET, algorithms=["HS256"]) + user_id = uuid.UUID(payload.get("sub")) + 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", + ) + 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}" + ) return RedirectResponse(redirect_url, status_code=302) else: return JSONResponse(bearer_response.model_dump()) From f3a9922eb96e2651e6d12dcc88063e9e9ae525c3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 17:29:50 +0200 Subject: [PATCH 05/10] Add refresh token auth routes and utilities --- .../versions/92_add_refresh_tokens_table.py | 92 +++++++++++ surfsense_backend/app/app.py | 4 + surfsense_backend/app/routes/auth_routes.py | 115 +++++++++++++ surfsense_backend/app/schemas/__init__.py | 5 + surfsense_backend/app/schemas/auth.py | 23 +++ surfsense_backend/app/users.py | 135 ++-------------- surfsense_backend/app/utils/auth_cookies.py | 29 ++++ surfsense_backend/app/utils/refresh_tokens.py | 153 ++++++++++++++++++ 8 files changed, 431 insertions(+), 125 deletions(-) create mode 100644 surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py create mode 100644 surfsense_backend/app/routes/auth_routes.py create mode 100644 surfsense_backend/app/schemas/auth.py create mode 100644 surfsense_backend/app/utils/auth_cookies.py create mode 100644 surfsense_backend/app/utils/refresh_tokens.py diff --git a/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py new file mode 100644 index 000000000..c7e133ae9 --- /dev/null +++ b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py @@ -0,0 +1,92 @@ +"""Add refresh_tokens table for user session management + +Revision ID: 92 +Revises: 91 + +Changes: +1. Create refresh_tokens table with columns: + - id (primary key) + - user_id (foreign key to user) + - token_hash (unique, indexed) + - expires_at (indexed) + - is_revoked + - family_id (indexed, for token rotation tracking) + - created_at, updated_at (timestamps) +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "92" +down_revision: str | None = "91" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create refresh_tokens table (idempotent).""" + # Check if table already exists + connection = op.get_bind() + result = connection.execute( + sa.text( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'refresh_tokens')" + ) + ) + table_exists = result.scalar() + + if not table_exists: + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", UUID(as_uuid=True), nullable=False), + sa.Column("token_hash", sa.String(256), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("is_revoked", sa.Boolean(), nullable=False, default=False), + sa.Column("family_id", UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ondelete="CASCADE", + ), + ) + + # Create indexes if they don't exist + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_user_id ON refresh_tokens (user_id)" + ) + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_refresh_tokens_token_hash ON refresh_tokens (token_hash)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_expires_at ON refresh_tokens (expires_at)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_family_id ON refresh_tokens (family_id)" + ) + + +def downgrade() -> None: + """Drop refresh_tokens table (idempotent).""" + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_family_id") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_expires_at") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_token_hash") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_user_id") + op.execute("DROP TABLE IF EXISTS refresh_tokens") diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 01dd0da3d..63da4e8ad 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -12,6 +12,7 @@ from app.agents.new_chat.checkpointer import ( from app.config import config, initialize_llm_router from app.db import User, create_db_and_tables, get_async_session 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.tasks.surfsense_docs_indexer import seed_surfsense_docs from app.users import SECRET, auth_backend, current_active_user, fastapi_users @@ -111,6 +112,9 @@ app.include_router( tags=["users"], ) +# Include custom auth routes (refresh token, logout) +app.include_router(auth_router) + if config.AUTH_TYPE == "GOOGLE": from fastapi.responses import RedirectResponse diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py new file mode 100644 index 000000000..67abc5482 --- /dev/null +++ b/surfsense_backend/app/routes/auth_routes.py @@ -0,0 +1,115 @@ +"""Authentication routes for refresh token management.""" + +import logging + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from sqlalchemy import select + +from app.db import User, async_session_maker +from app.schemas.auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse +from app.users import current_active_user, get_jwt_strategy +from app.utils.auth_cookies import ( + REFRESH_TOKEN_COOKIE_NAME, + delete_refresh_token_cookie, + set_refresh_token_cookie, +) +from app.utils.refresh_tokens import ( + revoke_all_user_tokens, + revoke_refresh_token, + rotate_refresh_token, + validate_refresh_token, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth/jwt", tags=["auth"]) + + +@router.post("/refresh", response_model=RefreshTokenResponse) +async def refresh_access_token( + response: Response, + refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME), +): + """ + Exchange a valid refresh token for a new access token and refresh token. + Reads refresh token from HTTP-only cookie. Implements token rotation for security. + """ + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token not found", + ) + + token_record = await validate_refresh_token(refresh_token) + + if not token_record: + 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) + ) + user = result.scalars().first() + + 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) + + # Set the new refresh token in cookie + set_refresh_token_cookie(response, new_refresh_token) + + logger.info(f"Refreshed token for user {user.id}") + + return RefreshTokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + ) + + +@router.post("/logout", response_model=LogoutResponse) +async def logout( + response: Response, + refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME), +): + """ + Logout current device by revoking the refresh token from cookie. + """ + if refresh_token: + await revoke_refresh_token(refresh_token) + + # Always delete the cookie + delete_refresh_token_cookie(response) + + logger.info("User logged out from current device") + return LogoutResponse() + + +@router.post("/logout-all", response_model=LogoutAllResponse) +async def logout_all_devices( + response: Response, + user: User = Depends(current_active_user), +): + """ + Logout from all devices by revoking all refresh tokens for the user. + Requires valid access token. + """ + await revoke_all_user_tokens(user.id) + + # Delete the cookie on current device + delete_refresh_token_cookie(response) + + logger.info(f"User {user.id} logged out from all devices") + return LogoutAllResponse() diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 6c9577c46..45dba2ba4 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -1,3 +1,4 @@ +from .auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse from .base import IDModel, TimestampModel from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate from .documents import ( @@ -117,6 +118,9 @@ __all__ = [ "LogFilter", "LogRead", "LogUpdate", + # Auth schemas + "LogoutAllResponse", + "LogoutResponse", # Search source connector schemas "MCPConnectorCreate", "MCPConnectorRead", @@ -146,6 +150,7 @@ __all__ = [ "PodcastCreate", "PodcastRead", "PodcastUpdate", + "RefreshTokenResponse", "RoleCreate", "RoleRead", "RoleUpdate", diff --git a/surfsense_backend/app/schemas/auth.py b/surfsense_backend/app/schemas/auth.py new file mode 100644 index 000000000..77c61de7e --- /dev/null +++ b/surfsense_backend/app/schemas/auth.py @@ -0,0 +1,23 @@ +"""Authentication schemas for refresh token endpoints.""" + +from pydantic import BaseModel + + +class RefreshTokenResponse(BaseModel): + """Response from token refresh endpoint.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class LogoutResponse(BaseModel): + """Response from logout endpoint (current device).""" + + detail: str = "Successfully logged out" + + +class LogoutAllResponse(BaseModel): + """Response from logout all devices endpoint.""" + + detail: str = "Successfully logged out from all devices" diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index cbffd359d..ffb6c89e8 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,8 +1,5 @@ -import hashlib import logging -import secrets import uuid -from datetime import UTC, datetime, timedelta import httpx from fastapi import Depends, Request, Response @@ -15,11 +12,9 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel -from sqlalchemy import select, update from app.config import config from app.db import ( - RefreshToken, SearchSpace, SearchSpaceMembership, SearchSpaceRole, @@ -28,6 +23,8 @@ from app.db import ( get_default_roles_config, get_user_db, ) +from app.utils.auth_cookies import set_refresh_token_cookie +from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) @@ -41,123 +38,6 @@ class BearerResponse(BaseModel): SECRET = config.SECRET_KEY -# Refresh token utilities (multi-session) -def generate_refresh_token() -> str: - """Generate a cryptographically secure refresh token.""" - return secrets.token_urlsafe(32) - - -def hash_token(token: str) -> str: - """Hash a token for secure storage.""" - return hashlib.sha256(token.encode()).hexdigest() - - -async def create_refresh_token( - user_id: uuid.UUID, - family_id: uuid.UUID | None = None, -) -> str: - """ - Create and store a new refresh token for a user. - - Args: - user_id: The user's ID - family_id: Optional family ID for token rotation - - Returns: - The plaintext refresh token - """ - token = generate_refresh_token() - token_hash = hash_token(token) - expires_at = datetime.now(UTC) + timedelta( - seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS - ) - - if family_id is None: - family_id = uuid.uuid4() - - async with async_session_maker() as session: - refresh_token = RefreshToken( - user_id=user_id, - token_hash=token_hash, - expires_at=expires_at, - family_id=family_id, - ) - session.add(refresh_token) - await session.commit() - - return 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 - """ - token_hash = hash_token(token) - - async with async_session_maker() as session: - result = await session.execute( - select(RefreshToken).where(RefreshToken.token_hash == token_hash) - ) - refresh_token = result.scalars().first() - - 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), - ) - ) - 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() - - return await create_refresh_token(old_token.user_id, old_token.family_id) - - -async def revoke_all_user_tokens(user_id: uuid.UUID) -> None: - """Revoke all refresh tokens for a user (logout all devices).""" - async with async_session_maker() as session: - await session.execute( - update(RefreshToken) - .where(RefreshToken.user_id == user_id) - .values(is_revoked=True) - ) - await session.commit() - if config.AUTH_TYPE == "GOOGLE": from httpx_oauth.clients.google import GoogleOAuth2 @@ -358,11 +238,16 @@ class CustomBearerTransport(BearerTransport): redirect_url = ( f"{config.NEXT_FRONTEND_URL}/auth/callback" f"?token={bearer_response.access_token}" - f"&refresh_token={bearer_response.refresh_token}" ) - return RedirectResponse(redirect_url, status_code=302) + response = RedirectResponse(redirect_url, status_code=302) else: - return JSONResponse(bearer_response.model_dump()) + response = JSONResponse(bearer_response.model_dump()) + + # Set refresh token as HTTP-only cookie + if refresh_token: + set_refresh_token_cookie(response, refresh_token) + + return response bearer_transport = CustomBearerTransport(tokenUrl="auth/jwt/login") diff --git a/surfsense_backend/app/utils/auth_cookies.py b/surfsense_backend/app/utils/auth_cookies.py new file mode 100644 index 000000000..52da80f9d --- /dev/null +++ b/surfsense_backend/app/utils/auth_cookies.py @@ -0,0 +1,29 @@ +"""Utilities for managing authentication cookies.""" + +from fastapi import Response + +from app.config import config + +REFRESH_TOKEN_COOKIE_NAME = "refresh_token" + + +def set_refresh_token_cookie(response: Response, token: str) -> None: + """Set the refresh token as an HTTP-only cookie.""" + response.set_cookie( + key=REFRESH_TOKEN_COOKIE_NAME, + value=token, + max_age=config.REFRESH_TOKEN_LIFETIME_SECONDS, + httponly=True, + secure=True, # Only send over HTTPS + samesite="lax", + ) + + +def delete_refresh_token_cookie(response: Response) -> None: + """Delete the refresh token cookie.""" + response.delete_cookie( + key=REFRESH_TOKEN_COOKIE_NAME, + httponly=True, + secure=True, + samesite="lax", + ) diff --git a/surfsense_backend/app/utils/refresh_tokens.py b/surfsense_backend/app/utils/refresh_tokens.py new file mode 100644 index 000000000..8c0312ba8 --- /dev/null +++ b/surfsense_backend/app/utils/refresh_tokens.py @@ -0,0 +1,153 @@ +"""Utilities for managing refresh tokens.""" + +import hashlib +import logging +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update + +from app.config import config +from app.db import RefreshToken, async_session_maker + +logger = logging.getLogger(__name__) + + +def generate_refresh_token() -> str: + """Generate a cryptographically secure refresh token.""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """Hash a token for secure storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + +async def create_refresh_token( + user_id: uuid.UUID, + family_id: uuid.UUID | None = None, +) -> str: + """ + Create and store a new refresh token for a user. + + Args: + user_id: The user's ID + family_id: Optional family ID for token rotation + + Returns: + The plaintext refresh token + """ + token = generate_refresh_token() + token_hash = hash_token(token) + expires_at = datetime.now(UTC) + timedelta( + seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS + ) + + if family_id is None: + family_id = uuid.uuid4() + + async with async_session_maker() as session: + refresh_token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + family_id=family_id, + ) + session.add(refresh_token) + await session.commit() + + return 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 + """ + token_hash = hash_token(token) + + async with async_session_maker() as session: + result = await session.execute( + select(RefreshToken).where(RefreshToken.token_hash == token_hash) + ) + refresh_token = result.scalars().first() + + 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), + ) + ) + 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() + + return await create_refresh_token(old_token.user_id, old_token.family_id) + + +async def revoke_refresh_token(token: str) -> bool: + """ + Revoke a single refresh token by its plaintext value. + + Args: + token: The plaintext refresh token + + Returns: + True if token was found and revoked, False otherwise + """ + token_hash = hash_token(token) + + async with async_session_maker() as session: + result = await session.execute( + update(RefreshToken) + .where(RefreshToken.token_hash == token_hash) + .values(is_revoked=True) + ) + await session.commit() + return result.rowcount > 0 + + +async def revoke_all_user_tokens(user_id: uuid.UUID) -> None: + """Revoke all refresh tokens for a user (logout all devices).""" + async with async_session_maker() as session: + await session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id) + .values(is_revoked=True) + ) + await session.commit() From 233852b681d2b9f7a76fca6db73b812c0f3264de Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 17:55:21 +0200 Subject: [PATCH 06/10] Switch refresh token storage from cookies to localStorage --- surfsense_backend/app/routes/auth_routes.py | 56 +++------- surfsense_backend/app/schemas/__init__.py | 10 +- surfsense_backend/app/schemas/auth.py | 12 ++ surfsense_backend/app/users.py | 12 +- surfsense_backend/app/utils/auth_cookies.py | 29 ----- surfsense_web/components/TokenHandler.tsx | 12 +- surfsense_web/lib/auth-utils.ts | 117 +++++++++++++++++++- 7 files changed, 160 insertions(+), 88 deletions(-) delete mode 100644 surfsense_backend/app/utils/auth_cookies.py diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py index 67abc5482..541dc9d58 100644 --- a/surfsense_backend/app/routes/auth_routes.py +++ b/surfsense_backend/app/routes/auth_routes.py @@ -2,17 +2,18 @@ import logging -from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from app.db import User, async_session_maker -from app.schemas.auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse -from app.users import current_active_user, get_jwt_strategy -from app.utils.auth_cookies import ( - REFRESH_TOKEN_COOKIE_NAME, - delete_refresh_token_cookie, - set_refresh_token_cookie, +from app.schemas.auth import ( + LogoutAllResponse, + LogoutRequest, + LogoutResponse, + RefreshTokenRequest, + RefreshTokenResponse, ) +from app.users import current_active_user, get_jwt_strategy from app.utils.refresh_tokens import ( revoke_all_user_tokens, revoke_refresh_token, @@ -26,21 +27,12 @@ router = APIRouter(prefix="/auth/jwt", tags=["auth"]) @router.post("/refresh", response_model=RefreshTokenResponse) -async def refresh_access_token( - response: Response, - refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME), -): +async def refresh_access_token(request: RefreshTokenRequest): """ Exchange a valid refresh token for a new access token and refresh token. - Reads refresh token from HTTP-only cookie. Implements token rotation for security. + Implements token rotation for security. """ - if not refresh_token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token not found", - ) - - token_record = await validate_refresh_token(refresh_token) + token_record = await validate_refresh_token(request.refresh_token) if not token_record: raise HTTPException( @@ -68,9 +60,6 @@ async def refresh_access_token( # Rotate refresh token new_refresh_token = await rotate_refresh_token(token_record) - # Set the new refresh token in cookie - set_refresh_token_cookie(response, new_refresh_token) - logger.info(f"Refreshed token for user {user.id}") return RefreshTokenResponse( @@ -80,36 +69,21 @@ async def refresh_access_token( @router.post("/logout", response_model=LogoutResponse) -async def logout( - response: Response, - refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME), -): +async def logout(request: LogoutRequest): """ - Logout current device by revoking the refresh token from cookie. + Logout current device by revoking the provided refresh token. """ - if refresh_token: - await revoke_refresh_token(refresh_token) - - # Always delete the cookie - delete_refresh_token_cookie(response) - + await revoke_refresh_token(request.refresh_token) logger.info("User logged out from current device") return LogoutResponse() @router.post("/logout-all", response_model=LogoutAllResponse) -async def logout_all_devices( - response: Response, - user: User = Depends(current_active_user), -): +async def logout_all_devices(user: User = Depends(current_active_user)): """ Logout from all devices by revoking all refresh tokens for the user. Requires valid access token. """ await revoke_all_user_tokens(user.id) - - # Delete the cookie on current device - delete_refresh_token_cookie(response) - logger.info(f"User {user.id} logged out from all devices") return LogoutAllResponse() diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 45dba2ba4..5ff166733 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -1,4 +1,10 @@ -from .auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse +from .auth import ( + LogoutAllResponse, + LogoutRequest, + LogoutResponse, + RefreshTokenRequest, + RefreshTokenResponse, +) from .base import IDModel, TimestampModel from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate from .documents import ( @@ -120,6 +126,7 @@ __all__ = [ "LogUpdate", # Auth schemas "LogoutAllResponse", + "LogoutRequest", "LogoutResponse", # Search source connector schemas "MCPConnectorCreate", @@ -150,6 +157,7 @@ __all__ = [ "PodcastCreate", "PodcastRead", "PodcastUpdate", + "RefreshTokenRequest", "RefreshTokenResponse", "RoleCreate", "RoleRead", diff --git a/surfsense_backend/app/schemas/auth.py b/surfsense_backend/app/schemas/auth.py index 77c61de7e..0d958a6d2 100644 --- a/surfsense_backend/app/schemas/auth.py +++ b/surfsense_backend/app/schemas/auth.py @@ -3,6 +3,12 @@ from pydantic import BaseModel +class RefreshTokenRequest(BaseModel): + """Request body for token refresh endpoint.""" + + refresh_token: str + + class RefreshTokenResponse(BaseModel): """Response from token refresh endpoint.""" @@ -11,6 +17,12 @@ class RefreshTokenResponse(BaseModel): token_type: str = "bearer" +class LogoutRequest(BaseModel): + """Request body for logout endpoint (current device).""" + + refresh_token: str + + class LogoutResponse(BaseModel): """Response from logout endpoint (current device).""" diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index ffb6c89e8..aef94d558 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -23,7 +23,6 @@ from app.db import ( get_default_roles_config, get_user_db, ) -from app.utils.auth_cookies import set_refresh_token_cookie from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) @@ -238,16 +237,11 @@ class CustomBearerTransport(BearerTransport): redirect_url = ( f"{config.NEXT_FRONTEND_URL}/auth/callback" f"?token={bearer_response.access_token}" + f"&refresh_token={bearer_response.refresh_token}" ) - response = RedirectResponse(redirect_url, status_code=302) + return RedirectResponse(redirect_url, status_code=302) else: - response = JSONResponse(bearer_response.model_dump()) - - # Set refresh token as HTTP-only cookie - if refresh_token: - set_refresh_token_cookie(response, refresh_token) - - return response + return JSONResponse(bearer_response.model_dump()) bearer_transport = CustomBearerTransport(tokenUrl="auth/jwt/login") diff --git a/surfsense_backend/app/utils/auth_cookies.py b/surfsense_backend/app/utils/auth_cookies.py deleted file mode 100644 index 52da80f9d..000000000 --- a/surfsense_backend/app/utils/auth_cookies.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Utilities for managing authentication cookies.""" - -from fastapi import Response - -from app.config import config - -REFRESH_TOKEN_COOKIE_NAME = "refresh_token" - - -def set_refresh_token_cookie(response: Response, token: str) -> None: - """Set the refresh token as an HTTP-only cookie.""" - response.set_cookie( - key=REFRESH_TOKEN_COOKIE_NAME, - value=token, - max_age=config.REFRESH_TOKEN_LIFETIME_SECONDS, - httponly=True, - secure=True, # Only send over HTTPS - samesite="lax", - ) - - -def delete_refresh_token_cookie(response: Response) -> None: - """Delete the refresh token cookie.""" - response.delete_cookie( - key=REFRESH_TOKEN_COOKIE_NAME, - httponly=True, - secure=True, - samesite="lax", - ) diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index e3295df7c..230cda81a 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; +import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; import { trackLoginSuccess } from "@/lib/posthog/events"; interface TokenHandlerProps { @@ -35,8 +35,9 @@ const TokenHandler = ({ // Only run on client-side if (typeof window === "undefined") return; - // Get token from URL parameters + // Get tokens from URL parameters const token = searchParams.get(tokenParamName); + const refreshToken = searchParams.get("refresh_token"); if (token) { try { @@ -50,10 +51,15 @@ const TokenHandler = ({ // Clear the flag for future logins sessionStorage.removeItem("login_success_tracked"); - // Store token in localStorage using both methods for compatibility + // Store access token in localStorage using both methods for compatibility localStorage.setItem(storageKey, token); setBearerToken(token); + // Store refresh token if provided + if (refreshToken) { + setRefreshToken(refreshToken); + } + // Check if there's a saved redirect path from before the auth flow const savedRedirectPath = getAndClearRedirectPath(); diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 604843292..24db377a8 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -4,6 +4,11 @@ const REDIRECT_PATH_KEY = "surfsense_redirect_path"; const BEARER_TOKEN_KEY = "surfsense_bearer_token"; +const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; + +// Flag to prevent multiple simultaneous refresh attempts +let isRefreshing = false; +let refreshPromise: Promise | null = null; /** * Saves the current path and redirects to login page @@ -21,8 +26,9 @@ export function handleUnauthorized(): void { localStorage.setItem(REDIRECT_PATH_KEY, currentPath); } - // Clear the token + // Clear both tokens localStorage.removeItem(BEARER_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); // Redirect to home page (which has login options) window.location.href = "/login"; @@ -66,6 +72,38 @@ export function clearBearerToken(): void { 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); +} + +/** + * 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(); +} + /** * Checks if the user is authenticated (has a token) */ @@ -106,14 +144,66 @@ export function getAuthHeaders(additionalHeaders?: Record): Reco } /** - * Authenticated fetch wrapper that handles 401 responses uniformly - * Automatically redirects to login on 401 and saves the current path + * Attempts to refresh the access token using the stored refresh token. + * Returns the new access token if successful, null otherwise. + */ +async function refreshAccessToken(): Promise { + // If already refreshing, wait for that request to complete + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + const currentRefreshToken = getRefreshToken(); + if (!currentRefreshToken) { + return null; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + const response = await fetch(`${backendUrl}/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 } + options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } ): Promise { - const { skipAuthRedirect = false, ...fetchOptions } = options || {}; + const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; const headers = getAuthHeaders(fetchOptions.headers as Record); @@ -124,6 +214,23 @@ export async function authenticatedFetch( // 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, + }); + } + } + + // Refresh failed or was skipped, redirect to login handleUnauthorized(); throw new Error("Unauthorized: Redirecting to login page"); } From 287e5afbac0c52a309dd70ec49fed539500efa80 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 18:11:33 +0200 Subject: [PATCH 07/10] Fix JWT audience validation when creating refresh token --- surfsense_backend/app/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index aef94d558..696cdf25e 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -219,7 +219,7 @@ class CustomBearerTransport(BearerTransport): # Decode JWT to get user_id for refresh token creation try: - payload = jwt.decode(token, SECRET, algorithms=["HS256"]) + payload = jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_aud": False}) user_id = uuid.UUID(payload.get("sub")) refresh_token = await create_refresh_token(user_id) except Exception as e: From f13345b226be4f280c7d0c22185c67f3219837f0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 18:56:38 +0200 Subject: [PATCH 08/10] Add logout with token revocation and loading states --- surfsense_backend/app/routes/auth_routes.py | 12 ++++--- surfsense_web/components/UserDropdown.tsx | 29 +++++++++++---- .../layout/providers/LayoutDataProvider.tsx | 6 +++- .../layout/ui/sidebar/SidebarUserProfile.tsx | 34 ++++++++++++++---- surfsense_web/lib/auth-utils.ts | 35 ++++++++++++++++++- surfsense_web/messages/en.json | 1 + surfsense_web/messages/zh.json | 1 + 7 files changed, 98 insertions(+), 20 deletions(-) diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py index 541dc9d58..b1cbaf2a5 100644 --- a/surfsense_backend/app/routes/auth_routes.py +++ b/surfsense_backend/app/routes/auth_routes.py @@ -68,13 +68,17 @@ async def refresh_access_token(request: RefreshTokenRequest): ) -@router.post("/logout", response_model=LogoutResponse) -async def logout(request: LogoutRequest): +@router.post("/revoke", response_model=LogoutResponse) +async def revoke_token(request: LogoutRequest): """ Logout current device by revoking the provided refresh token. + Does not require authentication - just the refresh token. """ - await revoke_refresh_token(request.refresh_token) - logger.info("User logged out from current device") + revoked = await revoke_refresh_token(request.refresh_token) + if revoked: + logger.info("User logged out from current device - token revoked") + else: + logger.warning("Logout called but no matching token found to revoke") return LogoutResponse() diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 3dac745cf..233a41a1f 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -1,7 +1,8 @@ "use client"; -import { BadgeCheck, LogOut } from "lucide-react"; +import { BadgeCheck, Loader2, LogOut } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -13,6 +14,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { logout } from "@/lib/auth-utils"; import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; @@ -26,8 +28,11 @@ export function UserDropdown({ }; }) { const router = useRouter(); + const [isLoggingOut, setIsLoggingOut] = useState(false); const handleLogout = async () => { + if (isLoggingOut) return; + setIsLoggingOut(true); try { // Track logout event and reset PostHog identity trackLogout(); @@ -41,15 +46,17 @@ export function UserDropdown({ console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); } + // Revoke refresh token on server and clear all tokens from localStorage + await logout(); + if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); window.location.href = "/"; } } catch (error) { console.error("Error during logout:", error); - // Optionally, provide user feedback + // Even if there's an error, try to clear tokens and redirect + await logout(); if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); window.location.href = "/"; } } @@ -85,9 +92,17 @@ export function UserDropdown({ - - - Log out + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? "Logging out..." : "Log out"} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..68350bce1 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -25,6 +25,7 @@ import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence"; +import { logout } from "@/lib/auth-utils"; import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -464,12 +465,15 @@ export function LayoutDataProvider({ console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); } + // Revoke refresh token on server and clear all tokens from localStorage + await logout(); + if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); router.push("/"); } } catch (error) { console.error("Error during logout:", error); + await logout(); router.push("/"); } }, [router]); diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 7c96b1dcb..38b3028d2 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,7 +1,8 @@ "use client"; -import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -124,6 +125,7 @@ export function SidebarUserProfile({ }: SidebarUserProfileProps) { const t = useTranslations("sidebar"); const { locale, setLocale } = useLocaleContext(); + const [isLoggingOut, setIsLoggingOut] = useState(false); const bgColor = stringToColor(user.email); const initials = getInitials(user.email); const displayName = user.name || user.email.split("@")[0]; @@ -136,6 +138,16 @@ export function SidebarUserProfile({ setTheme?.(newTheme); }; + const handleLogout = async () => { + if (isLoggingOut || !onLogout) return; + setIsLoggingOut(true); + try { + await onLogout(); + } finally { + setIsLoggingOut(false); + } + }; + // Collapsed view - just show avatar with dropdown if (isCollapsed) { return ( @@ -242,9 +254,13 @@ export function SidebarUserProfile({ - - - {t("logout")} + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? t("loggingOut") : t("logout")} @@ -360,9 +376,13 @@ export function SidebarUserProfile({ - - - {t("logout")} + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? t("loggingOut") : t("logout")} diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 24db377a8..409f29d0c 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -104,6 +104,39 @@ export function clearAllTokens(): void { clearRefreshToken(); } +/** + * Logout the current user by revoking the refresh token and clearing localStorage. + * Returns true if logout was successful (or tokens were cleared), false otherwise. + */ +export async function logout(): Promise { + const refreshToken = getRefreshToken(); + + // Call backend to revoke the refresh token + if (refreshToken) { + try { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const response = await fetch(`${backendUrl}/auth/jwt/revoke`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + console.warn("Failed to revoke refresh token:", response.status, await response.text()); + } + } catch (error) { + console.warn("Failed to revoke refresh token on server:", error); + // Continue to clear local tokens even if server call fails + } + } + + // Clear all tokens from localStorage + clearAllTokens(); + return true; +} + /** * Checks if the user is authenticated (has a token) */ @@ -161,7 +194,7 @@ async function refreshAccessToken(): Promise { isRefreshing = true; refreshPromise = (async () => { try { - const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; const response = await fetch(`${backendUrl}/auth/jwt/refresh`, { method: "POST", headers: { diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..93877096b 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -693,6 +693,7 @@ "dark": "Dark", "system": "System", "logout": "Logout", + "loggingOut": "Logging out...", "inbox": "Inbox", "search_inbox": "Search inbox", "mark_all_read": "Mark all as read", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..639bf31f4 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -678,6 +678,7 @@ "dark": "深色", "system": "系统", "logout": "退出登录", + "loggingOut": "正在退出...", "inbox": "收件箱", "search_inbox": "搜索收件箱", "mark_all_read": "全部标记为已读", From 1690ce1891580b80bc816514110b0c3a4036b8bb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 18:59:15 +0200 Subject: [PATCH 09/10] Document token lifetime env vars in .env.example --- surfsense_backend/.env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 2e10f4e36..628329917 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -32,6 +32,11 @@ ELECTRIC_DB_PASSWORD=electric_password SCHEDULE_CHECKER_INTERVAL=5m SECRET_KEY=SECRET + +# JWT Token Lifetimes (optional, defaults shown) +# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day +# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks + NEXT_FRONTEND_URL=http://localhost:3000 # Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS) From a8e8b0e2fd81720ea5f6245682262d7eaa4c6c95 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 5 Feb 2026 19:29:34 +0200 Subject: [PATCH 10/10] Fix auto-refresh on 401 in base API service --- surfsense_web/lib/apis/base-api.service.ts | 20 ++++++++++++++++++-- surfsense_web/lib/auth-utils.ts | 3 ++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index a87d4deaf..933e54656 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,5 +1,5 @@ import type { ZodType } from "zod"; -import { getBearerToken, handleUnauthorized } from "../auth-utils"; +import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils"; import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error"; enum ResponseType { @@ -17,6 +17,7 @@ export type RequestOptions = { signal?: AbortSignal; body?: any; responseType?: ResponseType; + _isRetry?: boolean; // Internal flag to prevent infinite retry loops // Add more options as needed }; @@ -135,8 +136,23 @@ class BaseApiService { throw new AppError("Failed to parse response", response.status, response.statusText); } - // Handle 401 first before other error handling - ensures token is cleared and user redirected + // Handle 401 - try to refresh token first (only once) if (response.status === 401) { + if (!options?._isRetry) { + const newToken = await refreshAccessToken(); + if (newToken) { + // Retry the request with the new token + return this.request(url, responseSchema, { + ...mergedOptions, + headers: { + ...mergedOptions.headers, + Authorization: `Bearer ${newToken}`, + }, + _isRetry: true, + } as RequestOptions & { responseType?: R }); + } + } + // Refresh failed or retry failed, redirect to login handleUnauthorized(); throw new AuthenticationError( typeof data === "object" && "detail" in data diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 409f29d0c..8c067a4b7 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -179,8 +179,9 @@ export function getAuthHeaders(additionalHeaders?: Record): Reco /** * Attempts to refresh the access token using the stored refresh token. * Returns the new access token if successful, null otherwise. + * Exported for use by API services. */ -async function refreshAccessToken(): Promise { +export async function refreshAccessToken(): Promise { // If already refreshing, wait for that request to complete if (isRefreshing && refreshPromise) { return refreshPromise;