refactor(auth): split OAuth + Telegram endpoints to app.domains.auth.oauth (P4.3 cont)

This commit is contained in:
Crypto Rug Munch 2026-07-07 14:50:16 +07:00
parent b31b564f36
commit f5e1e140dc
2 changed files with 414 additions and 520 deletions

View file

@ -4,7 +4,6 @@ Auth Router - Complete authentication system (email, wallet, OAuth, Telegram)
import json
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Request
@ -24,13 +23,14 @@ from app.domains.auth.jwt import (
_verify_jwt,
generate_nonce,
)
from app.domains.auth.oauth import router as oauth_router
from app.domains.auth.passwords import hash_password, verify_password
from app.domains.auth.schemas import (
EmailLoginRequest,
EmailRegisterRequest,
GoogleAuthResponse,
GoogleAuthResponse as GoogleAuthResponse,
NonceResponse,
TelegramAuthRequest,
TelegramAuthRequest as TelegramAuthRequest,
TwoFAEnableRequest,
TwoFALoginRequest,
TwoFASetupResponse as TwoFASetupResponse,
@ -68,6 +68,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])
# Include OAuth/Telegram routes from dedicated submodule
router.include_router(oauth_router, tags=["auth"])
# ── Config ──
@ -271,507 +274,6 @@ async def user_me(request: Request):
}
# ── Google OAuth ──
@router.get("/google/url", response_model=GoogleAuthResponse)
async def google_auth_url():
"""Get Google OAuth URL."""
client_id = os.getenv("GOOGLE_CLIENT_ID")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id:
return {"url": "/auth/callback?error=google_not_configured"}
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "consent",
}
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return {"url": url}
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://rugmunch.io")
@router.get("/google/callback")
async def google_callback(request: Request):
"""Handle Google OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
from fastapi.responses import RedirectResponse
code = request.query_params.get("code")
error = request.query_params.get("error")
if error:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error={error}")
if not code:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
import httpx
client_id = os.getenv("GOOGLE_CLIENT_ID")
client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id or not client_secret:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=not_configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=token_exchange_failed")
tokens = resp.json()
access_token = tokens.get("access_token")
resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=userinfo_failed")
google_user = resp.json()
email = google_user.get("email")
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": google_user.get("name", email),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
# Redirect to frontend with token
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?token={jwt_token}&provider=google")
@router.post("/google/callback", response_model=WalletAuthResponse)
async def google_callback_post(request: Request):
"""Legacy POST endpoint for manual code exchange."""
body = await request.json()
code = body.get("code")
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
import httpx
client_id = os.getenv("GOOGLE_CLIENT_ID")
client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id or not client_secret:
raise HTTPException(status_code=500, detail="Google OAuth not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to exchange code")
tokens = resp.json()
access_token = tokens.get("access_token")
resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to get user info")
google_user = resp.json()
email = google_user.get("email")
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": google_user.get("name", email),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return {
"access_token": jwt_token,
"refresh_token": jwt_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", email),
"wallet_address": None,
"wallet_chain": None,
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
}
# ── Telegram Auth ──
@router.post("/telegram", response_model=WalletAuthResponse)
async def telegram_auth(req: TelegramAuthRequest):
"""Authenticate via Telegram Web App data."""
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
if not bot_token:
raise HTTPException(status_code=500, detail="Telegram auth not configured")
data_check = {
"id": str(req.id),
"auth_date": str(req.auth_date),
}
if req.first_name:
data_check["first_name"] = req.first_name
if req.last_name:
data_check["last_name"] = req.last_name
if req.username:
data_check["username"] = req.username
if req.photo_url:
data_check["photo_url"] = req.photo_url
import hashlib
import hmac
sorted_data = "\n".join(f"{k}={v}" for k, v in sorted(data_check.items()))
secret = hashlib.sha256(bot_token.encode()).digest()
expected_hash = hmac.new(secret, sorted_data.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(req.hash, expected_hash):
raise HTTPException(status_code=401, detail="Invalid Telegram auth data")
telegram_user_id = f"tg:{req.id}"
user = _get_user(telegram_user_id)
if not user:
display_name = (
f"{req.first_name or ''} {req.last_name or ''}".strip()
or req.username
or f"User{req.id}"
)
email = f"{req.id}@telegram.rmi"
user = {
"id": telegram_user_id,
"email": email,
"display_name": display_name,
"telegram_id": req.id,
"telegram_username": req.username,
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", telegram_user_id, json.dumps(user))
r.hset("rmi:users:telegram", str(req.id), telegram_user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return {
"access_token": jwt_token,
"refresh_token": jwt_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name"),
"wallet_address": None,
"wallet_chain": None,
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
}
# ── GitHub OAuth ──
@router.get("/github/url", response_model=GoogleAuthResponse)
async def github_auth_url():
"""Get GitHub OAuth URL."""
client_id = os.getenv("GITHUB_CLIENT_ID")
redirect_uri = os.getenv(
"GITHUB_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/github/callback"
)
if not client_id:
return {"url": "/auth/callback?error=github_not_configured"}
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": "user:email",
}
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return {"url": url}
@router.get("/github/callback")
async def github_callback(request: Request):
"""Handle GitHub OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
from fastapi.responses import RedirectResponse
code = request.query_params.get("code")
error = request.query_params.get("error")
if error:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error={error}")
if not code:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
import httpx
client_id = os.getenv("GITHUB_CLIENT_ID")
client_secret = os.getenv("GITHUB_CLIENT_SECRET")
redirect_uri = os.getenv(
"GITHUB_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/github/callback"
)
if not client_id or not client_secret:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=not_configured")
async with httpx.AsyncClient() as client:
# Exchange code for access token
resp = await client.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=token_exchange_failed")
tokens = resp.json()
access_token = tokens.get("access_token")
# Get user info
resp = await client.get(
"https://api.github.com/user",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=userinfo_failed")
github_user = resp.json()
email = github_user.get("email")
# If no public email, fetch emails endpoint
if not email:
resp = await client.get(
"https://api.github.com/user/emails",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
if resp.status_code == 200:
emails = resp.json()
primary = next((e for e in emails if e.get("primary")), None)
email = (
primary.get("email")
if primary
else (emails[0].get("email") if emails else None)
)
if not email:
email = f"{github_user.get('login', 'github_user')}@github.rmi"
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": github_user.get("name", github_user.get("login", email)),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?token={jwt_token}&provider=github")
# ── X/Twitter OAuth ──
@router.get("/x/url", response_model=GoogleAuthResponse)
async def x_auth_url():
"""Get X/Twitter OAuth URL."""
client_id = os.getenv("X_CLIENT_ID")
redirect_uri = os.getenv("X_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/x/callback")
if not client_id:
return {"url": "/auth/callback?error=x_not_configured"}
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "tweet.read users.read",
}
url = f"https://twitter.com/i/oauth2/authorize?{urlencode(params)}"
return {"url": url}
@router.get("/x/callback")
async def x_callback(request: Request):
"""Handle X/Twitter OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
from fastapi.responses import RedirectResponse
code = request.query_params.get("code")
error = request.query_params.get("error")
if error:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error={error}")
if not code:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
import httpx
client_id = os.getenv("X_CLIENT_ID")
client_secret = os.getenv("X_CLIENT_SECRET")
redirect_uri = os.getenv("X_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/x/callback")
if not client_id or not client_secret:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=not_configured")
async with httpx.AsyncClient() as client:
# X uses OAuth 2.0 - exchange code for token
resp = await client.post(
"https://api.twitter.com/2/oauth2/token",
data={
"code": code,
"grant_type": "authorization_code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_verifier": "challenge", # X requires PKCE - we need to implement proper PKCE
},
auth=(client_id, client_secret),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=token_exchange_failed")
tokens = resp.json()
access_token = tokens.get("access_token")
# Get user info from X API
resp = await client.get(
"https://api.twitter.com/2/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=userinfo_failed")
x_user = resp.json().get("data", {})
username = x_user.get("username", f"x_user_{x_user.get('id', 'unknown')}")
email = f"{username}@x.rmi" # X API v2 doesn't always return email
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": x_user.get("name", username),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?token={jwt_token}&provider=x")
# ── 2FA / TOTP Endpoints ──

View file

@ -1,20 +1,412 @@
"""OAuth flows - re-exports from app.domains.auth.
"""OAuth + Telegram auth endpoints.
Phase 3B of AUDIT-2026-Q3.md.
Public API:
- google_auth_url, google_callback, google_callback_post
- github_auth_url, github_callback
- x_auth_url, x_callback
- telegram_auth
"""
from app.auth import ( # noqa: F401
github_auth_url,
github_callback,
google_auth_url,
google_callback,
google_callback_post,
telegram_auth,
x_auth_url,
x_callback,
from __future__ import annotations
import json
import os
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from app.core.redis import get_redis
from app.domains.auth.jwt import _create_jwt, _derive_user_id
from app.domains.auth.schemas import (
GoogleAuthResponse,
TelegramAuthRequest,
WalletAuthResponse,
)
from app.domains.auth.store import _get_user, _get_user_by_email
router = APIRouter(tags=["auth"])
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://rugmunch.io")
@router.get("/google/url", response_model=GoogleAuthResponse)
async def google_auth_url():
"""Get Google OAuth URL."""
client_id = os.getenv("GOOGLE_CLIENT_ID")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id:
return {"url": "/auth/callback?error=google_not_configured"}
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "consent",
}
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return {"url": url}
@router.get("/google/callback")
async def google_callback(request: Request):
"""Handle Google OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
from fastapi.responses import RedirectResponse
code = request.query_params.get("code")
error = request.query_params.get("error")
if error:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error={error}")
if not code:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
import httpx
client_id = os.getenv("GOOGLE_CLIENT_ID")
client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id or not client_secret:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=not_configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=token_exchange_failed")
tokens = resp.json()
access_token = tokens.get("access_token")
resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=userinfo_failed")
google_user = resp.json()
email = google_user.get("email")
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": google_user.get("name", email),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?token={jwt_token}&provider=google")
@router.post("/google/callback", response_model=WalletAuthResponse)
async def google_callback_post(request: Request):
"""Legacy POST endpoint for manual code exchange."""
body = await request.json()
code = body.get("code")
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
import httpx
client_id = os.getenv("GOOGLE_CLIENT_ID")
client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
redirect_uri = os.getenv(
"GOOGLE_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/google/callback"
)
if not client_id or not client_secret:
raise HTTPException(status_code=500, detail="Google OAuth not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to exchange code")
tokens = resp.json()
access_token = tokens.get("access_token")
resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to get user info")
google_user = resp.json()
email = google_user.get("email")
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": google_user.get("name", email),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return {
"access_token": jwt_token,
"refresh_token": jwt_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", email),
"wallet_address": None,
"wallet_chain": None,
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
}
@router.post("/telegram", response_model=WalletAuthResponse)
async def telegram_auth(req: TelegramAuthRequest):
"""Authenticate via Telegram Web App data."""
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
if not bot_token:
raise HTTPException(status_code=500, detail="Telegram auth not configured")
data_check = {
"id": str(req.id),
"auth_date": str(req.auth_date),
}
if req.first_name:
data_check["first_name"] = req.first_name
if req.last_name:
data_check["last_name"] = req.last_name
if req.username:
data_check["username"] = req.username
if req.photo_url:
data_check["photo_url"] = req.photo_url
import hashlib
import hmac
sorted_data = "\n".join(f"{k}={v}" for k, v in sorted(data_check.items()))
secret = hashlib.sha256(bot_token.encode()).digest()
expected_hash = hmac.new(secret, sorted_data.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(req.hash, expected_hash):
raise HTTPException(status_code=401, detail="Invalid Telegram auth data")
telegram_user_id = f"tg:{req.id}"
user = _get_user(telegram_user_id)
if not user:
display_name = (
f"{req.first_name or ''} {req.last_name or ''}".strip()
or req.username
or f"User{req.id}"
)
email = f"{req.id}@telegram.rmi"
user = {
"id": telegram_user_id,
"email": email,
"display_name": display_name,
"telegram_id": req.id,
"telegram_username": req.username,
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", telegram_user_id, json.dumps(user))
r.hset("rmi:users:telegram", str(req.id), telegram_user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return {
"access_token": jwt_token,
"refresh_token": jwt_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name"),
"wallet_address": None,
"wallet_chain": None,
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
}
@router.get("/github/url", response_model=GoogleAuthResponse)
async def github_auth_url():
"""Get GitHub OAuth URL."""
client_id = os.getenv("GITHUB_CLIENT_ID")
redirect_uri = os.getenv(
"GITHUB_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/github/callback"
)
if not client_id:
return {"url": "/auth/callback?error=github_not_configured"}
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": "user:email",
}
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return {"url": url}
@router.get("/github/callback")
async def github_callback(request: Request):
"""Handle GitHub OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
from fastapi.responses import RedirectResponse
code = request.query_params.get("code")
error = request.query_params.get("error")
if error:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error={error}")
if not code:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
import httpx
client_id = os.getenv("GITHUB_CLIENT_ID")
client_secret = os.getenv("GITHUB_CLIENT_SECRET")
redirect_uri = os.getenv(
"GITHUB_REDIRECT_URI", "https://rugmunch.io/api/v1/auth/github/callback"
)
if not client_id or not client_secret:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=not_configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=token_exchange_failed")
tokens = resp.json()
access_token = tokens.get("access_token")
resp = await client.get(
"https://api.github.com/user",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
if resp.status_code != 200:
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=userinfo_failed")
github_user = resp.json()
email = github_user.get("email")
if not email:
resp = await client.get(
"https://api.github.com/user/emails",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
if resp.status_code == 200:
emails = resp.json()
primary = next((e for e in emails if e.get("primary")), None)
email = (
primary.get("email")
if primary
else (emails[0].get("email") if emails else None)
)
if not email:
email = f"{github_user.get('login', 'github_user')}@github.rmi"
user = _get_user_by_email(email)
if not user:
user_id = _derive_user_id(email)
user = {
"id": user_id,
"email": email,
"display_name": github_user.get("name", github_user.get("login", email)),
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
r = get_redis()
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", email.lower(), user_id)
jwt_token = _create_jwt(
user["id"], user["email"], user.get("tier", "FREE"), user.get("role", "USER")
)
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?token={jwt_token}&provider=github")