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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user