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

@@ -0,0 +1,151 @@
"""Tests for OAuth authentication endpoints."""
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.services.oauth import OAuthUserInfo
class TestOAuthEndpoints:
"""Test OAuth API endpoints."""
@pytest.mark.asyncio
async def test_get_oauth_providers(self, test_client: AsyncClient) -> None:
"""Test getting list of OAuth providers."""
response = await test_client.get("/api/v1/oauth/providers")
assert response.status_code == 200
data = response.json()
assert "providers" in data
assert "google" in data["providers"]
assert "github" in data["providers"]
@pytest.mark.asyncio
async def test_oauth_authorize_google(self, test_client: AsyncClient) -> None:
"""Test OAuth authorization URL generation for Google."""
with patch("app.services.oauth.OAuthService.generate_state") as mock_state:
mock_state.return_value = "test_state_123"
response = await test_client.get("/api/v1/oauth/google/authorize")
assert response.status_code == 200
data = response.json()
assert "authorization_url" in data
assert "state" in data
assert data["state"] == "test_state_123"
assert "accounts.google.com" in data["authorization_url"]
@pytest.mark.asyncio
async def test_oauth_authorize_github(self, test_client: AsyncClient) -> None:
"""Test OAuth authorization URL generation for GitHub."""
with patch("app.services.oauth.OAuthService.generate_state") as mock_state:
mock_state.return_value = "test_state_456"
response = await test_client.get("/api/v1/oauth/github/authorize")
assert response.status_code == 200
data = response.json()
assert "authorization_url" in data
assert "state" in data
assert data["state"] == "test_state_456"
assert "github.com" in data["authorization_url"]
@pytest.mark.asyncio
async def test_oauth_authorize_invalid_provider(
self, test_client: AsyncClient
) -> None:
"""Test OAuth authorization with invalid provider."""
response = await test_client.get("/api/v1/oauth/invalid/authorize")
assert response.status_code == 400
data = response.json()
assert "Unsupported OAuth provider" in data["detail"]
@pytest.mark.asyncio
async def test_oauth_callback_new_user(
self, test_client: AsyncClient, ensure_plans: tuple[Any, Any]
) -> None:
"""Test OAuth callback for new user creation."""
# Mock OAuth user info
mock_user_info = OAuthUserInfo(
provider="google",
provider_user_id="google_123",
email="newuser@gmail.com",
name="New User",
picture="https://example.com/avatar.jpg",
)
# Mock the entire handle_callback method to avoid actual OAuth API calls
with patch("app.services.oauth.OAuthService.handle_callback") as mock_callback:
mock_callback.return_value = mock_user_info
response = await test_client.get(
"/api/v1/oauth/google/callback",
params={"code": "auth_code_123", "state": "test_state"},
follow_redirects=False,
)
# OAuth callback should successfully process and redirect to frontend
assert response.status_code == 302
assert response.headers["location"] == "http://localhost:8001/?auth=success"
# The fact that we get a 302 redirect means the OAuth login was successful
# Detailed cookie testing can be done in integration tests
@pytest.mark.asyncio
async def test_oauth_callback_existing_user_link(
self, test_client: AsyncClient, test_user: Any, ensure_plans: tuple[Any, Any]
) -> None:
"""Test OAuth callback for linking to existing user."""
# Mock OAuth user info with same email as test user
mock_user_info = OAuthUserInfo(
provider="github",
provider_user_id="github_456",
email=test_user.email, # Same email as existing user
name="Test User",
picture="https://github.com/avatar.jpg",
)
# Mock the entire handle_callback method to avoid actual OAuth API calls
with patch("app.services.oauth.OAuthService.handle_callback") as mock_callback:
mock_callback.return_value = mock_user_info
response = await test_client.get(
"/api/v1/oauth/github/callback",
params={"code": "auth_code_456", "state": "test_state"},
follow_redirects=False,
)
# OAuth callback should successfully process and redirect to frontend
assert response.status_code == 302
assert response.headers["location"] == "http://localhost:8001/?auth=success"
# The fact that we get a 302 redirect means the OAuth login was successful
# Detailed cookie testing can be done in integration tests
@pytest.mark.asyncio
async def test_oauth_callback_missing_code(self, test_client: AsyncClient) -> None:
"""Test OAuth callback with missing authorization code."""
response = await test_client.get(
"/api/v1/oauth/google/callback",
params={"state": "test_state"}, # Missing code parameter
)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_oauth_callback_invalid_provider(
self, test_client: AsyncClient
) -> None:
"""Test OAuth callback with invalid provider."""
response = await test_client.get(
"/api/v1/oauth/invalid/callback",
params={"code": "auth_code_123", "state": "test_state"},
)
assert response.status_code == 400
data = response.json()
assert "Unsupported OAuth provider" in data["detail"]