mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 20:33:41 +00:00
151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
import base64
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from fastmcp.server.dependencies import get_http_headers
|
|
|
|
from prowler_mcp_server import __version__
|
|
from prowler_mcp_server.lib.errors import CredentialError
|
|
from prowler_mcp_server.lib.logger import logger
|
|
|
|
|
|
class ProwlerAppAuth:
|
|
"""Handles authentication for Prowler API using API keys or JWT tokens."""
|
|
|
|
def __init__(
|
|
self,
|
|
mode: str = os.getenv("PROWLER_MCP_TRANSPORT_MODE", "stdio"),
|
|
base_url: str = os.getenv("API_BASE_URL", "https://api.prowler.com/api/v1"),
|
|
):
|
|
self.base_url = base_url.rstrip("/")
|
|
logger.info(f"Using Prowler API base URL: {self.base_url}")
|
|
self.mode = mode
|
|
self.access_token: str | None = None
|
|
self.api_key: str | None = None
|
|
|
|
if mode == "stdio": # STDIO mode
|
|
# PROWLER_API_KEY is the current variable; PROWLER_APP_API_KEY is kept
|
|
# as a backward-compatible fallback so existing setups keep working.
|
|
self.api_key = os.getenv("PROWLER_API_KEY") or os.getenv(
|
|
"PROWLER_APP_API_KEY"
|
|
)
|
|
|
|
if not self.api_key:
|
|
raise ValueError("PROWLER_API_KEY environment variable is required")
|
|
|
|
if not self.api_key.startswith("pk_"):
|
|
raise ValueError("Prowler API key format is incorrect")
|
|
|
|
def _parse_jwt(self, token: str) -> dict | None:
|
|
"""Parse JWT token and return payload
|
|
|
|
Args:
|
|
token: JWT token to parse
|
|
|
|
Returns:
|
|
Parsed JWT payload, or None if parsing fails
|
|
"""
|
|
if not token:
|
|
return None
|
|
|
|
try:
|
|
parts = token.split(".")
|
|
if len(parts) != 3:
|
|
return None
|
|
|
|
# Decode base64url
|
|
base64_payload = parts[1]
|
|
# Replace base64url characters
|
|
base64_payload = base64_payload.replace("-", "+").replace("_", "/")
|
|
|
|
# Add padding if necessary
|
|
while len(base64_payload) % 4:
|
|
base64_payload += "="
|
|
|
|
# Decode and parse JSON
|
|
decoded = base64.b64decode(base64_payload).decode("utf-8")
|
|
payload = json.loads(decoded)
|
|
|
|
# A JWT payload is a JSON object. A list or a scalar decodes just as
|
|
# cleanly, so the type is checked here rather than left to blow up as
|
|
# an AttributeError on the first claim read.
|
|
return payload if isinstance(payload, dict) else None
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse JWT token: {e}")
|
|
return None
|
|
|
|
async def authenticate(self) -> str:
|
|
"""Authenticate and return token (API key for STDIO, API key or JWT for HTTP)."""
|
|
if self.mode == "http":
|
|
headers = get_http_headers(include={"authorization"})
|
|
authorization_header = headers.get("authorization", None)
|
|
|
|
if not authorization_header:
|
|
raise CredentialError("No Authorization header was sent")
|
|
|
|
# Extract token from Bearer header. Authentication scheme names are
|
|
# case-insensitive (RFC 7235), and only the scheme prefix is removed:
|
|
# a token that happens to contain the word again keeps it.
|
|
scheme, _, credential = authorization_header.partition(" ")
|
|
token = credential.strip()
|
|
if scheme.lower() != "bearer" or not token:
|
|
raise CredentialError(
|
|
"The Authorization header is not in 'Bearer <token>' form"
|
|
)
|
|
|
|
# Check if it's an API key or JWT token
|
|
if token.startswith("pk_"):
|
|
# API key - no expiration check needed
|
|
return token
|
|
else:
|
|
# JWT token - validate and check expiration
|
|
payload = self._parse_jwt(token)
|
|
if not payload:
|
|
raise CredentialError("The token is not a readable JWT")
|
|
|
|
# Check if token is expired. `exp` is a numeric date in the
|
|
# spec, so a missing or non-numeric one makes the token
|
|
# unusable rather than merely stale -- comparing it would raise
|
|
# a TypeError and leave the failure masked as unclassified.
|
|
exp = payload.get("exp")
|
|
if isinstance(exp, bool) or not isinstance(exp, (int, float)):
|
|
raise CredentialError(
|
|
"The token carries no readable 'exp' expiration claim"
|
|
)
|
|
|
|
now = int(datetime.now().timestamp())
|
|
if exp <= now:
|
|
raise CredentialError("The token has expired")
|
|
|
|
return token
|
|
else:
|
|
# PROWLER_MCP_TRANSPORT_MODE holds something this server does not
|
|
# support. Nothing about a call caused it and nothing about a call
|
|
# can fix it, so it stays unclassified: masked for the model, logged
|
|
# for whoever runs the server.
|
|
raise RuntimeError(f"Invalid mode: {self.mode}")
|
|
|
|
async def get_valid_token(self) -> str:
|
|
"""Get a valid token (API key or JWT token)."""
|
|
if self.mode == "stdio" and self.api_key:
|
|
return self.api_key
|
|
else:
|
|
return await self.authenticate()
|
|
|
|
def get_headers(self, token: str) -> dict[str, str]:
|
|
"""Get headers for API requests with authentication."""
|
|
if token.startswith("pk_"):
|
|
authorization_header = f"Api-Key {token}"
|
|
else:
|
|
authorization_header = f"Bearer {token}"
|
|
|
|
headers = {
|
|
"Authorization": authorization_header,
|
|
"Content-Type": "application/vnd.api+json",
|
|
"Accept": "application/vnd.api+json",
|
|
"User-Agent": f"prowler-mcp-server/{__version__}",
|
|
}
|
|
|
|
return headers
|