|
1 | 1 | from datetime import timedelta |
2 | 2 | from typing import Annotated, Any |
| 3 | +from urllib.parse import urlencode |
3 | 4 |
|
4 | 5 | import httpx |
5 | 6 | from fastapi import APIRouter, Depends, HTTPException |
6 | | -from fastapi.responses import HTMLResponse |
| 7 | +from fastapi.responses import HTMLResponse, RedirectResponse |
7 | 8 | from fastapi.security import OAuth2PasswordRequestForm |
8 | 9 | from pydantic import BaseModel |
9 | 10 |
|
@@ -214,3 +215,130 @@ def login_google(session: SessionDep, body: GoogleLoginRequest) -> Token: |
214 | 215 | raise |
215 | 216 | except Exception as e: |
216 | 217 | raise HTTPException(status_code=500, detail=f"Error processing Google login: {str(e)}") |
| 218 | + |
| 219 | + |
| 220 | +@router.get("/login/google/authorize") |
| 221 | +def google_authorize() -> RedirectResponse: |
| 222 | + """ |
| 223 | + Initiate Google OAuth flow by redirecting to Google's authorization page |
| 224 | + """ |
| 225 | + if not settings.GOOGLE_CLIENT_ID: |
| 226 | + raise HTTPException( |
| 227 | + status_code=500, |
| 228 | + detail="Google OAuth is not configured. Please set GOOGLE_CLIENT_ID in environment variables." |
| 229 | + ) |
| 230 | + |
| 231 | + # Build the authorization URL |
| 232 | + params = { |
| 233 | + "client_id": settings.GOOGLE_CLIENT_ID, |
| 234 | + "redirect_uri": f"{settings.FRONTEND_HOST}/login/callback", |
| 235 | + "response_type": "code", |
| 236 | + "scope": "openid email profile", |
| 237 | + "access_type": "online", |
| 238 | + "prompt": "select_account", |
| 239 | + } |
| 240 | + |
| 241 | + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}" |
| 242 | + return RedirectResponse(url=auth_url) |
| 243 | + |
| 244 | + |
| 245 | +class GoogleCallbackRequest(BaseModel): |
| 246 | + code: str |
| 247 | + |
| 248 | + |
| 249 | +@router.post("/login/google/callback") |
| 250 | +def google_callback(session: SessionDep, body: GoogleCallbackRequest) -> Token: |
| 251 | + """ |
| 252 | + Handle Google OAuth callback and exchange authorization code for tokens |
| 253 | + """ |
| 254 | + if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET: |
| 255 | + raise HTTPException( |
| 256 | + status_code=500, |
| 257 | + detail="Google OAuth is not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in environment variables." |
| 258 | + ) |
| 259 | + |
| 260 | + try: |
| 261 | + # Exchange authorization code for tokens |
| 262 | + token_response = httpx.post( |
| 263 | + "https://oauth2.googleapis.com/token", |
| 264 | + data={ |
| 265 | + "code": body.code, |
| 266 | + "client_id": settings.GOOGLE_CLIENT_ID, |
| 267 | + "client_secret": settings.GOOGLE_CLIENT_SECRET, |
| 268 | + "redirect_uri": f"{settings.FRONTEND_HOST}/login/callback", |
| 269 | + "grant_type": "authorization_code", |
| 270 | + }, |
| 271 | + ) |
| 272 | + |
| 273 | + if token_response.status_code != 200: |
| 274 | + error_detail = token_response.json().get("error_description", "Failed to exchange authorization code") |
| 275 | + raise HTTPException(status_code=400, detail=f"Google OAuth error: {error_detail}") |
| 276 | + |
| 277 | + tokens = token_response.json() |
| 278 | + id_token = tokens.get("id_token") |
| 279 | + |
| 280 | + if not id_token: |
| 281 | + raise HTTPException(status_code=400, detail="No ID token received from Google") |
| 282 | + |
| 283 | + # Verify the ID token |
| 284 | + verify_response = httpx.get( |
| 285 | + f"https://oauth2.googleapis.com/tokeninfo?id_token={id_token}" |
| 286 | + ) |
| 287 | + |
| 288 | + if verify_response.status_code != 200: |
| 289 | + raise HTTPException(status_code=400, detail="Invalid Google token") |
| 290 | + |
| 291 | + token_info = verify_response.json() |
| 292 | + |
| 293 | + # Verify the audience (must match our Google Client ID) |
| 294 | + if token_info.get("aud") != settings.GOOGLE_CLIENT_ID: |
| 295 | + raise HTTPException(status_code=400, detail="Invalid token audience") |
| 296 | + |
| 297 | + # Verify the issuer |
| 298 | + if token_info.get("iss") not in ["https://accounts.google.com", "accounts.google.com"]: |
| 299 | + raise HTTPException(status_code=400, detail="Invalid token issuer") |
| 300 | + |
| 301 | + # Extract user information from the token |
| 302 | + email = token_info.get("email") |
| 303 | + full_name = token_info.get("name") |
| 304 | + google_id = token_info.get("sub") |
| 305 | + |
| 306 | + if not email or not google_id: |
| 307 | + raise HTTPException(status_code=400, detail="Invalid token: missing required fields") |
| 308 | + |
| 309 | + # Check if user exists |
| 310 | + user = crud.get_user_by_email(session=session, email=email) |
| 311 | + |
| 312 | + if not user: |
| 313 | + # Create new user with OAuth |
| 314 | + user_create = UserCreateOAuth( |
| 315 | + email=email, |
| 316 | + full_name=full_name, |
| 317 | + oauth_provider="google", |
| 318 | + oauth_id=google_id |
| 319 | + ) |
| 320 | + user = crud.create_user_oauth(session=session, user_create=user_create) |
| 321 | + else: |
| 322 | + # Update existing user with OAuth info if not already set |
| 323 | + if not user.oauth_provider: |
| 324 | + user.oauth_provider = "google" |
| 325 | + user.oauth_id = google_id |
| 326 | + session.add(user) |
| 327 | + session.commit() |
| 328 | + session.refresh(user) |
| 329 | + |
| 330 | + if not user.is_active: |
| 331 | + raise HTTPException(status_code=400, detail="Inactive user") |
| 332 | + |
| 333 | + # Create access token |
| 334 | + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) |
| 335 | + return Token( |
| 336 | + access_token=security.create_access_token( |
| 337 | + user.id, expires_delta=access_token_expires |
| 338 | + ) |
| 339 | + ) |
| 340 | + |
| 341 | + except HTTPException: |
| 342 | + raise |
| 343 | + except Exception as e: |
| 344 | + raise HTTPException(status_code=500, detail=f"Error processing Google callback: {str(e)}") |
0 commit comments