feat: Implement OAuth2 authentication with Google and GitHub

- Added OAuth2 endpoints for Google and GitHub authentication.
- Created OAuth service to handle provider interactions and user info retrieval.
- Implemented user OAuth repository for managing user OAuth links in the database.
- Updated auth service to support linking existing users and creating new users via OAuth.
- Added CORS middleware to allow frontend access.
- Created tests for OAuth endpoints and service functionality.
- Introduced environment configuration for OAuth client IDs and secrets.
- Added logging for OAuth operations and error handling.
This commit is contained in:
JSC
2025-07-26 14:38:13 +02:00
parent 52ebc59293
commit 51423779a8
14 changed files with 1119 additions and 37 deletions

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter
from app.api.v1 import auth, main
from app.api.v1 import auth, main, oauth
# V1 API router with v1 prefix
api_router = APIRouter(prefix="/v1")
@@ -10,3 +10,4 @@ api_router = APIRouter(prefix="/v1")
# Include all route modules
api_router.include_router(main.router, tags=["main"])
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(oauth.router, prefix="/oauth", tags=["oauth"])

View File

@@ -54,8 +54,6 @@ async def register(
samesite=settings.COOKIE_SAMESITE,
)
# Return only user data, tokens are now in cookies
return auth_response.user
except HTTPException:
raise
except Exception as e:
@@ -64,6 +62,8 @@ async def register(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed",
) from e
else:
return auth_response.user
@router.post("/login")
@@ -101,8 +101,6 @@ async def login(
samesite=settings.COOKIE_SAMESITE,
)
# Return only user data, tokens are now in cookies
return auth_response.user
except HTTPException:
raise
except Exception as e:
@@ -111,6 +109,8 @@ async def login(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed",
) from e
else:
return auth_response.user
@router.get("/me")
@@ -156,7 +156,6 @@ async def refresh_token(
samesite=settings.COOKIE_SAMESITE,
)
return {"message": "Token refreshed successfully"}
except HTTPException:
raise
except Exception as e:
@@ -165,6 +164,8 @@ async def refresh_token(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token refresh failed",
) from e
else:
return {"message": "Token refreshed successfully"}
@router.post("/logout")
@@ -176,7 +177,7 @@ async def logout(
) -> dict[str, str]:
"""Logout endpoint - clears cookies and revokes refresh token."""
user = None
# Try to get user from access token first
if access_token:
try:
@@ -188,7 +189,7 @@ async def logout(
logger.info("Found user from access token: %s", user.email)
except (HTTPException, Exception) as e:
logger.info("Access token validation failed: %s", str(e))
# If no user found, try refresh token
if not user and refresh_token:
try:
@@ -200,14 +201,14 @@ async def logout(
logger.info("Found user from refresh token: %s", user.email)
except (HTTPException, Exception) as e:
logger.info("Refresh token validation failed: %s", str(e))
# If we found a user, revoke their refresh token
if user:
await auth_service.revoke_refresh_token(user)
logger.info("Successfully revoked refresh token for user: %s", user.email)
else:
logger.info("No user found, skipping token revocation")
# Always clear both cookies regardless of token validity
response.delete_cookie(
key="access_token",
@@ -221,5 +222,5 @@ async def logout(
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
return {"message": "Successfully logged out"}

111
app/api/v1/oauth.py Normal file
View File

@@ -0,0 +1,111 @@
"""OAuth2 authentication endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import RedirectResponse
from app.core.config import settings
from app.core.dependencies import get_auth_service, get_oauth_service
from app.core.logging import get_logger
from app.services.auth import AuthService
from app.services.oauth import OAuthService
router = APIRouter()
logger = get_logger(__name__)
@router.get("/{provider}/authorize")
async def oauth_authorize(
provider: str,
oauth_service: Annotated[OAuthService, Depends(get_oauth_service)],
) -> dict[str, str]:
"""Get OAuth authorization URL."""
try:
# Generate secure state parameter
state = oauth_service.generate_state()
# Get authorization URL
auth_url = oauth_service.get_authorization_url(provider, state)
except HTTPException:
raise
except Exception as e:
logger.exception("OAuth authorization failed for provider: %s", provider)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authorization failed",
) from e
else:
return {
"authorization_url": auth_url,
"state": state,
}
@router.get("/{provider}/callback")
async def oauth_callback(
provider: str,
response: Response,
code: Annotated[str, Query()],
oauth_service: Annotated[OAuthService, Depends(get_oauth_service)],
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> RedirectResponse:
"""Handle OAuth callback."""
try:
# Handle OAuth callback and get user info
oauth_user_info = await oauth_service.handle_callback(provider, code)
# Perform OAuth login (link or create user)
auth_response = await auth_service.oauth_login(oauth_user_info)
# Create and store refresh token
user = await auth_service.get_current_user(auth_response.user.id)
refresh_token = await auth_service.create_and_store_refresh_token(user)
# Set HTTP-only cookies for both tokens
response.set_cookie(
key="access_token",
value=auth_response.token.access_token,
max_age=auth_response.token.expires_in,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
logger.info(
"OAuth login successful for user: %s via %s",
auth_response.user.email,
provider,
)
# Redirect back to frontend after successful authentication
return RedirectResponse(
url="http://localhost:8001/?auth=success",
status_code=302,
)
except HTTPException:
raise
except Exception as e:
logger.exception("OAuth callback failed for provider: %s", provider)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth callback failed",
) from e
@router.get("/providers")
async def get_oauth_providers() -> dict[str, list[str]]:
"""Get list of available OAuth providers."""
return {
"providers": ["google", "github"],
}