mantimon-tcg/backend/project_plans/PHASE_2_AUTH.json
Cal Corum 3ad79a4860 Fix OAuth absolute URLs and add account linking endpoints
- 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)
2026-01-27 22:06:22 -06:00

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"
}
}