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

@@ -13,13 +13,16 @@ class Settings(BaseSettings):
extra="ignore",
)
# Application Configuration
HOST: str = "localhost"
PORT: int = 8000
RELOAD: bool = True
# Database Configuration
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
DATABASE_ECHO: bool = False
# Logging Configuration
LOG_LEVEL: str = "info"
LOG_FILE: str = "logs/app.log"
LOG_MAX_SIZE: int = 10 * 1024 * 1024
@@ -31,12 +34,19 @@ class Settings(BaseSettings):
"your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
)
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # Shorter-lived access token
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Longer-lived refresh token
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Cookie Configuration
COOKIE_SECURE: bool = True # Set to False for development without HTTPS
COOKIE_SECURE: bool = True
COOKIE_SAMESITE: Literal["strict", "lax", "none"] = "lax"
# OAuth2 Configuration
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GITHUB_CLIENT_ID: str = ""
GITHUB_CLIENT_SECRET: str = ""
OAUTH_REDIRECT_URL: str = "http://localhost:8001/auth/callback"
settings = Settings()

View File

@@ -1,6 +1,6 @@
"""FastAPI dependencies."""
from typing import Annotated, NoReturn, cast
from typing import Annotated, cast
from fastapi import Cookie, Depends, HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -9,27 +9,12 @@ from app.core.database import get_db
from app.core.logging import get_logger
from app.models.user import User
from app.services.auth import AuthService
from app.services.oauth import OAuthService
from app.utils.auth import JWTUtils
logger = get_logger(__name__)
def _raise_invalid_token_error() -> NoReturn:
"""Raise an invalid token HTTP exception."""
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
def _raise_auth_error() -> NoReturn:
"""Raise an authentication HTTP exception."""
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
async def get_auth_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> AuthService:
@@ -37,6 +22,13 @@ async def get_auth_service(
return AuthService(session)
async def get_oauth_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> OAuthService:
"""Get the OAuth service."""
return OAuthService(session)
async def get_current_user(
access_token: Annotated[str | None, Cookie()],
auth_service: Annotated[AuthService, Depends(get_auth_service)],
@@ -46,7 +38,10 @@ async def get_current_user(
# Check if access token cookie exists
if not access_token:
logger.warning("No access token cookie found")
_raise_auth_error()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
# Decode the JWT token
payload = JWTUtils.decode_access_token(access_token)
@@ -54,7 +49,10 @@ async def get_current_user(
# Extract user ID from token
user_id_str = payload.get("sub")
if not user_id_str:
_raise_invalid_token_error()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
# At this point user_id_str is guaranteed to be truthy, safe to cast
user_id_str = cast("str", user_id_str)
@@ -74,9 +72,12 @@ async def get_current_user(
except HTTPException:
# Re-raise HTTPExceptions without wrapping them
raise
except Exception:
except Exception as e:
logger.exception("Failed to authenticate user")
_raise_auth_error()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
) from e
async def get_current_active_user(