feat: Add asymmetric JWT authentication with RS256/RS512 support (#10755)

* add possibility to change jwt algorithm

* add docs related to jwt auth

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* ruff check fixes

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* convert to enum auth types

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* copilot gh suggestions

* ruff check

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* add validation to images read file compnent

* [autofix.ci] apply automated fixes

* ruff fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* [autofix.ci] apply automated fixes

* revert: remove starter project changes

* revert: yarn lock

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* remove-docs-for-1.8-release

* remove starter projects

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* [autofix.ci] apply automated fixes

* improve logs message

* add missing dep in lfx

* [autofix.ci] apply automated fixes

* fix typo test error

* create helper functions

* [autofix.ci] apply automated fixes

* ruff checker

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa
2026-01-06 09:37:52 -03:00
committed by GitHub
parent 409ac6a0ed
commit 1769b0f2fa
9 changed files with 1275 additions and 22 deletions

View File

@ -1,4 +1,4 @@
.PHONY: all init format_backend format lint build run_backend dev help tests coverage clean_python_cache clean_npm_cache clean_frontend_build clean_all run_clic load_test_setup load_test_setup_basic load_test_list_flows load_test_run load_test_langflow_quick load_test_stress load_test_example load_test_clean load_test_remote_setup load_test_remote_run load_test_help
.PHONY: all init format_backend format lint build run_backend dev help tests coverage clean_python_cache clean_npm_cache clean_frontend_build clean_all run_clic load_test_setup load_test_setup_basic load_test_list_flows load_test_run load_test_langflow_quick load_test_stress load_test_example load_test_clean load_test_remote_setup load_test_remote_run load_test_help docs docs_build docs_install
# Configurations
VERSION=$(shell grep "^version" pyproject.toml | sed 's/.*\"\(.*\)\"$$/\1/')
@ -54,6 +54,7 @@ help: ## show basic help message with common commands
@echo " $(GREEN)make format$(NC) - Format all code (backend + frontend)"
@echo " $(GREEN)make tests$(NC) - Run all tests"
@echo " $(GREEN)make build$(NC) - Build the project"
@echo " $(GREEN)make docs$(NC) - Start documentation server (http://localhost:3030)"
@echo " $(GREEN)make clean_all$(NC) - Clean all caches and build artifacts"
@echo ''
@echo "$(GREEN)Specialized Help Commands:$(NC)"
@ -996,6 +997,39 @@ help_advanced: ## show advanced and miscellaneous commands
@echo "$(GREEN)═══════════════════════════════════════════════════════════════════$(NC)"
@echo ''
######################
# DOCUMENTATION
######################
docs_port ?= 3030
check_yarn:
@command -v yarn >/dev/null 2>&1 || { \
echo "$(RED)Error: yarn is not installed.$(NC)"; \
echo "$(YELLOW)The docs project requires yarn. Please install it:$(NC)"; \
echo " brew install yarn"; \
echo " # or"; \
echo " npm install -g yarn"; \
exit 1; \
}
docs_install: check_yarn ## install documentation dependencies
@echo "$(GREEN)Installing documentation dependencies...$(NC)"
@cd docs && yarn install
docs: docs_install ## start documentation development server (default port 3030)
@echo "$(GREEN)Starting documentation server at http://localhost:$(docs_port)$(NC)"
@cd docs && yarn start --port $(docs_port)
docs_build: docs_install ## build documentation for production
@echo "$(GREEN)Building documentation...$(NC)"
@cd docs && yarn build
@echo "$(GREEN)Documentation built successfully in docs/build/$(NC)"
docs_serve: docs_build ## build and serve documentation locally
@echo "$(GREEN)Serving built documentation...$(NC)"
@cd docs && yarn serve --port $(docs_port)
######################
# INCLUDE FRONTEND MAKEFILE
######################

View File

@ -44,6 +44,60 @@ AUTO_LOGIN_ERROR = (
REFRESH_TOKEN_TYPE: Final[str] = "refresh" # noqa: S105
ACCESS_TOKEN_TYPE: Final[str] = "access" # noqa: S105
# JWT key configuration error messages
PUBLIC_KEY_NOT_CONFIGURED_ERROR: Final[str] = (
"Server configuration error: Public key not configured for asymmetric JWT algorithm."
)
SECRET_KEY_NOT_CONFIGURED_ERROR: Final[str] = "Server configuration error: Secret key not configured." # noqa: S105
class JWTKeyError(HTTPException):
"""Raised when JWT key configuration is invalid."""
def __init__(self, detail: str, *, include_www_authenticate: bool = True):
headers = {"WWW-Authenticate": "Bearer"} if include_www_authenticate else None
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers=headers,
)
def get_jwt_verification_key(settings_service: SettingsService) -> str:
"""Get the appropriate key for JWT verification based on configured algorithm.
For asymmetric algorithms (RS256, RS512): returns public key
For symmetric algorithms (HS256): returns secret key
"""
algorithm = settings_service.auth_settings.ALGORITHM
if algorithm.is_asymmetric():
verification_key = settings_service.auth_settings.PUBLIC_KEY
if not verification_key:
logger.error("Public key is not set in settings for RS256/RS512.")
raise JWTKeyError(PUBLIC_KEY_NOT_CONFIGURED_ERROR)
return verification_key
secret_key = settings_service.auth_settings.SECRET_KEY.get_secret_value()
if secret_key is None:
logger.error("Secret key is not set in settings.")
raise JWTKeyError(SECRET_KEY_NOT_CONFIGURED_ERROR)
return secret_key
def get_jwt_signing_key(settings_service: SettingsService) -> str:
"""Get the appropriate key for JWT signing based on configured algorithm.
For asymmetric algorithms (RS256, RS512): returns private key
For symmetric algorithms (HS256): returns secret key
"""
algorithm = settings_service.auth_settings.ALGORITHM
if algorithm.is_asymmetric():
return settings_service.auth_settings.PRIVATE_KEY.get_secret_value()
return settings_service.auth_settings.SECRET_KEY.get_secret_value()
# Source: https://github.com/mrtolkien/fastapi_simple_security/blob/master/fastapi_simple_security/security_api_key.py
async def api_key_security(
@ -172,20 +226,13 @@ async def get_current_user_by_jwt(
if isinstance(token, Coroutine):
token = await token
secret_key = settings_service.auth_settings.SECRET_KEY.get_secret_value()
if secret_key is None:
logger.error("Secret key is not set in settings.")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
# Careful not to leak sensitive information
detail="Authentication failure: Verify authentication settings.",
headers={"WWW-Authenticate": "Bearer"},
)
algorithm = settings_service.auth_settings.ALGORITHM
verification_key = get_jwt_verification_key(settings_service)
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
payload = jwt.decode(token, secret_key, algorithms=[settings_service.auth_settings.ALGORITHM])
payload = jwt.decode(token, verification_key, algorithms=[algorithm])
user_id: UUID = payload.get("sub") # type: ignore[assignment]
token_type: str = payload.get("type") # type: ignore[assignment]
@ -364,10 +411,13 @@ def create_token(data: dict, expires_delta: timedelta):
expire = datetime.now(timezone.utc) + expires_delta
to_encode["exp"] = expire
algorithm = settings_service.auth_settings.ALGORITHM
signing_key = get_jwt_signing_key(settings_service)
return jwt.encode(
to_encode,
settings_service.auth_settings.SECRET_KEY.get_secret_value(),
algorithm=settings_service.auth_settings.ALGORITHM,
signing_key,
algorithm=algorithm,
)
@ -484,14 +534,17 @@ async def create_user_tokens(user_id: UUID, db: AsyncSession, *, update_last_log
async def create_refresh_token(refresh_token: str, db: AsyncSession):
settings_service = get_settings_service()
algorithm = settings_service.auth_settings.ALGORITHM
verification_key = get_jwt_verification_key(settings_service)
try:
# Ignore warning about datetime.utcnow
with warnings.catch_warnings():
warnings.simplefilter("ignore")
payload = jwt.decode(
refresh_token,
settings_service.auth_settings.SECRET_KEY.get_secret_value(),
algorithms=[settings_service.auth_settings.ALGORITHM],
verification_key,
algorithms=[algorithm],
)
user_id: UUID = payload.get("sub") # type: ignore[assignment]
token_type: str = payload.get("type") # type: ignore[assignment]

View File

@ -0,0 +1,989 @@
"""Comprehensive tests for JWT algorithm support (HS256, RS256, RS512).
Tests cover:
- AuthSettings configuration for each algorithm
- RSA key generation and persistence
- Token creation and verification
- Error cases and edge cases
- Authentication failure scenarios
"""
import tempfile
from datetime import timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from jose import JWTError, jwt
from pydantic import SecretStr
class TestAuthSettingsAlgorithms:
"""Test AuthSettings configuration for different JWT algorithms."""
def test_default_algorithm_is_hs256(self):
"""Default algorithm should be HS256 for backward compatibility (when not overridden by env)."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
# Explicitly set HS256 to test the setting works (env var may override default)
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
assert settings.ALGORITHM == "HS256"
def test_hs256_generates_secret_key(self):
"""HS256 should generate a secret key automatically."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
secret_key = settings.SECRET_KEY.get_secret_value()
assert secret_key is not None
assert len(secret_key) >= 32
def test_rs256_generates_rsa_key_pair(self):
"""RS256 should generate RSA key pair automatically."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
private_key = settings.PRIVATE_KEY.get_secret_value()
public_key = settings.PUBLIC_KEY
assert private_key is not None
assert public_key is not None
assert "-----BEGIN PRIVATE KEY-----" in private_key
assert "-----BEGIN PUBLIC KEY-----" in public_key
def test_rs512_generates_rsa_key_pair(self):
"""RS512 should generate RSA key pair automatically."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS512")
private_key = settings.PRIVATE_KEY.get_secret_value()
public_key = settings.PUBLIC_KEY
assert private_key is not None
assert public_key is not None
assert "-----BEGIN PRIVATE KEY-----" in private_key
assert "-----BEGIN PUBLIC KEY-----" in public_key
def test_rsa_keys_persisted_to_files(self):
"""RSA keys should be persisted to files in CONFIG_DIR."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
private_key_path = Path(tmpdir) / "private_key.pem"
public_key_path = Path(tmpdir) / "public_key.pem"
assert private_key_path.exists()
assert public_key_path.exists()
# Verify file contents match settings
assert private_key_path.read_text() == settings.PRIVATE_KEY.get_secret_value()
assert public_key_path.read_text() == settings.PUBLIC_KEY
def test_rsa_keys_loaded_from_existing_files(self):
"""RSA keys should be loaded from existing files."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
# First run - generate keys
settings1 = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
original_private = settings1.PRIVATE_KEY.get_secret_value()
original_public = settings1.PUBLIC_KEY
# Second run - should load existing keys
settings2 = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
assert settings2.PRIVATE_KEY.get_secret_value() == original_private
assert original_public == settings2.PUBLIC_KEY
def test_custom_private_key_derives_public_key(self):
"""When custom private key is provided, public key should be derived."""
from lfx.services.settings.auth import AuthSettings
from lfx.services.settings.utils import generate_rsa_key_pair
custom_private, expected_public = generate_rsa_key_pair()
with tempfile.TemporaryDirectory() as tmpdir:
settings = AuthSettings(
CONFIG_DIR=tmpdir,
ALGORITHM="RS256",
PRIVATE_KEY=SecretStr(custom_private),
)
assert settings.PRIVATE_KEY.get_secret_value() == custom_private
assert expected_public == settings.PUBLIC_KEY
def test_no_config_dir_generates_keys_in_memory(self):
"""Without CONFIG_DIR, keys should be generated in memory."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR="", ALGORITHM="RS256")
assert settings.PRIVATE_KEY.get_secret_value() is not None
assert settings.PUBLIC_KEY is not None
assert "-----BEGIN PRIVATE KEY-----" in settings.PRIVATE_KEY.get_secret_value()
def test_hs256_does_not_generate_rsa_keys(self):
"""HS256 should not trigger RSA key generation."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
private_key_path = Path(tmpdir) / "private_key.pem"
public_key_path = Path(tmpdir) / "public_key.pem"
# RSA key files should not be created for HS256
assert not private_key_path.exists()
assert not public_key_path.exists()
def test_invalid_algorithm_rejected(self):
"""Invalid algorithm should be rejected by pydantic."""
from lfx.services.settings.auth import AuthSettings
from pydantic import ValidationError
with tempfile.TemporaryDirectory() as tmpdir, pytest.raises(ValidationError):
AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="INVALID")
class TestRSAKeyGeneration:
"""Test RSA key pair generation utility."""
def test_generate_rsa_key_pair_returns_valid_keys(self):
"""Generated keys should be valid PEM format."""
from lfx.services.settings.utils import generate_rsa_key_pair
private_key, public_key = generate_rsa_key_pair()
assert "-----BEGIN PRIVATE KEY-----" in private_key
assert "-----END PRIVATE KEY-----" in private_key
assert "-----BEGIN PUBLIC KEY-----" in public_key
assert "-----END PUBLIC KEY-----" in public_key
def test_generated_keys_are_unique(self):
"""Each call should generate unique keys."""
from lfx.services.settings.utils import generate_rsa_key_pair
private1, public1 = generate_rsa_key_pair()
private2, public2 = generate_rsa_key_pair()
assert private1 != private2
assert public1 != public2
def test_generated_keys_can_sign_and_verify(self):
"""Generated keys should work for JWT signing and verification."""
from lfx.services.settings.utils import generate_rsa_key_pair
private_key, public_key = generate_rsa_key_pair()
# Sign a token
payload = {"sub": "test-user", "type": "access"}
token = jwt.encode(payload, private_key, algorithm="RS256")
# Verify the token
decoded = jwt.decode(token, public_key, algorithms=["RS256"])
assert decoded["sub"] == "test-user"
assert decoded["type"] == "access"
class TestTokenCreation:
"""Test JWT token creation with different algorithms."""
def _create_mock_settings_service(self, algorithm, tmpdir):
"""Helper to create mock settings service."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM=algorithm)
mock_service = MagicMock()
mock_service.auth_settings = settings
return mock_service
def test_create_token_hs256(self):
"""Token creation with HS256 should use secret key."""
from langflow.services.auth.utils import create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
token = create_token(
data={"sub": "user-123", "type": "access"},
expires_delta=timedelta(hours=1),
)
assert token is not None
# Verify token header shows HS256
header = jwt.get_unverified_header(token)
assert header["alg"] == "HS256"
def test_create_token_rs256(self):
"""Token creation with RS256 should use private key."""
from langflow.services.auth.utils import create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
token = create_token(
data={"sub": "user-456", "type": "access"},
expires_delta=timedelta(hours=1),
)
assert token is not None
# Verify token header shows RS256
header = jwt.get_unverified_header(token)
assert header["alg"] == "RS256"
def test_create_token_rs512(self):
"""Token creation with RS512 should use private key."""
from langflow.services.auth.utils import create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS512", tmpdir)
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
token = create_token(
data={"sub": "user-789", "type": "access"},
expires_delta=timedelta(hours=1),
)
assert token is not None
# Verify token header shows RS512
header = jwt.get_unverified_header(token)
assert header["alg"] == "RS512"
def test_token_contains_expiration(self):
"""Created token should contain expiration claim."""
from langflow.services.auth.utils import create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
token = create_token(
data={"sub": "user-123", "type": "access"},
expires_delta=timedelta(hours=1),
)
# Decode without verification to check claims
claims = jwt.get_unverified_claims(token)
assert "exp" in claims
class TestTokenVerification:
"""Test JWT token verification with different algorithms."""
def _create_mock_settings_service(self, algorithm, tmpdir):
"""Helper to create mock settings service."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM=algorithm)
mock_service = MagicMock()
mock_service.auth_settings = settings
return mock_service
@pytest.mark.asyncio
async def test_verify_hs256_token_success(self):
"""Valid HS256 token should be verified successfully."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
# Create a mock user
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.is_active = True
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
token = create_token(
data={"sub": "user-123", "type": "access"},
expires_delta=timedelta(hours=1),
)
user = await get_current_user_by_jwt(token, mock_db)
assert user == mock_user
@pytest.mark.asyncio
async def test_verify_rs256_token_success(self):
"""Valid RS256 token should be verified successfully."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
mock_user = MagicMock()
mock_user.id = "user-456"
mock_user.is_active = True
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
token = create_token(
data={"sub": "user-456", "type": "access"},
expires_delta=timedelta(hours=1),
)
user = await get_current_user_by_jwt(token, mock_db)
assert user == mock_user
@pytest.mark.asyncio
async def test_verify_rs512_token_success(self):
"""Valid RS512 token should be verified successfully."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS512", tmpdir)
mock_user = MagicMock()
mock_user.id = "user-789"
mock_user.is_active = True
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
token = create_token(
data={"sub": "user-789", "type": "access"},
expires_delta=timedelta(hours=1),
)
user = await get_current_user_by_jwt(token, mock_db)
assert user == mock_user
class TestAuthenticationFailures:
"""Test authentication failure scenarios."""
def _create_mock_settings_service(self, algorithm, tmpdir, **overrides):
"""Helper to create mock settings service with optional overrides."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM=algorithm)
# Apply overrides
for key, value in overrides.items():
object.__setattr__(settings, key, value)
mock_service = MagicMock()
mock_service.auth_settings = settings
return mock_service
@pytest.mark.asyncio
async def test_missing_public_key_rs256_raises_401(self):
"""Missing public key for RS256 should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir, PUBLIC_KEY="")
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt("some-token", mock_db)
assert exc_info.value.status_code == 401
assert "Server configuration error" in exc_info.value.detail
assert "Public key not configured" in exc_info.value.detail
@pytest.mark.asyncio
async def test_missing_secret_key_hs256_raises_401(self):
"""Missing secret key for HS256 should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
from lfx.services.settings.auth import JWTAlgorithm
# Create a fully mocked settings service without using AuthSettings
mock_auth_settings = MagicMock()
mock_auth_settings.ALGORITHM = JWTAlgorithm.HS256
mock_auth_settings.SECRET_KEY = MagicMock()
mock_auth_settings.SECRET_KEY.get_secret_value.return_value = None
mock_service = MagicMock()
mock_service.auth_settings = mock_auth_settings
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt("some-token", mock_db)
assert exc_info.value.status_code == 401
assert "Server configuration error" in exc_info.value.detail
assert "Secret key not configured" in exc_info.value.detail
@pytest.mark.asyncio
async def test_invalid_token_raises_401(self):
"""Invalid token should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt("invalid-token-format", mock_db)
assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail
@pytest.mark.asyncio
async def test_token_signed_with_wrong_key_raises_401(self):
"""Token signed with different key should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
# Create token with different secret
wrong_token = jwt.encode(
{"sub": "user-123", "type": "access"},
"different-secret-key",
algorithm="HS256",
)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(wrong_token, mock_db)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_expired_token_raises_401(self):
"""Expired token should raise 401."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
# Create token that's already expired
token = create_token(
data={"sub": "user-123", "type": "access"},
expires_delta=timedelta(seconds=-10), # Negative = already expired
)
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(token, mock_db)
assert exc_info.value.status_code == 401
# jose library raises JWTError for expired tokens before our custom check
assert (
"expired" in exc_info.value.detail.lower() or "could not validate" in exc_info.value.detail.lower()
)
@pytest.mark.asyncio
async def test_token_without_user_id_raises_401(self):
"""Token without user ID should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
# Create token without 'sub' claim
token = jwt.encode(
{"type": "access"},
mock_service.auth_settings.SECRET_KEY.get_secret_value(),
algorithm="HS256",
)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(token, mock_db)
assert exc_info.value.status_code == 401
assert "Invalid token" in exc_info.value.detail
@pytest.mark.asyncio
async def test_token_without_type_raises_401(self):
"""Token without type should raise 401."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
# Create token without 'type' claim
token = jwt.encode(
{"sub": "user-123"},
mock_service.auth_settings.SECRET_KEY.get_secret_value(),
algorithm="HS256",
)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(token, mock_db)
assert exc_info.value.status_code == 401
assert "invalid" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_user_not_found_raises_401(self):
"""Token for non-existent user should raise 401."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=None),
):
token = create_token(
data={"sub": "non-existent-user", "type": "access"},
expires_delta=timedelta(hours=1),
)
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(token, mock_db)
assert exc_info.value.status_code == 401
assert "User not found" in exc_info.value.detail
@pytest.mark.asyncio
async def test_inactive_user_raises_401(self):
"""Token for inactive user should raise 401."""
from langflow.services.auth.utils import create_token, get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
mock_user = MagicMock()
mock_user.is_active = False
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
token = create_token(
data={"sub": "inactive-user", "type": "access"},
expires_delta=timedelta(hours=1),
)
with pytest.raises(HTTPException) as exc_info:
await get_current_user_by_jwt(token, mock_db)
assert exc_info.value.status_code == 401
assert "inactive" in exc_info.value.detail.lower()
class TestRefreshTokenVerification:
"""Test refresh token verification with different algorithms."""
def _create_mock_settings_service(self, algorithm, tmpdir):
"""Helper to create mock settings service."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM=algorithm)
mock_service = MagicMock()
mock_service.auth_settings = settings
return mock_service
@pytest.mark.asyncio
async def test_refresh_token_rs256_success(self):
"""Valid RS256 refresh token should create new tokens."""
from langflow.services.auth.utils import create_refresh_token, create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.is_active = True
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
# Create refresh token
refresh_token = create_token(
data={"sub": "user-123", "type": "refresh"},
expires_delta=timedelta(days=7),
)
# Use refresh token to get new tokens
new_tokens = await create_refresh_token(refresh_token, mock_db)
assert "access_token" in new_tokens
assert "refresh_token" in new_tokens
assert new_tokens.get("token_type") == "bearer"
@pytest.mark.asyncio
async def test_refresh_token_wrong_type_raises_401(self):
"""Access token used as refresh token should raise 401."""
from langflow.services.auth.utils import create_refresh_token, create_token
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
mock_db = AsyncMock()
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
# Create access token (not refresh)
access_token = create_token(
data={"sub": "user-123", "type": "access"},
expires_delta=timedelta(hours=1),
)
with pytest.raises(HTTPException) as exc_info:
await create_refresh_token(access_token, mock_db)
assert exc_info.value.status_code == 401
assert "Invalid refresh token" in exc_info.value.detail
class TestAlgorithmMismatch:
"""Test scenarios where algorithm configuration changes."""
def test_hs256_token_fails_with_rs256_verification(self):
"""Token created with HS256 should fail RS256 verification."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
# Create token with HS256
hs256_settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
token = jwt.encode(
{"sub": "user-123", "type": "access"},
hs256_settings.SECRET_KEY.get_secret_value(),
algorithm="HS256",
)
with tempfile.TemporaryDirectory() as tmpdir2:
# Try to verify with RS256
rs256_settings = AuthSettings(CONFIG_DIR=tmpdir2, ALGORITHM="RS256")
with pytest.raises(JWTError):
jwt.decode(token, rs256_settings.PUBLIC_KEY, algorithms=["RS256"])
def test_rs256_token_fails_with_hs256_verification(self):
"""Token created with RS256 should fail HS256 verification."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
# Create token with RS256
rs256_settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
token = jwt.encode(
{"sub": "user-123", "type": "access"},
rs256_settings.PRIVATE_KEY.get_secret_value(),
algorithm="RS256",
)
with tempfile.TemporaryDirectory() as tmpdir2:
# Try to verify with HS256
hs256_settings = AuthSettings(CONFIG_DIR=tmpdir2, ALGORITHM="HS256")
with pytest.raises(JWTError):
jwt.decode(
token,
hs256_settings.SECRET_KEY.get_secret_value(),
algorithms=["HS256"],
)
class TestKeyPersistence:
"""Test key persistence and file permissions."""
def test_secret_key_file_created(self):
"""Secret key should be saved to file."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
secret_key_path = Path(tmpdir) / "secret_key"
assert secret_key_path.exists()
def test_rsa_key_files_created(self):
"""RSA keys should be saved to files."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
private_key_path = Path(tmpdir) / "private_key.pem"
public_key_path = Path(tmpdir) / "public_key.pem"
assert private_key_path.exists()
assert public_key_path.exists()
def test_keys_reloaded_on_restart(self):
"""Keys should be consistent across settings reloads."""
from lfx.services.settings.auth import AuthSettings
with tempfile.TemporaryDirectory() as tmpdir:
# First load
settings1 = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
private1 = settings1.PRIVATE_KEY.get_secret_value()
public1 = settings1.PUBLIC_KEY
# Simulate restart
settings2 = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="RS256")
private2 = settings2.PRIVATE_KEY.get_secret_value()
public2 = settings2.PUBLIC_KEY
assert private1 == private2
assert public1 == public2
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
def test_empty_config_dir_string(self):
"""Empty CONFIG_DIR string should work (in-memory keys)."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR="", ALGORITHM="RS256")
assert settings.PRIVATE_KEY.get_secret_value() is not None
assert settings.PUBLIC_KEY is not None
def test_token_with_extra_claims(self):
"""Token with extra claims should still work."""
from langflow.services.auth.utils import get_current_user_by_jwt
with tempfile.TemporaryDirectory() as tmpdir:
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
token = jwt.encode(
{
"sub": "user-123",
"type": "access",
"extra_claim": "some-value",
"another": 123,
},
settings.SECRET_KEY.get_secret_value(),
algorithm="HS256",
)
mock_service = MagicMock()
mock_service.auth_settings = settings
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.is_active = True
mock_db = AsyncMock()
with (
patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service),
patch("langflow.services.auth.utils.get_user_by_id", return_value=mock_user),
):
import asyncio
user = asyncio.get_event_loop().run_until_complete(get_current_user_by_jwt(token, mock_db))
assert user == mock_user
def test_very_long_user_id(self):
"""Very long user ID should work."""
from langflow.services.auth.utils import create_token
with tempfile.TemporaryDirectory() as tmpdir:
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM="HS256")
mock_service = MagicMock()
mock_service.auth_settings = settings
long_user_id = "a" * 1000
with patch("langflow.services.auth.utils.get_settings_service", return_value=mock_service):
token = create_token(
data={"sub": long_user_id, "type": "access"},
expires_delta=timedelta(hours=1),
)
claims = jwt.get_unverified_claims(token)
assert claims["sub"] == long_user_id
class TestJWTKeyHelpers:
"""Test JWT key helper functions."""
def _create_mock_settings_service(self, algorithm, tmpdir):
"""Helper to create mock settings service."""
from lfx.services.settings.auth import AuthSettings
settings = AuthSettings(CONFIG_DIR=tmpdir, ALGORITHM=algorithm)
mock_service = MagicMock()
mock_service.auth_settings = settings
return mock_service
def test_get_jwt_verification_key_hs256_returns_secret_key(self):
"""HS256 should return secret key for verification."""
from langflow.services.auth.utils import get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
key = get_jwt_verification_key(mock_service)
assert key == mock_service.auth_settings.SECRET_KEY.get_secret_value()
assert len(key) >= 32
def test_get_jwt_verification_key_rs256_returns_public_key(self):
"""RS256 should return public key for verification."""
from langflow.services.auth.utils import get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
key = get_jwt_verification_key(mock_service)
assert key == mock_service.auth_settings.PUBLIC_KEY
assert "-----BEGIN PUBLIC KEY-----" in key
def test_get_jwt_verification_key_rs512_returns_public_key(self):
"""RS512 should return public key for verification."""
from langflow.services.auth.utils import get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS512", tmpdir)
key = get_jwt_verification_key(mock_service)
assert key == mock_service.auth_settings.PUBLIC_KEY
assert "-----BEGIN PUBLIC KEY-----" in key
def test_get_jwt_verification_key_missing_public_key_raises_error(self):
"""Missing public key for asymmetric algorithm should raise JWTKeyError."""
from langflow.services.auth.utils import JWTKeyError, get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
object.__setattr__(mock_service.auth_settings, "PUBLIC_KEY", "")
with pytest.raises(JWTKeyError) as exc_info:
get_jwt_verification_key(mock_service)
assert exc_info.value.status_code == 401
assert "Public key not configured" in exc_info.value.detail
def test_get_jwt_verification_key_missing_secret_key_raises_error(self):
"""Missing secret key for HS256 should raise JWTKeyError."""
from langflow.services.auth.utils import JWTKeyError, get_jwt_verification_key
from lfx.services.settings.auth import JWTAlgorithm
mock_auth_settings = MagicMock()
mock_auth_settings.ALGORITHM = JWTAlgorithm.HS256
mock_auth_settings.SECRET_KEY = MagicMock()
mock_auth_settings.SECRET_KEY.get_secret_value.return_value = None
mock_service = MagicMock()
mock_service.auth_settings = mock_auth_settings
with pytest.raises(JWTKeyError) as exc_info:
get_jwt_verification_key(mock_service)
assert exc_info.value.status_code == 401
assert "Secret key not configured" in exc_info.value.detail
def test_get_jwt_signing_key_hs256_returns_secret_key(self):
"""HS256 should return secret key for signing."""
from langflow.services.auth.utils import get_jwt_signing_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
key = get_jwt_signing_key(mock_service)
assert key == mock_service.auth_settings.SECRET_KEY.get_secret_value()
def test_get_jwt_signing_key_rs256_returns_private_key(self):
"""RS256 should return private key for signing."""
from langflow.services.auth.utils import get_jwt_signing_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
key = get_jwt_signing_key(mock_service)
assert key == mock_service.auth_settings.PRIVATE_KEY.get_secret_value()
assert "-----BEGIN PRIVATE KEY-----" in key
def test_get_jwt_signing_key_rs512_returns_private_key(self):
"""RS512 should return private key for signing."""
from langflow.services.auth.utils import get_jwt_signing_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS512", tmpdir)
key = get_jwt_signing_key(mock_service)
assert key == mock_service.auth_settings.PRIVATE_KEY.get_secret_value()
assert "-----BEGIN PRIVATE KEY-----" in key
def test_verification_and_signing_keys_work_together_hs256(self):
"""Verification and signing keys should work together for HS256."""
from langflow.services.auth.utils import get_jwt_signing_key, get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("HS256", tmpdir)
signing_key = get_jwt_signing_key(mock_service)
verification_key = get_jwt_verification_key(mock_service)
# For symmetric algorithms, both keys are the same
assert signing_key == verification_key
# Sign and verify a token
payload = {"sub": "test-user", "type": "access"}
token = jwt.encode(payload, signing_key, algorithm="HS256")
decoded = jwt.decode(token, verification_key, algorithms=["HS256"])
assert decoded["sub"] == "test-user"
def test_verification_and_signing_keys_work_together_rs256(self):
"""Verification and signing keys should work together for RS256."""
from langflow.services.auth.utils import get_jwt_signing_key, get_jwt_verification_key
with tempfile.TemporaryDirectory() as tmpdir:
mock_service = self._create_mock_settings_service("RS256", tmpdir)
signing_key = get_jwt_signing_key(mock_service)
verification_key = get_jwt_verification_key(mock_service)
# For asymmetric algorithms, keys are different
assert signing_key != verification_key
# Sign and verify a token
payload = {"sub": "test-user", "type": "access"}
token = jwt.encode(payload, signing_key, algorithm="RS256")
decoded = jwt.decode(token, verification_key, algorithms=["RS256"])
assert decoded["sub"] == "test-user"

View File

@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.middleware.cors import CORSMiddleware
from lfx.services.settings.auth import JWTAlgorithm
from lfx.services.settings.base import Settings
@ -290,7 +291,7 @@ class TestRefreshTokenSecurity:
with patch("langflow.services.auth.utils.get_settings_service") as mock_settings:
mock_settings.return_value.auth_settings.SECRET_KEY.get_secret_value.return_value = "secret"
mock_settings.return_value.auth_settings.ALGORITHM = "HS256"
mock_settings.return_value.auth_settings.ALGORITHM = JWTAlgorithm.HS256
mock_settings.return_value.auth_settings.ACCESS_TOKEN_EXPIRE_SECONDS = 3600
mock_settings.return_value.auth_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 604800

View File

@ -41,6 +41,7 @@ dependencies = [
"validators>=0.34.0,<1.0.0",
"filelock>=3.20.0",
"pypdf>=5.1.0",
"cryptography>=43.0.0",
]
[project.scripts]

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,33 @@
import secrets
from enum import Enum
from pathlib import Path
from typing import Literal
from passlib.context import CryptContext
from pydantic import Field, SecretStr, field_validator
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from lfx.log.logger import logger
from lfx.services.settings.constants import DEFAULT_SUPERUSER, DEFAULT_SUPERUSER_PASSWORD
from lfx.services.settings.utils import read_secret_from_file, write_secret_to_file
from lfx.services.settings.utils import (
derive_public_key_from_private,
generate_rsa_key_pair,
read_secret_from_file,
write_public_key_to_file,
write_secret_to_file,
)
class JWTAlgorithm(str, Enum):
"""JWT signing algorithm options."""
HS256 = "HS256"
RS256 = "RS256"
RS512 = "RS512"
def is_asymmetric(self) -> bool:
"""Return True if this algorithm uses asymmetric (public/private key) cryptography."""
return self in (JWTAlgorithm.RS256, JWTAlgorithm.RS512)
class AuthSettings(BaseSettings):
@ -16,10 +35,22 @@ class AuthSettings(BaseSettings):
CONFIG_DIR: str
SECRET_KEY: SecretStr = Field(
default=SecretStr(""),
description="Secret key for JWT. If not provided, a random one will be generated.",
description="Secret key for JWT (used with HS256). If not provided, a random one will be generated.",
frozen=False,
)
ALGORITHM: str = "HS256"
PRIVATE_KEY: SecretStr = Field(
default=SecretStr(""),
description="RSA private key for JWT signing (RS256/RS512). Auto-generated if not provided.",
frozen=False,
)
PUBLIC_KEY: str = Field(
default="",
description="RSA public key for JWT verification (RS256/RS512). Derived from private key if not provided.",
)
ALGORITHM: JWTAlgorithm = Field(
default=JWTAlgorithm.HS256,
description="JWT signing algorithm. Use RS256 or RS512 for asymmetric signing (recommended for production).",
)
ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 60 # 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 days
@ -138,3 +169,63 @@ class AuthSettings(BaseSettings):
logger.debug("Saved secret key")
return value if isinstance(value, SecretStr) else SecretStr(value).get_secret_value()
@model_validator(mode="after")
def setup_rsa_keys(self):
"""Generate or load RSA keys when using RS256/RS512 algorithm."""
if not self.ALGORITHM.is_asymmetric():
return self
config_dir = self.CONFIG_DIR
private_key_value = self.PRIVATE_KEY.get_secret_value() if self.PRIVATE_KEY else ""
if not config_dir:
# No config dir - generate keys in memory if not provided
if not private_key_value:
logger.debug("No CONFIG_DIR provided, generating RSA keys in memory")
private_key_pem, public_key_pem = generate_rsa_key_pair()
object.__setattr__(self, "PRIVATE_KEY", SecretStr(private_key_pem))
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
elif not self.PUBLIC_KEY:
# Derive public key from private key
public_key_pem = derive_public_key_from_private(private_key_value)
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
return self
private_key_path = Path(config_dir) / "private_key.pem"
public_key_path = Path(config_dir) / "public_key.pem"
if private_key_value:
# Private key provided via env var - save it and derive public key
logger.debug("RSA private key provided")
write_secret_to_file(private_key_path, private_key_value)
if not self.PUBLIC_KEY:
public_key_pem = derive_public_key_from_private(private_key_value)
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
write_public_key_to_file(public_key_path, public_key_pem)
# No private key provided - load from file or generate
elif private_key_path.exists():
logger.debug("Loading RSA keys from files")
private_key_pem = read_secret_from_file(private_key_path)
object.__setattr__(self, "PRIVATE_KEY", SecretStr(private_key_pem))
if public_key_path.exists():
public_key_pem = public_key_path.read_text(encoding="utf-8")
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
else:
# Derive public key from private key
public_key_pem = derive_public_key_from_private(private_key_pem)
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
write_public_key_to_file(public_key_path, public_key_pem)
else:
# Generate new RSA key pair
logger.debug("Generating new RSA key pair")
private_key_pem, public_key_pem = generate_rsa_key_pair()
write_secret_to_file(private_key_path, private_key_pem)
write_public_key_to_file(public_key_path, public_key_pem)
object.__setattr__(self, "PRIVATE_KEY", SecretStr(private_key_pem))
object.__setattr__(self, "PUBLIC_KEY", public_key_pem)
logger.debug("RSA key pair generated and saved")
return self

View File

@ -1,9 +1,74 @@
import platform
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from lfx.log.logger import logger
class RSAKeyError(Exception):
"""Exception raised when RSA key operations fail."""
def derive_public_key_from_private(private_key_pem: str) -> str:
"""Derive a public key from a private key PEM string.
Args:
private_key_pem: The private key in PEM format.
Returns:
str: The public key in PEM format.
Raises:
RSAKeyError: If the private key is invalid or cannot be processed.
"""
try:
private_key = load_pem_private_key(private_key_pem.encode(), password=None)
return (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
except Exception as e:
msg = f"Failed to derive public key from private key: {e}"
logger.error(msg)
raise RSAKeyError(msg) from e
def generate_rsa_key_pair() -> tuple[str, str]:
"""Generate an RSA key pair for RS256 JWT signing.
Returns:
tuple[str, str]: A tuple of (private_key_pem, public_key_pem) as strings.
"""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_key_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
return private_key_pem, public_key_pem
def set_secure_permissions(file_path: Path) -> None:
if platform.system() in {"Linux", "Darwin"}: # Unix/Linux/Mac
file_path.chmod(0o600)
@ -38,3 +103,20 @@ def write_secret_to_file(path: Path, value: str) -> None:
def read_secret_from_file(path: Path) -> str:
return path.read_text(encoding="utf-8")
def write_public_key_to_file(path: Path, value: str) -> None:
"""Write a public key to file with appropriate permissions (0o644).
Public keys can be readable by others but should only be writable by owner.
Args:
path: The file path to write to.
value: The public key content.
"""
path.write_text(value, encoding="utf-8")
try:
if platform.system() in {"Linux", "Darwin"}:
path.chmod(0o644)
except Exception: # noqa: BLE001
logger.exception("Failed to set permissions on public key file")

4
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10, <3.14"
resolution-markers = [
"python_full_version >= '3.13' and platform_machine == 'arm64' and sys_platform == 'darwin'",
@ -6402,6 +6402,7 @@ dependencies = [
{ name = "asyncer" },
{ name = "cachetools" },
{ name = "chardet" },
{ name = "cryptography" },
{ name = "defusedxml" },
{ name = "docstring-parser" },
{ name = "emoji" },
@ -6457,6 +6458,7 @@ requires-dist = [
{ name = "asyncer", specifier = ">=0.0.8,<1.0.0" },
{ name = "cachetools", specifier = ">=6.0.0" },
{ name = "chardet", specifier = ">=5.2.0,<6.0.0" },
{ name = "cryptography", specifier = ">=43.0.0" },
{ name = "defusedxml", specifier = ">=0.7.1,<1.0.0" },
{ name = "docstring-parser", specifier = ">=0.16,<1.0.0" },
{ name = "emoji", specifier = ">=2.14.1,<3.0.0" },