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:
433
backend/app/api/routes/oauth.py
Normal file
433
backend/app/api/routes/oauth.py
Normal 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",
|
||||
)
|
||||
312
backend/app/api/routes/oauth_provider.py
Normal file
312
backend/app/api/routes/oauth_provider.py
Normal 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
|
||||
Reference in New Issue
Block a user