- Enforced stricter PKCE requirements by rejecting insecure 'plain' method for public clients. - Transitioned client secret hashing to bcrypt for improved security and migration compatibility. - Added constant-time comparison for state parameter validation to prevent timing attacks. - Improved error handling and logging for OAuth workflows, including malformed headers and invalid scopes. - Upgraded Google OIDC token validation to verify both signature and nonce. - Refactored OAuth service methods and schemas for better readability, consistency, and compliance with RFC specifications.
719 lines
23 KiB
Python
Executable File
719 lines
23 KiB
Python
Executable File
"""
|
|
Async CRUD operations for OAuth models using SQLAlchemy 2.0 patterns.
|
|
|
|
Provides operations for:
|
|
- OAuthAccount: Managing linked OAuth provider accounts
|
|
- OAuthState: CSRF protection state during OAuth flows
|
|
- OAuthClient: Registered OAuth clients (provider mode skeleton)
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import and_, delete, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
from app.crud.base import CRUDBase
|
|
from app.models.oauth_account import OAuthAccount
|
|
from app.models.oauth_client import OAuthClient
|
|
from app.models.oauth_state import OAuthState
|
|
from app.schemas.oauth import OAuthAccountCreate, OAuthClientCreate, OAuthStateCreate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# OAuth Account CRUD
|
|
# ============================================================================
|
|
|
|
|
|
class EmptySchema(BaseModel):
|
|
"""Placeholder schema for CRUD operations that don't need update schemas."""
|
|
|
|
|
|
class CRUDOAuthAccount(CRUDBase[OAuthAccount, OAuthAccountCreate, EmptySchema]):
|
|
"""CRUD operations for OAuth account links."""
|
|
|
|
async def get_by_provider_id(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
provider: str,
|
|
provider_user_id: str,
|
|
) -> OAuthAccount | None:
|
|
"""
|
|
Get OAuth account by provider and provider user ID.
|
|
|
|
Args:
|
|
db: Database session
|
|
provider: OAuth provider name (google, github)
|
|
provider_user_id: User ID from the OAuth provider
|
|
|
|
Returns:
|
|
OAuthAccount if found, None otherwise
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(OAuthAccount)
|
|
.where(
|
|
and_(
|
|
OAuthAccount.provider == provider,
|
|
OAuthAccount.provider_user_id == provider_user_id,
|
|
)
|
|
)
|
|
.options(joinedload(OAuthAccount.user))
|
|
)
|
|
return result.scalar_one_or_none()
|
|
except Exception as e: # pragma: no cover # pragma: no cover
|
|
logger.error(
|
|
f"Error getting OAuth account for {provider}:{provider_user_id}: {e!s}"
|
|
)
|
|
raise
|
|
|
|
async def get_by_provider_email(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
provider: str,
|
|
email: str,
|
|
) -> OAuthAccount | None:
|
|
"""
|
|
Get OAuth account by provider and email.
|
|
|
|
Used for auto-linking existing accounts by email.
|
|
|
|
Args:
|
|
db: Database session
|
|
provider: OAuth provider name
|
|
email: Email address from the OAuth provider
|
|
|
|
Returns:
|
|
OAuthAccount if found, None otherwise
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(OAuthAccount)
|
|
.where(
|
|
and_(
|
|
OAuthAccount.provider == provider,
|
|
OAuthAccount.provider_email == email,
|
|
)
|
|
)
|
|
.options(joinedload(OAuthAccount.user))
|
|
)
|
|
return result.scalar_one_or_none()
|
|
except Exception as e: # pragma: no cover # pragma: no cover
|
|
logger.error(
|
|
f"Error getting OAuth account for {provider} email {email}: {e!s}"
|
|
)
|
|
raise
|
|
|
|
async def get_user_accounts(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: str | UUID,
|
|
) -> list[OAuthAccount]:
|
|
"""
|
|
Get all OAuth accounts linked to a user.
|
|
|
|
Args:
|
|
db: Database session
|
|
user_id: User ID
|
|
|
|
Returns:
|
|
List of OAuthAccount objects
|
|
"""
|
|
try:
|
|
user_uuid = UUID(str(user_id)) if isinstance(user_id, str) else user_id
|
|
|
|
result = await db.execute(
|
|
select(OAuthAccount)
|
|
.where(OAuthAccount.user_id == user_uuid)
|
|
.order_by(OAuthAccount.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Error getting OAuth accounts for user {user_id}: {e!s}")
|
|
raise
|
|
|
|
async def get_user_account_by_provider(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: str | UUID,
|
|
provider: str,
|
|
) -> OAuthAccount | None:
|
|
"""
|
|
Get a specific OAuth account for a user and provider.
|
|
|
|
Args:
|
|
db: Database session
|
|
user_id: User ID
|
|
provider: OAuth provider name
|
|
|
|
Returns:
|
|
OAuthAccount if found, None otherwise
|
|
"""
|
|
try:
|
|
user_uuid = UUID(str(user_id)) if isinstance(user_id, str) else user_id
|
|
|
|
result = await db.execute(
|
|
select(OAuthAccount).where(
|
|
and_(
|
|
OAuthAccount.user_id == user_uuid,
|
|
OAuthAccount.provider == provider,
|
|
)
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(
|
|
f"Error getting OAuth account for user {user_id}, provider {provider}: {e!s}"
|
|
)
|
|
raise
|
|
|
|
async def create_account(
|
|
self, db: AsyncSession, *, obj_in: OAuthAccountCreate
|
|
) -> OAuthAccount:
|
|
"""
|
|
Create a new OAuth account link.
|
|
|
|
Args:
|
|
db: Database session
|
|
obj_in: OAuth account creation data
|
|
|
|
Returns:
|
|
Created OAuthAccount
|
|
|
|
Raises:
|
|
ValueError: If account already exists or creation fails
|
|
"""
|
|
try:
|
|
db_obj = OAuthAccount(
|
|
user_id=obj_in.user_id,
|
|
provider=obj_in.provider,
|
|
provider_user_id=obj_in.provider_user_id,
|
|
provider_email=obj_in.provider_email,
|
|
access_token_encrypted=obj_in.access_token_encrypted,
|
|
refresh_token_encrypted=obj_in.refresh_token_encrypted,
|
|
token_expires_at=obj_in.token_expires_at,
|
|
)
|
|
db.add(db_obj)
|
|
await db.commit()
|
|
await db.refresh(db_obj)
|
|
|
|
logger.info(
|
|
f"OAuth account created: {obj_in.provider} linked to user {obj_in.user_id}"
|
|
)
|
|
return db_obj
|
|
except IntegrityError as e: # pragma: no cover
|
|
await db.rollback()
|
|
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
|
|
if "uq_oauth_provider_user" in error_msg.lower():
|
|
logger.warning(
|
|
f"OAuth account already exists: {obj_in.provider}:{obj_in.provider_user_id}"
|
|
)
|
|
raise ValueError(
|
|
f"This {obj_in.provider} account is already linked to another user"
|
|
)
|
|
logger.error(f"Integrity error creating OAuth account: {error_msg}")
|
|
raise ValueError(f"Failed to create OAuth account: {error_msg}")
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error creating OAuth account: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
async def delete_account(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: str | UUID,
|
|
provider: str,
|
|
) -> bool:
|
|
"""
|
|
Delete an OAuth account link.
|
|
|
|
Args:
|
|
db: Database session
|
|
user_id: User ID
|
|
provider: OAuth provider name
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
try:
|
|
user_uuid = UUID(str(user_id)) if isinstance(user_id, str) else user_id
|
|
|
|
result = await db.execute(
|
|
delete(OAuthAccount).where(
|
|
and_(
|
|
OAuthAccount.user_id == user_uuid,
|
|
OAuthAccount.provider == provider,
|
|
)
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
deleted = result.rowcount > 0
|
|
if deleted:
|
|
logger.info(
|
|
f"OAuth account deleted: {provider} unlinked from user {user_id}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"OAuth account not found for deletion: {provider} for user {user_id}"
|
|
)
|
|
|
|
return deleted
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(
|
|
f"Error deleting OAuth account {provider} for user {user_id}: {e!s}"
|
|
)
|
|
raise
|
|
|
|
async def update_tokens(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
account: OAuthAccount,
|
|
access_token_encrypted: str | None = None,
|
|
refresh_token_encrypted: str | None = None,
|
|
token_expires_at: datetime | None = None,
|
|
) -> OAuthAccount:
|
|
"""
|
|
Update OAuth tokens for an account.
|
|
|
|
Args:
|
|
db: Database session
|
|
account: OAuthAccount to update
|
|
access_token_encrypted: New encrypted access token
|
|
refresh_token_encrypted: New encrypted refresh token
|
|
token_expires_at: New token expiration time
|
|
|
|
Returns:
|
|
Updated OAuthAccount
|
|
"""
|
|
try:
|
|
if access_token_encrypted is not None:
|
|
account.access_token_encrypted = access_token_encrypted
|
|
if refresh_token_encrypted is not None:
|
|
account.refresh_token_encrypted = refresh_token_encrypted
|
|
if token_expires_at is not None:
|
|
account.token_expires_at = token_expires_at
|
|
|
|
db.add(account)
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return account
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error updating OAuth tokens: {e!s}")
|
|
raise
|
|
|
|
|
|
# ============================================================================
|
|
# OAuth State CRUD
|
|
# ============================================================================
|
|
|
|
|
|
class CRUDOAuthState(CRUDBase[OAuthState, OAuthStateCreate, EmptySchema]):
|
|
"""CRUD operations for OAuth state (CSRF protection)."""
|
|
|
|
async def create_state(
|
|
self, db: AsyncSession, *, obj_in: OAuthStateCreate
|
|
) -> OAuthState:
|
|
"""
|
|
Create a new OAuth state for CSRF protection.
|
|
|
|
Args:
|
|
db: Database session
|
|
obj_in: OAuth state creation data
|
|
|
|
Returns:
|
|
Created OAuthState
|
|
"""
|
|
try:
|
|
db_obj = OAuthState(
|
|
state=obj_in.state,
|
|
code_verifier=obj_in.code_verifier,
|
|
nonce=obj_in.nonce,
|
|
provider=obj_in.provider,
|
|
redirect_uri=obj_in.redirect_uri,
|
|
user_id=obj_in.user_id,
|
|
expires_at=obj_in.expires_at,
|
|
)
|
|
db.add(db_obj)
|
|
await db.commit()
|
|
await db.refresh(db_obj)
|
|
|
|
logger.debug(f"OAuth state created for {obj_in.provider}")
|
|
return db_obj
|
|
except IntegrityError as e: # pragma: no cover
|
|
await db.rollback()
|
|
# State collision (extremely rare with cryptographic random)
|
|
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
|
|
logger.error(f"OAuth state collision: {error_msg}")
|
|
raise ValueError("Failed to create OAuth state, please retry")
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error creating OAuth state: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
async def get_and_consume_state(
|
|
self, db: AsyncSession, *, state: str
|
|
) -> OAuthState | None:
|
|
"""
|
|
Get and delete OAuth state (consume it).
|
|
|
|
This ensures each state can only be used once (replay protection).
|
|
|
|
Args:
|
|
db: Database session
|
|
state: State string to look up
|
|
|
|
Returns:
|
|
OAuthState if found and valid, None otherwise
|
|
"""
|
|
try:
|
|
# Get the state
|
|
result = await db.execute(
|
|
select(OAuthState).where(OAuthState.state == state)
|
|
)
|
|
db_obj = result.scalar_one_or_none()
|
|
|
|
if db_obj is None:
|
|
logger.warning(f"OAuth state not found: {state[:8]}...")
|
|
return None
|
|
|
|
# Check expiration
|
|
# Handle both timezone-aware and timezone-naive datetimes
|
|
now = datetime.now(UTC)
|
|
expires_at = db_obj.expires_at
|
|
if expires_at.tzinfo is None:
|
|
# SQLite returns naive datetimes, assume UTC
|
|
expires_at = expires_at.replace(tzinfo=UTC)
|
|
|
|
if expires_at < now:
|
|
logger.warning(f"OAuth state expired: {state[:8]}...")
|
|
await db.delete(db_obj)
|
|
await db.commit()
|
|
return None
|
|
|
|
# Delete it (consume)
|
|
await db.delete(db_obj)
|
|
await db.commit()
|
|
|
|
logger.debug(f"OAuth state consumed: {state[:8]}...")
|
|
return db_obj
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error consuming OAuth state: {e!s}")
|
|
raise
|
|
|
|
async def cleanup_expired(self, db: AsyncSession) -> int:
|
|
"""
|
|
Clean up expired OAuth states.
|
|
|
|
Should be called periodically to remove stale states.
|
|
|
|
Args:
|
|
db: Database session
|
|
|
|
Returns:
|
|
Number of states deleted
|
|
"""
|
|
try:
|
|
now = datetime.now(UTC)
|
|
|
|
stmt = delete(OAuthState).where(OAuthState.expires_at < now)
|
|
result = await db.execute(stmt)
|
|
await db.commit()
|
|
|
|
count = result.rowcount
|
|
if count > 0:
|
|
logger.info(f"Cleaned up {count} expired OAuth states")
|
|
|
|
return count
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error cleaning up expired OAuth states: {e!s}")
|
|
raise
|
|
|
|
|
|
# ============================================================================
|
|
# OAuth Client CRUD (Provider Mode - Skeleton)
|
|
# ============================================================================
|
|
|
|
|
|
class CRUDOAuthClient(CRUDBase[OAuthClient, OAuthClientCreate, EmptySchema]):
|
|
"""
|
|
CRUD operations for OAuth clients (provider mode).
|
|
|
|
This is a skeleton implementation for MCP client registration.
|
|
Full implementation can be expanded when needed.
|
|
"""
|
|
|
|
async def get_by_client_id(
|
|
self, db: AsyncSession, *, client_id: str
|
|
) -> OAuthClient | None:
|
|
"""
|
|
Get OAuth client by client_id.
|
|
|
|
Args:
|
|
db: Database session
|
|
client_id: OAuth client ID
|
|
|
|
Returns:
|
|
OAuthClient if found, None otherwise
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(OAuthClient).where(
|
|
and_(
|
|
OAuthClient.client_id == client_id,
|
|
OAuthClient.is_active == True, # noqa: E712
|
|
)
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Error getting OAuth client {client_id}: {e!s}")
|
|
raise
|
|
|
|
async def create_client(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
obj_in: OAuthClientCreate,
|
|
owner_user_id: UUID | None = None,
|
|
) -> tuple[OAuthClient, str | None]:
|
|
"""
|
|
Create a new OAuth client.
|
|
|
|
Args:
|
|
db: Database session
|
|
obj_in: OAuth client creation data
|
|
owner_user_id: Optional owner user ID
|
|
|
|
Returns:
|
|
Tuple of (created OAuthClient, client_secret or None for public clients)
|
|
"""
|
|
try:
|
|
# Generate client_id
|
|
client_id = secrets.token_urlsafe(32)
|
|
|
|
# Generate client_secret for confidential clients
|
|
client_secret = None
|
|
client_secret_hash = None
|
|
if obj_in.client_type == "confidential":
|
|
client_secret = secrets.token_urlsafe(48)
|
|
# SECURITY: Use bcrypt for secret storage (not SHA-256)
|
|
# bcrypt is computationally expensive, making brute-force attacks infeasible
|
|
from app.core.auth import get_password_hash
|
|
|
|
client_secret_hash = get_password_hash(client_secret)
|
|
|
|
db_obj = OAuthClient(
|
|
client_id=client_id,
|
|
client_secret_hash=client_secret_hash,
|
|
client_name=obj_in.client_name,
|
|
client_description=obj_in.client_description,
|
|
client_type=obj_in.client_type,
|
|
redirect_uris=obj_in.redirect_uris,
|
|
allowed_scopes=obj_in.allowed_scopes,
|
|
owner_user_id=owner_user_id,
|
|
is_active=True,
|
|
)
|
|
db.add(db_obj)
|
|
await db.commit()
|
|
await db.refresh(db_obj)
|
|
|
|
logger.info(
|
|
f"OAuth client created: {obj_in.client_name} ({client_id[:8]}...)"
|
|
)
|
|
return db_obj, client_secret
|
|
except IntegrityError as e: # pragma: no cover
|
|
await db.rollback()
|
|
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
|
|
logger.error(f"Error creating OAuth client: {error_msg}")
|
|
raise ValueError(f"Failed to create OAuth client: {error_msg}")
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error creating OAuth client: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
async def deactivate_client(
|
|
self, db: AsyncSession, *, client_id: str
|
|
) -> OAuthClient | None:
|
|
"""
|
|
Deactivate an OAuth client.
|
|
|
|
Args:
|
|
db: Database session
|
|
client_id: OAuth client ID
|
|
|
|
Returns:
|
|
Deactivated OAuthClient if found, None otherwise
|
|
"""
|
|
try:
|
|
client = await self.get_by_client_id(db, client_id=client_id)
|
|
if client is None:
|
|
return None
|
|
|
|
client.is_active = False
|
|
db.add(client)
|
|
await db.commit()
|
|
await db.refresh(client)
|
|
|
|
logger.info(f"OAuth client deactivated: {client.client_name}")
|
|
return client
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error deactivating OAuth client {client_id}: {e!s}")
|
|
raise
|
|
|
|
async def validate_redirect_uri(
|
|
self, db: AsyncSession, *, client_id: str, redirect_uri: str
|
|
) -> bool:
|
|
"""
|
|
Validate that a redirect URI is allowed for a client.
|
|
|
|
Args:
|
|
db: Database session
|
|
client_id: OAuth client ID
|
|
redirect_uri: Redirect URI to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
try:
|
|
client = await self.get_by_client_id(db, client_id=client_id)
|
|
if client is None:
|
|
return False
|
|
|
|
return redirect_uri in (client.redirect_uris or [])
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Error validating redirect URI: {e!s}")
|
|
return False
|
|
|
|
async def verify_client_secret(
|
|
self, db: AsyncSession, *, client_id: str, client_secret: str
|
|
) -> bool:
|
|
"""
|
|
Verify client credentials.
|
|
|
|
Args:
|
|
db: Database session
|
|
client_id: OAuth client ID
|
|
client_secret: Client secret to verify
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(OAuthClient).where(
|
|
and_(
|
|
OAuthClient.client_id == client_id,
|
|
OAuthClient.is_active == True, # noqa: E712
|
|
)
|
|
)
|
|
)
|
|
client = result.scalar_one_or_none()
|
|
|
|
if client is None or client.client_secret_hash is None:
|
|
return False
|
|
|
|
# SECURITY: Verify secret using bcrypt (not SHA-256)
|
|
# This supports both old SHA-256 hashes (for migration) and new bcrypt hashes
|
|
from app.core.auth import verify_password
|
|
|
|
stored_hash: str = str(client.client_secret_hash)
|
|
|
|
# Check if it's a bcrypt hash (starts with $2b$) or legacy SHA-256
|
|
if stored_hash.startswith("$2"):
|
|
# New bcrypt format
|
|
return verify_password(client_secret, stored_hash)
|
|
else:
|
|
# Legacy SHA-256 format - still support for migration
|
|
import hashlib
|
|
|
|
secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
|
|
return secrets.compare_digest(stored_hash, secret_hash)
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Error verifying client secret: {e!s}")
|
|
return False
|
|
|
|
async def get_all_clients(
|
|
self, db: AsyncSession, *, include_inactive: bool = False
|
|
) -> list[OAuthClient]:
|
|
"""
|
|
Get all OAuth clients.
|
|
|
|
Args:
|
|
db: Database session
|
|
include_inactive: Whether to include inactive clients
|
|
|
|
Returns:
|
|
List of OAuthClient objects
|
|
"""
|
|
try:
|
|
query = select(OAuthClient).order_by(OAuthClient.created_at.desc())
|
|
if not include_inactive:
|
|
query = query.where(OAuthClient.is_active == True) # noqa: E712
|
|
|
|
result = await db.execute(query)
|
|
return list(result.scalars().all())
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Error getting all OAuth clients: {e!s}")
|
|
raise
|
|
|
|
async def delete_client(self, db: AsyncSession, *, client_id: str) -> bool:
|
|
"""
|
|
Delete an OAuth client permanently.
|
|
|
|
Note: This will cascade delete related records (tokens, consents, etc.)
|
|
due to foreign key constraints.
|
|
|
|
Args:
|
|
db: Database session
|
|
client_id: OAuth client ID
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
delete(OAuthClient).where(OAuthClient.client_id == client_id)
|
|
)
|
|
await db.commit()
|
|
|
|
deleted = result.rowcount > 0
|
|
if deleted:
|
|
logger.info(f"OAuth client deleted: {client_id}")
|
|
else:
|
|
logger.warning(f"OAuth client not found for deletion: {client_id}")
|
|
|
|
return deleted
|
|
except Exception as e: # pragma: no cover
|
|
await db.rollback()
|
|
logger.error(f"Error deleting OAuth client {client_id}: {e!s}")
|
|
raise
|
|
|
|
|
|
# ============================================================================
|
|
# Singleton instances
|
|
# ============================================================================
|
|
|
|
oauth_account = CRUDOAuthAccount(OAuthAccount)
|
|
oauth_state = CRUDOAuthState(OAuthState)
|
|
oauth_client = CRUDOAuthClient(OAuthClient)
|