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 json
import logging import logging
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@ -24,13 +23,14 @@ from app.domains.auth.jwt import (
_verify_jwt, _verify_jwt,
generate_nonce, 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.passwords import hash_password, verify_password
from app.domains.auth.schemas import ( from app.domains.auth.schemas import (
EmailLoginRequest, EmailLoginRequest,
EmailRegisterRequest, EmailRegisterRequest,
GoogleAuthResponse, GoogleAuthResponse as GoogleAuthResponse,
NonceResponse, NonceResponse,
TelegramAuthRequest, TelegramAuthRequest as TelegramAuthRequest,
TwoFAEnableRequest, TwoFAEnableRequest,
TwoFALoginRequest, TwoFALoginRequest,
TwoFASetupResponse as TwoFASetupResponse, TwoFASetupResponse as TwoFASetupResponse,
@ -68,6 +68,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
# Include OAuth/Telegram routes from dedicated submodule
router.include_router(oauth_router, tags=["auth"])
# ── Config ── # ── 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 ── # ── 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. 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 from __future__ import annotations
github_auth_url,
github_callback, import json
google_auth_url, import os
google_callback, from datetime import datetime
google_callback_post,
telegram_auth, from fastapi import APIRouter, HTTPException, Request
x_auth_url,
x_callback, 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")