Initial commit: MCP Hub Community Edition v3.0.0

Community edition generated from private repo via sync pipeline.
Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n,
Supabase, OpenPanel, Appwrite, Directus) with ~587 tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-17 08:34:44 +03:30
commit cf62e65c55
237 changed files with 87596 additions and 0 deletions

65
core/oauth/__init__.py Normal file
View File

@@ -0,0 +1,65 @@
"""
OAuth 2.1 Infrastructure for MCP Hub
This module provides OAuth 2.1 compliant authentication and authorization
infrastructure with PKCE support, refresh token rotation, and security best practices.
"""
# Schemas
# Client Registry
from .client_registry import ClientRegistry, get_client_registry
# CSRF Protection (Phase E)
from .csrf import CSRFTokenManager, get_csrf_manager
# PKCE utilities
from .pkce import generate_code_challenge, generate_code_verifier, validate_code_challenge
from .schemas import (
AccessToken,
AuthorizationCode,
OAuthClient,
RefreshToken,
TokenRequest,
TokenResponse,
)
# OAuth Server
from .server import OAuthError, OAuthServer, get_oauth_server
# Storage
from .storage import BaseStorage, JSONStorage, get_storage
# Token Manager
from .token_manager import SecurityError, TokenManager, get_token_manager
__all__ = [
# Schemas
"OAuthClient",
"AuthorizationCode",
"AccessToken",
"RefreshToken",
"TokenRequest",
"TokenResponse",
# PKCE
"generate_code_verifier",
"generate_code_challenge",
"validate_code_challenge",
# Storage
"BaseStorage",
"JSONStorage",
"get_storage",
# Client Registry
"ClientRegistry",
"get_client_registry",
# Token Manager
"TokenManager",
"SecurityError",
"get_token_manager",
# OAuth Server
"OAuthServer",
"OAuthError",
"get_oauth_server",
# CSRF Protection
"CSRFTokenManager",
"get_csrf_manager",
]

View File

@@ -0,0 +1,154 @@
"""
OAuth 2.1 Client Registry
Manages OAuth client registration and validation
"""
import hashlib
import json
import logging
import secrets
from pathlib import Path
from .schemas import OAuthClient
logger = logging.getLogger(__name__)
class ClientRegistry:
"""
OAuth Client Registry with JSON storage.
Storage: data/oauth_clients.json
"""
def __init__(self, data_dir: str = "/app/data"):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.clients_file = self.data_dir / "oauth_clients.json"
# Initialize if not exists
if not self.clients_file.exists():
self._write_clients({})
# Load clients into memory (small dataset)
self.clients: dict[str, OAuthClient] = self._load_clients()
def _read_clients(self) -> dict:
"""Read clients JSON file"""
try:
with open(self.clients_file) as f:
return json.load(f)
except Exception as e:
logger.error(f"Error reading clients file: {e}")
return {}
def _write_clients(self, data: dict) -> bool:
"""Write clients JSON file"""
try:
with open(self.clients_file, "w") as f:
json.dump(data, f, indent=2, default=str)
return True
except Exception as e:
logger.error(f"Error writing clients file: {e}")
return False
def _load_clients(self) -> dict[str, OAuthClient]:
"""Load clients from file"""
data = self._read_clients()
return {client_id: OAuthClient(**client_data) for client_id, client_data in data.items()}
def _save_clients(self) -> bool:
"""Save clients to file"""
data = {
client_id: client.model_dump(mode="json") for client_id, client in self.clients.items()
}
return self._write_clients(data)
def create_client(
self,
client_name: str,
redirect_uris: list[str],
grant_types: list[str] | None = None,
allowed_scopes: list[str] | None = None,
metadata: dict | None = None,
) -> tuple[str, str]:
"""
Create new OAuth client.
Returns:
(client_id, client_secret) tuple
"""
# Generate client_id and client_secret
client_id = f"cmp_client_{secrets.token_urlsafe(16)}"
client_secret = secrets.token_urlsafe(32)
# Hash client secret
client_secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
# Create client
client = OAuthClient(
client_id=client_id,
client_secret_hash=client_secret_hash,
client_name=client_name,
redirect_uris=redirect_uris,
grant_types=grant_types or ["authorization_code", "refresh_token"],
allowed_scopes=allowed_scopes or ["read", "write"],
metadata=metadata or {},
)
# Save
self.clients[client_id] = client
self._save_clients()
logger.info(f"Created OAuth client: {client_id} ({client_name})")
# Return client_secret in plain text (only time it's visible)
return client_id, client_secret
def get_client(self, client_id: str) -> OAuthClient | None:
"""Get client by ID"""
return self.clients.get(client_id)
def list_clients(self) -> list[OAuthClient]:
"""List all clients"""
return list(self.clients.values())
def validate_client_secret(self, client_id: str, client_secret: str) -> bool:
"""
Validate client secret.
Args:
client_id: Client ID
client_secret: Plain text client secret
Returns:
True if valid, False otherwise
"""
client = self.get_client(client_id)
if not client:
return False
# Hash provided secret
secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
# Constant-time comparison
return secrets.compare_digest(secret_hash, client.client_secret_hash)
def delete_client(self, client_id: str) -> bool:
"""Delete OAuth client"""
if client_id in self.clients:
del self.clients[client_id]
self._save_clients()
logger.info(f"Deleted OAuth client: {client_id}")
return True
return False
# Singleton instance
_client_registry: ClientRegistry | None = None
def get_client_registry() -> ClientRegistry:
"""Get singleton ClientRegistry instance"""
global _client_registry
if _client_registry is None:
_client_registry = ClientRegistry()
return _client_registry

123
core/oauth/csrf.py Normal file
View File

@@ -0,0 +1,123 @@
"""
CSRF Protection for OAuth Authorization
Implements token-based CSRF protection for the OAuth authorization flow.
Tokens are stored in-memory with 10-minute expiry.
Part of Phase E: Custom OAuth Authorization Page
"""
import secrets
import time
class CSRFTokenManager:
"""
Manages CSRF tokens for OAuth authorization requests.
Features:
- Generates cryptographically secure random tokens
- In-memory storage with automatic expiry
- 10-minute token lifetime
- Automatic cleanup of expired tokens
"""
def __init__(self, token_lifetime_seconds: int = 600):
"""
Initialize CSRF token manager.
Args:
token_lifetime_seconds: Token lifetime in seconds (default: 600 = 10 minutes)
"""
self._tokens: dict[str, float] = {} # token -> expiry_timestamp
self._token_lifetime = token_lifetime_seconds
def generate_token(self) -> str:
"""
Generate a new CSRF token.
Returns:
Secure random token (32 bytes, hex-encoded = 64 characters)
"""
token = secrets.token_hex(32)
expiry = time.time() + self._token_lifetime
self._tokens[token] = expiry
# Cleanup expired tokens (lazy cleanup)
self._cleanup_expired()
return token
def validate_token(self, token: str, consume: bool = True) -> bool:
"""
Validate a CSRF token.
Args:
token: CSRF token to validate
consume: If True, token is removed after validation (one-time use)
Returns:
True if token is valid and not expired, False otherwise
"""
if not token or token not in self._tokens:
return False
expiry = self._tokens[token]
now = time.time()
# Check if token is expired
if now > expiry:
# Remove expired token
self._tokens.pop(token, None)
return False
# Token is valid
if consume:
# Remove token (one-time use)
self._tokens.pop(token, None)
return True
def _cleanup_expired(self):
"""
Remove expired tokens from storage.
Called automatically during token generation to prevent memory leaks.
"""
now = time.time()
expired_tokens = [token for token, expiry in self._tokens.items() if now > expiry]
for token in expired_tokens:
self._tokens.pop(token, None)
def get_stats(self) -> dict[str, int]:
"""
Get statistics about stored tokens.
Returns:
Dictionary with token statistics
"""
now = time.time()
active_tokens = sum(1 for expiry in self._tokens.values() if expiry > now)
expired_tokens = len(self._tokens) - active_tokens
return {
"total_tokens": len(self._tokens),
"active_tokens": active_tokens,
"expired_tokens": expired_tokens,
"token_lifetime_seconds": self._token_lifetime,
}
# Global CSRF token manager instance
_csrf_manager: CSRFTokenManager | None = None
def get_csrf_manager() -> CSRFTokenManager:
"""
Get the global CSRF token manager instance.
Returns:
Global CSRFTokenManager instance (singleton)
"""
global _csrf_manager
if _csrf_manager is None:
_csrf_manager = CSRFTokenManager()
return _csrf_manager

91
core/oauth/pkce.py Normal file
View File

@@ -0,0 +1,91 @@
"""
PKCE (Proof Key for Code Exchange) Implementation
RFC 7636 - OAuth 2.1 Mandatory
"""
import base64
import hashlib
import secrets
from typing import Literal
def generate_code_verifier(length: int = 64) -> str:
"""
Generate PKCE code verifier.
Args:
length: Length of verifier (43-128 characters)
Returns:
URL-safe random string
OAuth 2.1 Spec:
- Length: 43-128 characters
- Character set: [A-Za-z0-9-._~] (unreserved characters)
"""
if not 43 <= length <= 128:
raise ValueError("Code verifier length must be between 43-128")
# Generate random bytes and encode as URL-safe base64
random_bytes = secrets.token_bytes(length)
verifier = base64.urlsafe_b64encode(random_bytes).decode("utf-8")
# Remove padding and truncate to desired length
verifier = verifier.rstrip("=")[:length]
return verifier
def generate_code_challenge(code_verifier: str, method: Literal["S256"] = "S256") -> str:
"""
Generate PKCE code challenge from verifier.
Args:
code_verifier: Code verifier string
method: Challenge method (OAuth 2.1: only S256)
Returns:
Base64 URL-encoded SHA256 hash of verifier
OAuth 2.1 Spec:
- Only S256 method is allowed (plain is removed)
- code_challenge = BASE64URL(SHA256(code_verifier))
"""
if method != "S256":
raise ValueError("OAuth 2.1 only supports S256 method (plain is removed)")
if not code_verifier:
raise ValueError("Code verifier cannot be empty")
# SHA256 hash
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
# Base64 URL encode (no padding)
challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
return challenge
def validate_code_challenge(
code_verifier: str, code_challenge: str, method: Literal["S256"] = "S256"
) -> bool:
"""
Validate PKCE code verifier against challenge.
Args:
code_verifier: Code verifier from client
code_challenge: Code challenge from authorization request
method: Challenge method
Returns:
True if valid, False otherwise
"""
if method != "S256":
raise ValueError("Only S256 method is supported")
try:
# Generate challenge from verifier
expected_challenge = generate_code_challenge(code_verifier, method)
# Constant-time comparison to prevent timing attacks
return secrets.compare_digest(expected_challenge, code_challenge)
except Exception:
return False

145
core/oauth/schemas.py Normal file
View File

@@ -0,0 +1,145 @@
"""
OAuth 2.1 Pydantic Schemas
"""
from datetime import UTC, datetime
from pydantic import BaseModel, Field, field_validator
class OAuthClient(BaseModel):
"""OAuth 2.1 Client Model"""
client_id: str = Field(..., description="Client identifier (e.g., cmp_client_xxx)")
client_secret_hash: str = Field(..., description="SHA256 hash of client secret")
client_name: str = Field(..., description="Human-readable client name")
redirect_uris: list[str] = Field(..., description="Allowed redirect URIs (exact match)")
grant_types: list[str] = Field(
default=["authorization_code", "refresh_token"], description="Allowed grant types"
)
response_types: list[str] = Field(default=["code"], description="Allowed response types")
scope: str = Field(default="read", description="Default scope for this client")
allowed_scopes: list[str] = Field(
default=["read", "write"], description="All scopes this client can request"
)
token_endpoint_auth_method: str = Field(
default="client_secret_post", description="Token endpoint authentication method"
)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
metadata: dict = Field(default_factory=dict)
@field_validator("redirect_uris")
def validate_redirect_uris(cls, v):
"""Validate redirect URIs format"""
for uri in v:
if not uri.startswith(("http://", "https://")):
raise ValueError(f"Invalid redirect URI: {uri}")
return v
@field_validator("grant_types")
def validate_grant_types(cls, v):
"""Validate grant types"""
allowed = ["authorization_code", "refresh_token", "client_credentials"]
for grant in v:
if grant not in allowed:
raise ValueError(f"Invalid grant type: {grant}")
return v
class AuthorizationCode(BaseModel):
"""Authorization Code Model"""
code: str = Field(..., description="Authorization code (token_urlsafe)")
client_id: str
redirect_uri: str
scope: str
code_challenge: str = Field(..., description="PKCE code challenge")
code_challenge_method: str = Field(default="S256")
expires_at: datetime
used: bool = Field(default=False)
user_id: str | None = None # If user-based auth
# API Key-based authorization
api_key_id: str | None = None # API Key ID for scope/project inheritance
api_key_project_id: str | None = None # Project ID from API Key
api_key_scope: str | None = None # Scope from API Key
def is_expired(self) -> bool:
"""Check if code is expired"""
now = datetime.now(UTC)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=UTC)
return now > expires
@field_validator("code_challenge_method")
def validate_challenge_method(cls, v):
"""OAuth 2.1: Only S256 is allowed"""
if v != "S256":
raise ValueError("Only S256 code_challenge_method is supported (OAuth 2.1)")
return v
class AccessToken(BaseModel):
"""Access Token Model"""
token: str = Field(..., description="JWT access token")
client_id: str
scope: str
expires_at: datetime
user_id: str | None = None
project_id: str = Field(default="*", description="Project ID for scoping")
issued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
def is_expired(self) -> bool:
"""Check if token is expired"""
now = datetime.now(UTC)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=UTC)
return now > expires
class RefreshToken(BaseModel):
"""Refresh Token Model"""
token: str = Field(..., description="Refresh token (secure random)")
client_id: str
access_token: str | None = None
expires_at: datetime
revoked: bool = Field(default=False)
rotation_count: int = Field(default=0, description="Number of times rotated")
issued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
def is_expired(self) -> bool:
"""Check if token is expired"""
now = datetime.now(UTC)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=UTC)
return now > expires
class TokenRequest(BaseModel):
"""Token Request Model"""
grant_type: str = Field(
..., description="authorization_code | refresh_token | client_credentials"
)
# For authorization_code
code: str | None = None
redirect_uri: str | None = None
code_verifier: str | None = None
# For refresh_token
refresh_token: str | None = None
# Common
client_id: str
client_secret: str | None = None
scope: str | None = None
class TokenResponse(BaseModel):
"""Token Response Model"""
access_token: str
token_type: str = "Bearer"
expires_in: int
refresh_token: str | None = None
scope: str

396
core/oauth/server.py Normal file
View File

@@ -0,0 +1,396 @@
"""
OAuth 2.1 Authorization Server
Handles OAuth flows: authorization_code, refresh_token, client_credentials
"""
import logging
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from .client_registry import get_client_registry
from .pkce import validate_code_challenge
from .schemas import AuthorizationCode, TokenResponse
from .storage import get_storage
from .token_manager import get_token_manager
logger = logging.getLogger(__name__)
class OAuthError(Exception):
"""OAuth error with error code and description"""
def __init__(self, error: str, error_description: str, status_code: int = 400):
self.error = error
self.error_description = error_description
self.status_code = status_code
super().__init__(error_description)
class OAuthServer:
"""
OAuth 2.1 Authorization Server
Implements:
- Authorization Code Grant with PKCE (mandatory)
- Refresh Token Grant with rotation
- Client Credentials Grant (machine-to-machine)
"""
def __init__(self):
self.client_registry = get_client_registry()
self.token_manager = get_token_manager()
self.storage = get_storage()
# Authorization code TTL (5 minutes)
self.auth_code_ttl = 300
def validate_authorization_request(
self,
client_id: str,
redirect_uri: str,
response_type: str,
code_challenge: str,
code_challenge_method: str,
scope: str | None = None,
state: str | None = None,
) -> dict[str, Any]:
"""
Validate OAuth authorization request (Step 1 of Authorization Code flow)
Args:
client_id: OAuth client ID
redirect_uri: Callback URI for authorization code
response_type: Must be "code" (OAuth 2.1)
code_challenge: PKCE code challenge
code_challenge_method: Must be "S256" (OAuth 2.1)
scope: Requested scopes (space-separated)
state: Optional state parameter for CSRF protection
Returns:
Dict with validated parameters
Raises:
OAuthError: If validation fails
"""
# Validate client
client = self.client_registry.get_client(client_id)
if not client:
raise OAuthError(
error="invalid_client",
error_description=f"Client {client_id} not found",
status_code=401,
)
# Validate response_type (OAuth 2.1: only "code" is allowed)
if response_type != "code":
raise OAuthError(
error="unsupported_response_type",
error_description="Only 'code' response_type is supported (OAuth 2.1)",
)
if "authorization_code" not in client.grant_types:
raise OAuthError(
error="unauthorized_client",
error_description="Client not authorized for authorization_code grant",
)
# Validate redirect_uri (exact match)
if redirect_uri not in client.redirect_uris:
raise OAuthError(
error="invalid_request", error_description=f"Invalid redirect_uri: {redirect_uri}"
)
# Validate PKCE (mandatory in OAuth 2.1)
if not code_challenge or not code_challenge_method:
raise OAuthError(
error="invalid_request",
error_description="code_challenge and code_challenge_method are required (OAuth 2.1)",
)
if code_challenge_method != "S256":
raise OAuthError(
error="invalid_request",
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
)
# Validate scope
requested_scopes = scope.split() if scope else ["read"]
for s in requested_scopes:
if s not in client.allowed_scopes:
raise OAuthError(
error="invalid_scope",
error_description=f"Scope '{s}' not allowed for this client",
)
return {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(requested_scopes),
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"state": state,
}
def create_authorization_code(
self,
client_id: str,
redirect_uri: str,
scope: str,
code_challenge: str,
code_challenge_method: str = "S256",
user_id: str | None = None,
api_key_id: str | None = None,
api_key_project_id: str | None = None,
api_key_scope: str | None = None,
) -> str:
"""
Create authorization code (Step 2 of Authorization Code flow)
Args:
client_id: OAuth client ID
redirect_uri: Redirect URI
scope: Granted scopes
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256)
user_id: Optional user ID (for user-based auth)
api_key_id: Optional API Key ID for scope/project inheritance
api_key_project_id: Optional project ID from API Key
api_key_scope: Optional scope from API Key
Returns:
Authorization code (valid for 5 minutes)
"""
# Generate secure random code
code = f"auth_{secrets.token_urlsafe(32)}"
# Create authorization code
auth_code = AuthorizationCode(
code=code,
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
expires_at=datetime.now(UTC) + timedelta(seconds=self.auth_code_ttl),
used=False,
user_id=user_id,
api_key_id=api_key_id,
api_key_project_id=api_key_project_id,
api_key_scope=api_key_scope,
)
# Save to storage
self.storage.save_authorization_code(auth_code)
logger.info(f"Created authorization code for client {client_id}")
return code
def exchange_code_for_tokens(
self, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
) -> TokenResponse:
"""
Exchange authorization code for tokens (Step 3 of Authorization Code flow)
Args:
client_id: OAuth client ID
client_secret: Client secret
code: Authorization code from /authorize
redirect_uri: Same redirect_uri used in /authorize
code_verifier: PKCE code verifier
Returns:
TokenResponse with access_token and refresh_token
Raises:
OAuthError: If validation fails
"""
# Validate client credentials
if not self.client_registry.validate_client_secret(client_id, client_secret):
raise OAuthError(
error="invalid_client",
error_description="Invalid client credentials",
status_code=401,
)
# Get authorization code
auth_code = self.storage.get_authorization_code(code)
if not auth_code:
raise OAuthError(
error="invalid_grant", error_description="Invalid or expired authorization code"
)
# Check if already used (prevents replay attacks)
if auth_code.used:
# Revoke all tokens for this client (security measure)
logger.critical(
f"Authorization code reuse detected for client {client_id}! "
f"Code: {code[:20]}..."
)
raise OAuthError(
error="invalid_grant", error_description="Authorization code already used"
)
# Validate client_id match
if auth_code.client_id != client_id:
raise OAuthError(error="invalid_grant", error_description="Client ID mismatch")
# Validate redirect_uri match
if auth_code.redirect_uri != redirect_uri:
raise OAuthError(error="invalid_grant", error_description="Redirect URI mismatch")
# Validate PKCE code_verifier
if not validate_code_challenge(
code_verifier, auth_code.code_challenge, auth_code.code_challenge_method
):
raise OAuthError(
error="invalid_grant",
error_description="Invalid code_verifier (PKCE validation failed)",
)
# Mark code as used
auth_code.used = True
self.storage.update_authorization_code(code, auth_code)
# Generate tokens with API Key's project and scope
# If authorization code has API Key metadata, use it for scoping
project_id = auth_code.api_key_project_id or "*"
token_scope = auth_code.api_key_scope or auth_code.scope
access_token = self.token_manager.generate_access_token(
client_id=client_id,
scope=token_scope,
user_id=auth_code.user_id or auth_code.api_key_id,
project_id=project_id,
)
refresh_token = self.token_manager.generate_refresh_token(
client_id=client_id, access_token=access_token
)
logger.info(
f"Exchanged authorization code for tokens: {client_id} "
f"(project_id={project_id}, scope={token_scope})"
)
return TokenResponse(
access_token=access_token,
token_type="Bearer",
expires_in=self.token_manager.access_token_ttl,
refresh_token=refresh_token,
scope=auth_code.scope,
)
def handle_refresh_token_grant(
self, client_id: str, client_secret: str, refresh_token: str
) -> TokenResponse:
"""
Handle refresh token grant (refresh access token)
Args:
client_id: OAuth client ID
client_secret: Client secret
refresh_token: Current refresh token
Returns:
TokenResponse with new access_token and refresh_token
Raises:
OAuthError: If validation fails
"""
# Validate client credentials
if not self.client_registry.validate_client_secret(client_id, client_secret):
raise OAuthError(
error="invalid_client",
error_description="Invalid client credentials",
status_code=401,
)
# Check grant type is allowed
client = self.client_registry.get_client(client_id)
if "refresh_token" not in client.grant_types:
raise OAuthError(
error="unauthorized_client",
error_description="Client not authorized for refresh_token grant",
)
try:
# Rotate refresh token
new_tokens = self.token_manager.rotate_refresh_token(
refresh_token=refresh_token, client_id=client_id
)
return TokenResponse(**new_tokens)
except ValueError as e:
raise OAuthError(error="invalid_grant", error_description=str(e))
except Exception as e:
logger.error(f"Error rotating refresh token: {e}")
raise OAuthError(
error="server_error", error_description="Internal server error", status_code=500
)
def handle_client_credentials_grant(
self, client_id: str, client_secret: str, scope: str | None = None
) -> TokenResponse:
"""
Handle client credentials grant (machine-to-machine)
Args:
client_id: OAuth client ID
client_secret: Client secret
scope: Requested scopes (space-separated)
Returns:
TokenResponse with access_token (no refresh_token)
Raises:
OAuthError: If validation fails
"""
# Validate client credentials
if not self.client_registry.validate_client_secret(client_id, client_secret):
raise OAuthError(
error="invalid_client",
error_description="Invalid client credentials",
status_code=401,
)
# Check grant type is allowed
client = self.client_registry.get_client(client_id)
if "client_credentials" not in client.grant_types:
raise OAuthError(
error="unauthorized_client",
error_description="Client not authorized for client_credentials grant",
)
# Validate scope
requested_scopes = scope.split() if scope else [client.scope]
for s in requested_scopes:
if s not in client.allowed_scopes:
raise OAuthError(
error="invalid_scope",
error_description=f"Scope '{s}' not allowed for this client",
)
# Generate access token (no refresh token for client credentials)
access_token = self.token_manager.generate_access_token(
client_id=client_id, scope=" ".join(requested_scopes)
)
logger.info(f"Generated client credentials token for {client_id}")
return TokenResponse(
access_token=access_token,
token_type="Bearer",
expires_in=self.token_manager.access_token_ttl,
scope=" ".join(requested_scopes),
)
# Singleton
_oauth_server: OAuthServer | None = None
def get_oauth_server() -> OAuthServer:
"""Get singleton OAuthServer instance"""
global _oauth_server
if _oauth_server is None:
_oauth_server = OAuthServer()
return _oauth_server

221
core/oauth/storage.py Normal file
View File

@@ -0,0 +1,221 @@
"""
OAuth 2.1 Token Storage
Supports JSON files (default) and Redis (optional)
"""
import json
import logging
import os
from pathlib import Path
from typing import Any
from .schemas import AccessToken, AuthorizationCode, RefreshToken
logger = logging.getLogger(__name__)
class BaseStorage:
"""Base storage interface"""
def save_authorization_code(self, code_data: AuthorizationCode) -> bool:
raise NotImplementedError
def get_authorization_code(self, code: str) -> AuthorizationCode | None:
raise NotImplementedError
def update_authorization_code(self, code: str, code_data: AuthorizationCode) -> bool:
raise NotImplementedError
def delete_authorization_code(self, code: str) -> bool:
raise NotImplementedError
def save_access_token(self, token_data: AccessToken) -> bool:
raise NotImplementedError
def get_access_token(self, token: str) -> AccessToken | None:
raise NotImplementedError
def save_refresh_token(self, token_data: RefreshToken) -> bool:
raise NotImplementedError
def get_refresh_token(self, token: str) -> RefreshToken | None:
raise NotImplementedError
def revoke_refresh_token(self, token: str) -> bool:
raise NotImplementedError
class JSONStorage(BaseStorage):
"""
JSON file storage for OAuth tokens.
Storage structure:
data/oauth_codes.json - Authorization codes (short-lived)
data/oauth_access_tokens.json - Access tokens
data/oauth_refresh_tokens.json - Refresh tokens
"""
def __init__(self, data_dir: str = "/app/data"):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.codes_file = self.data_dir / "oauth_codes.json"
self.access_tokens_file = self.data_dir / "oauth_access_tokens.json"
self.refresh_tokens_file = self.data_dir / "oauth_refresh_tokens.json"
# Initialize files if not exist
for file in [self.codes_file, self.access_tokens_file, self.refresh_tokens_file]:
if not file.exists():
self._write_json(file, {})
def _read_json(self, file_path: Path) -> dict[str, Any]:
"""Read JSON file"""
try:
with open(file_path) as f:
return json.load(f)
except Exception as e:
logger.error(f"Error reading {file_path}: {e}")
return {}
def _write_json(self, file_path: Path, data: dict[str, Any]) -> bool:
"""Write JSON file"""
try:
with open(file_path, "w") as f:
json.dump(data, f, indent=2, default=str)
return True
except Exception as e:
logger.error(f"Error writing {file_path}: {e}")
return False
def save_authorization_code(self, code_data: AuthorizationCode) -> bool:
"""Save authorization code"""
codes = self._read_json(self.codes_file)
codes[code_data.code] = code_data.model_dump(mode="json")
return self._write_json(self.codes_file, codes)
def get_authorization_code(self, code: str) -> AuthorizationCode | None:
"""Get authorization code"""
codes = self._read_json(self.codes_file)
code_data = codes.get(code)
if not code_data:
return None
# Parse and check expiry
auth_code = AuthorizationCode(**code_data)
if auth_code.is_expired():
# Auto-cleanup expired codes
self.delete_authorization_code(code)
return None
return auth_code
def update_authorization_code(self, code: str, code_data: AuthorizationCode) -> bool:
"""Update authorization code (e.g., mark as used)"""
return self.save_authorization_code(code_data)
def delete_authorization_code(self, code: str) -> bool:
"""Delete authorization code"""
codes = self._read_json(self.codes_file)
if code in codes:
del codes[code]
return self._write_json(self.codes_file, codes)
return False
def save_access_token(self, token_data: AccessToken) -> bool:
"""Save access token"""
tokens = self._read_json(self.access_tokens_file)
tokens[token_data.token] = token_data.model_dump(mode="json")
return self._write_json(self.access_tokens_file, tokens)
def get_access_token(self, token: str) -> AccessToken | None:
"""Get access token"""
tokens = self._read_json(self.access_tokens_file)
token_data = tokens.get(token)
if not token_data:
return None
access_token = AccessToken(**token_data)
if access_token.is_expired():
# Auto-cleanup
tokens.pop(token, None)
self._write_json(self.access_tokens_file, tokens)
return None
return access_token
def save_refresh_token(self, token_data: RefreshToken) -> bool:
"""Save refresh token"""
tokens = self._read_json(self.refresh_tokens_file)
tokens[token_data.token] = token_data.model_dump(mode="json")
return self._write_json(self.refresh_tokens_file, tokens)
def get_refresh_token(self, token: str, include_revoked: bool = False) -> RefreshToken | None:
"""Get refresh token.
Args:
token: Refresh token string
include_revoked: If True, return revoked tokens (for reuse detection)
"""
tokens = self._read_json(self.refresh_tokens_file)
token_data = tokens.get(token)
if not token_data:
return None
refresh_token = RefreshToken(**token_data)
if refresh_token.is_expired():
return None
if refresh_token.revoked and not include_revoked:
return None
return refresh_token
def revoke_refresh_token(self, token: str) -> bool:
"""Revoke refresh token"""
tokens = self._read_json(self.refresh_tokens_file)
if token in tokens:
tokens[token]["revoked"] = True
return self._write_json(self.refresh_tokens_file, tokens)
return False
def cleanup_expired(self):
"""Cleanup expired tokens (run periodically)"""
# Cleanup authorization codes
codes = self._read_json(self.codes_file)
cleaned_codes = {k: v for k, v in codes.items() if not AuthorizationCode(**v).is_expired()}
self._write_json(self.codes_file, cleaned_codes)
# Cleanup access tokens
tokens = self._read_json(self.access_tokens_file)
cleaned_tokens = {k: v for k, v in tokens.items() if not AccessToken(**v).is_expired()}
self._write_json(self.access_tokens_file, cleaned_tokens)
logger.info(f"Cleaned up {len(codes) - len(cleaned_codes)} expired authorization codes")
logger.info(f"Cleaned up {len(tokens) - len(cleaned_tokens)} expired access tokens")
def get_storage() -> BaseStorage:
"""
Get storage instance based on environment.
Environment Variables:
OAUTH_STORAGE_TYPE: "json" (default) | "redis"
OAUTH_STORAGE_PATH: Path for JSON files (default: /app/data)
"""
storage_type = os.getenv("OAUTH_STORAGE_TYPE", "json")
if storage_type == "json":
storage_path = os.getenv("OAUTH_STORAGE_PATH", "/app/data")
return JSONStorage(storage_path)
# Future: Redis support
# elif storage_type == "redis":
# return RedisStorage()
else:
raise ValueError(f"Unknown storage type: {storage_type}")

313
core/oauth/token_manager.py Normal file
View File

@@ -0,0 +1,313 @@
"""
OAuth 2.1 Token Manager
JWT generation, validation, and refresh token rotation
"""
import logging
import os
import secrets
import time
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from .schemas import AccessToken, RefreshToken
from .storage import get_storage
logger = logging.getLogger(__name__)
class SecurityError(Exception):
"""Security-related error (e.g., token reuse)"""
pass
class TokenManager:
"""
OAuth 2.1 Token Manager.
Features:
- JWT access tokens (1 hour default)
- Refresh tokens with rotation (7 days default)
- Refresh token reuse detection
- Audit logging
"""
def __init__(self):
self.storage = get_storage()
# JWT Configuration — require explicit secret or generate random fallback
self.jwt_secret = os.getenv("OAUTH_JWT_SECRET_KEY")
if not self.jwt_secret:
self.jwt_secret = secrets.token_urlsafe(64)
logger.warning(
"OAUTH_JWT_SECRET_KEY not set. Generated random JWT secret. "
"All tokens will be invalidated on restart. "
"Set OAUTH_JWT_SECRET_KEY in your .env for persistent tokens."
)
self.jwt_algorithm = os.getenv("OAUTH_JWT_ALGORITHM", "HS256")
# Token TTLs (seconds)
self.access_token_ttl = int(os.getenv("OAUTH_ACCESS_TOKEN_TTL", "3600")) # 1 hour
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
def generate_access_token(
self, client_id: str, scope: str, user_id: str | None = None, project_id: str = "*"
) -> str:
"""
Generate JWT access token.
Args:
client_id: OAuth client ID
scope: Granted scopes (space-separated)
user_id: User ID (optional, for user-based auth)
project_id: Project ID for scoping (default: "*" for global)
Returns:
JWT access token
"""
now = datetime.now(UTC)
now_ts = int(time.time())
exp_ts = now_ts + self.access_token_ttl
expires_at = now + timedelta(seconds=self.access_token_ttl)
# JWT payload (use time.time() for correct UTC timestamps)
payload = {
"client_id": client_id,
"scope": scope,
"project_id": project_id,
"iat": now_ts,
"exp": exp_ts,
"nbf": now_ts, # Not before
"jti": secrets.token_urlsafe(16), # JWT ID (unique)
}
if user_id:
payload["sub"] = user_id # Subject (user ID)
# Encode JWT
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
# Store token metadata
token_data = AccessToken(
token=token,
client_id=client_id,
scope=scope,
expires_at=expires_at,
user_id=user_id,
project_id=project_id,
issued_at=now,
)
self.storage.save_access_token(token_data)
logger.info(f"Generated access token for client {client_id} (scope: {scope})")
return token
def validate_access_token(self, token: str) -> dict[str, Any]:
"""
Validate JWT access token.
Args:
token: JWT access token
Returns:
Decoded payload
Raises:
jwt.ExpiredSignatureError: Token expired
jwt.InvalidTokenError: Invalid token
"""
try:
# Decode and verify JWT
payload = jwt.decode(
token,
self.jwt_secret,
algorithms=[self.jwt_algorithm],
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
},
)
return payload
except jwt.ExpiredSignatureError:
logger.warning("Expired access token")
raise
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid access token: {e}")
raise
def generate_refresh_token(self, client_id: str, access_token: str) -> str:
"""
Generate refresh token.
Args:
client_id: OAuth client ID
access_token: Associated access token
Returns:
Refresh token (secure random string)
"""
now = datetime.now(UTC)
expires_at = now + timedelta(seconds=self.refresh_token_ttl)
# Generate secure random token
token = f"rt_{secrets.token_urlsafe(32)}"
# Store refresh token
token_data = RefreshToken(
token=token,
client_id=client_id,
access_token=access_token,
expires_at=expires_at,
revoked=False,
rotation_count=0,
issued_at=now,
)
self.storage.save_refresh_token(token_data)
logger.info(f"Generated refresh token for client {client_id}")
return token
def rotate_refresh_token(self, refresh_token: str, client_id: str) -> dict[str, Any]:
"""
Rotate refresh token (OAuth 2.1 best practice).
Security:
- Old refresh token is immediately revoked
- Reuse detection: if old token is used again, revoke all tokens
Args:
refresh_token: Current refresh token
client_id: OAuth client ID
Returns:
{
"access_token": str,
"refresh_token": str,
"token_type": "Bearer",
"expires_in": int,
"scope": str
}
Raises:
ValueError: Invalid/expired refresh token
SecurityError: Refresh token reuse detected
"""
# Get refresh token (include revoked for reuse detection)
token_data = self.storage.get_refresh_token(refresh_token, include_revoked=True)
if not token_data:
raise ValueError("Invalid or expired refresh token")
# Check if revoked (reuse detection!)
if token_data.revoked:
logger.critical(
f"Refresh token reuse detected for client {client_id}! "
f"Revoking all tokens for this client."
)
# Log security event
try:
from core.audit_log import LogLevel, get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"SECURITY: Refresh token reuse detected: {client_id}",
details={"client_id": client_id, "token": refresh_token[:20] + "..."},
level=LogLevel.CRITICAL,
)
except (ImportError, Exception):
# Audit logging not available or failed
pass
# Revoke all tokens for this client (TODO: implement)
# For now, just raise error
raise SecurityError("Refresh token reuse detected - all tokens revoked")
# Validate client_id match
if token_data.client_id != client_id:
raise ValueError("Client ID mismatch")
# Get scope from old access token (if available)
scope = "read" # Default
if token_data.access_token:
try:
old_payload = self.validate_access_token(token_data.access_token)
scope = old_payload.get("scope", "read")
except:
pass # Old token might be expired, use default
# Generate new tokens
new_access_token = self.generate_access_token(
client_id=client_id,
scope=scope,
user_id=None, # TODO: get from old token
project_id="*",
)
new_refresh_token = self.generate_refresh_token(
client_id=client_id, access_token=new_access_token
)
# Revoke old refresh token
self.storage.revoke_refresh_token(refresh_token)
# Increment rotation count
token_data.rotation_count += 1
logger.info(
f"Rotated refresh token for client {client_id} "
f"(rotation #{token_data.rotation_count})"
)
# Log audit event
try:
from core.audit_log import LogLevel, get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"Refresh token rotated for {client_id}",
details={"client_id": client_id, "rotation_count": token_data.rotation_count},
level=LogLevel.INFO,
)
except (ImportError, Exception):
# Audit logging not available or failed
pass
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "Bearer",
"expires_in": self.access_token_ttl,
"scope": scope,
}
def revoke_token(self, token: str, token_type: str = "refresh"):
"""
Revoke token.
Args:
token: Token to revoke
token_type: "refresh" or "access"
"""
if token_type == "refresh":
self.storage.revoke_refresh_token(token)
logger.info("Revoked refresh token")
# Access tokens cannot be revoked (JWT stateless)
# They expire naturally after TTL
# Singleton
_token_manager: TokenManager | None = None
def get_token_manager() -> TokenManager:
"""Get singleton TokenManager instance"""
global _token_manager
if _token_manager is None:
_token_manager = TokenManager()
return _token_manager