diff --git a/.env.example b/.env.example index d5cd871..59fc234 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # General Settings -PROJECT_NAME="FluentMeet" -VERSION="1.0.0" -API_V1_STR="/api/v1" +PROJECT_NAME=Spoken.ai +VERSION=1.0.0 +API_V1_STR=/api/v1 # Security -SECRET_KEY="your-super-secret-key-here" +SECRET_KEY=your-super-secret-key-here ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 VERIFICATION_TOKEN_EXPIRE_HOURS=24 diff --git a/app/modules/auth/api_docs.md b/app/modules/auth/api_docs.md index 2591e0c..8721565 100644 --- a/app/modules/auth/api_docs.md +++ b/app/modules/auth/api_docs.md @@ -23,6 +23,7 @@ - [POST /refresh-token](#post-refresh-token) - [GET /google/login](#get-googlelogin) - [GET /google/callback](#get-googlecallback) + - [POST /google/exchange](#post-googleexchange) - [Data Models](#data-models) - [Request / Response Schemas](#request--response-schemas) - [Error Codes Reference](#error-codes-reference) @@ -33,7 +34,7 @@ ## Overview -The FluentMeet authentication module provides a complete identity and access management system built on **FastAPI**. It supports: +The SpokenAI authentication module provides a complete identity and access management system built on **FastAPI**. It supports: - **Email/password registration** with mandatory email verification. - **Google OAuth 2.0** social login with automatic account linking. @@ -147,7 +148,7 @@ Client Server Redis │ ◄── 401 REFRESH_TOKEN_REUSE │ │ ``` -### Google OAuth 2.0 Flow +### Google OAuth 2.0 Flow (Secure Code Exchange) ``` Client Server Google @@ -167,8 +168,15 @@ Client Server Google │ │── Get user info ─────────►│ │ │ ◄── {email, name, ...} │ │ │── Find or create user │ - │ │── Issue AT + RT │ - │ ◄── 302 → frontend#access_token=... │ + │ │── Store login payload │ + │ │ in Redis (5min TTL) │ + │ ◄── 302 → frontend/oauth-callback?code={exchange_code} │ + │ │ │ + │ POST /google/exchange │ │ + │ {code: exchange_code} ──► │ │ + │ │── Atomically get & delete│ + │ │ tokens from Redis │ + │ ◄── 200 {access_token, ...} │ │ │ ◄── Set-Cookie: refresh_token│ │ ``` @@ -227,6 +235,9 @@ All sensitive endpoints are rate-limited using **SlowAPI** (based on client IP): | `POST /change-password` | 10/minute | | `POST /logout` | 20/minute | | `POST /refresh-token` | 30/minute | +| `GET /google/login` | 10/minute | +| `GET /google/callback` | 10/minute | +| `POST /google/exchange` | 20/minute | ### Cookie Security @@ -665,7 +676,7 @@ Redirects to Google's OAuth consent URL with: ### GET /google/callback -Handle the callback from Google after user authentication. This endpoint is called by Google, not by the client directly. +Handle the callback from Google after user authentication. This endpoint is invoked by the browser redirect from Google, not by the client directly via AJAX. **Query Parameters:** @@ -676,7 +687,58 @@ Handle the callback from Google after user authentication. This endpoint is call **Response: `302 Found`** -Redirects to: `{FRONTEND_BASE_URL}#access_token=` +Redirects to: `{FRONTEND_BASE_URL}/oauth-callback?code=` + +**Error Responses:** + +During the redirect callback, if an error occurs, it is returned as a JSON error response or standard HTTP error page: + +| Status | Code | Condition | +|--------|----------------------------|----------------------------------------------------------| +| `400` | `INVALID_OAUTH_STATE` | State token is invalid or expired | +| `400` | `INVALID_OAUTH_PROFILE` | Google account does not provide a verified email address | +| `403` | `ACCOUNT_LOCKED` | Account is locked due to failed attempts | +| `403` | `ACCOUNT_DEACTIVATED` | Account is deactivated or deleted | +| `409` | `GOOGLE_ID_ALREADY_LINKED` | Google account is already linked to another user account | +| `502` | `OAUTH_PROVIDER_ERROR` | Failed to communicate with Google | + +**User Resolution Logic:** +1. If a user with the `google_id` exists: + - If a user with the email exists and has a different user ID, raises `GOOGLE_ID_ALREADY_LINKED` (409 Conflict). + - Otherwise, resolve user. +2. If a user with the email exists but `google_id` is empty: + - Links the Google ID, updates avatar if missing, and marks email verified. +3. If no user exists: + - Creates a new verified user with a random hashed password, sets `google_id`, `full_name`, and `avatar_url`. + +--- + +### POST /google/exchange + +Exchange the short-lived single-use exchange code received from the callback redirection for the user's JWT access token and set the secure `refresh_token` cookie. + +**Request Body:** + +```json +{ + "code": "4V_T2gq_Y-S..." +} +``` + +| Field | Type | Required | Constraints | +|--------|----------|----------|--------------------------------| +| `code` | `string` | ✅ | Non-empty exchange code string | + +**Response: `200 OK`** + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "token_type": "bearer", + "expires_in": 3600 +} +``` **Response Headers:** @@ -686,22 +748,9 @@ Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/ **Error Responses:** -| Status | Code | Condition | -|--------|-------------------------|------------------------------------------| -| `400` | `INVALID_OAUTH_STATE` | State token is invalid or expired | -| `400` | `INVALID_OAUTH_PROFILE` | Google account does not provide an email | -| `403` | `ACCOUNT_LOCKED` | Account is locked due to failed attempts | -| `403` | `ACCOUNT_DEACTIVATED` | Account is deactivated or deleted | -| `502` | `OAUTH_PROVIDER_ERROR` | Failed to communicate with Google | - -**User Resolution Logic:** -1. If a user with the email exists: - - Links the Google ID if not already linked. - - Sets avatar URL if missing. - - Auto-verifies the email if not already verified. -2. If no user exists: - - Creates a new verified user with a random hashed password. - - Sets `google_id`, `full_name`, and `avatar_url` from the Google profile. +| Status | Code | Condition | +|--------|-------------------------|---------------------------------------------------| +| `400` | `INVALID_EXCHANGE_CODE` | The exchange code is invalid, reused, or expired. | --- @@ -780,6 +829,7 @@ Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/ | `ForgotPasswordRequest` | `POST /forgot-password` | `email` | | `ResetPasswordRequest` | `POST /reset-password` | `token` (min 1), `new_password` (min 8) | | `ChangePasswordRequest` | `POST /change-password` | `current_password`, `new_password` (min 8) | +| `GoogleExchangeRequest` | `POST /google/exchange` | `code` (non-empty) | ### Response Schemas @@ -829,6 +879,8 @@ All errors follow a consistent JSON structure: | `INVALID_OAUTH_STATE` | 400 | `/google/callback` | CSRF state token invalid or expired | | `INVALID_OAUTH_PROFILE` | 400 | `/google/callback` | Google profile missing email address | | `OAUTH_PROVIDER_ERROR` | 502 | `/google/callback` | Failed to communicate with Google APIs | +| `INVALID_EXCHANGE_CODE` | 400 | `/google/exchange` | The exchange code is invalid, reused, or expired | +| `GOOGLE_ID_ALREADY_LINKED` | 409 | `/google/callback` | Google account is already linked to another user account | --- @@ -1015,3 +1067,14 @@ curl -X POST http://localhost:8000/api/v1/auth/logout \ -H "Authorization: Bearer " \ -b cookies.txt ``` + +### cURL: Exchange Google OAuth Code + +```bash +curl -X POST http://localhost:8000/api/v1/auth/google/exchange \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{ + "code": "4V_T2gq_Y-S..." + }' +``` diff --git a/app/modules/auth/router.py b/app/modules/auth/router.py index 9d23580..80cdb30 100644 --- a/app/modules/auth/router.py +++ b/app/modules/auth/router.py @@ -26,6 +26,7 @@ ActionAcknowledgement, ChangePasswordRequest, ForgotPasswordRequest, + GoogleExchangeRequest, LoginRequest, LoginResponse, RefreshTokenResponse, @@ -386,13 +387,17 @@ async def refresh_token( summary="Initiate Google OAuth 2.0 login flow", status_code=status.HTTP_302_FOUND, ) +@limiter.limit("10/minute") async def google_login( + request: Request, google_oauth: GoogleOAuthService = Depends(get_google_oauth_service), ) -> RedirectResponse: import secrets from app.modules.auth.token_store import _get_redis_client + del request # consumed by slowapi + state = secrets.token_urlsafe(32) redis = _get_redis_client() await redis.set(f"oauth_state:{state}", "1", ex=600) # 10 minutes TTL @@ -405,15 +410,22 @@ async def google_login( "/google/callback", summary="Google OAuth 2.0 callback endpoint", ) +@limiter.limit("10/minute") async def google_callback( + request: Request, code: str, state: str, google_oauth: GoogleOAuthService = Depends(get_google_oauth_service), auth_service: AuthService = Depends(get_auth_service), ) -> RedirectResponse: + import json + import secrets + from app.core.exceptions import BadRequestException from app.modules.auth.token_store import _get_redis_client + del request # consumed by slowapi + redis = _get_redis_client() state_key = f"oauth_state:{state}" @@ -431,10 +443,11 @@ async def google_callback( user_info = await google_oauth.get_user_info(access_token=access_token) email = user_info.get("email") - if not email: + email_verified = user_info.get("email_verified", False) + if not email or not email_verified: raise BadRequestException( code="INVALID_OAUTH_PROFILE", - message="Google account does not provide an email address.", + message="Google account does not provide a verified email address.", ) google_id = str(user_info.get("sub", "")) @@ -449,22 +462,82 @@ async def google_callback( avatar_url=avatar, ) - # 4. Return tokens (Cookie & Redirect with access token) - # Using URL fragment as requested by the user - redirect_url = ( - f"{settings.FRONTEND_BASE_URL}#access_token={login_response.access_token}" + # 4. Generate temporary exchange code and store tokens securely in Redis + exchange_code = secrets.token_urlsafe(32) + exchange_data = { + "access_token": login_response.access_token, + "user_id": str(login_response.user_id), + "token_type": login_response.token_type, + "expires_in": login_response.expires_in, + "refresh_token": refresh_token, + "refresh_ttl": refresh_ttl, + } + await redis.set( + f"oauth_exchange:{exchange_code}", + json.dumps(exchange_data), + ex=300, # 5 minutes TTL + ) + + # 5. Redirect to frontend with exchange code + redirect_url = f"{settings.FRONTEND_BASE_URL}/oauth-callback?code={exchange_code}" + return RedirectResponse(url=redirect_url, status_code=302) + + +@router.post( + "/google/exchange", + response_model=LoginResponse, + summary="Exchange temporary OAuth code for access token", + description=( + "Exchanges a short-lived authorization code retrieved from Google Callback " + "for the user's access token and sets the HttpOnly refresh token cookie." + ), +) +@limiter.limit("20/minute") +async def google_exchange( + request: Request, + payload: GoogleExchangeRequest, +) -> JSONResponse: + import json + + from app.core.exceptions import BadRequestException + from app.modules.auth.token_store import _get_redis_client + + del request # consumed by slowapi + + redis = _get_redis_client() + exchange_key = f"oauth_exchange:{payload.code}" + + # Atomic retrieve-and-delete prevents TOCTOU race conditions + data_str = await redis.getdel(exchange_key) + if not data_str: + raise BadRequestException( + code="INVALID_EXCHANGE_CODE", + message="OAuth exchange code is invalid or has expired.", + ) + + data = json.loads(data_str) + + login_response = LoginResponse( + access_token=data["access_token"], + user_id=data["user_id"], + token_type=data["token_type"], + expires_in=data["expires_in"], + ) + + res = JSONResponse( + content=login_response.model_dump(mode="json"), + status_code=200, ) - response = RedirectResponse(url=redirect_url, status_code=302) # Set HttpOnly refresh-token cookie - response.set_cookie( + res.set_cookie( key="refresh_token", - value=refresh_token, + value=data["refresh_token"], httponly=True, secure=True, samesite="strict", path=f"{settings.API_V1_STR}/auth", - max_age=refresh_ttl, + max_age=data["refresh_ttl"], ) - return response + return res diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py index b634eb8..804e6f3 100644 --- a/app/modules/auth/schemas.py +++ b/app/modules/auth/schemas.py @@ -179,3 +179,9 @@ class RefreshTokenResponse(BaseModel): access_token: str token_type: str = "bearer" expires_in: int + + +class GoogleExchangeRequest(BaseModel): + """Payload submitted to ``POST /auth/google/exchange``.""" + + code: str = Field(..., min_length=1) diff --git a/app/modules/auth/service.py b/app/modules/auth/service.py index 896bdea..63b8573 100644 --- a/app/modules/auth/service.py +++ b/app/modules/auth/service.py @@ -358,6 +358,80 @@ async def refresh_token( return body, new_refresh_token, new_ttl + def _find_oauth_user(self, email: str, google_id: str) -> User | None: + user_by_google = None + if google_id: + user_by_google = self.db.execute( + select(User).where(User.google_id == google_id) + ).scalar_one_or_none() + + user_by_email = self.get_user_by_email(email) + + if user_by_google: + # Check for conflict if Google ID is linked to account A, + # but current email is account B + if user_by_email and user_by_email.id != user_by_google.id: + raise ConflictException( + code="GOOGLE_ID_ALREADY_LINKED", + message=( + "This Google account is already linked to " + "another FluentMeet account." + ), + ) + return user_by_google + return user_by_email + + async def _check_oauth_user_status(self, user: User) -> None: + # Check lockout + if await self.lockout_svc.is_locked(user.email): + raise ForbiddenException( + code="ACCOUNT_LOCKED", + message="Account is temporarily locked. Please try again later.", + ) + + # Verify if user is active + if user.deleted_at is not None or not user.is_active: + raise ForbiddenException( + code="ACCOUNT_DEACTIVATED", + message="This account has been deactivated or deleted.", + ) + + def _update_oauth_user_profile( + self, user: User, google_id: str, avatar_url: str | None + ) -> None: + updated = False + if not user.google_id: + user.google_id = google_id + updated = True + if not user.avatar_url and avatar_url: + user.avatar_url = avatar_url + updated = True + if not user.is_verified: + user.is_verified = True + updated = True + + if updated: + self.db.commit() + self.db.refresh(user) + + def _create_oauth_user( + self, email: str, google_id: str, name: str | None, avatar_url: str | None + ) -> User: + random_password = str(uuid.uuid4()) + user = User( + email=email, + hashed_password=self.security_service.hash_password(random_password), + full_name=name, + avatar_url=avatar_url, + google_id=google_id, + is_active=True, + is_verified=True, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + async def resolve_oauth_user( self, email: str, google_id: str, name: str | None, avatar_url: str | None ) -> tuple[LoginResponse, str, int]: @@ -374,59 +448,25 @@ async def resolve_oauth_user( and TTL in seconds. """ email = email.lower() - user = self.get_user_by_email(email) + user = self._find_oauth_user(email, google_id) if user: - # Check lockout - if await self.lockout_svc.is_locked(email): - raise ForbiddenException( - code="ACCOUNT_LOCKED", - message="Account is temporarily locked. Please try again later.", - ) - - # Verify if user is active - if user.deleted_at is not None or not user.is_active: - raise ForbiddenException( - code="ACCOUNT_DEACTIVATED", - message="This account has been deactivated or deleted.", - ) - - # Link account if not linked - if not user.google_id: - user.google_id = google_id - if not user.avatar_url and avatar_url: - user.avatar_url = avatar_url - if not user.is_verified: - user.is_verified = True - - self.db.commit() - self.db.refresh(user) + await self._check_oauth_user_status(user) + self._update_oauth_user_profile(user, google_id, avatar_url) else: - # Create new user - random_password = str(uuid.uuid4()) - user = User( - email=email, - hashed_password=self.security_service.hash_password(random_password), - full_name=name, - avatar_url=avatar_url, - google_id=google_id, - is_active=True, - is_verified=True, - ) - self.db.add(user) - self.db.commit() - self.db.refresh(user) + user = self._create_oauth_user(email, google_id, name, avatar_url) - # Issue tokens for successful OAuth login + # Issue tokens for successful OAuth login (use user.email, not the + # Google-provided email, in case user_by_google has a different stored email) access_token, expires_in = self.security_service.create_access_token( - email=email + email=user.email ) refresh_token, refresh_jti, refresh_ttl = ( - self.security_service.create_refresh_token(email=email) + self.security_service.create_refresh_token(email=user.email) ) await self.token_store.save_refresh_token( - email=email, jti=refresh_jti, ttl_seconds=refresh_ttl + email=user.email, jti=refresh_jti, ttl_seconds=refresh_ttl ) login_response = LoginResponse( diff --git a/tests/test_auth/test_oauth_google.py b/tests/test_auth/test_oauth_google.py index 9fadd15..c11b40d 100644 --- a/tests/test_auth/test_oauth_google.py +++ b/tests/test_auth/test_oauth_google.py @@ -1,5 +1,7 @@ +import json import uuid from unittest.mock import AsyncMock, patch +from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient @@ -32,8 +34,6 @@ def test_google_login_endpoint() -> None: # Verify state is stored in redis url = response.headers["location"] - from urllib.parse import parse_qs, urlparse - parsed_url = urlparse(url) qs = parse_qs(parsed_url.query) state = qs.get("state", [""])[0] @@ -64,7 +64,7 @@ def test_google_callback_invalid_state( @patch("app.modules.auth.oauth_google.GoogleOAuthService.exchange_code") @patch("app.modules.auth.oauth_google.GoogleOAuthService.get_user_info") @patch("app.modules.auth.token_store._get_redis_client") -def test_google_callback_success( +def test_google_callback_unverified_email( mock_redis, mock_get_user_info: AsyncMock, mock_exchange_code: AsyncMock, @@ -76,6 +76,37 @@ def test_google_callback_success( mock_exchange_code.return_value = "mock_token" mock_get_user_info.return_value = { "email": "user@google.com", + "email_verified": False, + "sub": "google123", + "name": "Google User", + "picture": "http://example.com/avatar.png", + } + + response = client.get( + "/api/v1/auth/google/callback?code=mockcode&state=validstate", + follow_redirects=False, + ) + + assert response.status_code == 400 + assert response.json()["code"] == "INVALID_OAUTH_PROFILE" + + +@patch("app.modules.auth.oauth_google.GoogleOAuthService.exchange_code") +@patch("app.modules.auth.oauth_google.GoogleOAuthService.get_user_info") +@patch("app.modules.auth.token_store._get_redis_client") +def test_google_callback_and_exchange_success( + mock_redis, + mock_get_user_info: AsyncMock, + mock_exchange_code: AsyncMock, +) -> None: + mock_redis_instance = AsyncMock() + mock_redis_instance.exists.return_value = True + mock_redis.return_value = mock_redis_instance + + mock_exchange_code.return_value = "mock_token" + mock_get_user_info.return_value = { + "email": "user@google.com", + "email_verified": True, "sub": "google123", "name": "Google User", "picture": "http://example.com/avatar.png", @@ -95,14 +126,71 @@ def test_google_callback_success( app.dependency_overrides[get_auth_service] = lambda: mock_auth_svc + # 1. Trigger the callback response = client.get( "/api/v1/auth/google/callback?code=mockcode&state=validstate", follow_redirects=False, ) + assert response.status_code == 302 + redirect_url = response.headers["location"] + assert "oauth-callback?code=" in redirect_url + + parsed_url = urlparse(redirect_url) + qs = parse_qs(parsed_url.query) + exchange_code = qs.get("code", [""])[0] + assert exchange_code != "" + + # Verify state was deleted + mock_redis_instance.delete.assert_any_call("oauth_state:validstate") + + # Verify oauth_exchange was set in Redis + mock_redis_instance.set.assert_any_call( + f"oauth_exchange:{exchange_code}", + pytest.approx( + json.dumps( + { + "access_token": "test_access_jwt", + "user_id": str( + mock_auth_svc.resolve_oauth_user.return_value[0].user_id + ), + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "test_refresh_jwt", + "refresh_ttl": 86400, + } + ) + ), + ex=300, + ) + + # 2. Mock Redis behavior for the exchange request + mock_redis_instance.getdel.return_value = json.dumps( + { + "access_token": "test_access_jwt", + "user_id": str(mock_auth_svc.resolve_oauth_user.return_value[0].user_id), + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "test_refresh_jwt", + "refresh_ttl": 86400, + } + ) + + # 3. Call the exchange endpoint + exchange_response = client.post( + "/api/v1/auth/google/exchange", + json={"code": exchange_code}, + ) + app.dependency_overrides.clear() - assert response.status_code == 302 - assert "access_token=test_access_jwt" in response.headers["location"] - assert "refresh_token" in response.cookies - mock_redis_instance.delete.assert_called_once_with("oauth_state:validstate") + assert exchange_response.status_code == 200 + assert exchange_response.json()["access_token"] == "test_access_jwt" + assert exchange_response.json()["token_type"] == "bearer" + assert "refresh_token" in exchange_response.cookies + assert exchange_response.cookies["refresh_token"] == "test_refresh_jwt" + + # Verify atomic getdel was called for the exchange code + mock_redis_instance.getdel.assert_called_once_with( + f"oauth_exchange:{exchange_code}" + ) diff --git a/uv.lock b/uv.lock index 06dd9dd..57d0919 100644 --- a/uv.lock +++ b/uv.lock @@ -1035,7 +1035,7 @@ wheels = [ [[package]] name = "fluentmeet" -version = "1.18.8" +version = "1.19.0" source = { virtual = "." } dependencies = [ { name = "aioboto3" },