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:
65
core/oauth/__init__.py
Normal file
65
core/oauth/__init__.py
Normal 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",
|
||||
]
|
||||
154
core/oauth/client_registry.py
Normal file
154
core/oauth/client_registry.py
Normal 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
123
core/oauth/csrf.py
Normal 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
91
core/oauth/pkce.py
Normal 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
145
core/oauth/schemas.py
Normal 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
396
core/oauth/server.py
Normal 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
221
core/oauth/storage.py
Normal 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
313
core/oauth/token_manager.py
Normal 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
|
||||
Reference in New Issue
Block a user