- Add base_url config setting for OAuth callback URLs
- Change OAuth callbacks from relative to absolute URLs
- Add account linking OAuth flow (GET /auth/link/{provider})
- Add unlink endpoint (DELETE /users/me/link/{provider})
- Add AccountLinkingError and service methods for linking
- Add 14 new tests for linking functionality
- Update Phase 2 plan to mark complete (1072 tests passing)
479 lines
19 KiB
JSON
479 lines
19 KiB
JSON
{
|
|
"meta": {
|
|
"version": "1.0.0",
|
|
"created": "2026-01-27",
|
|
"lastUpdated": "2026-01-27",
|
|
"planType": "phase",
|
|
"phaseId": "PHASE_2",
|
|
"phaseName": "Authentication",
|
|
"description": "OAuth login (Google, Discord), JWT session management, user management, premium tier tracking",
|
|
"totalEstimatedHours": 24,
|
|
"totalTasks": 15,
|
|
"completedTasks": 15,
|
|
"status": "complete",
|
|
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
|
},
|
|
|
|
"goals": [
|
|
"Implement OAuth 2.0 authentication with Google and Discord providers",
|
|
"Create JWT-based session management with access/refresh token pattern",
|
|
"Build user management service with create, read, update operations",
|
|
"Implement FastAPI dependencies for protected endpoints",
|
|
"Support account linking (multiple OAuth providers per user)",
|
|
"Track premium subscription status with expiration dates"
|
|
],
|
|
|
|
"architectureNotes": {
|
|
"tokenStrategy": {
|
|
"accessToken": "Short-lived JWT (30 min default), contains user_id",
|
|
"refreshToken": "Longer-lived JWT (7 days default), stored in Redis for revocation",
|
|
"storage": "Refresh tokens tracked in Redis for logout/revocation support"
|
|
},
|
|
"oauthFlow": {
|
|
"pattern": "Authorization Code Flow (PKCE deferred)",
|
|
"callback": "Backend receives code, exchanges for tokens, creates/updates user",
|
|
"security": "Never store OAuth provider tokens, only OAuth ID"
|
|
},
|
|
"accountLinking": {
|
|
"strategy": "Email-based matching + explicit linking via OAuth flow",
|
|
"flow": "If user exists with same email, add OAuth provider to existing account. Users can also explicitly link additional providers via /auth/link/{provider}"
|
|
},
|
|
"existingInfrastructure": {
|
|
"config": "JWT, OAuth, and base_url settings in app/config.py Settings class",
|
|
"dependencies": "python-jose, passlib, bcrypt, httpx already installed",
|
|
"userModel": "User model with OAuth fields in app/db/models/user.py"
|
|
}
|
|
},
|
|
|
|
"directoryStructure": {
|
|
"schemas": "backend/app/schemas/",
|
|
"services": "backend/app/services/",
|
|
"api": "backend/app/api/",
|
|
"tests": "backend/tests/api/, backend/tests/services/"
|
|
},
|
|
|
|
"tasks": [
|
|
{
|
|
"id": "AUTH-001",
|
|
"name": "Create Pydantic schemas for auth",
|
|
"description": "Define request/response models for authentication flows",
|
|
"category": "critical",
|
|
"priority": 1,
|
|
"completed": true,
|
|
"dependencies": [],
|
|
"files": [
|
|
{"path": "app/schemas/__init__.py", "status": "complete"},
|
|
{"path": "app/schemas/auth.py", "status": "complete"},
|
|
{"path": "app/schemas/user.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"TokenPayload: sub (user_id), exp, iat, type (access/refresh)",
|
|
"TokenResponse: access_token, refresh_token, token_type, expires_in",
|
|
"UserResponse: id, email, display_name, avatar_url, is_premium, premium_until",
|
|
"UserCreate: internal model for user creation from OAuth",
|
|
"OAuthUserInfo: normalized structure for OAuth provider data"
|
|
],
|
|
"estimatedHours": 1.5
|
|
},
|
|
{
|
|
"id": "AUTH-002",
|
|
"name": "Create JWT utilities service",
|
|
"description": "Functions for creating and verifying JWT tokens",
|
|
"category": "critical",
|
|
"priority": 2,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-001"],
|
|
"files": [
|
|
{"path": "app/services/jwt_service.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"create_access_token(user_id: UUID) -> str - Uses settings.jwt_expire_minutes",
|
|
"create_refresh_token(user_id: UUID) -> tuple[str, str] - Returns token and jti",
|
|
"verify_access_token(token: str) -> UUID | None - Returns user_id or None",
|
|
"verify_refresh_token(token: str) -> tuple[UUID, str] | None - Returns (user_id, jti) or None",
|
|
"Uses python-jose with HS256 algorithm",
|
|
"All timing uses datetime.now(UTC) per project standards"
|
|
],
|
|
"estimatedHours": 1.5
|
|
},
|
|
{
|
|
"id": "AUTH-003",
|
|
"name": "Create refresh token Redis storage",
|
|
"description": "Redis-based storage for refresh token tracking and revocation",
|
|
"category": "high",
|
|
"priority": 3,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-002"],
|
|
"files": [
|
|
{"path": "app/services/token_store.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Key format: refresh_token:{user_id}:{jti} -> '1' with TTL",
|
|
"store_refresh_token(user_id, jti, expires_at) - Store with TTL",
|
|
"is_token_valid(user_id, jti) -> bool - Check if not revoked",
|
|
"revoke_token(user_id, jti) - Delete specific token",
|
|
"revoke_all_user_tokens(user_id) - Logout from all devices",
|
|
"get_active_session_count(user_id) - Count valid tokens",
|
|
"Uses existing Redis connection from app/db/redis.py"
|
|
],
|
|
"estimatedHours": 1.5
|
|
},
|
|
{
|
|
"id": "AUTH-004",
|
|
"name": "Create UserService",
|
|
"description": "Service layer for user CRUD operations",
|
|
"category": "critical",
|
|
"priority": 4,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-001"],
|
|
"files": [
|
|
{"path": "app/services/user_service.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"get_by_id(db, user_id: UUID) -> User | None",
|
|
"get_by_email(db, email: str) -> User | None",
|
|
"get_by_oauth(db, provider: str, oauth_id: str) -> User | None",
|
|
"create(db, user_data: UserCreate) -> User",
|
|
"create_from_oauth(db, oauth_info: OAuthUserInfo) -> User",
|
|
"get_or_create_from_oauth(db, oauth_info: OAuthUserInfo) -> tuple[User, bool]",
|
|
"update(db, user: User, update_data: UserUpdate) -> User",
|
|
"update_last_login(db, user: User) -> User",
|
|
"update_premium(db, user: User, premium_until: datetime | None) -> User",
|
|
"link_oauth_account(db, user: User, oauth_info: OAuthUserInfo) -> OAuthLinkedAccount",
|
|
"unlink_oauth_account(db, user: User, provider: str) -> bool",
|
|
"All operations are async using SQLAlchemy async session"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-005",
|
|
"name": "Create OAuthLinkedAccount model",
|
|
"description": "Database model for multiple OAuth providers per user (account linking)",
|
|
"category": "high",
|
|
"priority": 5,
|
|
"completed": true,
|
|
"dependencies": [],
|
|
"files": [
|
|
{"path": "app/db/models/oauth_account.py", "status": "complete"},
|
|
{"path": "app/db/migrations/versions/5ce887128ab1_add_oauth_linked_accounts.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Fields: id, user_id (FK), provider, oauth_id, email, display_name, avatar_url, linked_at",
|
|
"Unique constraint on (provider, oauth_id)",
|
|
"User.oauth_provider/oauth_id kept as 'primary' provider",
|
|
"Relationship: User.linked_accounts -> list[OAuthLinkedAccount]"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-006",
|
|
"name": "Create Google OAuth service",
|
|
"description": "Handle Google OAuth authorization code flow",
|
|
"category": "critical",
|
|
"priority": 6,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-004"],
|
|
"files": [
|
|
{"path": "app/services/oauth/google.py", "status": "complete"},
|
|
{"path": "app/services/oauth/__init__.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"get_authorization_url(redirect_uri, state) -> str",
|
|
"get_user_info(code, redirect_uri) -> OAuthUserInfo",
|
|
"is_configured() -> bool",
|
|
"Uses httpx for async HTTP requests",
|
|
"Google OAuth endpoints: accounts.google.com/o/oauth2/v2/auth, oauth2.googleapis.com/token",
|
|
"User info endpoint: www.googleapis.com/oauth2/v2/userinfo"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-007",
|
|
"name": "Create Discord OAuth service",
|
|
"description": "Handle Discord OAuth authorization code flow",
|
|
"category": "critical",
|
|
"priority": 7,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-004"],
|
|
"files": [
|
|
{"path": "app/services/oauth/discord.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"get_authorization_url(redirect_uri, state) -> str",
|
|
"get_user_info(code, redirect_uri) -> OAuthUserInfo",
|
|
"is_configured() -> bool",
|
|
"Uses httpx for async HTTP requests",
|
|
"Discord OAuth endpoints: discord.com/oauth2/authorize, discord.com/api/oauth2/token",
|
|
"User info endpoint: discord.com/api/users/@me",
|
|
"Avatar URL construction from user ID and avatar hash"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-008",
|
|
"name": "Create FastAPI auth dependencies",
|
|
"description": "Dependency injection for protected endpoints",
|
|
"category": "critical",
|
|
"priority": 8,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-002", "AUTH-003", "AUTH-004"],
|
|
"files": [
|
|
{"path": "app/api/__init__.py", "status": "complete"},
|
|
{"path": "app/api/deps.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"OAuth2PasswordBearer scheme for token extraction",
|
|
"get_current_user(token, db) -> User - Validates token, fetches user",
|
|
"CurrentUser type alias with Annotated for dependency injection",
|
|
"DbSession type alias for database dependency",
|
|
"Proper error responses: 401 Unauthorized"
|
|
],
|
|
"estimatedHours": 1.5
|
|
},
|
|
{
|
|
"id": "AUTH-009",
|
|
"name": "Create auth API router",
|
|
"description": "REST endpoints for OAuth login, token refresh, logout",
|
|
"category": "critical",
|
|
"priority": 9,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-006", "AUTH-007", "AUTH-008"],
|
|
"files": [
|
|
{"path": "app/api/auth.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"GET /auth/google - Redirects to Google OAuth consent screen",
|
|
"GET /auth/google/callback - Handles OAuth callback, returns tokens",
|
|
"GET /auth/discord - Redirects to Discord OAuth consent screen",
|
|
"GET /auth/discord/callback - Handles OAuth callback, returns tokens",
|
|
"GET /auth/link/google - Start account linking for Google (requires auth)",
|
|
"GET /auth/link/google/callback - Handle account linking callback",
|
|
"GET /auth/link/discord - Start account linking for Discord (requires auth)",
|
|
"GET /auth/link/discord/callback - Handle account linking callback",
|
|
"POST /auth/refresh - Exchange refresh token for new access token",
|
|
"POST /auth/logout - Revoke refresh token",
|
|
"POST /auth/logout-all - Revoke all refresh tokens (requires auth)",
|
|
"State parameter stored in Redis with short TTL for CSRF protection",
|
|
"Uses settings.base_url for absolute OAuth callback URLs"
|
|
],
|
|
"estimatedHours": 3
|
|
},
|
|
{
|
|
"id": "AUTH-010",
|
|
"name": "Create user API router",
|
|
"description": "REST endpoints for user profile and account management",
|
|
"category": "high",
|
|
"priority": 10,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-008", "AUTH-004"],
|
|
"files": [
|
|
{"path": "app/api/users.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"GET /users/me - Get current user profile",
|
|
"PATCH /users/me - Update display_name, avatar_url",
|
|
"GET /users/me/linked-accounts - List linked OAuth providers",
|
|
"DELETE /users/me/link/{provider} - Unlink OAuth provider",
|
|
"GET /users/me/sessions - Get active session count",
|
|
"All endpoints require authentication via CurrentUser dependency"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-011",
|
|
"name": "Integrate routers in main.py",
|
|
"description": "Mount auth and user routers",
|
|
"category": "high",
|
|
"priority": 11,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-009", "AUTH-010"],
|
|
"files": [
|
|
{"path": "app/main.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Include auth router: prefix='/api'",
|
|
"Include users router: prefix='/api'"
|
|
],
|
|
"estimatedHours": 0.5
|
|
},
|
|
{
|
|
"id": "AUTH-012",
|
|
"name": "Create JWT service tests",
|
|
"description": "Unit tests for token creation and verification",
|
|
"category": "high",
|
|
"priority": 12,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-002"],
|
|
"files": [
|
|
{"path": "tests/services/test_jwt_service.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Test create_access_token returns valid JWT",
|
|
"Test create_refresh_token returns valid JWT with jti",
|
|
"Test verify_access_token extracts correct user_id",
|
|
"Test verify_refresh_token returns user_id and jti",
|
|
"Test expired tokens return None",
|
|
"Test invalid signatures return None",
|
|
"20 tests covering all token operations"
|
|
],
|
|
"estimatedHours": 1.5
|
|
},
|
|
{
|
|
"id": "AUTH-013",
|
|
"name": "Create UserService tests",
|
|
"description": "Integration tests for user CRUD operations",
|
|
"category": "high",
|
|
"priority": 13,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-004"],
|
|
"files": [
|
|
{"path": "tests/services/test_user_service.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Test get_by_id returns user or None",
|
|
"Test get_by_email returns user or None",
|
|
"Test get_by_oauth finds by provider+oauth_id",
|
|
"Test create creates user with correct fields",
|
|
"Test create_from_oauth creates from OAuthUserInfo",
|
|
"Test get_or_create_from_oauth handles all scenarios",
|
|
"Test update updates profile fields",
|
|
"Test update_last_login updates timestamp",
|
|
"Test update_premium manages subscription status",
|
|
"Test link_oauth_account links new providers",
|
|
"Test unlink_oauth_account removes linked providers",
|
|
"29 tests using real Postgres via testcontainers"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-014",
|
|
"name": "Create OAuth service tests",
|
|
"description": "Unit tests for OAuth flows with mocked HTTP",
|
|
"category": "high",
|
|
"priority": 14,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-006", "AUTH-007"],
|
|
"files": [
|
|
{"path": "tests/services/oauth/test_google.py", "status": "complete"},
|
|
{"path": "tests/services/oauth/test_discord.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Mock httpx responses for token exchange",
|
|
"Mock httpx responses for user info",
|
|
"Test authorization URL construction",
|
|
"Test error handling for invalid codes",
|
|
"Test OAuthUserInfo normalization",
|
|
"10 tests for Google, 14 tests for Discord",
|
|
"Uses respx for httpx mocking"
|
|
],
|
|
"estimatedHours": 2
|
|
},
|
|
{
|
|
"id": "AUTH-015",
|
|
"name": "Create auth API endpoint tests",
|
|
"description": "Integration tests for auth endpoints",
|
|
"category": "high",
|
|
"priority": 15,
|
|
"completed": true,
|
|
"dependencies": ["AUTH-009", "AUTH-010"],
|
|
"files": [
|
|
{"path": "tests/api/__init__.py", "status": "complete"},
|
|
{"path": "tests/api/conftest.py", "status": "complete"},
|
|
{"path": "tests/api/test_auth.py", "status": "complete"},
|
|
{"path": "tests/api/test_users.py", "status": "complete"}
|
|
],
|
|
"details": [
|
|
"Test OAuth redirect returns correct URL",
|
|
"Test refresh endpoint returns new access token",
|
|
"Test logout revokes refresh token",
|
|
"Test /users/me returns current user",
|
|
"Test /users/me update works",
|
|
"Test /users/me/linked-accounts returns accounts",
|
|
"Test /users/me/sessions returns count",
|
|
"Test DELETE /users/me/link/{provider} unlinks account",
|
|
"10 tests for auth, 15 tests for users",
|
|
"Uses TestClient with dependency overrides and fakeredis"
|
|
],
|
|
"estimatedHours": 3
|
|
}
|
|
],
|
|
|
|
"testingStrategy": {
|
|
"approach": "Unit tests for services, integration tests for API endpoints",
|
|
"mocking": "httpx responses mocked with respx for OAuth providers, fakeredis for token store",
|
|
"database": "Real Postgres via testcontainers for service tests",
|
|
"coverage": "1072 total tests, 98 tests for auth system"
|
|
},
|
|
|
|
"acceptanceCriteria": [
|
|
{"criterion": "User can login with Google OAuth and receive JWT tokens", "met": true},
|
|
{"criterion": "User can login with Discord OAuth and receive JWT tokens", "met": true},
|
|
{"criterion": "Access tokens expire after configured time", "met": true},
|
|
{"criterion": "Refresh tokens can be used to get new access tokens", "met": true},
|
|
{"criterion": "Logout revokes refresh token (cannot be reused)", "met": true},
|
|
{"criterion": "Protected endpoints return 401 without valid token", "met": true},
|
|
{"criterion": "User can link multiple OAuth providers to one account", "met": true},
|
|
{"criterion": "Premium status is tracked with expiration date", "met": true},
|
|
{"criterion": "All tests pass with high coverage", "met": true}
|
|
],
|
|
|
|
"securityConsiderations": [
|
|
"OAuth state parameter validated to prevent CSRF attacks",
|
|
"OAuth provider tokens never stored (only OAuth ID)",
|
|
"JWT secret key loaded from environment, never hardcoded",
|
|
"Refresh tokens stored in Redis with TTL for revocation support",
|
|
"Access tokens short-lived (30 min) to limit exposure",
|
|
"OAuth callbacks use absolute URLs (base_url config setting)",
|
|
"HTTPS required in production for all auth endpoints"
|
|
],
|
|
|
|
"deferredItems": [
|
|
{
|
|
"item": "PKCE for OAuth",
|
|
"reason": "Not strictly required for server-side OAuth flow",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"item": "Rate limiting on auth endpoints",
|
|
"reason": "Can be added as infrastructure concern later",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"item": "Refresh token rotation",
|
|
"reason": "Current implementation is secure; rotation adds complexity",
|
|
"priority": "low"
|
|
}
|
|
],
|
|
|
|
"dependencies": {
|
|
"existing": [
|
|
"python-jose>=3.5.0 (already installed)",
|
|
"passlib>=1.7.4 (already installed)",
|
|
"bcrypt>=5.0.0 (already installed)"
|
|
],
|
|
"added": [
|
|
"email-validator (for Pydantic EmailStr)",
|
|
"fakeredis (dev - Redis mocking in tests)",
|
|
"respx (dev - httpx mocking in tests)"
|
|
]
|
|
},
|
|
|
|
"phase1Prerequisites": {
|
|
"met": [
|
|
"JWT configuration in Settings class",
|
|
"OAuth configuration in Settings class",
|
|
"User model with OAuth fields",
|
|
"python-jose dependency installed",
|
|
"Database session management",
|
|
"Redis connection utilities"
|
|
]
|
|
},
|
|
|
|
"completionNotes": {
|
|
"totalNewTests": 98,
|
|
"totalTestsAfter": 1072,
|
|
"commit": "996c43f - Implement Phase 2: Authentication system",
|
|
"additionalCommitNeeded": "Fix OAuth absolute URLs and add account linking endpoints"
|
|
}
|
|
}
|