Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
13 changes: 13 additions & 0 deletions backend/src/adapters/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Authentication adapters package."""

from src.adapters.auth.supabase import (
SupabaseAuthMappers,
SupabaseAuthRepository,
SupabaseClient,
)

__all__ = [
"SupabaseAuthRepository",
"SupabaseClient",
"SupabaseAuthMappers",
]
11 changes: 11 additions & 0 deletions backend/src/adapters/auth/supabase/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
129 changes: 129 additions & 0 deletions backend/src/adapters/auth/supabase/auth_repository.py
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions backend/src/adapters/auth/supabase/client.py
Original file line number Diff line number Diff line change
@@ -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
Loading