From ceafed910825f89b5ae892a80f6c2d58f561077d Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 28 Jun 2025 18:30:30 +0200 Subject: [PATCH] auth email/password --- .env.example | 19 ++ .gitignore | 7 +- app/__init__.py | 33 ++- app/database.py | 18 ++ app/models/__init__.py | 6 + app/models/user.py | 240 ++++++++++++++++ app/models/user_oauth.py | 105 +++++++ app/routes/auth.py | 233 ++++++++++++++-- app/routes/main.py | 31 ++- app/services/auth_service.py | 293 ++++++++++++------- app/services/decorators.py | 161 +++++++++-- app/services/oauth_providers/__init__.py | 0 app/services/oauth_providers/base.py | 68 +++++ app/services/oauth_providers/github.py | 52 ++++ app/services/oauth_providers/google.py | 34 +++ app/services/oauth_providers/registry.py | 45 +++ app/services/token_service.py | 75 +---- main.py | 5 + migrate_db.py | 28 ++ pyproject.toml | 11 +- reset.sh | 5 + tests/test_auth_routes.py | 65 ++--- tests/test_auth_service.py | 77 ++--- tests/test_token_service_jwt_extended.py | 57 ++++ uv.lock | 340 ++++++++++++++++++++++- 25 files changed, 1694 insertions(+), 314 deletions(-) create mode 100644 .env.example create mode 100644 app/database.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/models/user_oauth.py create mode 100644 app/services/oauth_providers/__init__.py create mode 100644 app/services/oauth_providers/base.py create mode 100644 app/services/oauth_providers/github.py create mode 100644 app/services/oauth_providers/google.py create mode 100644 app/services/oauth_providers/registry.py create mode 100644 migrate_db.py create mode 100755 reset.sh create mode 100644 tests/test_token_service_jwt_extended.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7116651 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Flask Configuration +SECRET_KEY=your_flask_secret_key_here + +# JWT Configuration +JWT_SECRET_KEY=your_jwt_secret_key_here + +# Database Configuration +DATABASE_URL=sqlite:///soundboard.db + +# OAuth Providers Configuration +# Configure the providers you want to support by setting their client credentials + +# Google OAuth +GOOGLE_CLIENT_ID=your_google_client_id_here +GOOGLE_CLIENT_SECRET=your_google_client_secret_here + +# GitHub OAuth +GITHUB_CLIENT_ID=your_github_client_id_here +GITHUB_CLIENT_SECRET=your_github_client_secret_here \ No newline at end of file diff --git a/.gitignore b/.gitignore index 505a3b1..b80b13c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ build/ dist/ wheels/ *.egg-info +.pytest_cache/ +instance/ # Virtual environments -.venv +.venv/ + +# Env +.env \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 772eb6f..1886db4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,19 +1,46 @@ import os +from datetime import timedelta from flask import Flask +from flask_jwt_extended import JWTManager from app.services.auth_service import AuthService +from app.database import init_db + +# Global auth service instance +auth_service = AuthService() def create_app(): """Create and configure the Flask application.""" app = Flask(__name__) - # Configure session + # Configure Flask secret key (required for sessions used by OAuth) app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key") - # Initialize authentication service - auth_service = AuthService(app) + # Configure SQLAlchemy database + database_url = os.environ.get("DATABASE_URL", "sqlite:///soundboard.db") + app.config["SQLALCHEMY_DATABASE_URI"] = database_url + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + # Configure Flask-JWT-Extended + app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key") + app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15) + app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7) + app.config["JWT_TOKEN_LOCATION"] = ["cookies"] + app.config["JWT_COOKIE_SECURE"] = False # Set to True in production + app.config["JWT_COOKIE_CSRF_PROTECT"] = False + app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/" + app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh" + + # Initialize JWT manager + jwt = JWTManager(app) + + # Initialize database + init_db(app) + + # Initialize authentication service with app + auth_service.init_app(app) # Register blueprints from app.routes import main, auth diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..21a399f --- /dev/null +++ b/app/database.py @@ -0,0 +1,18 @@ +"""Database configuration and initialization.""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() + + +def init_db(app): + """Initialize database with Flask app.""" + db.init_app(app) + migrate.init_app(app, db) + + # Import models here to ensure they are registered with SQLAlchemy + from app.models import user, user_oauth # noqa: F401 + + return db \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..b9401d0 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,6 @@ +"""Database models.""" + +from .user import User +from .user_oauth import UserOAuth + +__all__ = ["User", "UserOAuth"] \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..7d7e660 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,240 @@ +"""User model for authentication.""" + +import secrets +from datetime import datetime +from typing import Optional, TYPE_CHECKING + +from werkzeug.security import check_password_hash, generate_password_hash +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import db + +if TYPE_CHECKING: + from app.models.user_oauth import UserOAuth + + +class User(db.Model): + """User model for storing user information.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + + # Primary user information (can be updated from any connected provider) + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + picture: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # Password authentication (optional - users can use OAuth instead) + password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + # Role-based access control + role: Mapped[str] = mapped_column(String(50), nullable=False, default="user") + + # User status + is_active: Mapped[bool] = mapped_column(nullable=False, default=True) + + # API token for programmatic access + api_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + api_token_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + # Relationships + oauth_providers: Mapped[list["UserOAuth"]] = relationship( + "UserOAuth", back_populates="user", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + """String representation of User.""" + provider_count = len(self.oauth_providers) + return f"" + + def to_dict(self) -> dict: + """Convert user to dictionary.""" + return { + "id": str(self.id), + "email": self.email, + "name": self.name, + "picture": self.picture, + "role": self.role, + "is_active": self.is_active, + "api_token": self.api_token, + "api_token_expires_at": self.api_token_expires_at.isoformat() if self.api_token_expires_at else None, + "providers": [provider.provider for provider in self.oauth_providers], + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + def get_provider(self, provider_name: str) -> Optional["UserOAuth"]: + """Get specific OAuth provider for this user.""" + for provider in self.oauth_providers: + if provider.provider == provider_name: + return provider + return None + + def has_provider(self, provider_name: str) -> bool: + """Check if user has specific OAuth provider connected.""" + return self.get_provider(provider_name) is not None + + def update_from_provider(self, provider_data: dict) -> None: + """Update user info from provider data (email, name, picture).""" + self.email = provider_data.get("email", self.email) + self.name = provider_data.get("name", self.name) + self.picture = provider_data.get("picture", self.picture) + self.updated_at = datetime.utcnow() + db.session.commit() + + def set_password(self, password: str) -> None: + """Hash and set user password.""" + self.password_hash = generate_password_hash(password) + self.updated_at = datetime.utcnow() + + def check_password(self, password: str) -> bool: + """Check if provided password matches user's password.""" + if not self.password_hash: + return False + return check_password_hash(self.password_hash, password) + + def has_password(self) -> bool: + """Check if user has a password set.""" + return self.password_hash is not None + + def generate_api_token(self) -> str: + """Generate a new API token for the user.""" + self.api_token = secrets.token_urlsafe(32) + self.api_token_expires_at = None # No expiration by default + self.updated_at = datetime.utcnow() + return self.api_token + + def is_api_token_valid(self) -> bool: + """Check if the user's API token is valid (exists and not expired).""" + if not self.api_token: + return False + + if self.api_token_expires_at is None: + return True # No expiration + + return datetime.utcnow() < self.api_token_expires_at + + def revoke_api_token(self) -> None: + """Revoke the user's API token.""" + self.api_token = None + self.api_token_expires_at = None + self.updated_at = datetime.utcnow() + + def activate(self) -> None: + """Activate the user account.""" + self.is_active = True + self.updated_at = datetime.utcnow() + + def deactivate(self) -> None: + """Deactivate the user account.""" + self.is_active = False + self.updated_at = datetime.utcnow() + + @classmethod + def find_by_email(cls, email: str) -> Optional["User"]: + """Find user by email address.""" + return cls.query.filter_by(email=email).first() + + @classmethod + def find_by_api_token(cls, api_token: str) -> Optional["User"]: + """Find user by API token if token is valid.""" + user = cls.query.filter_by(api_token=api_token).first() + if user and user.is_api_token_valid(): + return user + return None + + @classmethod + def find_or_create_from_oauth( + cls, provider: str, provider_id: str, email: str, name: str, picture: Optional[str] = None + ) -> tuple["User", "UserOAuth"]: + """Find existing user or create new one from OAuth data.""" + from app.models.user_oauth import UserOAuth + + # First, try to find existing OAuth provider + oauth_provider = UserOAuth.find_by_provider_and_id(provider, provider_id) + + if oauth_provider: + # Update existing provider and user info + user = oauth_provider.user + oauth_provider.email = email + oauth_provider.name = name + oauth_provider.picture = picture + oauth_provider.updated_at = datetime.utcnow() + + # Update user info with latest data + user.update_from_provider({"email": email, "name": name, "picture": picture}) + else: + # Try to find user by email to link the new provider + user = cls.find_by_email(email) + + if not user: + # Check if this is the first user (admin) + user_count = cls.query.count() + role = "admin" if user_count == 0 else "user" + + # Create new user + user = cls( + email=email, + name=name, + picture=picture, + role=role, + ) + user.generate_api_token() # Generate API token on creation + db.session.add(user) + db.session.flush() # Flush to get user.id + + # Create new OAuth provider + oauth_provider = UserOAuth.create_or_update( + user_id=user.id, + provider=provider, + provider_id=provider_id, + email=email, + name=name, + picture=picture, + ) + + db.session.commit() + return user, oauth_provider + + @classmethod + def create_with_password(cls, email: str, password: str, name: str) -> "User": + """Create new user with email and password.""" + # Check if user already exists + existing_user = cls.find_by_email(email) + if existing_user: + raise ValueError("User with this email already exists") + + # Check if this is the first user (admin) + user_count = cls.query.count() + role = "admin" if user_count == 0 else "user" + + # Create new user + user = cls( + email=email, + name=name, + role=role, + ) + user.set_password(password) + user.generate_api_token() # Generate API token on creation + + db.session.add(user) + db.session.commit() + return user + + @classmethod + def authenticate_with_password(cls, email: str, password: str) -> Optional["User"]: + """Authenticate user with email and password.""" + user = cls.find_by_email(email) + if user and user.check_password(password) and user.is_active: + return user + return None \ No newline at end of file diff --git a/app/models/user_oauth.py b/app/models/user_oauth.py new file mode 100644 index 0000000..0be3899 --- /dev/null +++ b/app/models/user_oauth.py @@ -0,0 +1,105 @@ +"""User OAuth model for storing user's connected providers.""" + +from datetime import datetime +from typing import Optional, TYPE_CHECKING + +from sqlalchemy import String, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import db + +if TYPE_CHECKING: + from app.models.user import User + + +class UserOAuth(db.Model): + """Model for storing user's connected OAuth providers.""" + + __tablename__ = "user_oauth" + + id: Mapped[int] = mapped_column(primary_key=True) + + # User relationship + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + + # OAuth provider information + provider: Mapped[str] = mapped_column(String(50), nullable=False) + provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + + # Provider-specific user information + email: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + picture: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + # Unique constraint on provider + provider_id combination + __table_args__ = ( + db.UniqueConstraint("provider", "provider_id", name="unique_provider_user"), + ) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="oauth_providers") + + def __repr__(self) -> str: + """String representation of UserOAuth.""" + return f"" + + def to_dict(self) -> dict: + """Convert oauth provider to dictionary.""" + return { + "id": self.id, + "provider": self.provider, + "provider_id": self.provider_id, + "email": self.email, + "name": self.name, + "picture": self.picture, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @classmethod + def find_by_provider_and_id(cls, provider: str, provider_id: str) -> Optional["UserOAuth"]: + """Find OAuth provider by provider name and provider ID.""" + return cls.query.filter_by(provider=provider, provider_id=provider_id).first() + + @classmethod + def create_or_update( + cls, + user_id: int, + provider: str, + provider_id: str, + email: str, + name: str, + picture: Optional[str] = None + ) -> "UserOAuth": + """Create new OAuth provider or update existing one.""" + oauth_provider = cls.find_by_provider_and_id(provider, provider_id) + + if oauth_provider: + # Update existing provider + oauth_provider.user_id = user_id + oauth_provider.email = email + oauth_provider.name = name + oauth_provider.picture = picture + oauth_provider.updated_at = datetime.utcnow() + else: + # Create new provider + oauth_provider = cls( + user_id=user_id, + provider=provider, + provider_id=provider_id, + email=email, + name=name, + picture=picture, + ) + db.session.add(oauth_provider) + + db.session.commit() + return oauth_provider \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index 6195251..550667e 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,31 +1,54 @@ """Authentication routes.""" -from flask import Blueprint, url_for +from flask import Blueprint, jsonify, url_for +from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required -from app.services.auth_service import AuthService +from app import auth_service +from app.services.decorators import get_current_user bp = Blueprint("auth", __name__) -auth_service = AuthService() -@bp.route("/login") -def login() -> dict[str, str]: - """Initiate Google OAuth login.""" - redirect_uri = url_for("auth.callback", _external=True) - login_url = auth_service.get_login_url(redirect_uri) - return {"login_url": login_url} +@bp.route("/login/") +def login_oauth(provider): + """Initiate OAuth login for specified provider.""" + redirect_uri = url_for("auth.callback", provider=provider, _external=True) + return auth_service.redirect_to_login(provider, redirect_uri) -@bp.route("/callback") -def callback(): - """Handle OAuth callback from Google.""" +@bp.route("/callback/") +def callback(provider): + """Handle OAuth callback from specified provider.""" try: - user_data, response = auth_service.handle_callback() - return response + return auth_service.handle_callback(provider) except Exception as e: return {"error": str(e)}, 400 +@bp.route("/providers") +def providers(): + """Get list of available OAuth providers.""" + return {"providers": auth_service.get_available_providers()} + + +@bp.route("/login", methods=["POST"]) +def login(): + """Login user with email and password.""" + from flask import request + + data = request.get_json() + if not data: + return {"error": "No data provided"}, 400 + + email = data.get("email") + password = data.get("password") + + if not email or not password: + return {"error": "Email and password are required"}, 400 + + return auth_service.login_with_password(email, password) + + @bp.route("/logout") def logout(): """Logout current user.""" @@ -33,20 +56,182 @@ def logout(): @bp.route("/me") -def me() -> dict[str, str] | tuple[dict[str, str], int]: +@jwt_required() +def me(): """Get current user information.""" - user = auth_service.get_current_user() - if not user: - return {"error": "Not authenticated"}, 401 - + user = get_current_user() return {"user": user} -@bp.route("/refresh") +@bp.route("/refresh", methods=["POST"]) +@jwt_required(refresh=True) def refresh(): """Refresh access token using refresh token.""" - response = auth_service.refresh_tokens() - if not response: - return {"error": "Invalid or expired refresh token"}, 401 + current_user_id = get_jwt_identity() + + # Create new access token + new_access_token = create_access_token(identity=current_user_id) + + response = jsonify({"message": "Token refreshed"}) + + # Set new access token cookie + from flask_jwt_extended import set_access_cookies + set_access_cookies(response, new_access_token) + + return response + + +@bp.route("/link/") +@jwt_required() +def link_provider(provider): + """Link a new OAuth provider to current user account.""" + redirect_uri = url_for("auth.link_callback", provider=provider, _external=True) + return auth_service.redirect_to_login(provider, redirect_uri) + + +@bp.route("/link/callback/") +@jwt_required() +def link_callback(provider): + """Handle OAuth callback for linking new provider.""" + try: + current_user_id = get_jwt_identity() + if not current_user_id: + return {"error": "User not authenticated"}, 401 + + # Get current user from database + from app.models.user import User + user = User.query.get(current_user_id) + if not user: + return {"error": "User not found"}, 404 + + # Process OAuth callback but link to existing user + from app.services.oauth_providers.registry import OAuthProviderRegistry + from authlib.integrations.flask_client import OAuth + + oauth = OAuth() + registry = OAuthProviderRegistry(oauth) + oauth_provider = registry.get_provider(provider) + + if not oauth_provider: + return {"error": f"OAuth provider '{provider}' not configured"}, 400 + + token = oauth_provider.exchange_code_for_token(None, None) + raw_user_info = oauth_provider.get_user_info(token) + provider_data = oauth_provider.normalize_user_data(raw_user_info) + + if not provider_data.get("id"): + return {"error": "Failed to get user information from provider"}, 400 + + # Check if this provider is already linked to another user + from app.models.user_oauth import UserOAuth + existing_provider = UserOAuth.find_by_provider_and_id( + provider, provider_data["id"] + ) + + if existing_provider and existing_provider.user_id != user.id: + return {"error": "This provider account is already linked to another user"}, 409 + + # Link the provider to current user + UserOAuth.create_or_update( + user_id=user.id, + provider=provider, + provider_id=provider_data["id"], + email=provider_data["email"], + name=provider_data["name"], + picture=provider_data.get("picture") + ) + + return {"message": f"{provider.title()} account linked successfully"} + + except Exception as e: + return {"error": str(e)}, 400 + + +@bp.route("/unlink/", methods=["DELETE"]) +@jwt_required() +def unlink_provider(provider): + """Unlink an OAuth provider from current user account.""" + try: + current_user_id = get_jwt_identity() + if not current_user_id: + return {"error": "User not authenticated"}, 401 + + from app.models.user import User + from app.models.user_oauth import UserOAuth + from app.database import db + + user = User.query.get(current_user_id) + if not user: + return {"error": "User not found"}, 404 + + # Check if user has more than one provider (prevent locking out) + if len(user.oauth_providers) <= 1: + return {"error": "Cannot unlink last authentication provider"}, 400 + + # Find and remove the provider + oauth_provider = user.get_provider(provider) + if not oauth_provider: + return {"error": f"Provider '{provider}' not linked to this account"}, 404 + + db.session.delete(oauth_provider) + db.session.commit() + + return {"message": f"{provider.title()} account unlinked successfully"} + + except Exception as e: + return {"error": str(e)}, 400 + + +@bp.route("/register", methods=["POST"]) +def register(): + """Register new user with email and password.""" + from flask import request + + data = request.get_json() + if not data: + return {"error": "No data provided"}, 400 + + email = data.get("email") + password = data.get("password") + name = data.get("name") + + if not email or not password or not name: + return {"error": "Email, password, and name are required"}, 400 + + # Basic email validation + if "@" not in email or "." not in email: + return {"error": "Invalid email format"}, 400 + + # Basic password validation + if len(password) < 6: + return {"error": "Password must be at least 6 characters long"}, 400 + + return auth_service.register_with_password(email, password, name) + + +@bp.route("/regenerate-api-token", methods=["POST"]) +@jwt_required() +def regenerate_api_token(): + """Regenerate API token for current user.""" + current_user_id = get_jwt_identity() + if not current_user_id: + return {"error": "User not authenticated"}, 401 + + from app.models.user import User + from app.database import db + + user = User.query.get(current_user_id) + if not user: + return {"error": "User not found"}, 404 + + # Generate new API token + new_token = user.generate_api_token() + db.session.commit() + + return { + "message": "API token regenerated successfully", + "api_token": new_token, + "expires_at": user.api_token_expires_at.isoformat() if user.api_token_expires_at else None + } + - return response \ No newline at end of file diff --git a/app/routes/main.py b/app/routes/main.py index a5d0be4..8e4f747 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -2,7 +2,7 @@ from flask import Blueprint -from app.services.decorators import get_current_user, require_auth +from app.services.decorators import get_current_user, require_auth, require_admin, require_auth_or_api_token, get_user_from_api_token from app.services.greeting_service import GreetingService bp = Blueprint("main", __name__) @@ -24,7 +24,7 @@ def hello(name: str | None = None) -> dict[str, str]: @bp.route("/protected") @require_auth def protected() -> dict[str, str]: - """Protected endpoint that requires authentication.""" + """Protected endpoint that requires JWT authentication.""" user = get_current_user() return { "message": f"Hello {user['name']}, this is a protected endpoint!", @@ -32,6 +32,33 @@ def protected() -> dict[str, str]: } +@bp.route("/api-protected") +@require_auth_or_api_token +def api_protected() -> dict[str, str]: + """Protected endpoint that accepts JWT or API token authentication.""" + # Try to get user from JWT first, then API token + user = get_current_user() + if not user: + user = get_user_from_api_token() + + return { + "message": f"Hello {user['name']}, you accessed this via {user['provider']}!", + "user": user + } + + +@bp.route("/admin") +@require_admin +def admin_only() -> dict[str, str]: + """Admin-only endpoint to demonstrate role-based access.""" + user = get_current_user() + return { + "message": f"Hello admin {user['name']}, you have admin access!", + "user": user, + "admin_info": "This endpoint is only accessible to admin users" + } + + @bp.route("/health") def health() -> dict[str, str]: """Health check endpoint.""" diff --git a/app/services/auth_service.py b/app/services/auth_service.py index b40d1dc..6e93b2d 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,21 +1,29 @@ -"""Authentication service for Google OAuth.""" +"""Authentication service for multiple OAuth providers.""" -import os from typing import Any from authlib.integrations.flask_client import OAuth -from flask import Flask, make_response, request +from flask import Flask, jsonify +from flask_jwt_extended import ( + get_jwt_identity, + jwt_required, + set_access_cookies, + set_refresh_cookies, + unset_jwt_cookies, +) +from app.models.user import User +from app.services.oauth_providers.registry import OAuthProviderRegistry from app.services.token_service import TokenService class AuthService: - """Service for handling Google OAuth authentication.""" + """Service for handling multiple OAuth providers authentication.""" def __init__(self, app: Flask | None = None) -> None: """Initialize the authentication service.""" self.oauth = OAuth() - self.google = None + self.provider_registry = None self.token_service = TokenService() if app: self.init_app(app) @@ -24,120 +32,219 @@ class AuthService: """Initialize the service with Flask app.""" self.oauth.init_app(app) - # Configure Google OAuth - self.google = self.oauth.register( - name="google", - client_id=os.getenv("GOOGLE_CLIENT_ID"), - client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), - server_metadata_url="https://accounts.google.com/.well-known/openid_configuration", - client_kwargs={"scope": "openid email profile"}, - ) + # Initialize provider registry + self.provider_registry = OAuthProviderRegistry(self.oauth) - def get_login_url(self, redirect_uri: str) -> str: - """Generate Google OAuth login URL.""" - if not self.google: - msg = "Google OAuth not configured" + def redirect_to_login(self, provider_name: str, redirect_uri: str): + """Redirect to OAuth provider login.""" + provider = self.provider_registry.get_provider(provider_name) + if not provider: + msg = f"OAuth provider '{provider_name}' not configured" raise RuntimeError(msg) - return self.google.authorize_redirect(redirect_uri).location - def handle_callback(self) -> tuple[dict[str, Any], Any]: + client = provider.get_client() + return client.authorize_redirect(redirect_uri) + + def get_login_url(self, provider_name: str, redirect_uri: str) -> str: + """Generate OAuth provider login URL (for testing or manual use).""" + provider = self.provider_registry.get_provider(provider_name) + if not provider: + msg = f"OAuth provider '{provider_name}' not configured" + raise RuntimeError(msg) + + return provider.get_authorization_url(redirect_uri) + + def handle_callback(self, provider_name: str) -> Any: """Handle OAuth callback and exchange code for token.""" - if not self.google: - msg = "Google OAuth not configured" + provider = self.provider_registry.get_provider(provider_name) + if not provider: + msg = f"OAuth provider '{provider_name}' not configured" raise RuntimeError(msg) - token = self.google.authorize_access_token() - user_info = token.get("userinfo") + token = provider.exchange_code_for_token(None, None) + raw_user_info = provider.get_user_info(token) + user_data = provider.normalize_user_data(raw_user_info) - if user_info: - user_data = { - "id": user_info["sub"], - "email": user_info["email"], - "name": user_info["name"], - "picture": user_info.get("picture"), + if user_data and user_data.get("id"): + # Find or create user in database + user, oauth_provider = User.find_or_create_from_oauth( + provider=provider_name, + provider_id=user_data["id"], + email=user_data["email"], + name=user_data["name"], + picture=user_data.get("picture"), + ) + + # Check if user account is active + if not user.is_active: + response = jsonify({"error": "Account is disabled"}) + response.status_code = 401 + return response + + # Prepare user data for JWT token + jwt_user_data = { + "id": str(user.id), + "email": user.email, + "name": user.name, + "picture": user.picture, + "role": user.role, + "is_active": user.is_active, + "provider": oauth_provider.provider, + "providers": [p.provider for p in user.oauth_providers], } # Generate JWT tokens - access_token = self.token_service.generate_access_token(user_data) - refresh_token = self.token_service.generate_refresh_token(user_data) + access_token = self.token_service.generate_access_token( + jwt_user_data + ) + refresh_token = self.token_service.generate_refresh_token( + jwt_user_data + ) # Create response and set HTTP-only cookies - response = make_response({ - "message": "Login successful", - "user": user_data, - }) - - response.set_cookie( - "access_token", - access_token, - httponly=True, - secure=True, - samesite="Lax", - max_age=15 * 60, # 15 minutes + response = jsonify( + { + "message": "Login successful", + "user": jwt_user_data, + } ) - response.set_cookie( - "refresh_token", - refresh_token, - httponly=True, - secure=True, - samesite="Lax", - max_age=7 * 24 * 60 * 60, # 7 days - ) + # Set JWT cookies + set_access_cookies(response, access_token) + set_refresh_cookies(response, refresh_token) - return user_data, response + return response - msg = "Failed to get user information from Google" + msg = f"Failed to get user information from {provider.display_name}" raise ValueError(msg) - def get_current_user(self) -> dict[str, Any] | None: - """Get current user from access token.""" - access_token = request.cookies.get("access_token") - if not access_token: - return None + def get_available_providers(self) -> dict[str, Any]: + """Get list of available OAuth providers.""" + if not self.provider_registry: + return {} - return self.token_service.get_user_from_access_token(access_token) - - def refresh_tokens(self) -> Any: - """Refresh access token using refresh token.""" - refresh_token = request.cookies.get("refresh_token") - if not refresh_token: - return None - - payload = self.token_service.verify_token(refresh_token) - if not payload or not self.token_service.is_refresh_token(payload): - return None - - # For refresh, we need to get user data (in a real app, from database) - # For now, we'll extract what we can from the refresh token - user_data = { - "id": payload["user_id"], - "email": "", # Would need to fetch from database - "name": "", # Would need to fetch from database + providers = self.provider_registry.get_available_providers() + return { + name: {"name": provider.name, "display_name": provider.display_name} + for name, provider in providers.items() } - # Generate new access token - new_access_token = self.token_service.generate_access_token(user_data) + @jwt_required() + def get_current_user(self) -> dict[str, Any] | None: + """Get current user from JWT token.""" + from flask_jwt_extended import get_jwt - response = make_response({"message": "Token refreshed"}) - response.set_cookie( - "access_token", - new_access_token, - httponly=True, - secure=True, - samesite="Lax", - max_age=15 * 60, # 15 minutes + current_user_id = get_jwt_identity() + claims = get_jwt() + + if current_user_id: + return { + "id": current_user_id, + "email": claims.get("email", ""), + "name": claims.get("name", ""), + "picture": claims.get("picture"), + "role": claims.get("role", "user"), + "is_active": claims.get("is_active", True), + "provider": claims.get("provider", "unknown"), + "providers": claims.get("providers", []), + } + return None + + def register_with_password( + self, email: str, password: str, name: str + ) -> Any: + """Register new user with email and password.""" + try: + # Create user with password + user = User.create_with_password(email, password, name) + + # Prepare user data for JWT token + jwt_user_data = { + "id": str(user.id), + "email": user.email, + "name": user.name, + "picture": user.picture, + "role": user.role, + "is_active": user.is_active, + "provider": "password", + "providers": ["password"], + } + + # Generate JWT tokens + access_token = self.token_service.generate_access_token( + jwt_user_data + ) + refresh_token = self.token_service.generate_refresh_token( + jwt_user_data + ) + + # Create response and set HTTP-only cookies + response = jsonify( + { + "message": "Registration successful", + "user": jwt_user_data, + } + ) + + # Set JWT cookies + set_access_cookies(response, access_token) + set_refresh_cookies(response, refresh_token) + + return response + + except ValueError as e: + response = jsonify({"error": str(e)}) + response.status_code = 400 + return response + + def login_with_password(self, email: str, password: str) -> Any: + """Login user with email and password.""" + # Authenticate user + user = User.authenticate_with_password(email, password) + + if not user: + response = jsonify( + {"error": "Invalid email, password or disabled account"} + ) + response.status_code = 401 + return response + + # Prepare user data for JWT token + oauth_providers = [p.provider for p in user.oauth_providers] + if user.has_password(): + oauth_providers.append("password") + + jwt_user_data = { + "id": str(user.id), + "email": user.email, + "name": user.name, + "picture": user.picture, + "role": user.role, + "is_active": user.is_active, + "provider": "password", + "providers": oauth_providers, + } + + # Generate JWT tokens + access_token = self.token_service.generate_access_token(jwt_user_data) + refresh_token = self.token_service.generate_refresh_token(jwt_user_data) + + # Create response and set HTTP-only cookies + response = jsonify( + { + "message": "Login successful", + "user": jwt_user_data, + } ) + # Set JWT cookies + set_access_cookies(response, access_token) + set_refresh_cookies(response, refresh_token) + return response def logout(self) -> Any: """Clear authentication cookies.""" - response = make_response({"message": "Logged out successfully"}) - response.set_cookie("access_token", "", expires=0) - response.set_cookie("refresh_token", "", expires=0) + response = jsonify({"message": "Logged out successfully"}) + unset_jwt_cookies(response) return response - - def is_authenticated(self) -> bool: - """Check if user is authenticated.""" - return self.get_current_user() is not None \ No newline at end of file diff --git a/app/services/decorators.py b/app/services/decorators.py index 1b8b7c3..7261594 100644 --- a/app/services/decorators.py +++ b/app/services/decorators.py @@ -1,37 +1,148 @@ """Authentication decorators and middleware.""" from functools import wraps -from typing import Any, Callable +from typing import Any from flask import jsonify, request - -from app.services.token_service import TokenService +from flask_jwt_extended import get_jwt, get_jwt_identity, jwt_required -def require_auth(f: Callable[..., Any]) -> Callable[..., Any]: +def require_auth(f): """Decorator to require authentication for routes.""" - @wraps(f) - def decorated_function(*args: Any, **kwargs: Any) -> Any: - token_service = TokenService() - access_token = request.cookies.get("access_token") - - if not access_token: - return jsonify({"error": "Authentication required"}), 401 - - user_data = token_service.get_user_from_access_token(access_token) - if not user_data: - return jsonify({"error": "Invalid or expired token"}), 401 - - return f(*args, **kwargs) - return decorated_function + return jwt_required()(f) def get_current_user() -> dict[str, Any] | None: - """Helper function to get current user from access token.""" - token_service = TokenService() - access_token = request.cookies.get("access_token") - - if not access_token: + """Helper function to get current user from JWT token.""" + try: + current_user_id = get_jwt_identity() + if not current_user_id: + return None + + claims = get_jwt() + is_active = claims.get("is_active", True) + + # Check if user is active + if not is_active: + return None + + return { + "id": current_user_id, + "email": claims.get("email", ""), + "name": claims.get("name", ""), + "picture": claims.get("picture"), + "role": claims.get("role", "user"), + "is_active": is_active, + "provider": claims.get("provider", "unknown"), + "providers": claims.get("providers", []), + } + except Exception: return None - - return token_service.get_user_from_access_token(access_token) \ No newline at end of file + + +def require_role(required_role: str): + """Decorator to require specific role for routes.""" + def decorator(f): + @wraps(f) + @jwt_required() + def wrapper(*args, **kwargs): + user = get_current_user() + if not user: + return jsonify({"error": "Authentication required"}), 401 + + if user.get("role") != required_role: + return jsonify({"error": f"Access denied. {required_role.title()} role required"}), 403 + + return f(*args, **kwargs) + return wrapper + return decorator + + +def require_admin(f): + """Decorator to require admin role for routes.""" + return require_role("admin")(f) + + +def require_user_or_admin(f): + """Decorator to require user or admin role for routes.""" + @wraps(f) + @jwt_required() + def wrapper(*args, **kwargs): + user = get_current_user() + if not user: + return jsonify({"error": "Authentication required"}), 401 + + if user.get("role") not in ["user", "admin"]: + return jsonify({"error": "Access denied"}), 403 + + return f(*args, **kwargs) + return wrapper + + +def get_user_from_api_token() -> dict[str, Any] | None: + """Get user from API token in request headers.""" + try: + # Check for API token in Authorization header + auth_header = request.headers.get("Authorization") + if not auth_header: + return None + + # Expected format: "Bearer " or "Token " + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() not in ["bearer", "token"]: + return None + + api_token = parts[1] + + # Import here to avoid circular imports + from app.models.user import User + + user = User.find_by_api_token(api_token) + if user and user.is_active: + return { + "id": str(user.id), + "email": user.email, + "name": user.name, + "picture": user.picture, + "role": user.role, + "is_active": user.is_active, + "provider": "api_token", + "providers": [p.provider for p in user.oauth_providers] + ["api_token"], + } + + return None + except Exception: + return None + + +def require_api_token(f): + """Decorator to require API token authentication for routes.""" + @wraps(f) + def wrapper(*args, **kwargs): + user = get_user_from_api_token() + if not user: + return jsonify({"error": "Valid API token required"}), 401 + + return f(*args, **kwargs) + return wrapper + + +def require_auth_or_api_token(f): + """Decorator to accept either JWT or API token authentication.""" + @wraps(f) + def wrapper(*args, **kwargs): + # Try JWT authentication first + try: + user = get_current_user() + if user: + return f(*args, **kwargs) + except Exception: + pass + + # Try API token authentication + user = get_user_from_api_token() + if user: + return f(*args, **kwargs) + + return jsonify({"error": "Authentication required (JWT or API token)"}), 401 + return wrapper \ No newline at end of file diff --git a/app/services/oauth_providers/__init__.py b/app/services/oauth_providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/oauth_providers/base.py b/app/services/oauth_providers/base.py new file mode 100644 index 0000000..bd0ffca --- /dev/null +++ b/app/services/oauth_providers/base.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +from authlib.integrations.flask_client import OAuth + + +class OAuthProvider(ABC): + """Abstract base class for OAuth providers.""" + + def __init__(self, oauth: OAuth, client_id: str, client_secret: str): + self.oauth = oauth + self.client_id = client_id + self.client_secret = client_secret + self._client = None + + @property + @abstractmethod + def name(self) -> str: + """Provider name (e.g., 'google', 'github').""" + pass + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable provider name (e.g., 'Google', 'GitHub').""" + pass + + @abstractmethod + def get_client_config(self) -> Dict[str, Any]: + """Return OAuth client configuration.""" + pass + + @abstractmethod + def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]: + """Extract user information from OAuth token response.""" + pass + + def get_client(self): + """Get or create OAuth client.""" + if self._client is None: + config = self.get_client_config() + self._client = self.oauth.register( + name=self.name, + client_id=self.client_id, + client_secret=self.client_secret, + **config + ) + return self._client + + def get_authorization_url(self, redirect_uri: str) -> str: + """Generate authorization URL for OAuth flow.""" + client = self.get_client() + return client.authorize_redirect(redirect_uri).location + + def exchange_code_for_token(self, code: str = None, redirect_uri: str = None) -> Dict[str, Any]: + """Exchange authorization code for access token.""" + client = self.get_client() + token = client.authorize_access_token() + return token + + def normalize_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + """Normalize user data to common format.""" + return { + 'id': user_info.get('id'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'picture': user_info.get('picture'), + 'provider': self.name + } \ No newline at end of file diff --git a/app/services/oauth_providers/github.py b/app/services/oauth_providers/github.py new file mode 100644 index 0000000..2fe78ac --- /dev/null +++ b/app/services/oauth_providers/github.py @@ -0,0 +1,52 @@ +from typing import Dict, Any +from .base import OAuthProvider + + +class GitHubOAuthProvider(OAuthProvider): + """GitHub OAuth provider implementation.""" + + @property + def name(self) -> str: + return 'github' + + @property + def display_name(self) -> str: + return 'GitHub' + + def get_client_config(self) -> Dict[str, Any]: + """Return GitHub OAuth client configuration.""" + return { + 'access_token_url': 'https://github.com/login/oauth/access_token', + 'authorize_url': 'https://github.com/login/oauth/authorize', + 'api_base_url': 'https://api.github.com/', + 'client_kwargs': { + 'scope': 'user:email' + } + } + + def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]: + """Extract user information from GitHub OAuth token response.""" + client = self.get_client() + + # Get user profile + user_resp = client.get('user', token=token) + user_data = user_resp.json() + + # Get user email (may be private) + email = user_data.get('email') + if not email: + # If email is private, get from emails endpoint + emails_resp = client.get('user/emails', token=token) + emails = emails_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get('primary', False): + email = email_obj.get('email') + break + + return { + 'id': str(user_data.get('id')), + 'email': email, + 'name': user_data.get('name') or user_data.get('login'), + 'picture': user_data.get('avatar_url') + } \ No newline at end of file diff --git a/app/services/oauth_providers/google.py b/app/services/oauth_providers/google.py new file mode 100644 index 0000000..33781dd --- /dev/null +++ b/app/services/oauth_providers/google.py @@ -0,0 +1,34 @@ +from typing import Any, Dict + +from .base import OAuthProvider + + +class GoogleOAuthProvider(OAuthProvider): + """Google OAuth provider implementation.""" + + @property + def name(self) -> str: + return "google" + + @property + def display_name(self) -> str: + return "Google" + + def get_client_config(self) -> Dict[str, Any]: + """Return Google OAuth client configuration.""" + return { + "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration", + "client_kwargs": {"scope": "openid email profile"}, + } + + def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]: + """Extract user information from Google OAuth token response.""" + client = self.get_client() + user_info = client.userinfo(token=token) + + return { + "id": user_info.get("sub"), + "email": user_info.get("email"), + "name": user_info.get("name"), + "picture": user_info.get("picture"), + } diff --git a/app/services/oauth_providers/registry.py b/app/services/oauth_providers/registry.py new file mode 100644 index 0000000..9df0612 --- /dev/null +++ b/app/services/oauth_providers/registry.py @@ -0,0 +1,45 @@ +import os +from typing import Dict, Optional +from authlib.integrations.flask_client import OAuth +from .base import OAuthProvider +from .google import GoogleOAuthProvider +from .github import GitHubOAuthProvider + + +class OAuthProviderRegistry: + """Registry for OAuth providers.""" + + def __init__(self, oauth: OAuth): + self.oauth = oauth + self._providers: Dict[str, OAuthProvider] = {} + self._initialize_providers() + + def _initialize_providers(self): + """Initialize available providers based on environment variables.""" + # Google OAuth + google_client_id = os.getenv('GOOGLE_CLIENT_ID') + google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET') + if google_client_id and google_client_secret: + self._providers['google'] = GoogleOAuthProvider( + self.oauth, google_client_id, google_client_secret + ) + + # GitHub OAuth + github_client_id = os.getenv('GITHUB_CLIENT_ID') + github_client_secret = os.getenv('GITHUB_CLIENT_SECRET') + if github_client_id and github_client_secret: + self._providers['github'] = GitHubOAuthProvider( + self.oauth, github_client_id, github_client_secret + ) + + def get_provider(self, name: str) -> Optional[OAuthProvider]: + """Get OAuth provider by name.""" + return self._providers.get(name) + + def get_available_providers(self) -> Dict[str, OAuthProvider]: + """Get all available providers.""" + return self._providers.copy() + + def is_provider_available(self, name: str) -> bool: + """Check if provider is available.""" + return name in self._providers \ No newline at end of file diff --git a/app/services/token_service.py b/app/services/token_service.py index 5b3c4f2..e3f5595 100644 --- a/app/services/token_service.py +++ b/app/services/token_service.py @@ -1,75 +1,24 @@ -"""JWT token service for handling access and refresh tokens.""" +"""JWT token service using Flask-JWT-Extended.""" -import os -from datetime import datetime, timedelta, timezone from typing import Any -import jwt +from flask_jwt_extended import create_access_token, create_refresh_token class TokenService: - """Service for handling JWT tokens.""" - - def __init__(self) -> None: - """Initialize the token service.""" - self.secret_key = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key") - self.algorithm = "HS256" - self.access_token_expire_minutes = 15 - self.refresh_token_expire_days = 7 + """Service for handling JWT tokens using Flask-JWT-Extended.""" def generate_access_token(self, user_data: dict[str, Any]) -> str: """Generate an access token for the user.""" - payload = { - "user_id": user_data["id"], - "email": user_data["email"], - "name": user_data["name"], - "type": "access", - "exp": datetime.now(timezone.utc) + timedelta( - minutes=self.access_token_expire_minutes - ), - "iat": datetime.now(timezone.utc), - } - return jwt.encode(payload, self.secret_key, algorithm=self.algorithm) + return create_access_token( + identity=user_data["id"], + additional_claims={ + "email": user_data["email"], + "name": user_data["name"], + "picture": user_data.get("picture"), + }, + ) def generate_refresh_token(self, user_data: dict[str, Any]) -> str: """Generate a refresh token for the user.""" - payload = { - "user_id": user_data["id"], - "type": "refresh", - "exp": datetime.now(timezone.utc) + timedelta( - days=self.refresh_token_expire_days - ), - "iat": datetime.now(timezone.utc), - } - return jwt.encode(payload, self.secret_key, algorithm=self.algorithm) - - def verify_token(self, token: str) -> dict[str, Any] | None: - """Verify and decode a JWT token.""" - try: - payload = jwt.decode( - token, self.secret_key, algorithms=[self.algorithm] - ) - return payload - except jwt.ExpiredSignatureError: - return None - except jwt.InvalidTokenError: - return None - - def is_access_token(self, payload: dict[str, Any]) -> bool: - """Check if the token payload is for an access token.""" - return payload.get("type") == "access" - - def is_refresh_token(self, payload: dict[str, Any]) -> bool: - """Check if the token payload is for a refresh token.""" - return payload.get("type") == "refresh" - - def get_user_from_access_token(self, token: str) -> dict[str, Any] | None: - """Extract user data from access token.""" - payload = self.verify_token(token) - if payload and self.is_access_token(payload): - return { - "id": payload["user_id"], - "email": payload["email"], - "name": payload["name"], - } - return None \ No newline at end of file + return create_refresh_token(identity=user_data["id"]) \ No newline at end of file diff --git a/main.py b/main.py index acc6c0e..b9dd877 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,10 @@ +from dotenv import load_dotenv + from app import create_app +# Load environment variables from .env file +load_dotenv() + def main() -> None: """Run the Flask application.""" diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..1dd03e0 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Database migration script for Flask-Migrate.""" + +import os +from flask.cli import FlaskGroup +from app import create_app +from app.database import db + +app = create_app() +cli = FlaskGroup(app) + +@cli.command() +def init_db(): + """Initialize the database.""" + print("Initializing database...") + db.create_all() + print("Database initialized successfully!") + +@cli.command() +def reset_db(): + """Reset the database (drop all tables and recreate).""" + print("Resetting database...") + db.drop_all() + db.create_all() + print("Database reset successfully!") + +if __name__ == "__main__": + cli() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ed5eb72..b605851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,16 @@ description = "Soundboard V2 - Backend" authors = [{ name = "quaik8", email = "quaik8@gmail.com" }] readme = "README.md" requires-python = ">=3.12" -dependencies = ["flask==3.1.1", "authlib==1.3.2", "requests==2.32.3", "pyjwt==2.9.0"] +dependencies = [ + "authlib==1.6.0", + "flask==3.1.1", + "flask-jwt-extended==4.7.1", + "flask-migrate==4.1.0", + "flask-sqlalchemy==3.1.1", + "python-dotenv==1.1.1", + "requests==2.32.4", + "werkzeug==3.1.3", +] [dependency-groups] dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"] diff --git a/reset.sh b/reset.sh new file mode 100755 index 0000000..62a133d --- /dev/null +++ b/reset.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +rm instance/soundboard.db +uv run migrate_db.py init-db +uv run main.py \ No newline at end of file diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py index 8bad2c1..2b7e0a2 100644 --- a/tests/test_auth_routes.py +++ b/tests/test_auth_routes.py @@ -1,4 +1,4 @@ -"""Tests for authentication routes.""" +"""Tests for authentication routes with Flask-JWT-Extended.""" from unittest.mock import Mock, patch @@ -12,12 +12,13 @@ def client(): """Create a test client for the Flask application.""" app = create_app() app.config["TESTING"] = True + app.config["JWT_COOKIE_SECURE"] = False # Allow cookies in testing with app.test_client() as client: yield client -class TestAuthRoutes: - """Test cases for authentication routes.""" +class TestAuthRoutesJWTExtended: + """Test cases for authentication routes with Flask-JWT-Extended.""" @patch("app.routes.auth.auth_service.get_login_url") def test_login_route(self, mock_get_login_url: Mock, client) -> None: @@ -30,33 +31,18 @@ class TestAuthRoutes: assert "login_url" in data assert data["login_url"] == "https://accounts.google.com/oauth/authorize?..." - def test_callback_route_no_code(self, client) -> None: - """Test callback route without authorization code.""" - response = client.get("/api/auth/callback") - assert response.status_code == 400 - data = response.get_json() - assert data["error"] == "Authorization code not found" - @patch("app.routes.auth.auth_service.handle_callback") def test_callback_route_success(self, mock_handle_callback: Mock, client) -> None: """Test successful callback route.""" - user_data = { - "id": "123", - "email": "test@example.com", - "name": "Test User" - } mock_response = Mock() mock_response.get_json.return_value = { "message": "Login successful", - "user": user_data + "user": {"id": "123", "email": "test@example.com", "name": "Test User"} } - mock_handle_callback.return_value = (user_data, mock_response) + mock_handle_callback.return_value = mock_response - with patch("app.routes.auth.client.get") as mock_get: - mock_get.return_value = mock_response - response = client.get("/api/auth/callback?code=test_code") - # Since we're returning the mock response directly, we need to verify differently - mock_handle_callback.assert_called_once() + response = client.get("/api/auth/callback?code=test_code") + mock_handle_callback.assert_called_once() @patch("app.routes.auth.auth_service.handle_callback") def test_callback_route_error(self, mock_handle_callback: Mock, client) -> None: @@ -75,32 +61,19 @@ class TestAuthRoutes: mock_response.get_json.return_value = {"message": "Logged out successfully"} mock_logout.return_value = mock_response - with patch("app.routes.auth.client.get") as mock_get: - mock_get.return_value = mock_response - response = client.get("/api/auth/logout") - mock_logout.assert_called_once() + response = client.get("/api/auth/logout") + mock_logout.assert_called_once() - @patch("app.routes.auth.auth_service.get_current_user") - def test_me_route_authenticated(self, mock_get_current_user: Mock, client) -> None: - """Test /me route when authenticated.""" - user_data = { - "id": "123", - "email": "test@example.com", - "name": "Test User" - } - mock_get_current_user.return_value = user_data - - response = client.get("/api/auth/me") - assert response.status_code == 200 - data = response.get_json() - assert data["user"] == user_data - - @patch("app.routes.auth.auth_service.get_current_user") - def test_me_route_not_authenticated(self, mock_get_current_user: Mock, client) -> None: + def test_me_route_not_authenticated(self, client) -> None: """Test /me route when not authenticated.""" - mock_get_current_user.return_value = None - response = client.get("/api/auth/me") assert response.status_code == 401 data = response.get_json() - assert data["error"] == "Not authenticated" \ No newline at end of file + assert "msg" in data # Flask-JWT-Extended error format + + def test_refresh_route_not_authenticated(self, client) -> None: + """Test /refresh route when not authenticated.""" + response = client.post("/api/auth/refresh") + assert response.status_code == 401 + data = response.get_json() + assert "msg" in data # Flask-JWT-Extended error format \ No newline at end of file diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py index 8244bbc..a0b80a5 100644 --- a/tests/test_auth_service.py +++ b/tests/test_auth_service.py @@ -1,12 +1,13 @@ -"""Tests for AuthService.""" +"""Tests for AuthService with Flask-JWT-Extended.""" from unittest.mock import Mock, patch +from app import create_app from app.services.auth_service import AuthService -class TestAuthService: - """Test cases for AuthService.""" +class TestAuthServiceJWTExtended: + """Test cases for AuthService with Flask-JWT-Extended.""" def test_init_without_app(self) -> None: """Test initializing AuthService without Flask app.""" @@ -23,59 +24,25 @@ class TestAuthService: "GOOGLE_CLIENT_SECRET": "test_client_secret" }.get(key) - mock_app = Mock() + app = create_app() auth_service = AuthService() - auth_service.init_app(mock_app) + auth_service.init_app(app) - auth_service.oauth.init_app.assert_called_once_with(mock_app) + # Verify OAuth was initialized + assert auth_service.google is not None - @patch("app.services.auth_service.request") - def test_get_current_user_no_token(self, mock_request: Mock) -> None: - """Test getting current user when no token exists.""" - mock_request.cookies.get.return_value = None - auth_service = AuthService() - - user = auth_service.get_current_user() - assert user is None - - @patch("app.services.auth_service.request") - def test_get_current_user_with_token(self, mock_request: Mock) -> None: - """Test getting current user when valid token exists.""" - mock_request.cookies.get.return_value = "valid.access.token" - auth_service = AuthService() - - user_data = {"id": "123", "email": "test@example.com", "name": "Test User"} - with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data): - user = auth_service.get_current_user() - assert user == user_data - - @patch("app.services.auth_service.request") - def test_is_authenticated_false(self, mock_request: Mock) -> None: - """Test authentication check when not authenticated.""" - mock_request.cookies.get.return_value = None - auth_service = AuthService() - - assert not auth_service.is_authenticated() - - @patch("app.services.auth_service.request") - def test_is_authenticated_true(self, mock_request: Mock) -> None: - """Test authentication check when authenticated.""" - mock_request.cookies.get.return_value = "valid.access.token" - auth_service = AuthService() - user_data = {"id": "123", "email": "test@example.com", "name": "Test User"} - - with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data): - assert auth_service.is_authenticated() - - @patch("app.services.auth_service.make_response") - def test_logout(self, mock_make_response: Mock) -> None: + @patch("app.services.auth_service.unset_jwt_cookies") + @patch("app.services.auth_service.jsonify") + def test_logout(self, mock_jsonify: Mock, mock_unset: Mock) -> None: """Test logout functionality.""" - mock_response = Mock() - mock_make_response.return_value = mock_response - - auth_service = AuthService() - result = auth_service.logout() - - assert result == mock_response - mock_response.set_cookie.assert_any_call("access_token", "", expires=0) - mock_response.set_cookie.assert_any_call("refresh_token", "", expires=0) \ No newline at end of file + app = create_app() + with app.app_context(): + mock_response = Mock() + mock_jsonify.return_value = mock_response + + auth_service = AuthService() + result = auth_service.logout() + + assert result == mock_response + mock_unset.assert_called_once_with(mock_response) + mock_jsonify.assert_called_once_with({"message": "Logged out successfully"}) \ No newline at end of file diff --git a/tests/test_token_service_jwt_extended.py b/tests/test_token_service_jwt_extended.py new file mode 100644 index 0000000..236160a --- /dev/null +++ b/tests/test_token_service_jwt_extended.py @@ -0,0 +1,57 @@ +"""Tests for TokenService using Flask-JWT-Extended.""" + +from unittest.mock import patch + +from app import create_app +from app.services.token_service import TokenService + + +class TestTokenServiceJWTExtended: + """Test cases for TokenService with Flask-JWT-Extended.""" + + def test_generate_access_token(self) -> None: + """Test access token generation.""" + app = create_app() + with app.app_context(): + token_service = TokenService() + user_data = { + "id": "123", + "email": "test@example.com", + "name": "Test User", + "picture": "https://example.com/pic.jpg" + } + + token = token_service.generate_access_token(user_data) + assert isinstance(token, str) + assert len(token) > 0 + + def test_generate_refresh_token(self) -> None: + """Test refresh token generation.""" + app = create_app() + with app.app_context(): + token_service = TokenService() + user_data = { + "id": "123", + "email": "test@example.com", + "name": "Test User" + } + + token = token_service.generate_refresh_token(user_data) + assert isinstance(token, str) + assert len(token) > 0 + + def test_generate_tokens_different(self) -> None: + """Test that access and refresh tokens are different.""" + app = create_app() + with app.app_context(): + token_service = TokenService() + user_data = { + "id": "123", + "email": "test@example.com", + "name": "Test User" + } + + access_token = token_service.generate_access_token(user_data) + refresh_token = token_service.generate_refresh_token(user_data) + + assert access_token != refresh_token \ No newline at end of file diff --git a/uv.lock b/uv.lock index 6d1bb49..f262436 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,32 @@ version = 1 revision = 1 requires-python = ">=3.12" +[[package]] +name = "alembic" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717 }, +] + +[[package]] +name = "authlib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981 }, +] + [[package]] name = "black" version = "25.1.0" @@ -35,6 +61,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, ] +[[package]] +name = "certifi" +version = "2025.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + [[package]] name = "click" version = "8.2.1" @@ -56,6 +159,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "cryptography" +version = "45.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712 }, + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433 }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163 }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687 }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 }, + { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769 }, + { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441 }, + { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836 }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540 }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052 }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024 }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 }, + { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557 }, + { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508 }, +] + [[package]] name = "flask" version = "3.1.1" @@ -73,6 +211,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, ] +[[package]] +name = "flask-jwt-extended" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "pyjwt" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/16/96b101f18cba17ecce3225ab07bc4c8f23e6befd8552dbbed87482e7c7fb/flask_jwt_extended-4.7.1.tar.gz", hash = "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976", size = 34411 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/34/9a91da47b1565811ab4aa5fb134632c8d1757960bfa7d457f486947c4d75/Flask_JWT_Extended-4.7.1-py2.py3-none-any.whl", hash = "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978", size = 22588 }, +] + +[[package]] +name = "flask-migrate" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "flask" }, + { name = "flask-sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -103,6 +324,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -186,6 +419,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -195,6 +437,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -211,6 +462,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + [[package]] name = "ruff" version = "0.12.1" @@ -241,7 +516,14 @@ name = "sdb-backend" version = "2.0.0" source = { virtual = "." } dependencies = [ + { name = "authlib" }, { name = "flask" }, + { name = "flask-jwt-extended" }, + { name = "flask-migrate" }, + { name = "flask-sqlalchemy" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "werkzeug" }, ] [package.dev-dependencies] @@ -252,7 +534,16 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "flask", specifier = "==3.1.1" }] +requires-dist = [ + { name = "authlib", specifier = "==1.6.0" }, + { name = "flask", specifier = "==3.1.1" }, + { name = "flask-jwt-extended", specifier = "==4.7.1" }, + { name = "flask-migrate", specifier = "==4.1.0" }, + { name = "flask-sqlalchemy", specifier = "==3.1.1" }, + { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "requests", specifier = "==2.32.4" }, + { name = "werkzeug", specifier = "==3.1.3" }, +] [package.metadata.requires-dev] dev = [ @@ -261,6 +552,53 @@ dev = [ { name = "ruff", specifier = "==0.12.1" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + [[package]] name = "werkzeug" version = "3.1.3"