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:
Felipe Cardoso
2025-11-25 00:37:23 +01:00
parent e6792c2d6c
commit 16ee4e0cb3
23 changed files with 4109 additions and 13 deletions

View File

@@ -1,9 +1,21 @@
from fastapi import APIRouter
from app.api.routes import admin, auth, organizations, sessions, users
from app.api.routes import (
admin,
auth,
oauth,
oauth_provider,
organizations,
sessions,
users,
)
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(oauth.router, prefix="/oauth", tags=["OAuth"])
api_router.include_router(
oauth_provider.router, prefix="/oauth", tags=["OAuth Provider"]
)
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(sessions.router, prefix="/sessions", tags=["Sessions"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])

View File

@@ -0,0 +1,433 @@
# app/api/routes/oauth.py
"""
OAuth routes for social authentication.
Endpoints:
- GET /oauth/providers - List enabled OAuth providers
- GET /oauth/authorize/{provider} - Get authorization URL
- POST /oauth/callback/{provider} - Handle OAuth callback
- GET /oauth/accounts - List linked OAuth accounts
- DELETE /oauth/accounts/{provider} - Unlink an OAuth account
"""
import logging
import os
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_user, get_optional_current_user
from app.core.auth import decode_token
from app.core.config import settings
from app.core.database import get_db
from app.core.exceptions import AuthenticationError as AuthError
from app.crud import oauth_account
from app.crud.session import session as session_crud
from app.models.user import User
from app.schemas.oauth import (
OAuthAccountsListResponse,
OAuthCallbackRequest,
OAuthCallbackResponse,
OAuthProvidersResponse,
OAuthUnlinkResponse,
)
from app.schemas.sessions import SessionCreate
from app.schemas.users import Token
from app.services.oauth_service import OAuthService
from app.utils.device import extract_device_info
router = APIRouter()
logger = logging.getLogger(__name__)
# Initialize limiter for this router
limiter = Limiter(key_func=get_remote_address)
# Use higher rate limits in test environment
IS_TEST = os.getenv("IS_TEST", "False") == "True"
RATE_MULTIPLIER = 100 if IS_TEST else 1
async def _create_oauth_login_session(
db: AsyncSession,
request: Request,
user: User,
tokens: Token,
provider: str,
) -> None:
"""
Create a session record for successful OAuth login.
This is a best-effort operation - login succeeds even if session creation fails.
"""
try:
device_info = extract_device_info(request)
# Decode refresh token to get JTI and expiration
refresh_payload = decode_token(tokens.refresh_token, verify_type="refresh")
session_data = SessionCreate(
user_id=user.id,
refresh_token_jti=refresh_payload.jti,
device_name=device_info.device_name or f"OAuth ({provider})",
device_id=device_info.device_id,
ip_address=device_info.ip_address,
user_agent=device_info.user_agent,
last_used_at=datetime.now(UTC),
expires_at=datetime.fromtimestamp(refresh_payload.exp, tz=UTC),
location_city=device_info.location_city,
location_country=device_info.location_country,
)
await session_crud.create_session(db, obj_in=session_data)
logger.info(
f"OAuth login successful: {user.email} via {provider} "
f"from {device_info.device_name} (IP: {device_info.ip_address})"
)
except Exception as session_err:
# Log but don't fail login if session creation fails
logger.error(
f"Failed to create session for OAuth login {user.email}: {session_err!s}",
exc_info=True,
)
@router.get(
"/providers",
response_model=OAuthProvidersResponse,
summary="List OAuth Providers",
description="""
Get list of enabled OAuth providers for the login/register UI.
Returns:
List of enabled providers with display info.
""",
operation_id="list_oauth_providers",
)
async def list_providers() -> Any:
"""
Get list of enabled OAuth providers.
This endpoint is public (no authentication required) as it's needed
for the login/register UI to display available social login options.
"""
return OAuthService.get_enabled_providers()
@router.get(
"/authorize/{provider}",
response_model=dict,
summary="Get OAuth Authorization URL",
description="""
Get the authorization URL to redirect the user to the OAuth provider.
The frontend should redirect the user to the returned URL.
After authentication, the provider will redirect back to the callback URL.
**Rate Limit**: 10 requests/minute
""",
operation_id="get_oauth_authorization_url",
)
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def get_authorization_url(
request: Request,
provider: str,
redirect_uri: str = Query(
..., description="Frontend callback URL after OAuth completes"
),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get OAuth authorization URL.
Args:
provider: OAuth provider (google, github)
redirect_uri: Frontend callback URL
current_user: Current user (optional, for account linking)
db: Database session
Returns:
dict with authorization_url and state
"""
if not settings.OAUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OAuth is not enabled",
)
try:
# If user is logged in, this is an account linking flow
user_id = str(current_user.id) if current_user else None
url, state = await OAuthService.create_authorization_url(
db,
provider=provider,
redirect_uri=redirect_uri,
user_id=user_id,
)
return {
"authorization_url": url,
"state": state,
}
except AuthError as e:
logger.warning(f"OAuth authorization failed: {e!s}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"OAuth authorization error: {e!s}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create authorization URL",
)
@router.post(
"/callback/{provider}",
response_model=OAuthCallbackResponse,
summary="OAuth Callback",
description="""
Handle OAuth callback from provider.
The frontend should call this endpoint with the code and state
parameters received from the OAuth provider redirect.
Returns:
JWT tokens for the authenticated user.
**Rate Limit**: 10 requests/minute
""",
operation_id="handle_oauth_callback",
)
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def handle_callback(
request: Request,
provider: str,
callback_data: OAuthCallbackRequest,
redirect_uri: str = Query(
..., description="Must match the redirect_uri used in authorization"
),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Handle OAuth callback.
Args:
provider: OAuth provider (google, github)
callback_data: Code and state from provider
redirect_uri: Original redirect URI (for validation)
db: Database session
Returns:
OAuthCallbackResponse with tokens
"""
if not settings.OAUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OAuth is not enabled",
)
try:
result = await OAuthService.handle_callback(
db,
code=callback_data.code,
state=callback_data.state,
redirect_uri=redirect_uri,
)
# Create session for the login (need to get the user first)
# Note: This requires fetching the user from the token
# For now, we skip session creation here as the result doesn't include user info
# The session will be created on next request if needed
return result
except AuthError as e:
logger.warning(f"OAuth callback failed: {e!s}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
except Exception as e:
logger.error(f"OAuth callback error: {e!s}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed",
)
@router.get(
"/accounts",
response_model=OAuthAccountsListResponse,
summary="List Linked OAuth Accounts",
description="""
Get list of OAuth accounts linked to the current user.
Requires authentication.
""",
operation_id="list_oauth_accounts",
)
async def list_accounts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
List OAuth accounts linked to the current user.
Args:
current_user: Current authenticated user
db: Database session
Returns:
List of linked OAuth accounts
"""
accounts = await oauth_account.get_user_accounts(db, user_id=current_user.id)
return OAuthAccountsListResponse(accounts=accounts)
@router.delete(
"/accounts/{provider}",
response_model=OAuthUnlinkResponse,
summary="Unlink OAuth Account",
description="""
Unlink an OAuth provider from the current user.
The user must have either a password set or another OAuth provider
linked to ensure they can still log in.
**Rate Limit**: 5 requests/minute
""",
operation_id="unlink_oauth_account",
)
@limiter.limit(f"{5 * RATE_MULTIPLIER}/minute")
async def unlink_account(
request: Request,
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Unlink an OAuth provider from the current user.
Args:
provider: Provider to unlink (google, github)
current_user: Current authenticated user
db: Database session
Returns:
Success message
"""
try:
await OAuthService.unlink_provider(
db,
user=current_user,
provider=provider,
)
return OAuthUnlinkResponse(
success=True,
message=f"{provider.capitalize()} account unlinked successfully",
)
except AuthError as e:
logger.warning(f"OAuth unlink failed for {current_user.email}: {e!s}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"OAuth unlink error: {e!s}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to unlink OAuth account",
)
@router.post(
"/link/{provider}",
response_model=dict,
summary="Start Account Linking",
description="""
Start the OAuth flow to link a new provider to the current user.
This is a convenience endpoint that redirects to /authorize/{provider}
with the current user context.
**Rate Limit**: 10 requests/minute
""",
operation_id="start_oauth_link",
)
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def start_link(
request: Request,
provider: str,
redirect_uri: str = Query(
..., description="Frontend callback URL after OAuth completes"
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Start OAuth account linking flow.
This endpoint requires authentication and will initiate an OAuth flow
to link a new provider to the current user's account.
Args:
provider: OAuth provider to link (google, github)
redirect_uri: Frontend callback URL
current_user: Current authenticated user
db: Database session
Returns:
dict with authorization_url and state
"""
if not settings.OAUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OAuth is not enabled",
)
# Check if user already has this provider linked
existing = await oauth_account.get_user_account_by_provider(
db, user_id=current_user.id, provider=provider
)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"You already have a {provider} account linked",
)
try:
url, state = await OAuthService.create_authorization_url(
db,
provider=provider,
redirect_uri=redirect_uri,
user_id=str(current_user.id),
)
return {
"authorization_url": url,
"state": state,
}
except AuthError as e:
logger.warning(f"OAuth link authorization failed: {e!s}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"OAuth link error: {e!s}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create authorization URL",
)

View File

@@ -0,0 +1,312 @@
# app/api/routes/oauth_provider.py
"""
OAuth Provider routes (Authorization Server mode).
This is a skeleton implementation for MCP (Model Context Protocol) client authentication.
Provides basic OAuth 2.0 endpoints that can be expanded for full functionality.
Endpoints:
- GET /.well-known/oauth-authorization-server - Server metadata (RFC 8414)
- GET /oauth/provider/authorize - Authorization endpoint (skeleton)
- POST /oauth/provider/token - Token endpoint (skeleton)
- POST /oauth/provider/revoke - Token revocation endpoint (skeleton)
NOTE: This is intentionally minimal. Full implementation should include:
- Complete authorization code flow
- Refresh token handling
- Scope validation
- Client authentication
- PKCE support
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import oauth_client
from app.schemas.oauth import OAuthServerMetadata
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
"/.well-known/oauth-authorization-server",
response_model=OAuthServerMetadata,
summary="OAuth Server Metadata",
description="""
OAuth 2.0 Authorization Server Metadata (RFC 8414).
Returns server metadata including supported endpoints, scopes,
and capabilities for MCP clients.
""",
operation_id="get_oauth_server_metadata",
tags=["OAuth Provider"],
)
async def get_server_metadata() -> Any:
"""
Get OAuth 2.0 server metadata.
This endpoint is used by MCP clients to discover the authorization
server's capabilities.
"""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled",
)
base_url = settings.OAUTH_ISSUER.rstrip("/")
return OAuthServerMetadata(
issuer=base_url,
authorization_endpoint=f"{base_url}/api/v1/oauth/provider/authorize",
token_endpoint=f"{base_url}/api/v1/oauth/provider/token",
revocation_endpoint=f"{base_url}/api/v1/oauth/provider/revoke",
registration_endpoint=None, # Dynamic registration not implemented
scopes_supported=[
"openid",
"profile",
"email",
"read:users",
"write:users",
"read:organizations",
"write:organizations",
],
response_types_supported=["code"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
)
@router.get(
"/provider/authorize",
summary="Authorization Endpoint (Skeleton)",
description="""
OAuth 2.0 Authorization Endpoint.
**NOTE**: This is a skeleton implementation. In a full implementation,
this would:
1. Validate client_id and redirect_uri
2. Display consent screen to user
3. Generate authorization code
4. Redirect back to client with code
Currently returns a 501 Not Implemented response.
""",
operation_id="oauth_provider_authorize",
tags=["OAuth Provider"],
)
async def authorize(
response_type: str = Query(..., description="Must be 'code'"),
client_id: str = Query(..., description="OAuth client ID"),
redirect_uri: str = Query(..., description="Redirect URI"),
scope: str = Query(default="", description="Requested scopes"),
state: str = Query(default="", description="CSRF state parameter"),
code_challenge: str | None = Query(default=None, description="PKCE code challenge"),
code_challenge_method: str | None = Query(
default=None, description="PKCE method (S256)"
),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Authorization endpoint (skeleton).
In a full implementation, this would:
1. Validate the client and redirect URI
2. Authenticate the user (if not already)
3. Show consent screen
4. Generate authorization code
5. Redirect to redirect_uri with code
"""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled",
)
# Validate client exists
client = await oauth_client.get_by_client_id(db, client_id=client_id)
if not client:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid_client: Unknown client_id",
)
# Validate redirect_uri
if redirect_uri not in (client.redirect_uris or []):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid_request: Invalid redirect_uri",
)
# Skeleton: Return not implemented
# Full implementation would redirect to consent screen
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Authorization endpoint not fully implemented. "
"This is a skeleton for MCP integration.",
)
@router.post(
"/provider/token",
summary="Token Endpoint (Skeleton)",
description="""
OAuth 2.0 Token Endpoint.
**NOTE**: This is a skeleton implementation. In a full implementation,
this would exchange authorization codes for access tokens.
Currently returns a 501 Not Implemented response.
""",
operation_id="oauth_provider_token",
tags=["OAuth Provider"],
)
async def token(
grant_type: str = Form(..., description="Grant type (authorization_code)"),
code: str | None = Form(default=None, description="Authorization code"),
redirect_uri: str | None = Form(default=None, description="Redirect URI"),
client_id: str | None = Form(default=None, description="Client ID"),
client_secret: str | None = Form(default=None, description="Client secret"),
code_verifier: str | None = Form(default=None, description="PKCE code verifier"),
refresh_token: str | None = Form(default=None, description="Refresh token"),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Token endpoint (skeleton).
Supported grant types (when fully implemented):
- authorization_code: Exchange code for tokens
- refresh_token: Refresh access token
"""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled",
)
if grant_type not in ["authorization_code", "refresh_token"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="unsupported_grant_type",
)
# Skeleton: Return not implemented
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Token endpoint not fully implemented. "
"This is a skeleton for MCP integration.",
)
@router.post(
"/provider/revoke",
summary="Token Revocation Endpoint (Skeleton)",
description="""
OAuth 2.0 Token Revocation Endpoint (RFC 7009).
**NOTE**: This is a skeleton implementation.
Currently returns a 501 Not Implemented response.
""",
operation_id="oauth_provider_revoke",
tags=["OAuth Provider"],
)
async def revoke(
token: str = Form(..., description="Token to revoke"),
token_type_hint: str | None = Form(
default=None, description="Token type hint (access_token, refresh_token)"
),
client_id: str | None = Form(default=None, description="Client ID"),
client_secret: str | None = Form(default=None, description="Client secret"),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Token revocation endpoint (skeleton).
In a full implementation, this would invalidate the specified token.
"""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled",
)
# Skeleton: Return not implemented
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Revocation endpoint not fully implemented. "
"This is a skeleton for MCP integration.",
)
# ============================================================================
# Client Management (Admin only)
# ============================================================================
@router.post(
"/provider/clients",
summary="Register OAuth Client (Admin)",
description="""
Register a new OAuth client (admin only).
This endpoint allows creating MCP clients that can authenticate
against this API.
**NOTE**: This is a minimal implementation.
""",
operation_id="register_oauth_client",
tags=["OAuth Provider"],
)
async def register_client(
client_name: str = Form(..., description="Client application name"),
redirect_uris: str = Form(..., description="Comma-separated list of redirect URIs"),
client_type: str = Form(default="public", description="public or confidential"),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Register a new OAuth client (skeleton).
In a full implementation, this would require admin authentication.
"""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled",
)
# NOTE: In production, this should require admin authentication
# For now, this is a skeleton that shows the structure
from app.schemas.oauth import OAuthClientCreate
client_data = OAuthClientCreate(
client_name=client_name,
client_description=None,
redirect_uris=[uri.strip() for uri in redirect_uris.split(",")],
allowed_scopes=["openid", "profile", "email"],
client_type=client_type,
)
client, secret = await oauth_client.create_client(db, obj_in=client_data)
result = {
"client_id": client.client_id,
"client_name": client.client_name,
"client_type": client.client_type,
"redirect_uris": client.redirect_uris,
}
if secret:
result["client_secret"] = secret
result["warning"] = (
"Store the client_secret securely. It will not be shown again."
)
return result