Skip to content

Commit 5c1bf6c

Browse files
committed
♻️ refactor: simplify auth system to spotify-only workflow
1 parent baa67b4 commit 5c1bf6c

13 files changed

Lines changed: 142 additions & 331 deletions

File tree

backend/src/adapters/auth/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Authentication adapters package."""
22

33
from src.adapters.auth.supabase import (
4+
SupabaseAuthMappers,
45
SupabaseAuthRepository,
56
SupabaseClient,
6-
SupabaseAuthMappers,
77
)
88

99
__all__ = [

backend/src/adapters/auth/supabase/auth_repository.py

Lines changed: 28 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -4,79 +4,76 @@
44
from src.adapters.auth.supabase.mappers import SupabaseAuthMappers
55
from src.core.config import AuthConfig, SupabaseConfig
66
from src.core.exceptions import AuthenticationError
7-
from src.domain.interfaces.auth_repository import AuthRepository
87
from src.domain.models.auth_session import (
98
AuthSession,
10-
LoginRequest,
11-
OAuthCallback,
12-
OAuthRequest,
13-
RegisterRequest,
14-
TokenPair,
9+
SpotifyOAuthCallback,
10+
SpotifyOAuthRequest,
1511
)
1612
from src.domain.models.user import User
1713
from src.infrastructure.logging import get_logger
1814

1915
logger = get_logger("adapters.auth.supabase.repository")
2016

2117

22-
class SupabaseAuthRepository(AuthRepository):
18+
class SupabaseAuthRepository:
2319
"""Supabase implementation of AuthRepository using OAuth."""
2420

2521
def __init__(self, supabase_config: SupabaseConfig, auth_config: AuthConfig):
2622
self.client = SupabaseClient(supabase_config, auth_config)
2723
self.mappers = SupabaseAuthMappers()
2824

29-
# OAuth methods (primary)
30-
async def get_oauth_url(self, request: OAuthRequest) -> str:
31-
"""Get OAuth authorization URL for the specified provider."""
25+
# Spotify OAuth methods
26+
async def get_spotify_oauth_url(self, request: SpotifyOAuthRequest) -> str:
27+
"""Get Spotify OAuth authorization URL."""
3228
try:
3329
return await self.client.get_oauth_url(
34-
provider=request.provider,
30+
provider="spotify",
3531
redirect_url=request.redirect_url,
3632
scopes=request.scopes,
3733
)
3834
except Exception as e:
39-
logger.error(f"OAuth URL generation failed: {str(e)}")
40-
raise AuthenticationError(f"OAuth URL generation failed: {str(e)}")
35+
logger.error(f"Spotify OAuth URL generation failed: {str(e)}")
36+
raise AuthenticationError(f"Spotify OAuth URL generation failed: {str(e)}")
4137

42-
async def exchange_oauth_code(
43-
self, callback: OAuthCallback
38+
async def exchange_spotify_oauth_code(
39+
self, callback: SpotifyOAuthCallback
4440
) -> tuple[User, AuthSession]:
45-
"""Exchange OAuth code for user and session."""
41+
"""Exchange Spotify OAuth code for user and session."""
4642
try:
4743
# Exchange the code for a session
4844
session_data = await self.client.exchange_oauth_code(callback.code)
4945

5046
if not session_data.get("user"):
51-
raise AuthenticationError("OAuth exchange failed: No user returned")
47+
raise AuthenticationError(
48+
"Spotify OAuth exchange failed: No user returned"
49+
)
5250

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)
51+
# Map to domain models - always Spotify
52+
user = self.mappers.user_from_oauth_session(session_data, "spotify")
53+
session = self.mappers.session_from_supabase(session_data, "spotify")
5754

58-
logger.info(f"OAuth exchange successful for user: {user.email}")
55+
logger.info(f"Spotify OAuth exchange successful for user: {user.email}")
5956
return user, session
6057
except Exception as e:
61-
logger.error(f"OAuth code exchange failed: {str(e)}")
58+
logger.error(f"Spotify OAuth code exchange failed: {str(e)}")
6259
if isinstance(e, AuthenticationError):
6360
raise
64-
raise AuthenticationError(f"OAuth exchange failed: {str(e)}")
61+
raise AuthenticationError(f"Spotify OAuth exchange failed: {str(e)}")
6562

66-
async def refresh_oauth_session(self, refresh_token: str) -> AuthSession:
67-
"""Refresh OAuth session using refresh token."""
63+
async def refresh_spotify_session(self, refresh_token: str) -> AuthSession:
64+
"""Refresh Spotify OAuth session using refresh token."""
6865
try:
6966
# Set the refresh token and refresh the session
7067
session_data = await self.client.refresh_session()
7168

72-
provider = "spotify" # Should be stored/determined from context
73-
session = self.mappers.session_from_supabase(session_data, provider)
69+
# Always Spotify
70+
session = self.mappers.session_from_supabase(session_data, "spotify")
7471

75-
logger.info("OAuth session refreshed successfully")
72+
logger.info("Spotify OAuth session refreshed successfully")
7673
return session
7774
except Exception as e:
78-
logger.error(f"OAuth session refresh failed: {str(e)}")
79-
raise AuthenticationError(f"Session refresh failed: {str(e)}")
75+
logger.error(f"Spotify OAuth session refresh failed: {str(e)}")
76+
raise AuthenticationError(f"Spotify session refresh failed: {str(e)}")
8077

8178
# Session management
8279
async def verify_session_token(self, token: str) -> User | None:
@@ -86,9 +83,6 @@ async def verify_session_token(self, token: str) -> User | None:
8683
if not user_data:
8784
return None
8885

89-
# Determine provider from user metadata or default to spotify
90-
provider = user_data.get("app_metadata", {}).get("provider", "spotify")
91-
9286
user = self.mappers.user_from_supabase(user_data)
9387
logger.debug(f"Session token verified for user: {user.email}")
9488
return user
@@ -136,114 +130,3 @@ async def get_user_by_auth_id(self, auth_id: str) -> User | None:
136130
except Exception as e:
137131
logger.error(f"User lookup by auth_id failed: {str(e)}")
138132
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

backend/src/adapters/auth/supabase/client.py

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Supabase client for OAuth authentication."""
22

3-
from supabase import Client, create_client
3+
from typing import Any
4+
45
from src.core.config import AuthConfig, SupabaseConfig
56
from src.infrastructure.logging import get_logger
7+
from supabase import Client, create_client
68

79
logger = get_logger("adapters.auth.supabase.client")
810

@@ -29,16 +31,17 @@ async def get_oauth_url(
2931
) -> str:
3032
"""Get OAuth authorization URL for the specified provider."""
3133
try:
32-
credentials = {"provider": provider}
34+
# Build OAuth credentials for Supabase
35+
credentials: dict[str, Any] = {"provider": provider}
3336
if redirect_url or scopes:
34-
options = {}
37+
options: dict[str, Any] = {}
3538
if redirect_url:
3639
options["redirect_to"] = redirect_url
3740
if scopes:
3841
options["scopes"] = scopes
3942
credentials["options"] = options
4043

41-
response = self.client.auth.sign_in_with_oauth(credentials)
44+
response = self.client.auth.sign_in_with_oauth(credentials) # type: ignore
4245

4346
if hasattr(response, "url") and response.url:
4447
logger.info(f"OAuth URL generated for provider: {provider}")
@@ -56,11 +59,11 @@ async def exchange_oauth_code(
5659
"""Exchange OAuth authorization code for session."""
5760
try:
5861
# Build code exchange parameters
59-
code_params = {"auth_code": code}
62+
code_params: dict[str, Any] = {"auth_code": code}
6063
if code_verifier:
6164
code_params["code_verifier"] = code_verifier
6265

63-
response = self.client.auth.exchange_code_for_session(code_params)
66+
response = self.client.auth.exchange_code_for_session(code_params) # type: ignore
6467
logger.info("OAuth code exchanged successfully")
6568
return response.model_dump()
6669
except Exception as e:
@@ -116,26 +119,3 @@ async def sign_out(self) -> bool:
116119
logger.warning(f"Sign out failed: {str(e)}")
117120
# Return True anyway - sign out should be permissive
118121
return True
119-
120-
# Fallback email/password methods
121-
async def sign_up_with_email(self, email: str, password: str) -> dict:
122-
"""Sign up user with email and password (fallback)."""
123-
try:
124-
response = self.client.auth.sign_up({"email": email, "password": password})
125-
logger.info(f"User signed up: {email}")
126-
return response.model_dump()
127-
except Exception as e:
128-
logger.error(f"Sign up failed for {email}: {str(e)}")
129-
raise
130-
131-
async def sign_in_with_email(self, email: str, password: str) -> dict:
132-
"""Sign in user with email and password (fallback)."""
133-
try:
134-
response = self.client.auth.sign_in_with_password(
135-
{"email": email, "password": password}
136-
)
137-
logger.info(f"User signed in: {email}")
138-
return response.model_dump()
139-
except Exception as e:
140-
logger.error(f"Sign in failed for {email}: {str(e)}")
141-
raise

backend/src/adapters/auth/supabase/mappers.py

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Mappers to convert Supabase data to domain models."""
22

33
from datetime import datetime
4-
from src.domain.models.auth_session import AuthSession, TokenPair
4+
5+
from src.domain.models.auth_session import AuthSession
56
from src.domain.models.user import User
67

78

@@ -38,36 +39,38 @@ def user_from_supabase(supabase_user: dict) -> User:
3839
is_active=True, # Supabase users are active by default
3940
)
4041

41-
@staticmethod
42-
def tokens_from_data(token_data: dict) -> TokenPair:
43-
"""Convert token data to TokenPair domain model."""
44-
return TokenPair(
45-
access_token=token_data["access_token"],
46-
refresh_token=token_data["refresh_token"],
47-
token_type="bearer",
48-
expires_in=token_data.get("expires_in", 900), # Default 15 minutes
49-
)
50-
51-
@staticmethod
52-
def tokens_from_supabase_session(session_data: dict) -> TokenPair:
53-
"""Convert Supabase session to TokenPair domain model."""
54-
session = session_data.get("session", {})
55-
return TokenPair(
56-
access_token=session.get("access_token", ""),
57-
refresh_token=session.get("refresh_token", ""),
58-
token_type="bearer",
59-
expires_in=session.get("expires_in", 3600), # Supabase default
60-
)
61-
6242
@staticmethod
6343
def session_from_supabase(session_data: dict, provider: str) -> AuthSession:
6444
"""Convert Supabase OAuth session to AuthSession domain model."""
6545
session = session_data.get("session", {})
6646
user = session_data.get("user", {})
6747

68-
# Extract provider token from session
69-
provider_token = session.get("provider_token")
70-
provider_refresh_token = session.get("provider_refresh_token")
48+
# Extract Spotify tokens - check multiple possible locations
49+
provider_token = None
50+
provider_refresh_token = None
51+
52+
# Method 1: Direct from session (most common)
53+
if session.get("provider_token"):
54+
provider_token = session.get("provider_token")
55+
provider_refresh_token = session.get("provider_refresh_token")
56+
57+
# Method 2: From user metadata (alternative location)
58+
elif user.get("user_metadata", {}).get("provider_token"):
59+
user_metadata = user.get("user_metadata", {})
60+
provider_token = user_metadata.get("provider_token")
61+
provider_refresh_token = user_metadata.get("provider_refresh_token")
62+
63+
# Method 3: From app metadata (admin/system tokens)
64+
elif user.get("app_metadata", {}).get("provider_token"):
65+
app_metadata = user.get("app_metadata", {})
66+
provider_token = app_metadata.get("provider_token")
67+
provider_refresh_token = app_metadata.get("provider_refresh_token")
68+
69+
# Method 4: From session user (nested structure)
70+
elif session.get("user", {}).get("user_metadata", {}).get("provider_token"):
71+
session_user_metadata = session.get("user", {}).get("user_metadata", {})
72+
provider_token = session_user_metadata.get("provider_token")
73+
provider_refresh_token = session_user_metadata.get("provider_refresh_token")
7174

7275
return AuthSession(
7376
user_id=user.get("id", ""),

0 commit comments

Comments
 (0)