- Make app/domains/auth/ and app/core/redis.py mypy-clean under strict. - Add mypy-gate.ini and Makefile mypy-gate target; promote typecheck-gate in CI. - Consolidate domains into app/domains/: bulletin, admin, intelligence, markets. - Extract referral domain incl. DeFi partner DEX ref links; keep Telegram bot wired. - Move app/mcp/ package and app/api/v1/mcp/router into app/domains/mcp/. - Archive dead app/mcp_router.py.
420 lines
14 KiB
Python
420 lines
14 KiB
Python
"""OAuth + Telegram auth endpoints.
|
|
|
|
Phase 3B of AUDIT-2026-Q3.md.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from typing import cast
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
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() -> GoogleAuthResponse:
|
|
"""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 cast("GoogleAuthResponse", {"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 cast("GoogleAuthResponse", {"url": url})
|
|
|
|
|
|
@router.get("/google/callback")
|
|
async def google_callback(request: Request) -> RedirectResponse:
|
|
"""Handle Google OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
|
|
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()
|
|
assert r is not None
|
|
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) -> WalletAuthResponse:
|
|
"""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()
|
|
assert r is not None
|
|
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 cast(
|
|
"WalletAuthResponse",
|
|
{
|
|
"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) -> WalletAuthResponse:
|
|
"""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()
|
|
assert r is not None
|
|
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 cast(
|
|
"WalletAuthResponse",
|
|
{
|
|
"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() -> GoogleAuthResponse:
|
|
"""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 cast("GoogleAuthResponse", {"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 cast("GoogleAuthResponse", {"url": url})
|
|
|
|
|
|
@router.get("/github/callback")
|
|
async def github_callback(request: Request) -> RedirectResponse:
|
|
"""Handle GitHub OAuth redirect - exchanges code, creates user, redirects to frontend with token."""
|
|
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()
|
|
assert r is not None
|
|
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")
|