From f5e1e140dc75905ec8a2a12b4ea896e1c50a048f Mon Sep 17 00:00:00 2001 From: cryptorugmunch Date: Tue, 7 Jul 2026 14:50:16 +0700 Subject: [PATCH] refactor(auth): split OAuth + Telegram endpoints to app.domains.auth.oauth (P4.3 cont) --- app/domains/auth/__init__.py | 510 +---------------------------------- app/domains/auth/oauth.py | 424 +++++++++++++++++++++++++++-- 2 files changed, 414 insertions(+), 520 deletions(-) diff --git a/app/domains/auth/__init__.py b/app/domains/auth/__init__.py index 25cb745..4479e4d 100644 --- a/app/domains/auth/__init__.py +++ b/app/domains/auth/__init__.py @@ -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 ── diff --git a/app/domains/auth/oauth.py b/app/domains/auth/oauth.py index 8c63a8f..c2f4c27 100644 --- a/app/domains/auth/oauth.py +++ b/app/domains/auth/oauth.py @@ -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")