forked from cardosofelipe/fast-next-template
- 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.
313 lines
10 KiB
Python
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
|