This commit is contained in:
Slipstream 2025-05-03 15:38:10 -06:00
parent f498a3e308
commit 1c94b958f3
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD

View File

@ -74,15 +74,31 @@ settings = get_api_settings()
# --- Constants derived from settings --- # --- Constants derived from settings ---
DISCORD_API_BASE_URL = "https://discord.com/api/v10" DISCORD_API_BASE_URL = "https://discord.com/api/v10"
DISCORD_API_ENDPOINT = DISCORD_API_BASE_URL # Alias for backward compatibility DISCORD_API_ENDPOINT = DISCORD_API_BASE_URL # Alias for backward compatibility
DISCORD_AUTH_URL = (
# Define dashboard-specific redirect URI
DASHBOARD_REDIRECT_URI = f"{settings.DISCORD_REDIRECT_URI.split('/api')[0]}/dashboard/api/auth/callback"
# We'll generate the full auth URL with PKCE parameters in the dashboard_login function
# This is just a base URL without the PKCE parameters
DISCORD_AUTH_BASE_URL = (
f"https://discord.com/api/oauth2/authorize?client_id={settings.DISCORD_CLIENT_ID}" f"https://discord.com/api/oauth2/authorize?client_id={settings.DISCORD_CLIENT_ID}"
f"&redirect_uri={settings.DISCORD_REDIRECT_URI}&response_type=code&scope=identify guilds" f"&redirect_uri={settings.DISCORD_REDIRECT_URI}&response_type=code&scope=identify guilds"
) )
# Dashboard-specific auth base URL
DASHBOARD_AUTH_BASE_URL = (
f"https://discord.com/api/oauth2/authorize?client_id={settings.DISCORD_CLIENT_ID}"
f"&redirect_uri={DASHBOARD_REDIRECT_URI}&response_type=code&scope=identify guilds"
)
DISCORD_TOKEN_URL = f"{DISCORD_API_BASE_URL}/oauth2/token" DISCORD_TOKEN_URL = f"{DISCORD_API_BASE_URL}/oauth2/token"
DISCORD_USER_URL = f"{DISCORD_API_BASE_URL}/users/@me" DISCORD_USER_URL = f"{DISCORD_API_BASE_URL}/users/@me"
DISCORD_USER_GUILDS_URL = f"{DISCORD_API_BASE_URL}/users/@me/guilds" DISCORD_USER_GUILDS_URL = f"{DISCORD_API_BASE_URL}/users/@me/guilds"
DISCORD_REDIRECT_URI = settings.DISCORD_REDIRECT_URI # Make it accessible directly DISCORD_REDIRECT_URI = settings.DISCORD_REDIRECT_URI # Make it accessible directly
# For backward compatibility, keep DISCORD_AUTH_URL but it will be replaced in the dashboard_login function
DISCORD_AUTH_URL = DISCORD_AUTH_BASE_URL
# --- Gurt Stats Storage (IPC) --- # --- Gurt Stats Storage (IPC) ---
latest_gurt_stats: Optional[Dict[str, Any]] = None latest_gurt_stats: Optional[Dict[str, Any]] = None
@ -521,13 +537,41 @@ async def verify_dashboard_guild_admin(guild_id: int, current_user: dict = Depen
# --- Dashboard Authentication Routes --- # --- Dashboard Authentication Routes ---
@dashboard_api_app.get("/auth/login", tags=["Dashboard Authentication"]) @dashboard_api_app.get("/auth/login", tags=["Dashboard Authentication"])
async def dashboard_login(): async def dashboard_login():
"""Redirects the user to Discord for OAuth2 authorization (Dashboard Flow).""" """Redirects the user to Discord for OAuth2 authorization (Dashboard Flow) with PKCE."""
# Uses constants derived from settings loaded at the top import secrets
log.info(f"Dashboard: Redirecting user to Discord auth URL: {DISCORD_AUTH_URL}") import hashlib
return RedirectResponse(url=DISCORD_AUTH_URL, status_code=status.HTTP_307_TEMPORARY_REDIRECT) import base64
# Generate a random state for CSRF protection
state = secrets.token_urlsafe(32)
# Generate a code verifier for PKCE
code_verifier = secrets.token_urlsafe(64)
# Generate a code challenge from the code verifier
code_challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(code_challenge_bytes).decode().rstrip("=")
# Store the code verifier for later use
code_verifier_store.store_code_verifier(state, code_verifier)
# Build the authorization URL with PKCE parameters using the dashboard-specific redirect URI
auth_url = (
f"{DASHBOARD_AUTH_BASE_URL}"
f"&state={state}"
f"&code_challenge={code_challenge}"
f"&code_challenge_method=S256"
f"&prompt=consent"
)
log.info(f"Dashboard: Redirecting user to Discord auth URL with PKCE: {auth_url}")
log.info(f"Dashboard: Using redirect URI: {DASHBOARD_REDIRECT_URI}")
log.info(f"Dashboard: Stored code verifier for state {state}: {code_verifier[:10]}...")
return RedirectResponse(url=auth_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@dashboard_api_app.get("/auth/callback", tags=["Dashboard Authentication"]) @dashboard_api_app.get("/auth/callback", tags=["Dashboard Authentication"])
async def dashboard_auth_callback(request: Request, code: str | None = None, error: str | None = None): async def dashboard_auth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
"""Handles the callback from Discord after authorization (Dashboard Flow).""" """Handles the callback from Discord after authorization (Dashboard Flow)."""
global http_session # Use the global aiohttp session global http_session # Use the global aiohttp session
if error: if error:
@ -538,24 +582,45 @@ async def dashboard_auth_callback(request: Request, code: str | None = None, err
log.error("Dashboard: Discord OAuth callback missing code.") log.error("Dashboard: Discord OAuth callback missing code.")
return RedirectResponse(url="/dashboard?error=missing_code") return RedirectResponse(url="/dashboard?error=missing_code")
if not state:
log.error("Dashboard: Discord OAuth callback missing state parameter.")
return RedirectResponse(url="/dashboard?error=missing_state")
if not http_session: if not http_session:
log.error("Dashboard: aiohttp session not initialized.") log.error("Dashboard: aiohttp session not initialized.")
raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.") raise HTTPException(status_code=500, detail="Internal server error: HTTP session not ready.")
try: try:
# 1. Exchange code for access token # Get the code verifier from the store
code_verifier = code_verifier_store.get_code_verifier(state)
if not code_verifier:
log.error(f"Dashboard: No code_verifier found for state {state}")
return RedirectResponse(url="/dashboard?error=missing_code_verifier")
log.info(f"Dashboard: Found code_verifier for state {state}: {code_verifier[:10]}...")
# Remove the code verifier from the store after retrieving it
code_verifier_store.remove_code_verifier(state)
# 1. Exchange code for access token with PKCE
token_data = { token_data = {
'client_id': settings.DISCORD_CLIENT_ID, 'client_id': settings.DISCORD_CLIENT_ID,
'client_secret': settings.DISCORD_CLIENT_SECRET,
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'code': code, 'code': code,
'redirect_uri': settings.DISCORD_REDIRECT_URI # Must match exactly 'redirect_uri': DASHBOARD_REDIRECT_URI, # Must match exactly what was used in the auth request
'code_verifier': code_verifier # Add the code verifier for PKCE
} }
headers = {'Content-Type': 'application/x-www-form-urlencoded'} headers = {'Content-Type': 'application/x-www-form-urlencoded'}
log.debug(f"Dashboard: Exchanging code for token at {DISCORD_TOKEN_URL}") log.debug(f"Dashboard: Exchanging code for token at {DISCORD_TOKEN_URL} with PKCE")
log.debug(f"Dashboard: Token exchange data: {token_data}")
async with http_session.post(DISCORD_TOKEN_URL, data=token_data, headers=headers) as resp: async with http_session.post(DISCORD_TOKEN_URL, data=token_data, headers=headers) as resp:
resp.raise_for_status() if resp.status != 200:
error_text = await resp.text()
log.error(f"Dashboard: Failed to exchange code: {error_text}")
return RedirectResponse(url=f"/dashboard?error=token_exchange_failed&details={error_text}")
token_response = await resp.json() token_response = await resp.json()
access_token = token_response.get('access_token') access_token = token_response.get('access_token')
log.debug("Dashboard: Token exchange successful.") log.debug("Dashboard: Token exchange successful.")