diff --git a/README.md b/README.md index c8f0f69..0b429b0 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ For manual Auth0 testing, see the [manual OAuth testing guide](manual_oauth_qa/R ## Project Status and Roadmap -### Project Status: In Development, Pre-Release +### Project Status: Experimental -This project is under active development and is **pre-release** software. Think of it as an early beta! +This project is under active development and is **experimental** software. We do not officially support it, nor are there long-term plans to maintain it. ### Roadmap diff --git a/agent_memory_server/__init__.py b/agent_memory_server/__init__.py index b48f3c6..c06ed54 100644 --- a/agent_memory_server/__init__.py +++ b/agent_memory_server/__init__.py @@ -1,3 +1,3 @@ """Redis Agent Memory Server - A memory system for conversational AI.""" -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/agent_memory_server/auth.py b/agent_memory_server/auth.py index f98ee13..ec62230 100644 --- a/agent_memory_server/auth.py +++ b/agent_memory_server/auth.py @@ -1,8 +1,10 @@ +import secrets import threading import time from datetime import UTC, datetime from typing import Any +import bcrypt import httpx import structlog from fastapi import Depends, HTTPException, status @@ -11,6 +13,8 @@ from pydantic import BaseModel from agent_memory_server.config import settings +from agent_memory_server.utils.keys import Keys +from agent_memory_server.utils.redis import get_redis_conn logger = structlog.get_logger() @@ -27,6 +31,15 @@ class UserInfo(BaseModel): roles: list[str] | None = None +class TokenInfo(BaseModel): + """Token information stored in Redis.""" + + description: str + created_at: datetime + expires_at: datetime | None = None + token_hash: str + + class JWKSCache: def __init__(self, cache_duration: int = 3600): self._cache: dict[str, Any] = {} @@ -245,10 +258,98 @@ def verify_jwt(token: str) -> UserInfo: ) from e +def generate_token() -> str: + """Generate a secure random token.""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """Hash a token using bcrypt.""" + return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_token_hash(token: str, token_hash: str) -> bool: + """Verify a token against its hash.""" + try: + return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("utf-8")) + except Exception as e: + logger.warning("Token hash verification failed", error=str(e)) + return False + + +async def verify_token(token: str) -> UserInfo: + """Verify a token and return user info.""" + try: + redis = await get_redis_conn() + + # Get all auth tokens and check each one + # This is not the most efficient approach, but it works for now + # In a production system, you might want to store a mapping of token prefixes + pattern = Keys.auth_token_key("*") + token_keys = [] + + async for key in redis.scan_iter(pattern): + token_keys.append(key) + + for key in token_keys: + token_data = await redis.get(key) + if not token_data: + continue + + try: + token_info = TokenInfo.model_validate_json(token_data) + + # Check if token matches + if verify_token_hash(token, token_info.token_hash): + # Check if token is expired + if ( + token_info.expires_at + and datetime.now(UTC) > token_info.expires_at + ): + logger.warning("Token has expired") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + ) + + # Return user info for valid token + return UserInfo( + sub="token-user", + aud="token-auth", + scope="admin", + roles=["admin"], + exp=int(token_info.expires_at.timestamp()) + if token_info.expires_at + else None, + iat=int(token_info.created_at.timestamp()), + ) + + except HTTPException: + # Re-raise HTTP exceptions (like token expired) + raise + except Exception as e: + logger.warning("Error processing token", error=str(e)) + continue + + # If no token matched, authentication failed + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + + except HTTPException: + raise + except Exception as e: + logger.error("Unexpected error during token verification", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during authentication", + ) from e + + def get_current_user( credentials: HTTPAuthorizationCredentials | None = Depends(oauth2_scheme), ) -> UserInfo: - if settings.disable_auth: + if settings.disable_auth or settings.auth_mode == "disabled": logger.debug("Authentication disabled, returning default user") return UserInfo( sub="local-dev-user", aud="local-dev", scope="admin", roles=["admin"] @@ -268,6 +369,14 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + # Determine authentication mode + if settings.auth_mode == "token" or settings.token_auth_enabled: + import asyncio + + return asyncio.run(verify_token(credentials.credentials)) + if settings.auth_mode == "oauth2": + return verify_jwt(credentials.credentials) + # Default to OAuth2 for backward compatibility return verify_jwt(credentials.credentials) @@ -304,10 +413,34 @@ def role_dependency(user: UserInfo = Depends(get_current_user)) -> UserInfo: def verify_auth_config(): - if settings.disable_auth: + if settings.disable_auth or settings.auth_mode == "disabled": logger.warning("Authentication is DISABLED - suitable for development only") return + if settings.auth_mode == "token" or settings.token_auth_enabled: + logger.info("Token authentication configured") + return + + if settings.auth_mode == "oauth2": + if not settings.oauth2_issuer_url: + raise ValueError( + "OAUTH2_ISSUER_URL must be set when OAuth2 authentication is enabled" + ) + + if not settings.oauth2_audience: + logger.warning( + "OAUTH2_AUDIENCE not set - audience validation will be skipped" + ) + + logger.info( + "OAuth2 authentication configured", + issuer=settings.oauth2_issuer_url, + audience=settings.oauth2_audience or "not-set", + algorithms=settings.oauth2_algorithms, + ) + return + + # Default to OAuth2 for backward compatibility if not settings.oauth2_issuer_url: raise ValueError("OAUTH2_ISSUER_URL must be set when authentication is enabled") @@ -315,7 +448,7 @@ def verify_auth_config(): logger.warning("OAUTH2_AUDIENCE not set - audience validation will be skipped") logger.info( - "OAuth2 authentication configured", + "OAuth2 authentication configured (default)", issuer=settings.oauth2_issuer_url, audience=settings.oauth2_audience or "not-set", algorithms=settings.oauth2_algorithms, diff --git a/agent_memory_server/cli.py b/agent_memory_server/cli.py index 3f2e33e..4a09940 100644 --- a/agent_memory_server/cli.py +++ b/agent_memory_server/cli.py @@ -2,9 +2,9 @@ Command-line interface for agent-memory-server. """ -import datetime import importlib import sys +from datetime import UTC, datetime, timedelta import click import uvicorn @@ -236,11 +236,255 @@ def task_worker(concurrency: int, redelivery_timeout: int): docket_name=settings.docket_name, url=settings.redis_url, concurrency=concurrency, - redelivery_timeout=datetime.timedelta(seconds=redelivery_timeout), + redelivery_timeout=timedelta(seconds=redelivery_timeout), tasks=["agent_memory_server.docket_tasks:task_collection"], ) ) +@cli.group() +def token(): + """Manage authentication tokens.""" + pass + + +@token.command() +@click.option("--description", "-d", required=True, help="Token description") +@click.option("--expires-days", "-e", type=int, help="Token expiration in days") +def add(description: str, expires_days: int | None): + """Add a new authentication token.""" + import asyncio + + from agent_memory_server.auth import TokenInfo, generate_token, hash_token + from agent_memory_server.utils.keys import Keys + + async def create_token(): + redis = await get_redis_conn() + + # Generate token + token = generate_token() + token_hash = hash_token(token) + + # Calculate expiration + now = datetime.now(UTC) + expires_at = now + timedelta(days=expires_days) if expires_days else None + + # Create token info + token_info = TokenInfo( + description=description, + created_at=now, + expires_at=expires_at, + token_hash=token_hash, + ) + + # Store in Redis + key = Keys.auth_token_key(token_hash) + await redis.set(key, token_info.model_dump_json()) + + # Set TTL if expiration is set + if expires_at: + ttl_seconds = int((expires_at - now).total_seconds()) + await redis.expire(key, ttl_seconds) + + # Add to tokens list (for listing purposes) + list_key = Keys.auth_tokens_list_key() + await redis.sadd(list_key, token_hash) + + click.echo("Token created successfully!") + click.echo(f"Token: {token}") + click.echo(f"Description: {description}") + if expires_at: + click.echo(f"Expires: {expires_at.isoformat()}") + else: + click.echo("Expires: Never") + click.echo("\nWARNING: Save this token securely. It will not be shown again.") + + asyncio.run(create_token()) + + +@token.command() +def list(): + """List all authentication tokens.""" + import asyncio + + from agent_memory_server.auth import TokenInfo + from agent_memory_server.utils.keys import Keys + + async def list_tokens(): + redis = await get_redis_conn() + + # Get all token hashes + list_key = Keys.auth_tokens_list_key() + token_hashes = await redis.smembers(list_key) + + if not token_hashes: + click.echo("No tokens found.") + return + + click.echo("Authentication Tokens:") + click.echo("=" * 50) + + for token_hash in token_hashes: + key = Keys.auth_token_key(token_hash) + token_data = await redis.get(key) + + if not token_data: + # Token expired or deleted, remove from list + await redis.srem(list_key, token_hash) + continue + + try: + token_info = TokenInfo.model_validate_json(token_data) + + # Mask the token hash for display + masked_hash = token_hash[:8] + "..." + token_hash[-8:] + + click.echo(f"Token: {masked_hash}") + click.echo(f"Description: {token_info.description}") + click.echo(f"Created: {token_info.created_at.isoformat()}") + if token_info.expires_at: + click.echo(f"Expires: {token_info.expires_at.isoformat()}") + else: + click.echo("Expires: Never") + click.echo("-" * 30) + + except Exception as e: + click.echo(f"Error processing token {token_hash}: {e}") + + asyncio.run(list_tokens()) + + +@token.command() +@click.argument("token_hash") +def show(token_hash: str): + """Show details for a specific token.""" + import asyncio + + from agent_memory_server.auth import TokenInfo + from agent_memory_server.utils.keys import Keys + + async def show_token(): + nonlocal token_hash + redis = await get_redis_conn() + + # Try to find the token by partial hash + if len(token_hash) < 16: + # If partial hash provided, find the full hash + list_key = Keys.auth_tokens_list_key() + token_hashes = await redis.smembers(list_key) + + matching_hashes = [h for h in token_hashes if h.startswith(token_hash)] + + if not matching_hashes: + click.echo(f"No token found matching '{token_hash}'") + return + if len(matching_hashes) > 1: + click.echo(f"Multiple tokens match '{token_hash}':") + for h in matching_hashes: + click.echo(f" {h[:8]}...{h[-8:]}") + return + token_hash = matching_hashes[0] + + key = Keys.auth_token_key(token_hash) + token_data = await redis.get(key) + + if not token_data: + click.echo(f"Token not found: {token_hash}") + return + + try: + token_info = TokenInfo.model_validate_json(token_data) + + click.echo("Token Details:") + click.echo("=" * 30) + click.echo(f"Hash: {token_hash}") + click.echo(f"Description: {token_info.description}") + click.echo(f"Created: {token_info.created_at.isoformat()}") + if token_info.expires_at: + click.echo(f"Expires: {token_info.expires_at.isoformat()}") + # Check if expired + if datetime.now(UTC) > token_info.expires_at: + click.echo("Status: EXPIRED") + else: + click.echo("Status: Active") + else: + click.echo("Expires: Never") + click.echo("Status: Active") + + except Exception as e: + click.echo(f"Error processing token: {e}") + + asyncio.run(show_token()) + + +@token.command() +@click.argument("token_hash") +@click.option("--force", "-f", is_flag=True, help="Force removal without confirmation") +def remove(token_hash: str, force: bool): + """Remove an authentication token.""" + import asyncio + + from agent_memory_server.auth import TokenInfo + from agent_memory_server.utils.keys import Keys + + async def remove_token(): + nonlocal token_hash + redis = await get_redis_conn() + + # Try to find the token by partial hash + if len(token_hash) < 16: + # If partial hash provided, find the full hash + list_key = Keys.auth_tokens_list_key() + token_hashes = await redis.smembers(list_key) + + matching_hashes = [h for h in token_hashes if h.startswith(token_hash)] + + if not matching_hashes: + click.echo(f"No token found matching '{token_hash}'") + return + if len(matching_hashes) > 1: + click.echo(f"Multiple tokens match '{token_hash}':") + for h in matching_hashes: + click.echo(f" {h[:8]}...{h[-8:]}") + return + token_hash = matching_hashes[0] + + key = Keys.auth_token_key(token_hash) + token_data = await redis.get(key) + + if not token_data: + click.echo(f"Token not found: {token_hash}") + return + + try: + token_info = TokenInfo.model_validate_json(token_data) + + # Show token info before removal + click.echo("Token to remove:") + click.echo(f" Description: {token_info.description}") + click.echo(f" Created: {token_info.created_at.isoformat()}") + + # Confirm removal + if not force and not click.confirm( + "Are you sure you want to remove this token?" + ): + click.echo("Token removal cancelled.") + return + + # Remove from Redis + await redis.delete(key) + + # Remove from tokens list + list_key = Keys.auth_tokens_list_key() + await redis.srem(list_key, token_hash) + + click.echo("Token removed successfully.") + + except Exception as e: + click.echo(f"Error processing token: {e}") + + asyncio.run(remove_token()) + + if __name__ == "__main__": cli() diff --git a/agent_memory_server/config.py b/agent_memory_server/config.py index 78b1c8c..8fdde4d 100644 --- a/agent_memory_server/config.py +++ b/agent_memory_server/config.py @@ -100,13 +100,19 @@ class Settings(BaseSettings): docket_name: str = "memory-server" use_docket: bool = True - # OAuth2/JWT Authentication settings + # Authentication settings disable_auth: bool = True + auth_mode: Literal["disabled", "token", "oauth2"] = "disabled" + + # OAuth2/JWT Authentication settings oauth2_issuer_url: str | None = None oauth2_audience: str | None = None oauth2_jwks_url: str | None = None oauth2_algorithms: list[str] = ["RS256"] + # Token Authentication settings + token_auth_enabled: bool = False + # Auth0 Client Credentials (for testing and client applications) auth0_client_id: str | None = None auth0_client_secret: str | None = None diff --git a/agent_memory_server/utils/keys.py b/agent_memory_server/utils/keys.py index 5123241..cf3bed5 100644 --- a/agent_memory_server/utils/keys.py +++ b/agent_memory_server/utils/keys.py @@ -77,3 +77,13 @@ def working_memory_key( def search_index_name() -> str: """Return the name of the search index.""" return settings.redisvl_index_name + + @staticmethod + def auth_token_key(token_hash: str) -> str: + """Get the auth token key for a hashed token.""" + return f"auth_token:{token_hash}" + + @staticmethod + def auth_tokens_list_key() -> str: + """Get the key for the list of all auth tokens.""" + return "auth_tokens:list" diff --git a/docs/authentication.md b/docs/authentication.md index a232b9b..afd44cd 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,32 +1,99 @@ # Authentication -The Redis Agent Memory Server supports OAuth2/JWT Bearer token authentication for secure API access. All API endpoints (except `/health`, `/docs`, and `/openapi.json`) require valid JWT authentication unless disabled for development. +The Redis Agent Memory Server supports multiple authentication modes for secure API access. All API endpoints (except `/health`, `/docs`, and `/openapi.json`) require valid authentication unless disabled for development. + +## Authentication Modes + +The server supports three authentication modes: + +1. **Disabled** (default): No authentication required - suitable for development only +2. **Token Authentication**: Simple API tokens stored in Redis with optional expiration +3. **OAuth2/JWT**: Industry-standard authentication using JWT access tokens ## Features +- **Simple Token Authentication**: Generate and manage API tokens via CLI with optional expiration - **OAuth2/JWT Bearer Token Authentication**: Industry-standard authentication using JWT access tokens - **JWKS Public Key Validation**: Automatic fetching and caching of public keys for token signature verification - **Multi-Provider Support**: Compatible with Auth0, AWS Cognito, Okta, Azure AD, and any standard OAuth2 provider - **Flexible Configuration**: Environment variable-based configuration for different deployment scenarios - **Development Mode**: `DISABLE_AUTH` setting for local development and testing - **Role and Scope Support**: Fine-grained access control using JWT claims +- **CLI Token Management**: Create, list, view, and remove tokens via command line ## Configuration +### Basic Configuration + Authentication is configured using environment variables: ```bash -# OAuth2 Provider Configuration +# Authentication Mode Selection +AUTH_MODE=disabled # Options: disabled, token, oauth2 (default: disabled) +# OR legacy setting: +DISABLE_AUTH=true # Set to true to bypass all authentication (development only) + +# Token Authentication (when AUTH_MODE=token) +TOKEN_AUTH_ENABLED=true # Alternative way to enable token auth + +# OAuth2 Provider Configuration (when AUTH_MODE=oauth2) OAUTH2_ISSUER_URL=https://your-auth-provider.com OAUTH2_AUDIENCE=your-api-audience OAUTH2_JWKS_URL=https://your-auth-provider.com/.well-known/jwks.json # Optional, auto-derived from issuer OAUTH2_ALGORITHMS=["RS256"] # Supported signing algorithms +``` + +### Token Authentication Setup + +To use token authentication: + +1. **Enable token authentication:** + ```bash + export AUTH_MODE=token + # OR + export TOKEN_AUTH_ENABLED=true + ``` + +2. **Create tokens using the CLI:** + ```bash + # Create a token with 30-day expiration + uv run agent-memory token add --description "API access token" --expires-days 30 + + # Create a permanent token (no expiration) + uv run agent-memory token add --description "Service account token" + ``` -# Development Mode (DISABLE AUTHENTICATION - USE ONLY FOR DEVELOPMENT) -DISABLE_AUTH=true # Set to true to bypass all authentication (development only) +3. **Use the token in API requests:** + ```bash + curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:8000/v1/working-memory/ + ``` + +### Token Management Commands + +The CLI provides comprehensive token management: + +```bash +# List all tokens (shows masked token hashes) +uv run agent-memory token list + +# Show details for a specific token (supports partial hash matching) +uv run agent-memory token show abc12345 + +# Remove a token (with confirmation) +uv run agent-memory token remove abc12345 + +# Remove a token without confirmation +uv run agent-memory token remove abc12345 --force ``` -## Provider Examples +**Security Features:** +- Tokens are hashed using bcrypt before storage +- Only hashed values are stored in Redis (server never has access to plaintext tokens) +- Automatic expiration using Redis TTL +- Secure token generation using `secrets.token_urlsafe()` + +## OAuth2 Provider Examples ### Auth0 @@ -58,10 +125,32 @@ OAUTH2_AUDIENCE=your-application-id ## Usage Examples -### With Authentication (Production) +### With Token Authentication + +```bash +# First, create a token +uv run agent-memory token add --description "My API token" --expires-days 30 + +# Use the returned token in API requests +curl -H "Authorization: Bearer YOUR_API_TOKEN" \ + -H "Content-Type: application/json" \ + http://localhost:8000/v1/working-memory/ + +# Python example +import httpx + +headers = { + "Authorization": "Bearer YOUR_API_TOKEN", + "Content-Type": "application/json" +} + +response = httpx.get("http://localhost:8000/v1/working-memory/", headers=headers) +``` + +### With OAuth2/JWT Authentication ```bash -# Make authenticated API request +# Make authenticated API request with JWT curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Content-Type: application/json" \ http://localhost:8000/v1/working-memory/ @@ -90,6 +179,14 @@ curl -H "Content-Type: application/json" \ ## Token Requirements +### API Tokens (Token Authentication) + +- **Valid token**: Must exist in Redis and not be expired +- **Secure generation**: Generated using cryptographically secure random bytes +- **Optional expiration**: Tokens can be created with or without expiration dates + +### JWT Tokens (OAuth2 Authentication) + JWT tokens must include: - **Valid signature**: Verified using JWKS public keys from the issuer @@ -111,8 +208,15 @@ Authentication failures return HTTP 401 with details: Common error scenarios: +**General Authentication Errors:** - `Missing authorization header`: No `Authorization: Bearer` header provided - `Missing bearer token`: Empty or malformed authorization header + +**Token Authentication Errors:** +- `Invalid token`: Token not found in Redis or malformed +- `Token has expired`: Token expiration date has passed + +**OAuth2/JWT Authentication Errors:** - `Invalid token header`: Malformed JWT structure - `Token has expired`: JWT `exp` claim is in the past - `Invalid audience`: JWT `aud` claim doesn't match expected audience @@ -120,12 +224,28 @@ Common error scenarios: ## Security Best Practices +### General Security + 1. **Never use `DISABLE_AUTH=true` in production** 2. **Use HTTPS in production** to protect tokens in transit -3. **Implement token refresh** in your clients for long-running applications -4. **Monitor token expiration** and handle 401 responses appropriately +3. **Monitor authentication failures** and implement rate limiting if needed +4. **Handle 401 responses appropriately** in your clients 5. **Validate tokens server-side** - never trust client-side validation alone -6. **Use appropriate scopes/roles** for fine-grained access control + +### Token Authentication Security + +6. **Use token expiration** when creating tokens for enhanced security +7. **Rotate tokens regularly** by removing old tokens and creating new ones +8. **Store tokens securely** in your applications (use environment variables, not code) +9. **Remove unused tokens** using the CLI to minimize attack surface +10. **Monitor token usage** and remove tokens that are no longer needed + +### OAuth2/JWT Security + +11. **Implement token refresh** in your clients for long-running applications +12. **Use appropriate scopes/roles** for fine-grained access control +13. **Validate token expiration** and audience claims properly +14. **Cache JWKS appropriately** but refresh periodically ## Manual Testing diff --git a/docs/cli.md b/docs/cli.md index 7012be1..07e4df3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -116,6 +116,114 @@ Runs data migrations. Migrations are reentrant. agent-memory migrate_memories ``` +### `token` Commands + +Manages authentication tokens for token-based authentication. The token command group provides subcommands for creating, listing, viewing, and removing API tokens. + +#### `token add` + +Creates a new authentication token. + +```bash +agent-memory token add --description "DESCRIPTION" [--expires-days DAYS] +``` + +**Options:** + +- `--description TEXT` / `-d TEXT`: **Required**. Description for the token (e.g., "API access for service X") +- `--expires-days INTEGER` / `-e INTEGER`: **Optional**. Number of days until token expires. If not specified, token never expires. + +**Examples:** + +```bash +# Create a token that expires in 30 days +agent-memory token add --description "API access token" --expires-days 30 + +# Create a permanent token (no expiration) +agent-memory token add --description "Service account token" +``` + +**Security Note:** The generated token is displayed only once. Store it securely as it cannot be retrieved again. + +#### `token list` + +Lists all authentication tokens, showing masked token hashes, descriptions, and expiration dates. + +```bash +agent-memory token list +``` + +**Example Output:** +``` +Authentication Tokens: +================================================== +Token: abc12345...xyz67890 +Description: API access token +Created: 2025-07-10T18:30:00.000000+00:00 +Expires: 2025-08-09T18:30:00.000000+00:00 +------------------------------ +Token: def09876...uvw54321 +Description: Service account token +Created: 2025-07-10T19:00:00.000000+00:00 +Expires: Never +------------------------------ +``` + +#### `token show` + +Shows detailed information about a specific token. Supports partial hash matching for convenience. + +```bash +agent-memory token show TOKEN_HASH +``` + +**Arguments:** + +- `TOKEN_HASH`: The token hash (or partial hash) to display. Can be the full hash or just the first few characters. + +**Examples:** + +```bash +# Show token details using full hash +agent-memory token show abc12345def67890xyz + +# Show token details using partial hash (must be unique) +agent-memory token show abc123 +``` + +#### `token remove` + +Removes an authentication token. By default, asks for confirmation before removal. + +```bash +agent-memory token remove TOKEN_HASH [--force] +``` + +**Arguments:** + +- `TOKEN_HASH`: The token hash (or partial hash) to remove. Can be the full hash or just the first few characters. + +**Options:** + +- `--force` / `-f`: Remove the token without asking for confirmation. + +**Examples:** + +```bash +# Remove token with confirmation prompt +agent-memory token remove abc123 + +# Remove token without confirmation +agent-memory token remove abc123 --force +``` + +**Security Features:** + +- All tokens are hashed using bcrypt before storage +- Tokens automatically expire based on Redis TTL if expiration is set +- Server never stores plaintext tokens +- Partial hash matching for CLI convenience + ## Getting Help To see help for any command, you can use `--help`: diff --git a/docs/configuration.md b/docs/configuration.md index d0c7c0e..99a0af5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,3 +48,63 @@ these to make sure your schema is up to date, like so: ```bash uv run agent-memory migrate-memories ``` + +## Authentication Configuration + +The Redis Memory Server supports multiple authentication modes configured via environment variables: + +### Authentication Mode Settings + +```bash +# Primary authentication mode setting +AUTH_MODE=disabled # Options: disabled, token, oauth2 (default: disabled) + +# Legacy setting (for backward compatibility) +DISABLE_AUTH=true # Set to true to bypass all authentication + +# Alternative token auth setting +TOKEN_AUTH_ENABLED=true # Alternative way to enable token authentication +``` + +### OAuth2/JWT Settings + +Required when `AUTH_MODE=oauth2`: + +```bash +OAUTH2_ISSUER_URL=https://your-auth-provider.com +OAUTH2_AUDIENCE=your-api-audience +OAUTH2_JWKS_URL=https://your-auth-provider.com/.well-known/jwks.json # Optional +OAUTH2_ALGORITHMS=["RS256"] # Supported algorithms (default: ["RS256"]) +``` + +### Token Authentication + +When using `AUTH_MODE=token`: + +- Tokens are managed via CLI commands (`agent-memory token`) +- No additional environment variables required +- Tokens are stored securely in Redis with bcrypt hashing +- Optional expiration dates supported + +### Examples + +**Development (No Authentication):** +```bash +export AUTH_MODE=disabled +# OR +export DISABLE_AUTH=true +``` + +**Production with Token Authentication:** +```bash +export AUTH_MODE=token +``` + +**Production with OAuth2:** +```bash +export AUTH_MODE=oauth2 +export OAUTH2_ISSUER_URL=https://your-domain.auth0.com/ +export OAUTH2_AUDIENCE=https://your-api.com +``` + +For detailed authentication setup and usage, see the [Authentication Documentation](authentication.md). diff --git a/pyproject.toml b/pyproject.toml index 35030d4..aae3142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "langchain-openai>=0.3.18", "langchain-redis>=0.2.1", "python-ulid>=3.0.0", + "bcrypt>=4.0.0", ] [project.scripts] diff --git a/tests/test_auth.py b/tests/test_auth.py index 577ec60..0627cff 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -27,6 +27,8 @@ def mock_settings(): """Mock settings for testing""" original_settings = { "disable_auth": settings.disable_auth, + "auth_mode": settings.auth_mode, + "token_auth_enabled": settings.token_auth_enabled, "oauth2_issuer_url": settings.oauth2_issuer_url, "oauth2_audience": settings.oauth2_audience, "oauth2_jwks_url": settings.oauth2_jwks_url, @@ -695,6 +697,7 @@ async def test_get_current_user_disabled_auth(self, mock_settings): async def test_get_current_user_missing_credentials(self, mock_settings): """Test get_current_user with missing credentials""" mock_settings.disable_auth = False + mock_settings.auth_mode = "oauth2" with pytest.raises(HTTPException) as exc_info: get_current_user(None) @@ -707,6 +710,7 @@ async def test_get_current_user_missing_credentials(self, mock_settings): async def test_get_current_user_empty_credentials(self, mock_settings): """Test get_current_user with empty credentials""" mock_settings.disable_auth = False + mock_settings.auth_mode = "oauth2" from fastapi.security import HTTPAuthorizationCredentials @@ -722,6 +726,7 @@ async def test_get_current_user_empty_credentials(self, mock_settings): async def test_get_current_user_valid_token(self, mock_settings, valid_token): """Test get_current_user with valid token""" mock_settings.disable_auth = False + mock_settings.auth_mode = "oauth2" from fastapi.security import HTTPAuthorizationCredentials @@ -856,6 +861,7 @@ def test_verify_auth_config_disabled(self, mock_settings): def test_verify_auth_config_missing_issuer(self, mock_settings): """Test auth config verification with missing issuer""" mock_settings.disable_auth = False + mock_settings.auth_mode = "oauth2" mock_settings.oauth2_issuer_url = None with pytest.raises(ValueError) as exc_info: diff --git a/tests/test_token_auth.py b/tests/test_token_auth.py new file mode 100644 index 0000000..f2c4804 --- /dev/null +++ b/tests/test_token_auth.py @@ -0,0 +1,344 @@ +"""Tests for token authentication functionality.""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi import HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials + +from agent_memory_server.auth import ( + TokenInfo, + generate_token, + get_current_user, + hash_token, + verify_auth_config, + verify_token, + verify_token_hash, +) +from agent_memory_server.config import settings +from agent_memory_server.utils.keys import Keys + + +class AsyncIterator: + """Helper class for creating async iterators in tests.""" + + def __init__(self, items): + self.items = iter(items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.items) + except StopIteration: + raise StopAsyncIteration from None + + +@pytest.fixture +def mock_settings(): + """Mock settings for testing.""" + original_settings = { + "disable_auth": settings.disable_auth, + "auth_mode": settings.auth_mode, + "token_auth_enabled": settings.token_auth_enabled, + } + + yield settings + + # Restore original settings + for key, value in original_settings.items(): + setattr(settings, key, value) + + +@pytest.fixture +def mock_redis(): + """Mock Redis connection.""" + return AsyncMock() + + +@pytest.fixture +def sample_token_info(): + """Sample token info for testing.""" + return TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash="$2b$12$test_hash_here", + ) + + +class TestTokenGeneration: + """Test token generation and hashing.""" + + def test_generate_token(self): + """Test token generation.""" + token = generate_token() + assert isinstance(token, str) + assert len(token) > 0 + + # Tokens should be unique + token2 = generate_token() + assert token != token2 + + def test_hash_token(self): + """Test token hashing.""" + token = "test_token_123" + token_hash = hash_token(token) + + assert isinstance(token_hash, str) + assert len(token_hash) > 0 + assert token_hash != token + assert token_hash.startswith("$2b$") + + def test_verify_token_hash(self): + """Test token hash verification.""" + token = "test_token_123" + token_hash = hash_token(token) + + # Correct token should verify + assert verify_token_hash(token, token_hash) is True + + # Wrong token should not verify + assert verify_token_hash("wrong_token", token_hash) is False + + # Invalid hash should return False + assert verify_token_hash(token, "invalid_hash") is False + + +class TestTokenVerification: + """Test token verification functionality.""" + + @pytest.mark.asyncio + async def test_verify_token_success(self, mock_redis, sample_token_info): + """Test successful token verification.""" + token = "test_token_123" + token_hash = hash_token(token) + sample_token_info.token_hash = token_hash + + mock_redis.scan_iter = Mock( + return_value=AsyncIterator([Keys.auth_token_key(token_hash)]) + ) + mock_redis.get.return_value = sample_token_info.model_dump_json() + + with patch("agent_memory_server.auth.get_redis_conn", return_value=mock_redis): + user_info = await verify_token(token) + + assert user_info.sub == "token-user" + assert user_info.aud == "token-auth" + assert user_info.scope == "admin" + assert "admin" in user_info.roles + + @pytest.mark.asyncio + async def test_verify_token_expired(self, mock_redis, sample_token_info): + """Test verification of expired token.""" + token = "test_token_123" + token_hash = hash_token(token) + sample_token_info.token_hash = token_hash + sample_token_info.expires_at = datetime.now(UTC) - timedelta(days=1) # Expired + + mock_redis.scan_iter = Mock( + return_value=AsyncIterator([Keys.auth_token_key(token_hash)]) + ) + mock_redis.get.return_value = sample_token_info.model_dump_json() + + with patch("agent_memory_server.auth.get_redis_conn", return_value=mock_redis): + with pytest.raises(HTTPException) as exc_info: + await verify_token(token) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "expired" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_verify_token_not_found(self, mock_redis): + """Test verification of non-existent token.""" + token = "nonexistent_token" + + # Mock Redis responses - no tokens found + mock_redis.scan_iter = Mock(return_value=AsyncIterator([])) + + with patch("agent_memory_server.auth.get_redis_conn", return_value=mock_redis): + with pytest.raises(HTTPException) as exc_info: + await verify_token(token) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "Invalid token" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_verify_token_wrong_token(self, mock_redis, sample_token_info): + """Test verification with wrong token.""" + correct_token = "test_token_123" + wrong_token = "wrong_token_456" + token_hash = hash_token(correct_token) + sample_token_info.token_hash = token_hash + + mock_redis.scan_iter = Mock( + return_value=AsyncIterator([Keys.auth_token_key(token_hash)]) + ) + mock_redis.get.return_value = sample_token_info.model_dump_json() + + with patch("agent_memory_server.auth.get_redis_conn", return_value=mock_redis): + with pytest.raises(HTTPException) as exc_info: + await verify_token(wrong_token) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "Invalid token" in exc_info.value.detail + + +class TestGetCurrentUser: + """Test get_current_user with token authentication.""" + + def test_get_current_user_disabled_auth(self, mock_settings): + """Test get_current_user with disabled authentication.""" + mock_settings.disable_auth = True + mock_settings.auth_mode = "disabled" + + user_info = get_current_user(None) + + assert user_info.sub == "local-dev-user" + assert user_info.aud == "local-dev" + + def test_get_current_user_missing_credentials(self, mock_settings): + """Test get_current_user with missing credentials.""" + mock_settings.disable_auth = False + mock_settings.auth_mode = "token" + + with pytest.raises(HTTPException) as exc_info: + get_current_user(None) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "Missing authorization header" in exc_info.value.detail + + def test_get_current_user_missing_token(self, mock_settings): + """Test get_current_user with missing token.""" + mock_settings.disable_auth = False + mock_settings.auth_mode = "token" + + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="") + + with pytest.raises(HTTPException) as exc_info: + get_current_user(credentials) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "Missing bearer token" in exc_info.value.detail + + @patch("agent_memory_server.auth.verify_token") + def test_get_current_user_token_auth(self, mock_verify_token, mock_settings): + """Test get_current_user with token authentication.""" + mock_settings.disable_auth = False + mock_settings.auth_mode = "token" + + # Mock verify_token to return a user + mock_user = Mock() + mock_user.sub = "token-user" + + # Mock asyncio.run to return the user directly + with patch("asyncio.run", return_value=mock_user): + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", credentials="test_token" + ) + + user_info = get_current_user(credentials) + + assert user_info.sub == "token-user" + + +class TestAuthConfig: + """Test authentication configuration validation.""" + + def test_verify_auth_config_disabled(self, mock_settings): + """Test auth config verification when disabled.""" + mock_settings.disable_auth = True + mock_settings.auth_mode = "disabled" + + # Should not raise any exception + verify_auth_config() + + def test_verify_auth_config_token_mode(self, mock_settings): + """Test auth config verification for token mode.""" + mock_settings.disable_auth = False + mock_settings.auth_mode = "token" + + # Should not raise any exception + verify_auth_config() + + def test_verify_auth_config_token_enabled(self, mock_settings): + """Test auth config verification when token_auth_enabled is True.""" + mock_settings.disable_auth = False + mock_settings.auth_mode = "disabled" + mock_settings.token_auth_enabled = True + + # Should not raise any exception + verify_auth_config() + + +class TestTokenInfo: + """Test TokenInfo model.""" + + def test_token_info_creation(self): + """Test TokenInfo model creation.""" + now = datetime.now(UTC) + expires = now + timedelta(days=30) + + token_info = TokenInfo( + description="Test token", + created_at=now, + expires_at=expires, + token_hash="test_hash", + ) + + assert token_info.description == "Test token" + assert token_info.created_at == now + assert token_info.expires_at == expires + assert token_info.token_hash == "test_hash" + + def test_token_info_json_serialization(self): + """Test TokenInfo JSON serialization.""" + now = datetime.now(UTC) + expires = now + timedelta(days=30) + + token_info = TokenInfo( + description="Test token", + created_at=now, + expires_at=expires, + token_hash="test_hash", + ) + + json_str = token_info.model_dump_json() + assert isinstance(json_str, str) + + # Verify it can be parsed back + parsed = TokenInfo.model_validate_json(json_str) + assert parsed.description == token_info.description + assert parsed.token_hash == token_info.token_hash + + def test_token_info_no_expiration(self): + """Test TokenInfo without expiration.""" + now = datetime.now(UTC) + + token_info = TokenInfo( + description="Permanent token", + created_at=now, + expires_at=None, + token_hash="test_hash", + ) + + assert token_info.expires_at is None + + +class TestKeys: + """Test Redis key generation for tokens.""" + + def test_auth_token_key(self): + """Test auth token key generation.""" + token_hash = "test_hash_123" + key = Keys.auth_token_key(token_hash) + + assert key == f"auth_token:{token_hash}" + + def test_auth_tokens_list_key(self): + """Test auth tokens list key generation.""" + key = Keys.auth_tokens_list_key() + + assert key == "auth_tokens:list" diff --git a/tests/test_token_cli.py b/tests/test_token_cli.py new file mode 100644 index 0000000..4205ce2 --- /dev/null +++ b/tests/test_token_cli.py @@ -0,0 +1,273 @@ +"""Integration tests for token CLI commands.""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, patch + +import pytest +from click.testing import CliRunner + +from agent_memory_server.auth import TokenInfo +from agent_memory_server.cli import token + + +@pytest.fixture +def mock_redis(): + """Mock Redis connection for CLI tests.""" + return AsyncMock() + + +@pytest.fixture +def cli_runner(): + """Click CLI runner for testing.""" + return CliRunner() + + +class TestTokenCLI: + """Test token CLI commands.""" + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_add_command(self, mock_get_redis, mock_redis, cli_runner): + """Test token add command.""" + mock_get_redis.return_value = mock_redis + + result = cli_runner.invoke( + token, ["add", "--description", "Test token", "--expires-days", "30"] + ) + + assert result.exit_code == 0 + assert "Token created successfully!" in result.output + assert "Description: Test token" in result.output + assert "WARNING: Save this token securely" in result.output + + # Verify Redis calls + mock_redis.set.assert_called_once() + mock_redis.expire.assert_called_once() + mock_redis.sadd.assert_called_once() + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_add_command_no_expiry(self, mock_get_redis, mock_redis, cli_runner): + """Test token add command without expiration.""" + mock_get_redis.return_value = mock_redis + + result = cli_runner.invoke(token, ["add", "--description", "Permanent token"]) + + assert result.exit_code == 0 + assert "Token created successfully!" in result.output + assert "Expires: Never" in result.output + + # Verify Redis calls + mock_redis.set.assert_called_once() + mock_redis.expire.assert_not_called() # No expiry set + mock_redis.sadd.assert_called_once() + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_list_command_empty(self, mock_get_redis, mock_redis, cli_runner): + """Test token list command with no tokens.""" + mock_get_redis.return_value = mock_redis + mock_redis.smembers.return_value = set() + + result = cli_runner.invoke(token, ["list"]) + + assert result.exit_code == 0 + assert "No tokens found." in result.output + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_list_command_with_tokens( + self, mock_get_redis, mock_redis, cli_runner + ): + """Test token list command with tokens.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.smembers.return_value = {token_hash} + mock_redis.get.return_value = token_info.model_dump_json() + + result = cli_runner.invoke(token, ["list"]) + + assert result.exit_code == 0 + assert "Authentication Tokens:" in result.output + assert "Test token" in result.output + assert "test_has...34567890" in result.output # Masked hash + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_show_command(self, mock_get_redis, mock_redis, cli_runner): + """Test token show command.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.get.return_value = token_info.model_dump_json() + + result = cli_runner.invoke(token, ["show", token_hash]) + + assert result.exit_code == 0 + assert "Token Details:" in result.output + assert "Test token" in result.output + assert "Status: Active" in result.output + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_show_command_partial_hash( + self, mock_get_redis, mock_redis, cli_runner + ): + """Test token show command with partial hash.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.smembers.return_value = {token_hash} + mock_redis.get.return_value = token_info.model_dump_json() + + result = cli_runner.invoke(token, ["show", "test_hash"]) + + assert result.exit_code == 0 + assert "Token Details:" in result.output + assert "Test token" in result.output + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_show_command_not_found(self, mock_get_redis, mock_redis, cli_runner): + """Test token show command with non-existent token.""" + mock_get_redis.return_value = mock_redis + mock_redis.get.return_value = None + + result = cli_runner.invoke(token, ["show", "nonexistent"]) + + assert result.exit_code == 0 + assert "No token found matching" in result.output + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_remove_command_with_confirmation( + self, mock_get_redis, mock_redis, cli_runner + ): + """Test token remove command with confirmation.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.get.return_value = token_info.model_dump_json() + + # Simulate user confirming removal + result = cli_runner.invoke(token, ["remove", token_hash], input="y\n") + + assert result.exit_code == 0 + assert "Token to remove:" in result.output + assert "Token removed successfully." in result.output + + # Verify Redis calls + mock_redis.delete.assert_called_once() + mock_redis.srem.assert_called_once() + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_remove_command_force(self, mock_get_redis, mock_redis, cli_runner): + """Test token remove command with force flag.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.get.return_value = token_info.model_dump_json() + + result = cli_runner.invoke(token, ["remove", token_hash, "--force"]) + + assert result.exit_code == 0 + assert "Token removed successfully." in result.output + + # Verify Redis calls + mock_redis.delete.assert_called_once() + mock_redis.srem.assert_called_once() + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_remove_command_cancelled( + self, mock_get_redis, mock_redis, cli_runner + ): + """Test token remove command cancelled by user.""" + mock_get_redis.return_value = mock_redis + + # Create sample token data + token_hash = "test_hash_123456789012345678901234567890" + token_info = TokenInfo( + description="Test token", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(days=30), + token_hash=token_hash, + ) + + mock_redis.get.return_value = token_info.model_dump_json() + + # Simulate user cancelling removal + result = cli_runner.invoke(token, ["remove", token_hash], input="n\n") + + assert result.exit_code == 0 + assert "Token removal cancelled." in result.output + + # Verify Redis calls - should not delete + mock_redis.delete.assert_not_called() + mock_redis.srem.assert_not_called() + + @patch("agent_memory_server.cli.get_redis_conn") + def test_token_remove_command_partial_hash_multiple_matches( + self, mock_get_redis, mock_redis, cli_runner + ): + """Test token remove command with partial hash that matches multiple tokens.""" + mock_get_redis.return_value = mock_redis + + # Create multiple token hashes with same prefix + token_hashes = { + "test_hash_111111111111111111111111111111", + "test_hash_222222222222222222222222222222", + } + + mock_redis.smembers.return_value = token_hashes + + result = cli_runner.invoke(token, ["remove", "test_hash"]) + + assert result.exit_code == 0 + assert "Multiple tokens match" in result.output + assert "test_has...111111" in result.output + assert "test_has...222222" in result.output + + def test_token_commands_help(self, cli_runner): + """Test token commands help text.""" + result = cli_runner.invoke(token, ["--help"]) + + assert result.exit_code == 0 + assert "Manage authentication tokens." in result.output + + # Test individual command help + for cmd in ["add", "list", "show", "remove"]: + result = cli_runner.invoke(token, [cmd, "--help"]) + assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index 6073fd1..2188b84 100644 --- a/uv.lock +++ b/uv.lock @@ -68,6 +68,7 @@ dependencies = [ { name = "accelerate" }, { name = "agent-memory-client" }, { name = "anthropic" }, + { name = "bcrypt" }, { name = "click" }, { name = "cryptography" }, { name = "fastapi" }, @@ -124,6 +125,7 @@ requires-dist = [ { name = "agent-memory-client", editable = "agent-memory-client" }, { name = "agent-memory-client", marker = "extra == 'dev'", editable = "agent-memory-client" }, { name = "anthropic", specifier = ">=0.15.0" }, + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "bertopic", marker = "extra == 'dev'", specifier = ">=0.16.4,<0.17.0" }, { name = "click", specifier = ">=8.1.0" }, { name = "cryptography", specifier = ">=3.4.8" }, @@ -217,6 +219,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, ] +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019 }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174 }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870 }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601 }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660 }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083 }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237 }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737 }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741 }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472 }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606 }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867 }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794 }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969 }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158 }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285 }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583 }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896 }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492 }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162 }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856 }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726 }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664 }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128 }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 }, +] + [[package]] name = "bertopic" version = "0.16.4"