forked from cardosofelipe/fast-next-template
Initial implementation of OAuth models, endpoints, and migrations
- Added models for `OAuthClient`, `OAuthState`, and `OAuthAccount`. - Created Pydantic schemas to support OAuth flows, client management, and linked accounts. - Implemented skeleton endpoints for OAuth Provider mode: authorization, token, and revocation. - Updated router imports to include new `/oauth` and `/oauth/provider` routes. - Added Alembic migration script to create OAuth-related database tables. - Enhanced `users` table to allow OAuth-only accounts by making `password_hash` nullable.
This commit is contained in:
@@ -7,6 +7,11 @@ Imports all models to ensure they're registered with SQLAlchemy.
|
||||
from app.core.database import Base
|
||||
|
||||
from .base import TimestampMixin, UUIDMixin
|
||||
|
||||
# OAuth models
|
||||
from .oauth_account import OAuthAccount
|
||||
from .oauth_client import OAuthClient
|
||||
from .oauth_state import OAuthState
|
||||
from .organization import Organization
|
||||
|
||||
# Import models
|
||||
@@ -16,6 +21,9 @@ from .user_session import UserSession
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"OAuthAccount",
|
||||
"OAuthClient",
|
||||
"OAuthState",
|
||||
"Organization",
|
||||
"OrganizationRole",
|
||||
"TimestampMixin",
|
||||
|
||||
55
backend/app/models/oauth_account.py
Normal file
55
backend/app/models/oauth_account.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""OAuth account model for linking external OAuth providers to users."""
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class OAuthAccount(Base, UUIDMixin, TimestampMixin):
|
||||
"""
|
||||
Links OAuth provider accounts to users.
|
||||
|
||||
Supports multiple OAuth providers per user (e.g., user can have both
|
||||
Google and GitHub connected). Each provider account is uniquely identified
|
||||
by (provider, provider_user_id).
|
||||
"""
|
||||
|
||||
__tablename__ = "oauth_accounts"
|
||||
|
||||
# Link to user
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# OAuth provider identification
|
||||
provider = Column(
|
||||
String(50), nullable=False, index=True
|
||||
) # google, github, microsoft
|
||||
provider_user_id = Column(String(255), nullable=False) # Provider's unique user ID
|
||||
provider_email = Column(
|
||||
String(255), nullable=True, index=True
|
||||
) # Email from provider (for reference)
|
||||
|
||||
# Optional: store provider tokens for API access
|
||||
# These should be encrypted at rest in production
|
||||
access_token_encrypted = Column(String(2048), nullable=True)
|
||||
refresh_token_encrypted = Column(String(2048), nullable=True)
|
||||
token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", back_populates="oauth_accounts")
|
||||
|
||||
__table_args__ = (
|
||||
# Each provider account can only be linked to one user
|
||||
UniqueConstraint("provider", "provider_user_id", name="uq_oauth_provider_user"),
|
||||
# Index for finding all OAuth accounts for a user + provider
|
||||
Index("ix_oauth_accounts_user_provider", "user_id", "provider"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OAuthAccount {self.provider}:{self.provider_user_id}>"
|
||||
67
backend/app/models/oauth_client.py
Normal file
67
backend/app/models/oauth_client.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""OAuth client model for OAuth provider mode (MCP clients)."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class OAuthClient(Base, UUIDMixin, TimestampMixin):
|
||||
"""
|
||||
Registered OAuth clients (for OAuth provider mode).
|
||||
|
||||
This model stores third-party applications that can authenticate
|
||||
against this API using OAuth 2.0. Used for MCP (Model Context Protocol)
|
||||
client authentication and API access.
|
||||
|
||||
NOTE: This is a skeleton implementation. The full OAuth provider
|
||||
functionality (authorization endpoint, token endpoint, etc.) can be
|
||||
expanded when needed.
|
||||
"""
|
||||
|
||||
__tablename__ = "oauth_clients"
|
||||
|
||||
# Client credentials
|
||||
client_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
client_secret_hash = Column(
|
||||
String(255), nullable=True
|
||||
) # NULL for public clients (PKCE)
|
||||
|
||||
# Client metadata
|
||||
client_name = Column(String(255), nullable=False)
|
||||
client_description = Column(String(1000), nullable=True)
|
||||
|
||||
# Client type: "public" (SPA, mobile) or "confidential" (server-side)
|
||||
client_type = Column(String(20), nullable=False, default="public")
|
||||
|
||||
# Allowed redirect URIs (JSON array)
|
||||
redirect_uris = Column(JSONB, nullable=False, default=list)
|
||||
|
||||
# Allowed scopes (JSON array of scope names)
|
||||
allowed_scopes = Column(JSONB, nullable=False, default=list)
|
||||
|
||||
# Token lifetimes (in seconds)
|
||||
access_token_lifetime = Column(String(10), nullable=False, default="3600") # 1 hour
|
||||
refresh_token_lifetime = Column(
|
||||
String(10), nullable=False, default="604800"
|
||||
) # 7 days
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
# Optional: owner user (for user-registered applications)
|
||||
owner_user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# MCP-specific: URL of the MCP server this client represents
|
||||
mcp_server_url = Column(String(2048), nullable=True)
|
||||
|
||||
# Relationship
|
||||
owner = relationship("User", backref="owned_oauth_clients")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OAuthClient {self.client_name} ({self.client_id[:8]}...)>"
|
||||
45
backend/app/models/oauth_state.py
Normal file
45
backend/app/models/oauth_state.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""OAuth state model for CSRF protection during OAuth flows."""
|
||||
|
||||
from sqlalchemy import Column, DateTime, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from .base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class OAuthState(Base, UUIDMixin, TimestampMixin):
|
||||
"""
|
||||
Temporary storage for OAuth state parameters.
|
||||
|
||||
Prevents CSRF attacks during OAuth flows by storing a random state
|
||||
value that must match on callback. Also stores PKCE code_verifier
|
||||
for the Authorization Code flow with PKCE.
|
||||
|
||||
These records are short-lived (10 minutes by default) and should
|
||||
be deleted after use or expiration.
|
||||
"""
|
||||
|
||||
__tablename__ = "oauth_states"
|
||||
|
||||
# Random state parameter (CSRF protection)
|
||||
state = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# PKCE code_verifier (used to generate code_challenge)
|
||||
code_verifier = Column(String(128), nullable=True)
|
||||
|
||||
# OIDC nonce for ID token replay protection
|
||||
nonce = Column(String(255), nullable=True)
|
||||
|
||||
# OAuth provider (google, github, etc.)
|
||||
provider = Column(String(50), nullable=False)
|
||||
|
||||
# Original redirect URI (for callback validation)
|
||||
redirect_uri = Column(String(500), nullable=True)
|
||||
|
||||
# User ID if this is an account linking flow (user is already logged in)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Expiration time
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OAuthState {self.state[:8]}... ({self.provider})>"
|
||||
@@ -9,7 +9,8 @@ class User(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
# Nullable to support OAuth-only users who never set a password
|
||||
password_hash = Column(String(255), nullable=True)
|
||||
first_name = Column(String(100), nullable=False, default="user")
|
||||
last_name = Column(String(100), nullable=True)
|
||||
phone_number = Column(String(20))
|
||||
@@ -23,6 +24,19 @@ class User(Base, UUIDMixin, TimestampMixin):
|
||||
user_organizations = relationship(
|
||||
"UserOrganization", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
oauth_accounts = relationship(
|
||||
"OAuthAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def has_password(self) -> bool:
|
||||
"""Check if user can login with password (not OAuth-only)."""
|
||||
return self.password_hash is not None
|
||||
|
||||
@property
|
||||
def can_remove_oauth(self) -> bool:
|
||||
"""Check if user can safely remove an OAuth account link."""
|
||||
return self.has_password or len(self.oauth_accounts) > 1
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email}>"
|
||||
|
||||
Reference in New Issue
Block a user