Phase 9: OAuth & Account Switching — Google + Authentik, multi-account

Backend:
- OAuth service with pluggable provider architecture (Google + Authentik)
- Generic authorize/callback endpoints for any provider
- Authentik OIDC integration (configurable base URL)
- hashed_password made nullable for OAuth-only users
- Migration 009: nullable password column
- /auth/switch endpoint returns full AuthResponse for account switching
- OAuth-only users get clear error on password login attempt
- UserResponse includes oauth_provider + avatar_url

Frontend:
- OAuth buttons on login form (Google + Authentik)
- OAuth callback handler (/auth/callback route)
- Multi-account auth store (accounts array, addAccount, switchTo, removeAccount)
- Account switcher dropdown in header (hover to see other accounts)
- "Add another account" option
- English + Russian translations

Config:
- GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI
- AUTHENTIK_CLIENT_ID/SECRET/BASE_URL/REDIRECT_URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:56:20 +03:00
parent d86d53f473
commit 5c651b7988
18 changed files with 436 additions and 33 deletions

View File

@@ -77,3 +77,53 @@ async def logout(
@router.get("/me", response_model=UserResponse)
async def me(user: Annotated[User, Depends(get_current_user)]):
return UserResponse.model_validate(user)
# --- OAuth ---
@router.get("/oauth/{provider}/authorize")
async def oauth_authorize(provider: str):
from app.services.oauth_service import get_authorize_url
url = await get_authorize_url(provider)
return {"authorize_url": url}
@router.get("/oauth/{provider}/callback")
async def oauth_callback(
provider: str,
code: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
from app.services.oauth_service import handle_callback
result = await handle_callback(
provider, code, db,
ip_address=request.client.host if request.client else None,
device_info=request.headers.get("user-agent"),
)
# Redirect to frontend with tokens
from fastapi.responses import RedirectResponse
redirect_url = f"/auth/callback?access_token={result['access_token']}&refresh_token={result['refresh_token']}"
return RedirectResponse(url=redirect_url)
@router.post("/switch", response_model=AuthResponse)
async def switch_account(
data: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Switch to another account using its refresh token. Returns full AuthResponse."""
tokens = await auth_service.refresh_tokens(db, data.refresh_token)
# Get user from new access token
from app.core.security import decode_access_token
import uuid as uuid_mod
payload = decode_access_token(tokens.access_token)
user_id = uuid_mod.UUID(payload["sub"])
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
return AuthResponse(
user=UserResponse.model_validate(user),
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
)