diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3d21ef6..934fb78 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,10 @@ dependencies = [ # Web client dependencies "jinja2>=3.1.0", "python-multipart>=0.0.6", + # Authentication + "supabase>=2.0.0", + "pyjwt>=2.8.0", + "passlib[bcrypt]>=1.7.4", # "redis>=5.0.0", "ruff>=0.12.1", ] diff --git a/backend/src/adapters/auth/__init__.py b/backend/src/adapters/auth/__init__.py new file mode 100644 index 0000000..9b602d4 --- /dev/null +++ b/backend/src/adapters/auth/__init__.py @@ -0,0 +1,13 @@ +"""Authentication adapters package.""" + +from src.adapters.auth.supabase import ( + SupabaseAuthMappers, + SupabaseAuthRepository, + SupabaseClient, +) + +__all__ = [ + "SupabaseAuthRepository", + "SupabaseClient", + "SupabaseAuthMappers", +] diff --git a/backend/src/adapters/auth/supabase/__init__.py b/backend/src/adapters/auth/supabase/__init__.py new file mode 100644 index 0000000..8943b83 --- /dev/null +++ b/backend/src/adapters/auth/supabase/__init__.py @@ -0,0 +1,11 @@ +"""Supabase authentication adapter.""" + +from src.adapters.auth.supabase.auth_repository import SupabaseAuthRepository +from src.adapters.auth.supabase.client import SupabaseClient +from src.adapters.auth.supabase.mappers import SupabaseAuthMappers + +__all__ = [ + "SupabaseAuthRepository", + "SupabaseClient", + "SupabaseAuthMappers", +] diff --git a/backend/src/adapters/auth/supabase/auth_repository.py b/backend/src/adapters/auth/supabase/auth_repository.py new file mode 100644 index 0000000..6231758 --- /dev/null +++ b/backend/src/adapters/auth/supabase/auth_repository.py @@ -0,0 +1,129 @@ +from src.adapters.auth.supabase.client import SupabaseClient +from src.adapters.auth.supabase.mappers import SupabaseAuthMappers +from src.core.config import AuthConfig, SupabaseConfig +from src.core.exceptions import AuthenticationError +from src.domain.models.auth_session import AuthSession +from src.domain.models.user import User +from src.infrastructure.logging import get_logger + +logger = get_logger("adapters.auth.supabase.repository") + + +class SupabaseAuthRepository: + """Supabase implementation of AuthRepository using OAuth.""" + + def __init__(self, supabase_config: SupabaseConfig, auth_config: AuthConfig): + self.client = SupabaseClient(supabase_config, auth_config) + self.mappers = SupabaseAuthMappers() + + # Generic OAuth methods + async def get_oauth_url(self, provider: str, scopes: str) -> str: + """Get OAuth authorization URL for specified provider.""" + try: + return await self.client.get_oauth_url( + provider=provider, + redirect_url=None, # Supabase handles redirect URL + scopes=scopes, + ) + except Exception as e: + logger.error(f"{provider} OAuth URL generation failed: {str(e)}") + raise AuthenticationError( + f"{provider} OAuth URL generation failed: {str(e)}" + ) + + async def exchange_oauth_code( + self, provider: str, code: str, state: str | None = None + ) -> tuple[User, AuthSession]: + """Exchange OAuth code for user and session.""" + try: + # Exchange the code for a session + session_data = await self.client.exchange_oauth_code(code) + + if not session_data.get("user"): + raise AuthenticationError( + f"{provider} OAuth exchange failed: No user returned" + ) + + # Map to domain models + user = self.mappers.user_from_oauth_session(session_data, provider) + session = self.mappers.session_from_supabase(session_data, provider) + + logger.info(f"{provider} OAuth exchange successful for user: {user.email}") + return user, session + except Exception as e: + logger.error(f"{provider} OAuth code exchange failed: {str(e)}") + if isinstance(e, AuthenticationError): + raise + raise AuthenticationError(f"{provider} OAuth exchange failed: {str(e)}") + + async def refresh_session(self, refresh_token: str) -> AuthSession: + """Refresh OAuth session using refresh token.""" + try: + # Set the refresh token and refresh the session + session_data = await self.client.refresh_session() + + # Determine provider from session data, default to spotify + provider = session_data.get("session", {}).get("provider", "spotify") + session = self.mappers.session_from_supabase(session_data, provider) + + logger.info(f"{provider} OAuth session refreshed successfully") + return session + except Exception as e: + logger.error(f"OAuth session refresh failed: {str(e)}") + raise AuthenticationError(f"Session refresh failed: {str(e)}") + + # Session management + async def verify_session_token(self, token: str) -> User | None: + """Verify Supabase session token and return user.""" + try: + user_data = await self.client.get_user_from_token(token) + if not user_data: + return None + + user = self.mappers.user_from_supabase(user_data) + logger.debug(f"Session token verified for user: {user.email}") + return user + except Exception as e: + logger.warning(f"Session token verification failed: {str(e)}") + return None + + async def get_current_session(self, token: str) -> AuthSession | None: + """Get current session information.""" + try: + # Set token and get session + session_data = await self.client.get_session() + if not session_data: + return None + + provider = "spotify" # Should be determined from session data + session = self.mappers.session_from_supabase(session_data, provider) + + logger.debug("Current session retrieved successfully") + return session + except Exception as e: + logger.warning(f"Failed to get current session: {str(e)}") + return None + + async def revoke_session(self, token: str) -> bool: + """Revoke a session (logout).""" + try: + result = await self.client.sign_out() + logger.info("Session revoked successfully") + return result + except Exception as e: + logger.warning(f"Session revocation failed: {str(e)}") + # Be permissive with logout + return True + + # User management + async def get_user_by_auth_id(self, auth_id: str) -> User | None: + """Get user by auth provider ID.""" + try: + # This would typically query the Supabase database + # For now, we'll implement a simple approach + logger.debug(f"Getting user by auth_id: {auth_id}") + # TODO: Implement user lookup by auth_id from Supabase database + return None + except Exception as e: + logger.error(f"User lookup by auth_id failed: {str(e)}") + return None diff --git a/backend/src/adapters/auth/supabase/client.py b/backend/src/adapters/auth/supabase/client.py new file mode 100644 index 0000000..3c5ce49 --- /dev/null +++ b/backend/src/adapters/auth/supabase/client.py @@ -0,0 +1,121 @@ +from typing import Any + +from src.core.config import AuthConfig, SupabaseConfig +from src.infrastructure.logging import get_logger +from supabase import Client, ClientOptions, create_client + +logger = get_logger("adapters.auth.supabase.client") + + +class SupabaseClient: + """Supabase client wrapper for OAuth authentication operations.""" + + def __init__(self, supabase_config: SupabaseConfig, auth_config: AuthConfig): + self.supabase_config = supabase_config + self.auth_config = auth_config + self._client: Client | None = None + + @property + def client(self) -> Client: + """Get or create Supabase client.""" + if self._client is None: + self._client = create_client( + self.supabase_config.url, + self.supabase_config.anon_key, + options=ClientOptions(flow_type="implicit"), + ) + return self._client + + async def get_oauth_url( + self, provider: str, redirect_url: str | None = None, scopes: str | None = None + ) -> str: + """Get OAuth authorization URL for the specified provider.""" + try: + # Build OAuth credentials for Supabase + credentials: dict[str, Any] = {"provider": provider} + if redirect_url or scopes: + options: dict[str, Any] = {} + if redirect_url: + options["redirect_to"] = redirect_url + if scopes: + options["scopes"] = scopes + credentials["options"] = options + + response = self.client.auth.sign_in_with_oauth(credentials) # type: ignore + + if hasattr(response, "url") and response.url: + logger.info(f"OAuth URL generated for provider: {provider}") + return response.url + else: + raise Exception("No OAuth URL returned from Supabase") + + except Exception as e: + logger.error(f"OAuth URL generation failed for {provider}: {str(e)}") + raise + + async def exchange_oauth_code( + self, code: str, code_verifier: str | None = None + ) -> dict: + """Exchange OAuth authorization code for session.""" + try: + # Build code exchange parameters + code_params: dict[str, Any] = {"auth_code": code} + if code_verifier: + code_params["code_verifier"] = code_verifier + + response = self.client.auth.exchange_code_for_session(code_params) # type: ignore + logger.info("OAuth code exchanged successfully") + return response.model_dump() + except Exception as e: + logger.error(f"OAuth code exchange failed: {str(e)}") + raise + + async def get_session(self) -> dict | None: + """Get current session.""" + try: + session = self.client.auth.get_session() + if session: + return session.model_dump() + return None + except Exception as e: + logger.warning(f"Failed to get session: {str(e)}") + return None + + async def get_user_from_token(self, token: str) -> dict | None: + """Get user information from session token.""" + try: + # Set the session with the provided token + self.client.auth.set_session(token, "") + + # Get the user + user_response = self.client.auth.get_user() + + if user_response and user_response.user: + logger.debug(f"Token verified for user: {user_response.user.email}") + return user_response.user.model_dump() + + return None + except Exception as e: + logger.warning(f"Token verification failed: {str(e)}") + return None + + async def refresh_session(self) -> dict: + """Refresh current session using stored refresh token.""" + try: + response = self.client.auth.refresh_session() + logger.info("Session refreshed successfully") + return response.model_dump() + except Exception as e: + logger.error(f"Session refresh failed: {str(e)}") + raise + + async def sign_out(self) -> bool: + """Sign out user and invalidate session.""" + try: + self.client.auth.sign_out() + logger.info("User signed out successfully") + return True + except Exception as e: + logger.warning(f"Sign out failed: {str(e)}") + # Return True anyway - sign out should be permissive + return True diff --git a/backend/src/adapters/auth/supabase/mappers.py b/backend/src/adapters/auth/supabase/mappers.py new file mode 100644 index 0000000..ef29eb0 --- /dev/null +++ b/backend/src/adapters/auth/supabase/mappers.py @@ -0,0 +1,128 @@ +from datetime import datetime + +from src.domain.models.auth_session import AuthSession +from src.domain.models.user import User + + +class SupabaseAuthMappers: + """Mappers for Supabase authentication data.""" + + @staticmethod + def user_from_supabase(supabase_user: dict) -> User: + """Convert Supabase user to domain User model.""" + return User( + id=supabase_user.get("id", ""), + email=supabase_user.get("email"), + display_name=supabase_user.get("user_metadata", {}).get("display_name"), + external_id="", # Not applicable for auth user + provider="auth", # This is the auth user, not music provider + external_url="", + image_url=supabase_user.get("user_metadata", {}).get("avatar_url"), + country=None, + followers_count=0, + # Auth-specific fields + auth_id=supabase_user.get("id"), + is_authenticated=True, + roles=supabase_user.get("user_metadata", {}).get("roles", []), + created_at=datetime.fromisoformat( + supabase_user.get("created_at", "").replace("Z", "+00:00") + ) + if supabase_user.get("created_at") + else None, + last_login=datetime.fromisoformat( + supabase_user.get("last_sign_in_at", "").replace("Z", "+00:00") + ) + if supabase_user.get("last_sign_in_at") + else None, + is_active=True, # Supabase users are active by default + ) + + @staticmethod + def session_from_supabase(session_data: dict, provider: str) -> AuthSession: + """Convert Supabase OAuth session to AuthSession domain model.""" + session = session_data.get("session", {}) + user = session_data.get("user", {}) + + # Extract Spotify tokens - check multiple possible locations + provider_token = None + provider_refresh_token = None + + # Method 1: Direct from session (most common) + if session.get("provider_token"): + provider_token = session.get("provider_token") + provider_refresh_token = session.get("provider_refresh_token") + + # Method 2: From user metadata (alternative location) + elif user.get("user_metadata", {}).get("provider_token"): + user_metadata = user.get("user_metadata", {}) + provider_token = user_metadata.get("provider_token") + provider_refresh_token = user_metadata.get("provider_refresh_token") + + # Method 3: From app metadata (admin/system tokens) + elif user.get("app_metadata", {}).get("provider_token"): + app_metadata = user.get("app_metadata", {}) + provider_token = app_metadata.get("provider_token") + provider_refresh_token = app_metadata.get("provider_refresh_token") + + # Method 4: From session user (nested structure) + elif session.get("user", {}).get("user_metadata", {}).get("provider_token"): + session_user_metadata = session.get("user", {}).get("user_metadata", {}) + provider_token = session_user_metadata.get("provider_token") + provider_refresh_token = session_user_metadata.get("provider_refresh_token") + + return AuthSession( + user_id=user.get("id", ""), + access_token=session.get("access_token", ""), + refresh_token=session.get("refresh_token", ""), + provider_token=provider_token, + provider_refresh_token=provider_refresh_token, + provider=provider, + expires_at=datetime.fromisoformat( + session.get("expires_at", "").replace("Z", "+00:00") + ) + if session.get("expires_at") + else datetime.utcnow(), + created_at=datetime.fromisoformat( + user.get("created_at", "").replace("Z", "+00:00") + ) + if user.get("created_at") + else datetime.utcnow(), + last_used_at=datetime.fromisoformat( + user.get("last_sign_in_at", "").replace("Z", "+00:00") + ) + if user.get("last_sign_in_at") + else datetime.utcnow(), + ) + + @staticmethod + def user_from_oauth_session(session_data: dict, provider: str) -> User: + """Convert Supabase OAuth session user to domain User model.""" + user = session_data.get("user", {}) + user_metadata = user.get("user_metadata", {}) + + return User( + id=user.get("id", ""), + email=user.get("email"), + display_name=user_metadata.get("full_name") or user_metadata.get("name"), + external_id=user_metadata.get("provider_id", ""), + provider=provider, + external_url=user_metadata.get("avatar_url", ""), + image_url=user_metadata.get("picture") or user_metadata.get("avatar_url"), + country=user_metadata.get("country"), + followers_count=0, + # Auth-specific fields + auth_id=user.get("id"), + is_authenticated=True, + roles=user_metadata.get("roles", []), + created_at=datetime.fromisoformat( + user.get("created_at", "").replace("Z", "+00:00") + ) + if user.get("created_at") + else None, + last_login=datetime.fromisoformat( + user.get("last_sign_in_at", "").replace("Z", "+00:00") + ) + if user.get("last_sign_in_at") + else None, + is_active=True, + ) diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py index 62267b4..e69de29 100644 --- a/backend/src/api/__init__.py +++ b/backend/src/api/__init__.py @@ -1 +0,0 @@ -"""API module for Playlist Porter.""" diff --git a/backend/src/api/auth/__init__.py b/backend/src/api/auth/__init__.py new file mode 100644 index 0000000..508eafb --- /dev/null +++ b/backend/src/api/auth/__init__.py @@ -0,0 +1,3 @@ +from src.api.auth.endpoints import router + +__all__ = ["router"] diff --git a/backend/src/api/auth/endpoints.py b/backend/src/api/auth/endpoints.py new file mode 100644 index 0000000..c354745 --- /dev/null +++ b/backend/src/api/auth/endpoints.py @@ -0,0 +1,199 @@ +from datetime import timedelta +from typing import Annotated, Literal, cast + +from fastapi import ( + APIRouter, + Cookie, + Depends, + HTTPException, + Query, + Response, + status, +) +from fastapi.responses import RedirectResponse +from src.api.dependencies.auth import ( + get_auth_config, + get_auth_service, + get_current_user, +) +from src.core.config import AuthConfig +from src.domain.models.auth_session import AuthSession, OAuthCallback, OAuthRequest +from src.domain.models.user import User +from src.domain.services.auth_service import AuthenticationError, AuthService + +router = APIRouter() + + +@router.get("/{provider}/login", response_class=RedirectResponse) +async def oauth_login( + provider: str, + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> RedirectResponse: + """Initiate OAuth login for specified provider.""" + try: + scopes_map = { + "spotify": "user-read-private user-read-email playlist-read-private playlist-modify-public playlist-modify-private", + "apple": "name email", + "youtube": "https://www.googleapis.com/auth/youtube.readonly", + } + + if provider not in scopes_map: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider}' is not supported", + ) + + request = OAuthRequest( + provider=provider, + scopes=scopes_map[provider], + ) + + oauth_url = await auth_service.get_oauth_url(request) + return RedirectResponse(url=oauth_url) + + except AuthenticationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + +@router.post("/{provider}/callback", response_model=User) +async def oauth_callback( + provider: str, + response: Response, + auth_service: Annotated[AuthService, Depends(get_auth_service)], + auth_config: Annotated[AuthConfig, Depends(get_auth_config)], + code: str = Query(..., description="OAuth authorization code"), + state: str | None = Query(default=None, description="OAuth state parameter"), +) -> User: + """Handle OAuth callback and create user session.""" + try: + callback = OAuthCallback(provider=provider, code=code, state=state) + user, session = await auth_service.handle_oauth_callback(callback) + + # Set secure cookies with Supabase tokens + _set_session_cookies(response, session, auth_config) + + return user + except AuthenticationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + +@router.post("/refresh", response_model=AuthSession) +async def refresh_session( + response: Response, + auth_service: Annotated[AuthService, Depends(get_auth_service)], + auth_config: Annotated[AuthConfig, Depends(get_auth_config)], + refresh_token: Annotated[str | None, Cookie()] = None, +) -> AuthSession: + """Refresh OAuth session using refresh token.""" + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token provided" + ) + + try: + new_session = await auth_service.refresh_session(refresh_token) + + # Update cookies with new session + _set_session_cookies(response, new_session, auth_config) + + return new_session + except AuthenticationError as e: + _clear_session_cookies(response) + raise HTTPException(status_code=e.status_code, detail=e.message) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout( + response: Response, + auth_service: Annotated[AuthService, Depends(get_auth_service)], + access_token: Annotated[str | None, Cookie()] = None, +) -> None: + """Logout user and revoke session.""" + if access_token: + try: + await auth_service.logout_user(access_token) + except Exception: + pass # Be permissive with logout + + # Clear cookies + _clear_session_cookies(response) + + +@router.get("/me", response_model=User) +async def get_current_user_info( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Get current authenticated user.""" + return current_user + + +@router.get("/session", response_model=AuthSession) +async def get_current_session( + auth_service: Annotated[AuthService, Depends(get_auth_service)], + access_token: Annotated[str | None, Cookie()] = None, +) -> AuthSession: + """Get current session information including provider tokens.""" + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="No access token provided" + ) + + try: + session = await auth_service.get_current_session(access_token) + return session + except AuthenticationError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + +def _set_session_cookies( + response: Response, session: AuthSession, auth_config: AuthConfig +) -> None: + """Set secure session cookies.""" + # Supabase access token cookie (short-lived) + response.set_cookie( + key="access_token", + value=session.access_token, + max_age=3600, # 1 hour - Supabase default + httponly=True, + secure=auth_config.cookie_secure, + samesite=cast(Literal["lax", "strict", "none"], auth_config.cookie_samesite), + domain=auth_config.cookie_domain, + path="/", + ) + + # Supabase refresh token cookie (long-lived) + if session.refresh_token: + response.set_cookie( + key="refresh_token", + value=session.refresh_token, + max_age=int(timedelta(days=7).total_seconds()), # 7 days + httponly=True, + secure=auth_config.cookie_secure, + samesite=cast( + Literal["lax", "strict", "none"], auth_config.cookie_samesite + ), + domain=auth_config.cookie_domain, + path="/", + ) + + # Provider token (for API calls to Spotify/etc) - optional + if session.provider_token: + response.set_cookie( + key="provider_token", + value=session.provider_token, + max_age=3600, # 1 hour - depends on provider + httponly=True, + secure=auth_config.cookie_secure, + samesite=cast( + Literal["lax", "strict", "none"], auth_config.cookie_samesite + ), + domain=auth_config.cookie_domain, + path="/", + ) + + +def _clear_session_cookies(response: Response) -> None: + """Clear session cookies.""" + response.delete_cookie(key="access_token", path="/") + response.delete_cookie(key="refresh_token", path="/") + response.delete_cookie(key="provider_token", path="/") diff --git a/backend/src/api/dependencies/auth.py b/backend/src/api/dependencies/auth.py new file mode 100644 index 0000000..73ff599 --- /dev/null +++ b/backend/src/api/dependencies/auth.py @@ -0,0 +1,115 @@ +from collections.abc import Callable, Coroutine +from typing import Annotated, Any + +from fastapi import Cookie, Depends, HTTPException, status +from src.adapters.auth.supabase import SupabaseAuthRepository +from src.core.config import AuthConfig, SupabaseConfig, auth_config, supabase_config +from src.domain.models.user import User +from src.domain.services.auth_service import AuthenticationError, AuthService + + +def get_supabase_auth_repository() -> SupabaseAuthRepository: + """Get Supabase authentication repository instance.""" + return SupabaseAuthRepository(supabase_config, auth_config) + + +def get_auth_service( + auth_repository: Annotated[ + SupabaseAuthRepository, Depends(get_supabase_auth_repository) + ], +) -> AuthService: + """Get authentication service instance.""" + return AuthService(auth_repository) + + +def get_auth_config() -> AuthConfig: + """Get authentication configuration.""" + return auth_config + + +def get_supabase_config() -> SupabaseConfig: + """Get Supabase configuration.""" + return supabase_config + + +async def get_current_user( + auth_service: Annotated[AuthService, Depends(get_auth_service)], + access_token: Annotated[str | None, Cookie()] = None, +) -> User: + """Get current authenticated user from session token.""" + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No access token provided", + ) + + try: + user = await auth_service.verify_session_token(access_token) + return user + except AuthenticationError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=e.message, + ) + + +async def get_current_user_optional( + auth_service: Annotated[AuthService, Depends(get_auth_service)], + access_token: Annotated[str | None, Cookie()] = None, +) -> User | None: + """Get current authenticated user from session token (optional).""" + if not access_token: + return None + + try: + user = await auth_service.verify_session_token(access_token) + return user + except AuthenticationError: + return None + + +def require_roles(*required_roles: str) -> Callable[..., Coroutine[Any, Any, User]]: + """Dependency factory for role-based access control.""" + + async def role_checker( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + """Check if user has required roles.""" + if not any(role in current_user.roles for role in required_roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient permissions. Required roles: {', '.join(required_roles)}", + ) + return current_user + + return role_checker + + +def require_active_user( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Ensure user account is active.""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated", + ) + return current_user + + +def require_authenticated_user( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Ensure user is authenticated and active.""" + if not current_user.is_authenticated or not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return current_user + + +CurrentUser = Annotated[User, Depends(get_current_user)] +CurrentUserOptional = Annotated[User | None, Depends(get_current_user_optional)] +ActiveUser = Annotated[User, Depends(require_active_user)] +AuthenticatedUser = Annotated[User, Depends(require_authenticated_user)] diff --git a/backend/src/api/dependencies/spotify.py b/backend/src/api/dependencies/spotify.py index b1e9f60..d1def68 100644 --- a/backend/src/api/dependencies/spotify.py +++ b/backend/src/api/dependencies/spotify.py @@ -1,5 +1,3 @@ -"""Spotify-specific API dependencies for FastAPI.""" - from typing import Annotated from fastapi import Depends, HTTPException, Request, status @@ -9,7 +7,6 @@ from src.domain.services.migration_manager import MigrationManager from src.domain.services.playlist_manager import PlaylistManager -# Simple Bearer token for Swagger UI bearer = HTTPBearer(auto_error=False) diff --git a/backend/src/api/health.py b/backend/src/api/health.py index a9ecff4..63e7f35 100644 --- a/backend/src/api/health.py +++ b/backend/src/api/health.py @@ -1,5 +1,3 @@ -"""Health check endpoints.""" - from typing import Any from fastapi import APIRouter @@ -10,7 +8,6 @@ @router.get("/") async def health_check() -> dict[str, Any]: - """Health check endpoint.""" return { "status": "healthy", "service": app_config.title, diff --git a/backend/src/api/router.py b/backend/src/api/router.py index ac7e982..296c3d2 100644 --- a/backend/src/api/router.py +++ b/backend/src/api/router.py @@ -1,9 +1,8 @@ -"""Main API router for all endpoints.""" - from fastapi import APIRouter +from src.api.auth import router as auth_router from src.api.spotify.playlists import router as spotify_router router = APIRouter() -# Include all provider routers +router.include_router(auth_router, prefix="/auth", tags=["Authentication"]) router.include_router(spotify_router, prefix="/spotify", tags=["Spotify"]) diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 19baa8c..bbc98de 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -35,10 +35,49 @@ class SpotifyConfig(BaseSettings): ) -def get_settings() -> tuple[AppConfig, SpotifyConfig]: +class SupabaseConfig(BaseSettings): + """Supabase configuration.""" + + url: str = Field(default="", description="Supabase URL") + anon_key: str = Field(default="", description="Supabase Anonymous Key") + service_key: str = Field(default="", description="Supabase Service Key") + jwt_secret: str = Field(default="", description="Supabase JWT Secret") + + model_config = SettingsConfigDict( + env_prefix="SUPABASE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +class AuthConfig(BaseSettings): + """Authentication configuration.""" + + jwt_algorithm: str = Field(default="HS256", description="JWT Algorithm") + access_token_expire_minutes: int = Field( + default=15, description="Access token lifetime" + ) + refresh_token_expire_days: int = Field( + default=7, description="Refresh token lifetime" + ) + + # Cookie settings + cookie_secure: bool = Field(default=True, description="Secure cookies") + cookie_samesite: str = Field(default="strict", description="SameSite policy") + cookie_domain: str | None = Field(default=None, description="Cookie domain") + + model_config = SettingsConfigDict( + env_prefix="AUTH_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +def get_settings() -> tuple[AppConfig, SpotifyConfig, SupabaseConfig, AuthConfig]: """Get all configurations.""" - return AppConfig(), SpotifyConfig() + return AppConfig(), SpotifyConfig(), SupabaseConfig(), AuthConfig() -# Global instances -app_config, spotify_config = get_settings() +app_config, spotify_config, supabase_config, auth_config = get_settings() diff --git a/backend/src/core/exceptions.py b/backend/src/core/exceptions.py index cdf247a..d7b8036 100644 --- a/backend/src/core/exceptions.py +++ b/backend/src/core/exceptions.py @@ -22,6 +22,10 @@ class AuthenticationError(PlaylistPorterError): """Raised when authentication fails.""" +class AuthorizationError(PlaylistPorterError): + """Raised when authorization fails.""" + + class SpotifyAPIError(PlaylistPorterError): """Raised when Spotify API calls fail.""" diff --git a/backend/src/domain/__init__.py b/backend/src/domain/__init__.py index d278a83..0c15844 100644 --- a/backend/src/domain/__init__.py +++ b/backend/src/domain/__init__.py @@ -1,5 +1,3 @@ -"""Domain layer for Playlist Porter - Business Logic and Models.""" - from src.domain.interfaces import PlaylistRepository from src.domain.models import Album, Artist, Playlist, Track, User from src.domain.services import MigrationManager, PlaylistManager diff --git a/backend/src/domain/interfaces/__init__.py b/backend/src/domain/interfaces/__init__.py index 3053ba6..2a2b952 100644 --- a/backend/src/domain/interfaces/__init__.py +++ b/backend/src/domain/interfaces/__init__.py @@ -1,7 +1,9 @@ """Domain interfaces for Playlist Porter.""" +from src.domain.interfaces.auth_repository import AuthRepository from src.domain.interfaces.playlist_repository import PlaylistRepository __all__ = [ "PlaylistRepository", + "AuthRepository", ] diff --git a/backend/src/domain/interfaces/auth_repository.py b/backend/src/domain/interfaces/auth_repository.py new file mode 100644 index 0000000..b0167a9 --- /dev/null +++ b/backend/src/domain/interfaces/auth_repository.py @@ -0,0 +1,41 @@ +from typing import Protocol + +from src.domain.models.auth_session import AuthSession +from src.domain.models.user import User + + +class AuthRepository(Protocol): + """Authentication repository protocol.""" + + # Generic OAuth methods + async def get_oauth_url(self, provider: str, scopes: str) -> str: + """Get OAuth authorization URL for specified provider.""" + ... + + async def exchange_oauth_code( + self, provider: str, code: str, state: str | None = None + ) -> tuple[User, AuthSession]: + """Exchange OAuth code for user and session.""" + ... + + async def refresh_session(self, refresh_token: str) -> AuthSession: + """Refresh OAuth session using refresh token.""" + ... + + # Session management + async def verify_session_token(self, token: str) -> User | None: + """Verify Supabase session token and return user.""" + ... + + async def get_current_session(self, token: str) -> AuthSession | None: + """Get current session information.""" + ... + + async def revoke_session(self, token: str) -> bool: + """Revoke a session (logout).""" + ... + + # User management + async def get_user_by_auth_id(self, auth_id: str) -> User | None: + """Get user by auth provider ID.""" + ... diff --git a/backend/src/domain/models/__init__.py b/backend/src/domain/models/__init__.py index a60c3a0..1e687c0 100644 --- a/backend/src/domain/models/__init__.py +++ b/backend/src/domain/models/__init__.py @@ -1,5 +1,9 @@ -"""Domain models for Playlist Porter.""" - +from src.domain.models.auth_session import ( + AuthSession, + OAuthCallback, + OAuthRequest, + TokenPair, +) from src.domain.models.playlist import Playlist from src.domain.models.track import Album, Artist, Track from src.domain.models.user import User @@ -10,4 +14,8 @@ "Album", "Artist", "User", + "AuthSession", + "TokenPair", + "OAuthRequest", + "OAuthCallback", ] diff --git a/backend/src/domain/models/auth_session.py b/backend/src/domain/models/auth_session.py new file mode 100644 index 0000000..6b81c2d --- /dev/null +++ b/backend/src/domain/models/auth_session.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AuthSession(BaseModel): + """Domain model for an authentication session.""" + + user_id: str = Field(..., description="User ID") + access_token: str = Field(..., description="Supabase JWT access token") + refresh_token: str | None = Field( + default=None, description="Supabase JWT refresh token" + ) + provider_token: str | None = Field( + default=None, description="OAuth provider access token" + ) + provider_refresh_token: str | None = Field( + default=None, description="OAuth provider refresh token" + ) + provider: str = Field(..., description="OAuth provider (spotify, apple, etc.)") + expires_at: datetime = Field(..., description="Token expiration time") + created_at: datetime = Field(..., description="Session creation time") + last_used_at: datetime = Field(..., description="Last token usage time") + user_agent: str | None = Field(default=None, description="User agent string") + ip_address: str | None = Field(default=None, description="Client IP address") + + +class TokenPair(BaseModel): + """Access and refresh token pair.""" + + access_token: str = Field(..., description="JWT access token") + refresh_token: str = Field(..., description="JWT refresh token") + token_type: str = Field(default="bearer", description="Token type") + expires_in: int = Field(..., description="Access token lifetime in seconds") + + +class OAuthRequest(BaseModel): + """Generic OAuth authentication request.""" + + provider: str = Field(..., description="OAuth provider (spotify, apple, etc.)") + scopes: str | None = Field(default=None, description="OAuth scopes") + + +class OAuthCallback(BaseModel): + """Generic OAuth callback data.""" + + provider: str = Field(..., description="OAuth provider (spotify, apple, etc.)") + code: str = Field(..., description="OAuth authorization code") + state: str | None = Field(default=None, description="OAuth state parameter") diff --git a/backend/src/domain/models/user.py b/backend/src/domain/models/user.py index bb4b4a8..4ab0ee5 100644 --- a/backend/src/domain/models/user.py +++ b/backend/src/domain/models/user.py @@ -1,4 +1,4 @@ -"""Domain model for User.""" +from datetime import datetime from pydantic import BaseModel, Field @@ -15,3 +15,11 @@ class User(BaseModel): image_url: str | None = Field(default=None, description="User profile image URL") country: str | None = Field(default=None, description="User country") followers_count: int = Field(default=0, description="Number of followers") + + # Authentication fields + auth_id: str | None = Field(default=None, description="Supabase Auth ID") + is_authenticated: bool = Field(default=False, description="Auth status") + roles: list[str] = Field(default_factory=list, description="User roles") + created_at: datetime | None = Field(default=None, description="Account creation") + last_login: datetime | None = Field(default=None, description="Last login") + is_active: bool = Field(default=True, description="Account status") diff --git a/backend/src/domain/services/__init__.py b/backend/src/domain/services/__init__.py index fa97b23..d31365c 100644 --- a/backend/src/domain/services/__init__.py +++ b/backend/src/domain/services/__init__.py @@ -1,9 +1,12 @@ """Domain services for Playlist Porter.""" +from src.domain.services.auth_service import AuthenticationError, AuthService from src.domain.services.migration_manager import MigrationManager from src.domain.services.playlist_manager import PlaylistManager __all__ = [ "PlaylistManager", "MigrationManager", + "AuthService", + "AuthenticationError", ] diff --git a/backend/src/domain/services/auth_service.py b/backend/src/domain/services/auth_service.py new file mode 100644 index 0000000..3fd8083 --- /dev/null +++ b/backend/src/domain/services/auth_service.py @@ -0,0 +1,90 @@ +from src.domain.interfaces.auth_repository import AuthRepository +from src.domain.models.auth_session import AuthSession, OAuthCallback, OAuthRequest +from src.domain.models.user import User + + +class AuthenticationError(Exception): + """Authentication-related errors.""" + + def __init__(self, message: str, status_code: int = 401): + self.message = message + self.status_code = status_code + super().__init__(message) + + +class AuthService: + """Domain service for authentication business logic.""" + + def __init__(self, auth_repository: AuthRepository): + self.auth_repository = auth_repository + + # Generic OAuth methods + async def get_oauth_url(self, request: OAuthRequest) -> str: + """Get OAuth authorization URL for specified provider.""" + try: + return await self.auth_repository.get_oauth_url( + request.provider, request.scopes or "" + ) + except Exception as e: + raise AuthenticationError( + f"OAuth URL generation failed for {request.provider}: {str(e)}", 400 + ) + + async def handle_oauth_callback( + self, callback: OAuthCallback + ) -> tuple[User, AuthSession]: + """Handle OAuth callback and create user session.""" + try: + user, session = await self.auth_repository.exchange_oauth_code( + callback.provider, callback.code, callback.state + ) + + if not user.is_active: + raise AuthenticationError("Account is deactivated", 403) + + return user, session + except AuthenticationError: + raise + except Exception as e: + raise AuthenticationError( + f"OAuth callback failed for {callback.provider}: {str(e)}", 401 + ) + + async def verify_session_token(self, token: str) -> User: + """Verify session token and return authenticated user.""" + user = await self.auth_repository.verify_session_token(token) + if not user: + raise AuthenticationError("Invalid or expired session", 401) + + if not user.is_active: + raise AuthenticationError("Account is deactivated", 403) + + return user + + async def get_current_session(self, token: str) -> AuthSession: + """Get current session information.""" + session = await self.auth_repository.get_current_session(token) + if not session: + raise AuthenticationError("Invalid session", 401) + + return session + + async def refresh_session(self, refresh_token: str) -> AuthSession: + """Refresh session using refresh token.""" + try: + session = await self.auth_repository.refresh_session(refresh_token) + return session + except Exception as e: + raise AuthenticationError(f"Session refresh failed: {str(e)}", 401) + + async def logout_user(self, token: str) -> bool: + """Logout user by revoking session.""" + try: + return await self.auth_repository.revoke_session(token) + except Exception: + # Logout should be permissive - even if token is invalid + return True + + async def get_user_by_auth_id(self, auth_id: str) -> User | None: + """Get user by authentication ID.""" + return await self.auth_repository.get_user_by_auth_id(auth_id) diff --git a/backend/src/main.py b/backend/src/main.py index 3f177b8..0bdd291 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,5 +1,3 @@ -"""Main application for Playlist Porter.""" - from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -16,13 +14,8 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """Application lifespan management.""" - # Startup logger.info(f"Starting Playlist Porter ({app_config.environment})") - yield - - # Shutdown logger.info("Shutting down...") @@ -34,7 +27,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: lifespan=lifespan, ) -# CORS configuration - Fixed for credentials support allowed_origins = [ "http://localhost:3000", "http://localhost:3001", @@ -44,7 +36,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "https://localhost:3001", ] -# Add production domains if not in development if app_config.environment == "production": allowed_origins.extend( [ @@ -61,6 +52,5 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: allow_headers=["*"], ) -# Include routers app.include_router(health.router, prefix="/health", tags=["Health"]) -app.include_router(providers_router, prefix="/playlists", tags=["Music Providers"]) +app.include_router(providers_router, tags=["API"]) diff --git a/backend/uv.lock b/backend/uv.lock index a336569..4761747 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -27,16 +27,61 @@ wheels = [ ] [[package]] -name = "autoflake" -version = "2.3.1" +name = "bcrypt" +version = "4.3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyflakes" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/55/2d/0c7e5ab0524bf1a443e34cdd3926ec6f5879889b2f3c32b2f5074e99ed53/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1", size = 275367, upload-time = "2025-02-28T01:23:54.578Z" }, + { url = "https://files.pythonhosted.org/packages/10/4f/f77509f08bdff8806ecc4dc472b6e187c946c730565a7470db772d25df70/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d", size = 280644, upload-time = "2025-02-28T01:23:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/7d9dc16a3a4d530d0a9b845160e9e5d8eb4f00483e05d44bb4116a1861da/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492", size = 274881, upload-time = "2025-02-28T01:23:57.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/c4/ae6921088adf1e37f2a3a6a688e72e7d9e45fdd3ae5e0bc931870c1ebbda/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90", size = 280203, upload-time = "2025-02-28T01:23:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b1/1289e21d710496b88340369137cc4c5f6ee036401190ea116a7b4ae6d32a/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a", size = 275103, upload-time = "2025-02-28T01:24:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/19be9fe17e4ffc5d10b7b67f10e459fc4eee6ffe9056a88de511920cfd8d/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce", size = 280513, upload-time = "2025-02-28T01:24:02.243Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/05687a9ef89edebdd8ad7474c16d8af685eb4591c3c38300bb6aad4f0076/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8", size = 274685, upload-time = "2025-02-28T01:24:04.512Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/47bba97924ebe86a62ef83dc75b7c8a881d53c535f83e2c54c4bd701e05c/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", size = 280110, upload-time = "2025-02-28T01:24:05.896Z" }, ] [[package]] @@ -69,6 +114,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -95,6 +152,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" }, ] +[[package]] +name = "gotrue" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/67/ae47f68daae1bbb56a9fbf960dfb7d08b3dec52a6ad1e96f69c2ba5b3116/gotrue-2.12.3.tar.gz", hash = "sha256:f874cf9d0b2f0335bfbd0d6e29e3f7aff79998cd1c14d2ad814db8c06cee3852", size = 38323, upload-time = "2025-07-04T06:50:03.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/fa/4165d298ef89254c9f742faa3f99a61fe6fd3552b4ba44df6924f8d307d7/gotrue-2.12.3-py3-none-any.whl", hash = "sha256:b1a3c6a5fe3f92e854a026c4c19de58706a96fd5fbdcc3d620b2802f6a46a26b", size = 44022, upload-time = "2025-07-04T06:50:02.591Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -104,6 +175,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682, upload-time = "2025-02-02T07:43:51.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957, upload-time = "2025-02-01T11:02:26.481Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -168,6 +261,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -415,6 +522,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pastel" version = "0.2.1" @@ -441,17 +562,19 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "jinja2" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-multipart" }, { name = "ruff" }, + { name = "supabase" }, { name = "uvicorn", extra = ["standard"] }, { name = "yarl" }, ] [package.dev-dependencies] dev = [ - { name = "autoflake" }, { name = "mypy" }, { name = "poethepoet" }, ] @@ -468,17 +591,19 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.104.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "jinja2", specifier = ">=3.1.0" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "ruff", specifier = ">=0.12.1" }, + { name = "supabase", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, { name = "yarl", specifier = ">=1.9.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "autoflake", specifier = ">=2.3.1" }, { name = "mypy", specifier = ">=1.16.1" }, { name = "poethepoet", specifier = ">=0.35.0" }, ] @@ -513,6 +638,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/08/abc2d7e2400dd8906e3208f9b88ac610f097d7ee0c7a1fa4a157b49a9e86/poethepoet-0.35.0-py3-none-any.whl", hash = "sha256:bed5ae1fd63f179dfa67aabb93fa253d79695c69667c927d8b24ff378799ea75", size = 87164, upload-time = "2025-06-09T12:58:17.084Z" }, ] +[[package]] +name = "postgrest" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "strenum", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/1b50568e1f5db0bdced4a82c7887e37326585faef7ca43ead86849cb4861/postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322", size = 15431, upload-time = "2025-06-23T19:21:34.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/188a50ea64c17f73ff4df5196ec1553a8f1723421eb2d1069c73bab47d78/postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a", size = 22366, upload-time = "2025-06-23T19:21:33.637Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -719,21 +859,21 @@ wheels = [ ] [[package]] -name = "pyflakes" -version = "3.4.0" +name = "pygments" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] -name = "pygments" -version = "2.19.2" +name = "pyjwt" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] @@ -778,6 +918,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -840,6 +992,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "realtime" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/94/3cf962b814303a1688eece56a94b25a7bd423d60705f1124cba0896c9c07/realtime-2.5.3.tar.gz", hash = "sha256:0587594f3bc1c84bf007ff625075b86db6528843e03250dc84f4f2808be3d99a", size = 18527, upload-time = "2025-06-26T22:39:01.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/2a/f69c156a58d44b7b9ca22dab181b91e4d93d074f99923c75907bf3953d40/realtime-2.5.3-py3-none-any.whl", hash = "sha256:eb0994636946eff04c4c7f044f980c8c633c7eb632994f549f61053a474ac970", size = 21784, upload-time = "2025-06-26T22:38:59.98Z" }, +] + [[package]] name = "respx" version = "0.22.0" @@ -877,6 +1042,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209, upload-time = "2025-06-26T20:34:12.928Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -898,6 +1072,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "storage3" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/86/9eae84e8ad4ba73f575f3eaf5536d949f2fd7d6adc85829f9af04bce97e2/storage3-0.12.0.tar.gz", hash = "sha256:94243f20922d57738bf42e96b9f5582b4d166e8bf209eccf20b146909f3f71b0", size = 10024, upload-time = "2025-06-19T17:50:51.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7e/693d6d603db142eb5e6f3cb76eb6e9c439582b7539e2695e175e2de3ac44/storage3-0.12.0-py3-none-any.whl", hash = "sha256:1c4585693ca42243ded1512b58e54c697111e91a20916cd14783eebc37e7c87d", size = 18422, upload-time = "2025-06-19T17:50:50.294Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "supabase" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gotrue" }, + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supafunc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/93/335b91e8d09a95a337f051f84e85495f7732400f10c1bcb698a7571f8f1c/supabase-2.16.0.tar.gz", hash = "sha256:98f3810158012d4ec0e3083f2e5515f5e10b32bd71e7d458662140e963c1d164", size = 14595, upload-time = "2025-06-23T16:09:29.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/75/2ab71e6605d20a740ff041c6176a328cfaa3fcee0dd0db885e081d98df06/supabase-2.16.0-py3-none-any.whl", hash = "sha256:99065caab3d90a56650bf39fbd0e49740995da3738ab28706c61bd7f2401db55", size = 17713, upload-time = "2025-06-23T16:09:28.299Z" }, +] + +[[package]] +name = "supafunc" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/4b/16f94bcae8a49f5e09544a4fb0e6ad1c2288038036cefdeedb72fcffd92c/supafunc-0.10.1.tar.gz", hash = "sha256:a5b33c8baecb6b5297d25da29a2503e2ec67ee6986f3d44c137e651b8a59a17d", size = 5036, upload-time = "2025-06-23T18:26:50.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/4a/9cbea12d86a741d4e73a6e278c2b1d6479fb03d1002efb00e8e71aea76db/supafunc-0.10.1-py3-none-any.whl", hash = "sha256:26df9bd25ff2ef56cb5bfb8962de98f43331f7f8ff69572bac3ed9c3a9672040", size = 8028, upload-time = "2025-06-23T18:26:49.176Z" }, +] + [[package]] name = "tomli" version = "2.2.1" diff --git a/env.example b/env.example index 9d3d215..b510889 100644 --- a/env.example +++ b/env.example @@ -11,9 +11,34 @@ DEBUG=false # SPOTIFY API CONFIGURATION # ============================================================================= # Create a Spotify app at https://developer.spotify.com/dashboard +# Configure OAuth redirect URI: http://localhost:8000/auth/oauth/callback +# OR for production: https://yourdomain.com/auth/oauth/callback SPOTIFY_CLIENT_ID=your_spotify_client_id_here SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here +# ============================================================================= +# SUPABASE CONFIGURATION +# ============================================================================= +# Create a Supabase project at https://supabase.com/dashboard +# Configure OAuth provider: Spotify +# 1. Enable Spotify provider in Authentication > Providers +# 2. Add your Spotify Client ID and Secret +# 3. Set redirect URL to match your app +SUPABASE_URL=your_supabase_url_here +SUPABASE_ANON_KEY=your_supabase_anon_key_here +SUPABASE_SERVICE_KEY=your_supabase_service_key_here +SUPABASE_JWT_SECRET=your_supabase_jwt_secret_here + +# ============================================================================= +# AUTHENTICATION CONFIGURATION +# ============================================================================= +AUTH_JWT_ALGORITHM=HS256 +AUTH_ACCESS_TOKEN_EXPIRE_MINUTES=15 +AUTH_REFRESH_TOKEN_EXPIRE_DAYS=7 +AUTH_COOKIE_SECURE=true +AUTH_COOKIE_SAMESITE=strict +AUTH_COOKIE_DOMAIN= + # ============================================================================= # API CONFIGURATION # ============================================================================= @@ -32,4 +57,16 @@ API_URL=http://localhost:8000 # PRODUCTION OVERRIDES # ============================================================================= # For production deployment, adjust these: -# API_URL=https://api.yourdomain.com \ No newline at end of file +# API_URL=https://api.yourdomain.com + +# ============================================================================= +# SPOTIFY OAUTH WORKFLOW INFORMATION +# ============================================================================= +# This app uses Spotify OAuth authentication flow: +# 1. User visits /auth/spotify/login +# 2. Redirected to Spotify for authorization +# 3. Spotify redirects back to /auth/spotify/callback +# 4. Backend exchanges code for Supabase session + Spotify tokens +# 5. User is authenticated with both Supabase JWT and Spotify access tokens +# 6. Session cookies are set for frontend authentication +# 7. Spotify provider token is available for playlist operations \ No newline at end of file