Skip to content

Commit baa67b4

Browse files
committed
✨ feat: add supabase authentication integration
1 parent 6e5d370 commit baa67b4

21 files changed

Lines changed: 1468 additions & 23 deletions

File tree

backend/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ dependencies = [
1313
# Web client dependencies
1414
"jinja2>=3.1.0",
1515
"python-multipart>=0.0.6",
16+
# Authentication
17+
"supabase>=2.0.0",
18+
"pyjwt>=2.8.0",
19+
"passlib[bcrypt]>=1.7.4",
1620
# "redis>=5.0.0",
1721
"ruff>=0.12.1",
1822
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Authentication adapters package."""
2+
3+
from src.adapters.auth.supabase import (
4+
SupabaseAuthRepository,
5+
SupabaseClient,
6+
SupabaseAuthMappers,
7+
)
8+
9+
__all__ = [
10+
"SupabaseAuthRepository",
11+
"SupabaseClient",
12+
"SupabaseAuthMappers",
13+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Supabase authentication adapter."""
2+
3+
from src.adapters.auth.supabase.auth_repository import SupabaseAuthRepository
4+
from src.adapters.auth.supabase.client import SupabaseClient
5+
from src.adapters.auth.supabase.mappers import SupabaseAuthMappers
6+
7+
__all__ = [
8+
"SupabaseAuthRepository",
9+
"SupabaseClient",
10+
"SupabaseAuthMappers",
11+
]
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""Supabase OAuth implementation of AuthRepository."""
2+
3+
from src.adapters.auth.supabase.client import SupabaseClient
4+
from src.adapters.auth.supabase.mappers import SupabaseAuthMappers
5+
from src.core.config import AuthConfig, SupabaseConfig
6+
from src.core.exceptions import AuthenticationError
7+
from src.domain.interfaces.auth_repository import AuthRepository
8+
from src.domain.models.auth_session import (
9+
AuthSession,
10+
LoginRequest,
11+
OAuthCallback,
12+
OAuthRequest,
13+
RegisterRequest,
14+
TokenPair,
15+
)
16+
from src.domain.models.user import User
17+
from src.infrastructure.logging import get_logger
18+
19+
logger = get_logger("adapters.auth.supabase.repository")
20+
21+
22+
class SupabaseAuthRepository(AuthRepository):
23+
"""Supabase implementation of AuthRepository using OAuth."""
24+
25+
def __init__(self, supabase_config: SupabaseConfig, auth_config: AuthConfig):
26+
self.client = SupabaseClient(supabase_config, auth_config)
27+
self.mappers = SupabaseAuthMappers()
28+
29+
# OAuth methods (primary)
30+
async def get_oauth_url(self, request: OAuthRequest) -> str:
31+
"""Get OAuth authorization URL for the specified provider."""
32+
try:
33+
return await self.client.get_oauth_url(
34+
provider=request.provider,
35+
redirect_url=request.redirect_url,
36+
scopes=request.scopes,
37+
)
38+
except Exception as e:
39+
logger.error(f"OAuth URL generation failed: {str(e)}")
40+
raise AuthenticationError(f"OAuth URL generation failed: {str(e)}")
41+
42+
async def exchange_oauth_code(
43+
self, callback: OAuthCallback
44+
) -> tuple[User, AuthSession]:
45+
"""Exchange OAuth code for user and session."""
46+
try:
47+
# Exchange the code for a session
48+
session_data = await self.client.exchange_oauth_code(callback.code)
49+
50+
if not session_data.get("user"):
51+
raise AuthenticationError("OAuth exchange failed: No user returned")
52+
53+
# Map to domain models
54+
provider = "spotify" # Default for now, could be extracted from callback
55+
user = self.mappers.user_from_oauth_session(session_data, provider)
56+
session = self.mappers.session_from_supabase(session_data, provider)
57+
58+
logger.info(f"OAuth exchange successful for user: {user.email}")
59+
return user, session
60+
except Exception as e:
61+
logger.error(f"OAuth code exchange failed: {str(e)}")
62+
if isinstance(e, AuthenticationError):
63+
raise
64+
raise AuthenticationError(f"OAuth exchange failed: {str(e)}")
65+
66+
async def refresh_oauth_session(self, refresh_token: str) -> AuthSession:
67+
"""Refresh OAuth session using refresh token."""
68+
try:
69+
# Set the refresh token and refresh the session
70+
session_data = await self.client.refresh_session()
71+
72+
provider = "spotify" # Should be stored/determined from context
73+
session = self.mappers.session_from_supabase(session_data, provider)
74+
75+
logger.info("OAuth session refreshed successfully")
76+
return session
77+
except Exception as e:
78+
logger.error(f"OAuth session refresh failed: {str(e)}")
79+
raise AuthenticationError(f"Session refresh failed: {str(e)}")
80+
81+
# Session management
82+
async def verify_session_token(self, token: str) -> User | None:
83+
"""Verify Supabase session token and return user."""
84+
try:
85+
user_data = await self.client.get_user_from_token(token)
86+
if not user_data:
87+
return None
88+
89+
# Determine provider from user metadata or default to spotify
90+
provider = user_data.get("app_metadata", {}).get("provider", "spotify")
91+
92+
user = self.mappers.user_from_supabase(user_data)
93+
logger.debug(f"Session token verified for user: {user.email}")
94+
return user
95+
except Exception as e:
96+
logger.warning(f"Session token verification failed: {str(e)}")
97+
return None
98+
99+
async def get_current_session(self, token: str) -> AuthSession | None:
100+
"""Get current session information."""
101+
try:
102+
# Set token and get session
103+
session_data = await self.client.get_session()
104+
if not session_data:
105+
return None
106+
107+
provider = "spotify" # Should be determined from session data
108+
session = self.mappers.session_from_supabase(session_data, provider)
109+
110+
logger.debug("Current session retrieved successfully")
111+
return session
112+
except Exception as e:
113+
logger.warning(f"Failed to get current session: {str(e)}")
114+
return None
115+
116+
async def revoke_session(self, token: str) -> bool:
117+
"""Revoke a session (logout)."""
118+
try:
119+
result = await self.client.sign_out()
120+
logger.info("Session revoked successfully")
121+
return result
122+
except Exception as e:
123+
logger.warning(f"Session revocation failed: {str(e)}")
124+
# Be permissive with logout
125+
return True
126+
127+
# User management
128+
async def get_user_by_auth_id(self, auth_id: str) -> User | None:
129+
"""Get user by auth provider ID."""
130+
try:
131+
# This would typically query the Supabase database
132+
# For now, we'll implement a simple approach
133+
logger.debug(f"Getting user by auth_id: {auth_id}")
134+
# TODO: Implement user lookup by auth_id from Supabase database
135+
return None
136+
except Exception as e:
137+
logger.error(f"User lookup by auth_id failed: {str(e)}")
138+
return None
139+
140+
# Fallback email/password methods (optional)
141+
async def register_user(self, request: RegisterRequest) -> User:
142+
"""Register a new user with email/password (fallback)."""
143+
try:
144+
response = await self.client.sign_up_with_email(
145+
request.email, request.password
146+
)
147+
148+
if not response.get("user"):
149+
raise AuthenticationError("Registration failed: No user returned")
150+
151+
user = self.mappers.user_from_supabase(response["user"])
152+
logger.info(f"User registered successfully: {user.email}")
153+
return user
154+
except Exception as e:
155+
logger.error(f"Registration failed: {str(e)}")
156+
if isinstance(e, AuthenticationError):
157+
raise
158+
raise AuthenticationError(f"Registration failed: {str(e)}")
159+
160+
async def authenticate_user(self, request: LoginRequest) -> User:
161+
"""Authenticate user with email/password (fallback)."""
162+
try:
163+
response = await self.client.sign_in_with_email(
164+
request.email, request.password
165+
)
166+
167+
if not response.get("user"):
168+
raise AuthenticationError("Authentication failed: Invalid credentials")
169+
170+
user = self.mappers.user_from_supabase(response["user"])
171+
logger.info(f"User authenticated successfully: {user.email}")
172+
return user
173+
except Exception as e:
174+
logger.error(f"Authentication failed: {str(e)}")
175+
if isinstance(e, AuthenticationError):
176+
raise
177+
raise AuthenticationError("Invalid email or password")
178+
179+
async def generate_tokens(self, user: User, remember_me: bool = False) -> TokenPair:
180+
"""Generate access and refresh tokens for user."""
181+
try:
182+
token_data = self.client.generate_jwt_tokens(
183+
user.auth_id or user.id, remember_me
184+
)
185+
tokens = self.mappers.tokens_from_data(token_data)
186+
logger.debug(f"Tokens generated for user: {user.email}")
187+
return tokens
188+
except Exception as e:
189+
logger.error(f"Token generation failed: {str(e)}")
190+
raise AuthenticationError(f"Token generation failed: {str(e)}")
191+
192+
async def verify_access_token(self, token: str) -> User | None:
193+
"""Verify access token and return user."""
194+
try:
195+
user_data = await self.client.get_user_from_token(token)
196+
if not user_data:
197+
return None
198+
199+
user = self.mappers.user_from_supabase(user_data)
200+
logger.debug(f"Access token verified for user: {user.email}")
201+
return user
202+
except Exception as e:
203+
logger.warning(f"Access token verification failed: {str(e)}")
204+
return None
205+
206+
async def verify_refresh_token(self, token: str) -> User | None:
207+
"""Verify refresh token and return user."""
208+
try:
209+
# For refresh tokens, we verify the JWT structure first
210+
user_data = await self.client.get_user_from_token(token)
211+
if not user_data:
212+
return None
213+
214+
user = self.mappers.user_from_supabase(user_data)
215+
logger.debug(f"Refresh token verified for user: {user.email}")
216+
return user
217+
except Exception as e:
218+
logger.warning(f"Refresh token verification failed: {str(e)}")
219+
return None
220+
221+
async def refresh_tokens(self, refresh_token: str) -> TokenPair:
222+
"""Generate new tokens from refresh token."""
223+
try:
224+
# First verify the refresh token
225+
user = await self.verify_refresh_token(refresh_token)
226+
if not user:
227+
raise AuthenticationError("Invalid refresh token")
228+
229+
# Generate new tokens
230+
token_data = self.client.generate_jwt_tokens(user.auth_id or user.id)
231+
tokens = self.mappers.tokens_from_data(token_data)
232+
logger.info(f"Tokens refreshed for user: {user.email}")
233+
return tokens
234+
except Exception as e:
235+
logger.error(f"Token refresh failed: {str(e)}")
236+
if isinstance(e, AuthenticationError):
237+
raise
238+
raise AuthenticationError(f"Token refresh failed: {str(e)}")
239+
240+
async def revoke_token(self, token: str) -> bool:
241+
"""Revoke a token (logout)."""
242+
try:
243+
result = await self.client.sign_out(token)
244+
logger.info("Token revoked successfully")
245+
return result
246+
except Exception as e:
247+
logger.warning(f"Token revocation failed: {str(e)}")
248+
# Be permissive with logout
249+
return True

0 commit comments

Comments
 (0)