Skip to content
6 changes: 4 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# PORT=8000
# CORS_ORIGINS=http://localhost:3000

# SUPABASE_URL=
# SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

DISCORD_BOT_TOKEN=
# ENABLE_DISCORD_BOT=true
Expand All @@ -12,6 +12,8 @@ DISCORD_BOT_TOKEN=
# EMBEDDING_MAX_BATCH_SIZE=32
# EMBEDDING_DEVICE=cpu

BACKEND_URL=

GEMINI_API_KEY=
TAVILY_API_KEY=

Expand Down
274 changes: 274 additions & 0 deletions backend/app/api/v1/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse
from app.db.supabase.supabase_client import get_supabase_client
from app.db.supabase.users_service import find_user_by_session_and_verify, get_verification_session_info
from typing import Optional
import logging

logger = logging.getLogger(__name__)
router = APIRouter()

@router.get("/callback", response_class=HTMLResponse)
async def auth_callback(request: Request, code: Optional[str] = Query(None), session: Optional[str] = Query(None)):
"""
Handles the OAuth callback from Supabase after a user authorizes on GitHub.
"""
logger.info(
f"OAuth callback received with code: {'[PRESENT]' if code else '[MISSING]'}, session: {'[PRESENT]' if session else '[MISSING]'}")

if not code:
logger.error("Missing authorization code in callback")
return _error_response("Missing authorization code. Please try the verification process again.")

if not session:
logger.error("Missing session ID in callback")
return _error_response("Missing session ID. Please try the !verify_github command again.")

# Check if session is valid and not expired
session_info = await get_verification_session_info(session)
if not session_info:
logger.error(f"Invalid or expired session ID: {session}")
return _error_response("Your verification session has expired. Please run the !verify_github command again.")

supabase = get_supabase_client()
try:
# Exchange code for session
logger.info("Exchanging authorization code for session")
session_response = await supabase.auth.exchange_code_for_session({
"auth_code": code,
})

if not session_response or not session_response.user:
logger.error("Failed to exchange code for session")
return _error_response("Authentication failed. Could not retrieve user session.")

user = session_response.user
logger.info(f"Successfully got user session for user: {user.id}")

# Extract GitHub info from user metadata
github_id = user.user_metadata.get("provider_id")
github_username = user.user_metadata.get("user_name")
email = user.email

if not github_id or not github_username:
logger.error(f"Missing GitHub details - ID: {github_id}, Username: {github_username}")
return _error_response("Could not retrieve GitHub details from user session.")

# Verify user using session ID
logger.info(f"Verifying user with session ID: {session}")
verified_user = await find_user_by_session_and_verify(
session_id=session,
github_id=str(github_id),
github_username=github_username,
email=email
)

if not verified_user:
logger.error("User verification failed - no pending verification found")
return _error_response("No pending verification found or verification has expired. Please try the !verify_github command again.")

logger.info(f"Successfully verified user: {verified_user.id}")
return _success_response(github_username)

except Exception as e:
logger.error(f"Unexpected error in OAuth callback: {str(e)}", exc_info=True)

# Handle specific error cases
if "already linked" in str(e):
return _error_response(f"Error: {str(e)}")

return _error_response("An unexpected error occurred during verification. Please try again.")

@router.get("/session/{session_id}")
async def get_session_status(session_id: str):
"""Get the status of a verification session"""
session_info = await get_verification_session_info(session_id)
if not session_info:
raise HTTPException(status_code=404, detail="Session not found or expired")

return {
"valid": True,
"discord_id": session_info["discord_id"],
"expiry_time": session_info["expiry_time"],
"time_remaining": session_info["time_remaining"]
}

def _success_response(github_username: str) -> str:
"""Generate success HTML response"""
return f"""
<!DOCTYPE html>
<html>
<head>
<title>Verification Successful!</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 20px;
box-sizing: border-box;
}}
.container {{
text-align: center;
padding: 40px;
background: white;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
max-width: 500px;
width: 100%;
}}
h1 {{
color: #28a745;
margin-bottom: 20px;
font-size: 2rem;
}}
.github-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #28a745;
}}
code {{
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', monospace;
color: #495057;
font-weight: bold;
}}
.close-btn {{
margin-top: 20px;
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}}
.close-btn:hover {{
background: #0056b3;
}}
.success-icon {{
font-size: 4rem;
margin-bottom: 20px;
}}
.auto-close {{
margin-top: 15px;
color: #6c757d;
font-size: 0.9rem;
}}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✅</div>
<h1>Verification Successful!</h1>
<div class="github-info">
<p><strong>Your Discord account has been successfully linked!</strong></p>
<p>GitHub User: <code>{github_username}</code></p>
</div>
<p>You can now access all features that require GitHub authentication.</p>
<button class="close-btn" onclick="window.close()">Close Window</button>
<p class="auto-close">This window will close automatically in 5 seconds.</p>
</div>
<script>
// Auto-close after 5 seconds
setTimeout(() => {{
window.close();
}}, 5000);
</script>
</body>
</html>
"""

def _error_response(error_message: str) -> str:
"""Generate error HTML response"""
return f"""
<!DOCTYPE html>
<html>
<head>
<title>Verification Failed</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
margin: 0;
padding: 20px;
box-sizing: border-box;
}}
.container {{
text-align: center;
padding: 40px;
background: white;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
max-width: 500px;
width: 100%;
}}
h1 {{
color: #dc3545;
margin-bottom: 20px;
font-size: 2rem;
}}
.error-message {{
background: #f8d7da;
color: #721c24;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #dc3545;
}}
.close-btn {{
margin-top: 20px;
padding: 12px 24px;
background: #dc3545;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}}
.close-btn:hover {{
background: #c82333;
}}
.error-icon {{
font-size: 4rem;
margin-bottom: 20px;
}}
.help-text {{
color: #6c757d;
font-size: 0.9rem;
margin-top: 15px;
}}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">❌</div>
<h1>Verification Failed</h1>
<div class="error-message">
<p>{error_message}</p>
</div>
<p>Please return to Discord and try the <code>!verify_github</code> command again.</p>
<button class="close-btn" onclick="window.close()">Close Window</button>
<p class="help-text">If you continue to experience issues, please contact support.</p>
</div>
</body>
</html>
"""
3 changes: 3 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class Settings(BaseSettings):
agent_timeout: int = 30
max_retries: int = 3

# Backend URL
backend_url: str = ""

@field_validator("supabase_url", "supabase_key", mode="before")
@classmethod
def _not_empty(cls, v, field):
Expand Down
53 changes: 36 additions & 17 deletions backend/app/db/supabase/auth.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
import asyncio
from app.db.supabase.supabase_client import supabase_client
from typing import Optional
from app.db.supabase.supabase_client import get_supabase_client
import logging

logger = logging.getLogger(__name__)

async def login_with_oauth(provider: str):
async def login_with_oauth(provider: str, redirect_to: Optional[str] = None, state: Optional[str] = None):
"""
Generates an asynchronous OAuth sign-in URL.
"""
supabase = get_supabase_client()
try:
result = await asyncio.to_thread(
supabase_client.auth.sign_in_with_oauth,
{"provider": provider}
)
options = {}
if redirect_to:
options['redirect_to'] = redirect_to
if state:
options['queryParams'] = {'state': state}

result = await supabase.auth.sign_in_with_oauth({
"provider": provider,
"options": options
})
return {"url": result.url}
except Exception as e:
raise Exception(f"OAuth login failed for {provider}") from e
logger.error(f"OAuth login failed for provider {provider}: {e}", exc_info=True)
raise

async def login_with_github():
return await login_with_oauth("github")
async def login_with_github(redirect_to: Optional[str] = None, state: Optional[str] = None):
"""Generates a GitHub OAuth login URL."""
return await login_with_oauth("github", redirect_to=redirect_to, state=state)

async def login_with_discord():
return await login_with_oauth("discord")
async def login_with_discord(redirect_to: Optional[str] = None):
"""Generates a Discord OAuth login URL."""
return await login_with_oauth("discord", redirect_to=redirect_to)

async def login_with_slack():
return await login_with_oauth("slack")
async def login_with_slack(redirect_to: Optional[str] = None):
"""Generates a Slack OAuth login URL."""
return await login_with_oauth("slack", redirect_to=redirect_to)

async def logout(access_token: str):
"""Logs out a user by revoking their session."""
supabase = get_supabase_client()
try:
supabase_client.auth.set_session(access_token, refresh_token="")
supabase_client.auth.sign_out()
await supabase.auth.set_session(access_token, refresh_token="")
await supabase.auth.sign_out()
return {"message": "User logged out successfully"}
except Exception as e:
raise Exception(f"Logout failed: {str(e)}")
logger.error(f"Logout failed: {e}", exc_info=True)
raise
16 changes: 9 additions & 7 deletions backend/app/db/supabase/supabase_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from app.core.config import settings
from supabase import create_client
from supabase._async.client import AsyncClient

SUPABASE_URL = settings.supabase_url
SUPABASE_KEY = settings.supabase_key
supabase_client: AsyncClient = AsyncClient(
settings.supabase_url,
settings.supabase_key
)

supabase_client = create_client(SUPABASE_URL, SUPABASE_KEY)


def get_supabase_client():
def get_supabase_client() -> AsyncClient:
"""
Returns a shared asynchronous Supabase client instance.
"""
return supabase_client
Loading