Files
syndarix/backend/app/api/routes/oauth_provider.py
Felipe Cardoso 16ee4e0cb3 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.
2025-11-25 00:37:23 +01:00

313 lines
10 KiB
Python

# 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