-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Add Discord OAuth provider and corresponding tests #2428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Aisha630
wants to merge
3
commits into
jlowin:main
Choose a base branch
from
Aisha630:discord-auth
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+442
−0
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,322 @@ | ||
| """Discord OAuth provider for FastMCP. | ||
|
|
||
| This module provides a complete Discord OAuth integration that's ready to use | ||
| with just a client ID and client secret. It handles all the complexity of | ||
| Discord's OAuth flow, token validation, and user management. | ||
|
|
||
| Example: | ||
| ```python | ||
| from fastmcp import FastMCP | ||
| from fastmcp.server.auth.providers.discord import DiscordProvider | ||
|
|
||
| # Simple Discord OAuth protection | ||
| auth = DiscordProvider( | ||
| client_id="your-discord-client-id", | ||
| client_secret="your-discord-client-secret" | ||
| ) | ||
|
|
||
| mcp = FastMCP("My Protected Server", auth=auth) | ||
| ``` | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import time | ||
|
|
||
| import httpx | ||
| from key_value.aio.protocols import AsyncKeyValue | ||
| from pydantic import AnyHttpUrl, SecretStr, field_validator | ||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||
|
|
||
| from fastmcp.server.auth import TokenVerifier | ||
| from fastmcp.server.auth.auth import AccessToken | ||
| from fastmcp.server.auth.oauth_proxy import OAuthProxy | ||
| from fastmcp.settings import ENV_FILE | ||
| from fastmcp.utilities.auth import parse_scopes | ||
| from fastmcp.utilities.logging import get_logger | ||
| from fastmcp.utilities.types import NotSet, NotSetT | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| class DiscordProviderSettings(BaseSettings): | ||
| """Settings for Discord OAuth provider.""" | ||
|
|
||
| model_config = SettingsConfigDict( | ||
| env_prefix="FASTMCP_SERVER_AUTH_DISCORD_", | ||
| env_file=ENV_FILE, | ||
| extra="ignore", | ||
| ) | ||
|
|
||
| client_id: str | None = None | ||
| client_secret: SecretStr | None = None | ||
| base_url: AnyHttpUrl | str | None = None | ||
| issuer_url: AnyHttpUrl | str | None = None | ||
| redirect_path: str | None = None | ||
| required_scopes: list[str] | None = None | ||
| timeout_seconds: int | None = None | ||
| allowed_client_redirect_uris: list[str] | None = None | ||
| jwt_signing_key: str | None = None | ||
|
|
||
| @field_validator("required_scopes", mode="before") | ||
| @classmethod | ||
| def _parse_scopes(cls, v): | ||
| return parse_scopes(v) | ||
|
|
||
|
|
||
| class DiscordTokenVerifier(TokenVerifier): | ||
| """Token verifier for Discord OAuth tokens. | ||
|
|
||
| Discord OAuth tokens are opaque (not JWTs), so we verify them | ||
| by calling Discord's tokeninfo API to check if they're valid and get user info. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| required_scopes: list[str] | None = None, | ||
| timeout_seconds: int = 10, | ||
| ): | ||
| """Initialize the Discord token verifier. | ||
|
|
||
| Args: | ||
| required_scopes: Required OAuth scopes (e.g., ['email']) | ||
| timeout_seconds: HTTP request timeout | ||
| """ | ||
| super().__init__(required_scopes=required_scopes) | ||
| self.timeout_seconds = timeout_seconds | ||
|
|
||
| async def verify_token(self, token: str) -> AccessToken | None: | ||
| """Verify Discord OAuth token by calling Discord's tokeninfo API.""" | ||
| try: | ||
| async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: | ||
| # Use Discord's tokeninfo endpoint to validate the token | ||
| headers = { | ||
| "Authorization": f"Bearer {token}", | ||
| "User-Agent": "FastMCP-Discord-OAuth", | ||
| } | ||
| response = await client.get( | ||
| "https://discord.com/api/oauth2/@me", | ||
| headers=headers, | ||
| ) | ||
|
|
||
| if response.status_code != 200: | ||
| logger.debug( | ||
| "Discord token verification failed: %d", | ||
| response.status_code, | ||
| ) | ||
| return None | ||
|
|
||
| token_info = response.json() | ||
|
|
||
| # Check if token is expired | ||
| expires_in = token_info.get("expires_in") | ||
| if expires_in and int(expires_in) <= 0: | ||
| logger.debug("Discord token has expired") | ||
| return None | ||
|
|
||
| # Extract scopes from token info | ||
| scope_string = token_info.get("scope", "") | ||
| token_scopes = [ | ||
| scope.strip() for scope in scope_string.split(" ") if scope.strip() | ||
| ] | ||
|
|
||
| # Check required scopes | ||
| if self.required_scopes: | ||
| token_scopes_set = set(token_scopes) | ||
| required_scopes_set = set(self.required_scopes) | ||
| if not required_scopes_set.issubset(token_scopes_set): | ||
| logger.debug( | ||
| "Discord token missing required scopes. Has %d, needs %d", | ||
| len(token_scopes_set), | ||
| len(required_scopes_set), | ||
| ) | ||
| return None | ||
|
|
||
| # Get additional user info if we have the right scopes | ||
| user_data = {} | ||
| if "identify" in token_scopes: | ||
| try: | ||
| userinfo_response = await client.get( | ||
| "https://discord.com/api/users/@me", | ||
| headers=headers, | ||
| ) | ||
| if userinfo_response.status_code == 200: | ||
| user_data = userinfo_response.json() | ||
| except Exception as e: | ||
| logger.debug("Failed to fetch Discord user info: %s", e) | ||
|
|
||
| # Calculate expiration time | ||
| expires_at = None | ||
| if expires_in: | ||
| expires_at = int(time.time() + int(expires_in)) | ||
|
|
||
| application = token_info.get("application") or {} | ||
| client_id = str(application.get("id", "unknown")) | ||
|
|
||
| # Create AccessToken with Discord user info | ||
| access_token = AccessToken( | ||
| token=token, | ||
| client_id=client_id, | ||
| scopes=token_scopes, | ||
| expires_at=expires_at, | ||
| claims={ | ||
| "sub": user_data.get("id"), | ||
| "username": user_data.get("username"), | ||
| "discriminator": user_data.get("discriminator"), | ||
| "avatar": user_data.get("avatar"), | ||
| "email": user_data.get("email"), | ||
| "verified": user_data.get("verified"), | ||
| "locale": user_data.get("locale"), | ||
| "discord_user": user_data, | ||
| "discord_token_info": token_info, | ||
| }, | ||
| ) | ||
| logger.debug("Discord token verified successfully") | ||
| return access_token | ||
|
|
||
| except httpx.RequestError as e: | ||
| logger.debug("Failed to verify Discord token: %s", e) | ||
| return None | ||
| except Exception as e: | ||
| logger.debug("Discord token verification error: %s", e) | ||
| return None | ||
|
|
||
|
|
||
| class DiscordProvider(OAuthProxy): | ||
| """Complete Discord OAuth provider for FastMCP. | ||
|
|
||
| This provider makes it trivial to add Discord OAuth protection to any | ||
| FastMCP server. Just provide your Discord OAuth app credentials and | ||
| a base URL, and you're ready to go. | ||
|
|
||
| Features: | ||
| - Transparent OAuth proxy to Discord | ||
| - Automatic token validation via Discord's API | ||
| - User information extraction from Discord APIs | ||
| - Minimal configuration required | ||
|
|
||
| Example: | ||
| ```python | ||
| from fastmcp import FastMCP | ||
| from fastmcp.server.auth.providers.discord import DiscordProvider | ||
|
|
||
| auth = DiscordProvider( | ||
| client_id="123456789", | ||
| client_secret="discord-client-secret-abc123...", | ||
| base_url="https://my-server.com" | ||
| ) | ||
|
|
||
| mcp = FastMCP("My App", auth=auth) | ||
| ``` | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| client_id: str | NotSetT = NotSet, | ||
| client_secret: str | NotSetT = NotSet, | ||
| base_url: AnyHttpUrl | str | NotSetT = NotSet, | ||
| issuer_url: AnyHttpUrl | str | NotSetT = NotSet, | ||
| redirect_path: str | NotSetT = NotSet, | ||
| required_scopes: list[str] | NotSetT = NotSet, | ||
| timeout_seconds: int | NotSetT = NotSet, | ||
| allowed_client_redirect_uris: list[str] | NotSetT = NotSet, | ||
| client_storage: AsyncKeyValue | None = None, | ||
| jwt_signing_key: str | bytes | NotSetT = NotSet, | ||
| require_authorization_consent: bool = True, | ||
| ): | ||
| """Initialize Discord OAuth provider. | ||
|
|
||
| Args: | ||
| client_id: Discord OAuth client ID (e.g., "123456789") | ||
| client_secret: Discord OAuth client secret (e.g., "S....") | ||
| base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) | ||
| issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL | ||
| to avoid 404s during discovery when mounting under a path. | ||
| redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") | ||
| required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: | ||
| - "identify" for profile info (default) | ||
| - "email" for email access | ||
| - "guilds" for server membership info | ||
| timeout_seconds: HTTP request timeout for Discord API calls | ||
| allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. | ||
| If None (default), all URIs are allowed. If empty list, no URIs are allowed. | ||
| client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). | ||
| If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The | ||
| disk store will be encrypted using a key derived from the JWT Signing Key. | ||
| jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, | ||
| they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not | ||
| provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. | ||
| require_authorization_consent: Whether to require user consent before authorizing clients (default True). | ||
| When True, users see a consent screen before being redirected to Discord. | ||
| When False, authorization proceeds directly without user confirmation. | ||
| SECURITY WARNING: Only disable for local development or testing environments. | ||
| """ | ||
|
|
||
| settings = DiscordProviderSettings.model_validate( | ||
| { | ||
| k: v | ||
| for k, v in { | ||
| "client_id": client_id, | ||
| "client_secret": client_secret, | ||
| "base_url": base_url, | ||
| "issuer_url": issuer_url, | ||
| "redirect_path": redirect_path, | ||
| "required_scopes": required_scopes, | ||
| "timeout_seconds": timeout_seconds, | ||
| "allowed_client_redirect_uris": allowed_client_redirect_uris, | ||
| "jwt_signing_key": jwt_signing_key, | ||
| }.items() | ||
| if v is not NotSet | ||
| } | ||
| ) | ||
|
|
||
| # Validate required settings | ||
| if not settings.client_id: | ||
| raise ValueError( | ||
| "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID" | ||
| ) | ||
| if not settings.client_secret: | ||
| raise ValueError( | ||
| "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET" | ||
| ) | ||
|
|
||
| # Apply defaults | ||
| timeout_seconds_final = settings.timeout_seconds or 10 | ||
| required_scopes_final = settings.required_scopes or ["identify"] | ||
| allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris | ||
|
|
||
| # Create Discord token verifier | ||
| token_verifier = DiscordTokenVerifier( | ||
| required_scopes=required_scopes_final, | ||
| timeout_seconds=timeout_seconds_final, | ||
| ) | ||
|
|
||
| # Extract secret string from SecretStr | ||
| client_secret_str = ( | ||
| settings.client_secret.get_secret_value() if settings.client_secret else "" | ||
| ) | ||
|
|
||
| # Initialize OAuth proxy with Discord endpoints | ||
| super().__init__( | ||
| upstream_authorization_endpoint="https://discord.com/oauth2/authorize", | ||
| upstream_token_endpoint="https://discord.com/api/oauth2/token", | ||
| upstream_client_id=settings.client_id, | ||
| upstream_client_secret=client_secret_str, | ||
| token_verifier=token_verifier, | ||
| base_url=settings.base_url, | ||
| redirect_path=settings.redirect_path, | ||
| issuer_url=settings.issuer_url | ||
| or settings.base_url, # Default to base_url if not specified | ||
| allowed_client_redirect_uris=allowed_client_redirect_uris_final, | ||
| client_storage=client_storage, | ||
| jwt_signing_key=settings.jwt_signing_key, | ||
| require_authorization_consent=require_authorization_consent, | ||
| ) | ||
|
|
||
| logger.debug( | ||
| "Initialized Discord OAuth provider for client %s with scopes: %s", | ||
| settings.client_id, | ||
| required_scopes_final, | ||
| ) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.