Skip to content

Commit 7e0c598

Browse files
committed
feat: login with google
1 parent b5cb9d3 commit 7e0c598

File tree

15 files changed

+605
-252
lines changed

15 files changed

+605
-252
lines changed

.claude/settings.local.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@
66
"Bash(uv run alembic:*)",
77
"Bash(uv run python:*)",
88
"Bash(git rm:*)",
9-
"Bash(git add:*)"
9+
"Bash(git add:*)",
10+
"Bash(npm run generate-client:*)",
11+
"Bash(cat:*)",
12+
"Bash(npm uninstall:*)",
13+
"Bash(npm run dev:*)",
14+
"Bash(netstat:*)",
15+
"Bash(findstr:*)",
16+
"Bash(dir /s /b \"c:\\Users\\makara\\Desktop\\full-stack-fastapi-template\\frontend\\src\\routes\")",
17+
"Bash(taskkill:*)",
18+
"Bash(powershell -Command \"Stop-Process -Id 8644 -Force\")",
19+
"Bash(powershell -Command:*)",
20+
"Bash(uv sync:*)",
21+
"Bash(python:*)"
1022
],
1123
"deny": [],
1224
"ask": []

backend/app/api/routes/login.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from datetime import timedelta
22
from typing import Annotated, Any
3+
from urllib.parse import urlencode
34

45
import httpx
56
from fastapi import APIRouter, Depends, HTTPException
6-
from fastapi.responses import HTMLResponse
7+
from fastapi.responses import HTMLResponse, RedirectResponse
78
from fastapi.security import OAuth2PasswordRequestForm
89
from pydantic import BaseModel
910

@@ -214,3 +215,130 @@ def login_google(session: SessionDep, body: GoogleLoginRequest) -> Token:
214215
raise
215216
except Exception as e:
216217
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)}")

backend/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ dependencies = [
1818
"authlib<2.0.0,>=1.3.0",
1919
"psycopg[binary]<4.0.0,>=3.1.13",
2020
"sqlmodel<1.0.0,>=0.0.21",
21-
# Pin bcrypt until passlib supports the latest
22-
"bcrypt==4.3.0",
21+
# Pin bcrypt to 4.0.x for passlib compatibility
22+
"bcrypt>=4.0.0,<4.1.0",
2323
"pydantic-settings<3.0.0,>=2.2.1",
2424
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
2525
"pyjwt<3.0.0,>=2.8.0",

0 commit comments

Comments
 (0)