Complete OAuth-based authentication with JWT session management:
Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support
API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions
Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table
Dependencies added: email-validator, fakeredis (dev), respx (dev)
84 new tests, 1058 total passing
123 lines
3.6 KiB
Python
123 lines
3.6 KiB
Python
"""OAuth linked account model for Mantimon TCG.
|
|
|
|
This module defines the OAuthLinkedAccount model for supporting multiple
|
|
OAuth providers per user (account linking).
|
|
|
|
A user can have multiple linked accounts (e.g., both Google and Discord),
|
|
allowing them to log in with either provider.
|
|
|
|
Example:
|
|
# User links Discord to their existing Google account
|
|
linked_account = OAuthLinkedAccount(
|
|
user_id=user.id,
|
|
provider="discord",
|
|
oauth_id="123456789",
|
|
email="player@example.com"
|
|
)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, Index, String, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db.base import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.db.models.user import User
|
|
|
|
|
|
class OAuthLinkedAccount(Base):
|
|
"""Linked OAuth account for multi-provider authentication.
|
|
|
|
Allows users to link multiple OAuth providers to a single account,
|
|
enabling login via any linked provider.
|
|
|
|
The User model still has oauth_provider/oauth_id for the "primary"
|
|
provider (the one used to create the account). This table tracks
|
|
additional linked providers.
|
|
|
|
Attributes:
|
|
id: Unique identifier (UUID).
|
|
user_id: Foreign key to the user who owns this linked account.
|
|
provider: OAuth provider name ('google', 'discord').
|
|
oauth_id: Unique ID from the OAuth provider.
|
|
email: Email address from this OAuth provider (may differ from user's primary email).
|
|
display_name: Display name from this OAuth provider.
|
|
avatar_url: Avatar URL from this OAuth provider.
|
|
linked_at: When this account was linked.
|
|
|
|
Relationships:
|
|
user: The User who owns this linked account.
|
|
"""
|
|
|
|
__tablename__ = "oauth_linked_accounts"
|
|
|
|
# Foreign key to user
|
|
user_id: Mapped[str] = mapped_column(
|
|
UUID(as_uuid=False),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
doc="User who owns this linked account",
|
|
)
|
|
|
|
# OAuth provider info
|
|
provider: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
doc="OAuth provider name (google, discord)",
|
|
)
|
|
oauth_id: Mapped[str] = mapped_column(
|
|
String(255),
|
|
nullable=False,
|
|
doc="Unique ID from OAuth provider",
|
|
)
|
|
|
|
# Additional info from provider
|
|
email: Mapped[str | None] = mapped_column(
|
|
String(255),
|
|
nullable=True,
|
|
doc="Email from this OAuth provider",
|
|
)
|
|
display_name: Mapped[str | None] = mapped_column(
|
|
String(100),
|
|
nullable=True,
|
|
doc="Display name from this OAuth provider",
|
|
)
|
|
avatar_url: Mapped[str | None] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
doc="Avatar URL from this OAuth provider",
|
|
)
|
|
|
|
# When linked
|
|
linked_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
doc="When this account was linked",
|
|
)
|
|
|
|
# Relationship back to user
|
|
user: Mapped["User"] = relationship(
|
|
"User",
|
|
back_populates="linked_accounts",
|
|
)
|
|
|
|
# Indexes and constraints
|
|
__table_args__ = (
|
|
# Each OAuth provider+ID can only be linked to one user
|
|
Index(
|
|
"ix_oauth_linked_accounts_provider_oauth_id",
|
|
"provider",
|
|
"oauth_id",
|
|
unique=True,
|
|
),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<OAuthLinkedAccount(user_id={self.user_id!r}, provider={self.provider!r})>"
|