Skip to content

Commit e1111ef

Browse files
Merge pull request #81 from smokeyScraper/supabase_config
[feat]: refactor supabase database service with user linking to github
2 parents 8d877d2 + 4a05d78 commit e1111ef

File tree

12 files changed

+1079
-1192
lines changed

12 files changed

+1079
-1192
lines changed

backend/.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
# PORT=8000
33
# CORS_ORIGINS=http://localhost:3000
44

5-
# SUPABASE_URL=
6-
# SUPABASE_SERVICE_ROLE_KEY=
5+
SUPABASE_URL=
6+
SUPABASE_SERVICE_ROLE_KEY=
77

88
DISCORD_BOT_TOKEN=
99
# ENABLE_DISCORD_BOT=true
@@ -12,6 +12,8 @@ DISCORD_BOT_TOKEN=
1212
# EMBEDDING_MAX_BATCH_SIZE=32
1313
# EMBEDDING_DEVICE=cpu
1414

15+
BACKEND_URL=
16+
1517
GEMINI_API_KEY=
1618
TAVILY_API_KEY=
1719

backend/app/api/v1/auth.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
from fastapi import APIRouter, Request, HTTPException, Query
2+
from fastapi.responses import HTMLResponse
3+
from app.db.supabase.supabase_client import get_supabase_client
4+
from app.db.supabase.users_service import find_user_by_session_and_verify, get_verification_session_info
5+
from typing import Optional
6+
import logging
7+
8+
logger = logging.getLogger(__name__)
9+
router = APIRouter()
10+
11+
@router.get("/callback", response_class=HTMLResponse)
12+
async def auth_callback(request: Request, code: Optional[str] = Query(None), session: Optional[str] = Query(None)):
13+
"""
14+
Handles the OAuth callback from Supabase after a user authorizes on GitHub.
15+
"""
16+
logger.info(
17+
f"OAuth callback received with code: {'[PRESENT]' if code else '[MISSING]'}, session: {'[PRESENT]' if session else '[MISSING]'}")
18+
19+
if not code:
20+
logger.error("Missing authorization code in callback")
21+
return _error_response("Missing authorization code. Please try the verification process again.")
22+
23+
if not session:
24+
logger.error("Missing session ID in callback")
25+
return _error_response("Missing session ID. Please try the !verify_github command again.")
26+
27+
# Check if session is valid and not expired
28+
session_info = await get_verification_session_info(session)
29+
if not session_info:
30+
logger.error(f"Invalid or expired session ID: {session}")
31+
return _error_response("Your verification session has expired. Please run the !verify_github command again.")
32+
33+
supabase = get_supabase_client()
34+
try:
35+
# Exchange code for session
36+
logger.info("Exchanging authorization code for session")
37+
session_response = await supabase.auth.exchange_code_for_session({
38+
"auth_code": code,
39+
})
40+
41+
if not session_response or not session_response.user:
42+
logger.error("Failed to exchange code for session")
43+
return _error_response("Authentication failed. Could not retrieve user session.")
44+
45+
user = session_response.user
46+
logger.info(f"Successfully got user session for user: {user.id}")
47+
48+
# Extract GitHub info from user metadata
49+
github_id = user.user_metadata.get("provider_id")
50+
github_username = user.user_metadata.get("user_name")
51+
email = user.email
52+
53+
if not github_id or not github_username:
54+
logger.error(f"Missing GitHub details - ID: {github_id}, Username: {github_username}")
55+
return _error_response("Could not retrieve GitHub details from user session.")
56+
57+
# Verify user using session ID
58+
logger.info(f"Verifying user with session ID: {session}")
59+
verified_user = await find_user_by_session_and_verify(
60+
session_id=session,
61+
github_id=str(github_id),
62+
github_username=github_username,
63+
email=email
64+
)
65+
66+
if not verified_user:
67+
logger.error("User verification failed - no pending verification found")
68+
return _error_response("No pending verification found or verification has expired. Please try the !verify_github command again.")
69+
70+
logger.info(f"Successfully verified user: {verified_user.id}")
71+
return _success_response(github_username)
72+
73+
except Exception as e:
74+
logger.error(f"Unexpected error in OAuth callback: {str(e)}", exc_info=True)
75+
76+
# Handle specific error cases
77+
if "already linked" in str(e):
78+
return _error_response(f"Error: {str(e)}")
79+
80+
return _error_response("An unexpected error occurred during verification. Please try again.")
81+
82+
@router.get("/session/{session_id}")
83+
async def get_session_status(session_id: str):
84+
"""Get the status of a verification session"""
85+
session_info = await get_verification_session_info(session_id)
86+
if not session_info:
87+
raise HTTPException(status_code=404, detail="Session not found or expired")
88+
89+
return {
90+
"valid": True,
91+
"discord_id": session_info["discord_id"],
92+
"expiry_time": session_info["expiry_time"],
93+
"time_remaining": session_info["time_remaining"]
94+
}
95+
96+
def _success_response(github_username: str) -> str:
97+
"""Generate success HTML response"""
98+
return f"""
99+
<!DOCTYPE html>
100+
<html>
101+
<head>
102+
<title>Verification Successful!</title>
103+
<meta charset="utf-8">
104+
<meta name="viewport" content="width=device-width, initial-scale=1">
105+
<style>
106+
body {{
107+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
108+
display: flex;
109+
justify-content: center;
110+
align-items: center;
111+
min-height: 100vh;
112+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
113+
margin: 0;
114+
padding: 20px;
115+
box-sizing: border-box;
116+
}}
117+
.container {{
118+
text-align: center;
119+
padding: 40px;
120+
background: white;
121+
border-radius: 16px;
122+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
123+
max-width: 500px;
124+
width: 100%;
125+
}}
126+
h1 {{
127+
color: #28a745;
128+
margin-bottom: 20px;
129+
font-size: 2rem;
130+
}}
131+
.github-info {{
132+
background: #f8f9fa;
133+
padding: 20px;
134+
border-radius: 8px;
135+
margin: 20px 0;
136+
border-left: 4px solid #28a745;
137+
}}
138+
code {{
139+
background: #e9ecef;
140+
padding: 4px 8px;
141+
border-radius: 4px;
142+
font-family: 'Monaco', 'Consolas', monospace;
143+
color: #495057;
144+
font-weight: bold;
145+
}}
146+
.close-btn {{
147+
margin-top: 20px;
148+
padding: 12px 24px;
149+
background: #007bff;
150+
color: white;
151+
border: none;
152+
border-radius: 6px;
153+
cursor: pointer;
154+
font-size: 16px;
155+
transition: background-color 0.3s;
156+
}}
157+
.close-btn:hover {{
158+
background: #0056b3;
159+
}}
160+
.success-icon {{
161+
font-size: 4rem;
162+
margin-bottom: 20px;
163+
}}
164+
.auto-close {{
165+
margin-top: 15px;
166+
color: #6c757d;
167+
font-size: 0.9rem;
168+
}}
169+
</style>
170+
</head>
171+
<body>
172+
<div class="container">
173+
<div class="success-icon">✅</div>
174+
<h1>Verification Successful!</h1>
175+
<div class="github-info">
176+
<p><strong>Your Discord account has been successfully linked!</strong></p>
177+
<p>GitHub User: <code>{github_username}</code></p>
178+
</div>
179+
<p>You can now access all features that require GitHub authentication.</p>
180+
<button class="close-btn" onclick="window.close()">Close Window</button>
181+
<p class="auto-close">This window will close automatically in 5 seconds.</p>
182+
</div>
183+
<script>
184+
// Auto-close after 5 seconds
185+
setTimeout(() => {{
186+
window.close();
187+
}}, 5000);
188+
</script>
189+
</body>
190+
</html>
191+
"""
192+
193+
def _error_response(error_message: str) -> str:
194+
"""Generate error HTML response"""
195+
return f"""
196+
<!DOCTYPE html>
197+
<html>
198+
<head>
199+
<title>Verification Failed</title>
200+
<meta charset="utf-8">
201+
<meta name="viewport" content="width=device-width, initial-scale=1">
202+
<style>
203+
body {{
204+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
205+
display: flex;
206+
justify-content: center;
207+
align-items: center;
208+
min-height: 100vh;
209+
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
210+
margin: 0;
211+
padding: 20px;
212+
box-sizing: border-box;
213+
}}
214+
.container {{
215+
text-align: center;
216+
padding: 40px;
217+
background: white;
218+
border-radius: 16px;
219+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
220+
max-width: 500px;
221+
width: 100%;
222+
}}
223+
h1 {{
224+
color: #dc3545;
225+
margin-bottom: 20px;
226+
font-size: 2rem;
227+
}}
228+
.error-message {{
229+
background: #f8d7da;
230+
color: #721c24;
231+
padding: 20px;
232+
border-radius: 8px;
233+
margin: 20px 0;
234+
border-left: 4px solid #dc3545;
235+
}}
236+
.close-btn {{
237+
margin-top: 20px;
238+
padding: 12px 24px;
239+
background: #dc3545;
240+
color: white;
241+
border: none;
242+
border-radius: 6px;
243+
cursor: pointer;
244+
font-size: 16px;
245+
transition: background-color 0.3s;
246+
}}
247+
.close-btn:hover {{
248+
background: #c82333;
249+
}}
250+
.error-icon {{
251+
font-size: 4rem;
252+
margin-bottom: 20px;
253+
}}
254+
.help-text {{
255+
color: #6c757d;
256+
font-size: 0.9rem;
257+
margin-top: 15px;
258+
}}
259+
</style>
260+
</head>
261+
<body>
262+
<div class="container">
263+
<div class="error-icon">❌</div>
264+
<h1>Verification Failed</h1>
265+
<div class="error-message">
266+
<p>{error_message}</p>
267+
</div>
268+
<p>Please return to Discord and try the <code>!verify_github</code> command again.</p>
269+
<button class="close-btn" onclick="window.close()">Close Window</button>
270+
<p class="help-text">If you continue to experience issues, please contact support.</p>
271+
</div>
272+
</body>
273+
</html>
274+
"""

backend/app/core/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
3232
agent_timeout: int = 30
3333
max_retries: int = 3
3434

35+
# Backend URL
36+
backend_url: str = ""
37+
3538
@field_validator("supabase_url", "supabase_key", mode="before")
3639
@classmethod
3740
def _not_empty(cls, v, field):

backend/app/db/supabase/auth.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,49 @@
1-
import asyncio
2-
from app.db.supabase.supabase_client import supabase_client
1+
from typing import Optional
2+
from app.db.supabase.supabase_client import get_supabase_client
3+
import logging
34

5+
logger = logging.getLogger(__name__)
46

5-
async def login_with_oauth(provider: str):
7+
async def login_with_oauth(provider: str, redirect_to: Optional[str] = None, state: Optional[str] = None):
8+
"""
9+
Generates an asynchronous OAuth sign-in URL.
10+
"""
11+
supabase = get_supabase_client()
612
try:
7-
result = await asyncio.to_thread(
8-
supabase_client.auth.sign_in_with_oauth,
9-
{"provider": provider}
10-
)
13+
options = {}
14+
if redirect_to:
15+
options['redirect_to'] = redirect_to
16+
if state:
17+
options['queryParams'] = {'state': state}
18+
19+
result = await supabase.auth.sign_in_with_oauth({
20+
"provider": provider,
21+
"options": options
22+
})
1123
return {"url": result.url}
1224
except Exception as e:
13-
raise Exception(f"OAuth login failed for {provider}") from e
25+
logger.error(f"OAuth login failed for provider {provider}: {e}", exc_info=True)
26+
raise
1427

15-
async def login_with_github():
16-
return await login_with_oauth("github")
28+
async def login_with_github(redirect_to: Optional[str] = None, state: Optional[str] = None):
29+
"""Generates a GitHub OAuth login URL."""
30+
return await login_with_oauth("github", redirect_to=redirect_to, state=state)
1731

18-
async def login_with_discord():
19-
return await login_with_oauth("discord")
32+
async def login_with_discord(redirect_to: Optional[str] = None):
33+
"""Generates a Discord OAuth login URL."""
34+
return await login_with_oauth("discord", redirect_to=redirect_to)
2035

21-
async def login_with_slack():
22-
return await login_with_oauth("slack")
36+
async def login_with_slack(redirect_to: Optional[str] = None):
37+
"""Generates a Slack OAuth login URL."""
38+
return await login_with_oauth("slack", redirect_to=redirect_to)
2339

2440
async def logout(access_token: str):
41+
"""Logs out a user by revoking their session."""
42+
supabase = get_supabase_client()
2543
try:
26-
supabase_client.auth.set_session(access_token, refresh_token="")
27-
supabase_client.auth.sign_out()
44+
await supabase.auth.set_session(access_token, refresh_token="")
45+
await supabase.auth.sign_out()
2846
return {"message": "User logged out successfully"}
2947
except Exception as e:
30-
raise Exception(f"Logout failed: {str(e)}")
48+
logger.error(f"Logout failed: {e}", exc_info=True)
49+
raise
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from app.core.config import settings
2-
from supabase import create_client
2+
from supabase._async.client import AsyncClient
33

4-
SUPABASE_URL = settings.supabase_url
5-
SUPABASE_KEY = settings.supabase_key
4+
supabase_client: AsyncClient = AsyncClient(
5+
settings.supabase_url,
6+
settings.supabase_key
7+
)
68

7-
supabase_client = create_client(SUPABASE_URL, SUPABASE_KEY)
8-
9-
10-
def get_supabase_client():
9+
def get_supabase_client() -> AsyncClient:
10+
"""
11+
Returns a shared asynchronous Supabase client instance.
12+
"""
1113
return supabase_client

0 commit comments

Comments
 (0)