Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
107 changes: 85 additions & 22 deletions app/modules/auth/api_docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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│ │
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:**

Expand All @@ -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=<jwt>`
Redirects to: `{FRONTEND_BASE_URL}/oauth-callback?code=<exchange_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:**

Expand All @@ -686,22 +748,9 @@ Set-Cookie: refresh_token=<rt>; 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. |

---

Expand Down Expand Up @@ -780,6 +829,7 @@ Set-Cookie: refresh_token=<rt>; 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

Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -1015,3 +1067,14 @@ curl -X POST http://localhost:8000/api/v1/auth/logout \
-H "Authorization: Bearer <access_token>" \
-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..."
}'
```
95 changes: 84 additions & 11 deletions app/modules/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ActionAcknowledgement,
ChangePasswordRequest,
ForgotPasswordRequest,
GoogleExchangeRequest,
LoginRequest,
LoginResponse,
RefreshTokenResponse,
Expand Down Expand Up @@ -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
Expand All @@ -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}"

Expand All @@ -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", ""))
Expand All @@ -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
6 changes: 6 additions & 0 deletions app/modules/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading