Files
prowler/mcp_server/tests/prowler_app/utils/test_auth.py
T

265 lines
9.0 KiB
Python

"""Tests for Prowler API authentication.
Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode``,
``base_url`` and ``jwt_verifying_key`` in default arguments, which Python
evaluates once at module import. ``monkeypatch.setenv`` therefore has no effect
on them -- always pass them explicitly, as these tests do.
"""
import base64
import json
import jwt
import pytest
from prowler_mcp_server.lib.errors import CredentialError
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
from tests.helpers.tokens import (
FAKE_API_KEY,
JWT_SIGNING_KEY,
JWT_VERIFYING_KEY,
MALFORMED_API_KEY,
ROGUE_JWT_SIGNING_KEY,
fake_jwt,
hmac_jwt_keyed_with,
unsigned_jwt,
)
async def test_stdio_mode_reads_the_api_key_from_the_environment():
"""In STDIO transport the key comes from the process environment."""
auth = ProwlerAppAuth(mode="stdio")
assert await auth.get_valid_token() == FAKE_API_KEY
def test_stdio_mode_rejects_a_key_without_the_prowler_prefix(
monkeypatch: pytest.MonkeyPatch,
):
"""A key that is not `pk_`-prefixed is refused at construction.
Failing here rather than on the first API call is what turns a
misconfiguration into an immediate, readable startup error.
"""
monkeypatch.setenv("PROWLER_API_KEY", MALFORMED_API_KEY)
with pytest.raises(ValueError, match="Prowler API key format is incorrect"):
ProwlerAppAuth(mode="stdio")
async def test_http_mode_accepts_a_bearer_api_key(http_request_headers):
"""In HTTP transport the token comes from the request's Authorization header."""
http_request_headers(authorization=f"Bearer {FAKE_API_KEY}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
assert await auth.get_valid_token() == FAKE_API_KEY
def _jwt_with_payload(payload: object) -> str:
"""Mint an unsigned JWT carrying an arbitrary payload.
``fake_jwt`` always writes a well-formed object, so the malformed payloads
below are built here instead.
"""
encoded = (
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
)
return f"header.{encoded}.fake-signature-not-verified"
async def test_http_mode_accepts_a_lowercase_bearer_scheme(http_request_headers):
"""Authentication scheme names are case-insensitive (RFC 7235)."""
http_request_headers(authorization=f"bearer {FAKE_API_KEY}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
assert await auth.get_valid_token() == FAKE_API_KEY
async def test_http_mode_strips_only_the_scheme_prefix(http_request_headers):
"""A token that repeats the scheme keeps it: only the prefix is removed."""
token = f"{FAKE_API_KEY}_Bearer_suffix"
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
assert await auth.get_valid_token() == token
async def test_http_mode_rejects_an_authorization_header_without_a_token(
http_request_headers,
):
"""A bare scheme carries no credential to authenticate with."""
http_request_headers(authorization="Bearer ")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="'Bearer <token>' form"):
await auth.get_valid_token()
# ------------------------------------------------- JWT with a verifying key
async def test_http_mode_accepts_a_jwt_signed_by_the_api_key_pair(
http_request_headers,
):
"""A token signed with the private half of the configured key pair passes."""
token = fake_jwt()
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
assert await auth.get_valid_token() == token
async def test_http_mode_rejects_a_jwt_with_a_forged_signature(http_request_headers):
"""A well-formed, unexpired token signed by another key pair is refused."""
token = fake_jwt(signing_key=ROGUE_JWT_SIGNING_KEY)
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="could not be verified"):
await auth.get_valid_token()
async def test_http_mode_rejects_a_jwt_declaring_the_none_algorithm(
http_request_headers,
):
"""`alg: none` is not in the pinned algorithm list, so the token is refused."""
http_request_headers(authorization=f"Bearer {unsigned_jwt()}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="could not be verified"):
await auth.get_valid_token()
async def test_http_mode_rejects_an_hmac_jwt_keyed_with_the_public_key(
http_request_headers,
):
"""An HS256 token signed with the public key as the secret is refused.
This is the algorithm-confusion attack: the public key is not secret, so a
verifier that honoured the token's own `alg` would accept it.
"""
token = hmac_jwt_keyed_with(JWT_VERIFYING_KEY)
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="could not be verified"):
await auth.get_valid_token()
async def test_http_mode_rejects_an_expired_jwt_with_a_valid_signature(
http_request_headers,
):
"""A correctly signed but expired token is refused locally."""
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="The token has expired"):
await auth.get_valid_token()
async def test_http_mode_rejects_a_signed_jwt_without_an_expiration(
http_request_headers,
):
"""`exp` is required: a token that never expires is refused even if signed."""
token = jwt.encode({"sub": "user"}, JWT_SIGNING_KEY, algorithm="RS256")
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
with pytest.raises(CredentialError, match="could not be verified"):
await auth.get_valid_token()
async def test_http_mode_accepts_the_verifying_key_with_escaped_newlines(
http_request_headers,
):
"""The PEM arrives through an env file, where newlines are written as `\\n`."""
token = fake_jwt()
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(
mode="http", jwt_verifying_key=JWT_VERIFYING_KEY.replace("\n", "\\n")
)
assert await auth.get_valid_token() == token
# ----------------------------------------------- JWT without a verifying key
async def test_http_mode_without_a_verifying_key_only_checks_expiration(
http_request_headers,
):
"""With no key configured the token is forwarded for the API to verify."""
token = fake_jwt(signing_key=ROGUE_JWT_SIGNING_KEY)
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
assert await auth.get_valid_token() == token
async def test_http_mode_rejects_a_jwt_whose_payload_is_not_an_object(
http_request_headers,
):
"""A payload that decodes to a list has no claims, so it is a bad credential.
Without the type check it would reach `payload.get` and fail as an
unclassified `AttributeError`, which the client only sees masked.
"""
http_request_headers(authorization=f"Bearer {_jwt_with_payload(['exp'])}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
with pytest.raises(CredentialError, match="not a readable JWT"):
await auth.get_valid_token()
@pytest.mark.parametrize(
"payload",
[
pytest.param({"sub": "user"}, id="missing"),
pytest.param({"exp": "1700000000"}, id="string"),
pytest.param({"exp": None}, id="null"),
],
)
async def test_http_mode_rejects_a_jwt_without_a_numeric_expiration(
http_request_headers, payload: dict
):
"""`exp` is a numeric date: comparing anything else raises a `TypeError`."""
http_request_headers(authorization=f"Bearer {_jwt_with_payload(payload)}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
with pytest.raises(CredentialError, match="no readable 'exp' expiration claim"):
await auth.get_valid_token()
async def test_http_mode_rejects_an_expired_jwt(http_request_headers):
"""An expired JWT is refused locally instead of being forwarded to the API."""
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
with pytest.raises(CredentialError, match="The token has expired"):
await auth.get_valid_token()
def test_api_keys_and_jwts_use_different_authorization_schemes():
"""Prowler API keys authenticate with `Api-Key`, JWTs with `Bearer`."""
auth = ProwlerAppAuth(mode="stdio")
assert auth.get_headers(FAKE_API_KEY)["Authorization"] == f"Api-Key {FAKE_API_KEY}"
token = fake_jwt()
assert auth.get_headers(token)["Authorization"] == f"Bearer {token}"