blob: 067ffc3190420722aee6e94a34bc4250f6ae80b5 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Claim, blacklist, and rate-limit boundaries for JWT validation."""
from __future__ import annotations
import time
from types import SimpleNamespace
import pytest
from doris_mcp_server.auth.token_validators import (
RateLimiter,
TokenBlacklist,
TokenValidator,
)
def _security_config() -> SimpleNamespace:
return SimpleNamespace(
jwt_verify_signature=True,
jwt_verify_audience=True,
jwt_verify_issuer=True,
jwt_require_exp=True,
jwt_require_iat=True,
jwt_require_nbf=False,
jwt_leeway=0,
jwt_audience="doris-mcp-clients",
jwt_issuer="doris-mcp-test",
)
def _validator() -> TokenValidator:
return TokenValidator(SimpleNamespace(security=_security_config()))
def _valid_payload(**overrides: object) -> dict[str, object]:
now = time.time()
payload: dict[str, object] = {
"iss": "doris-mcp-test",
"aud": "doris-mcp-clients",
"exp": now + 120,
"iat": now,
"jti": "token-1",
"sub": "reader-1",
}
payload.update(overrides)
return payload
@pytest.mark.asyncio
async def test_blacklist_lifecycle_and_cleanup() -> None:
blacklist = TokenBlacklist(cleanup_interval=1)
now = time.time()
await blacklist.add_token("expired", now - 1)
await blacklist.add_token("active", now + 60)
assert await blacklist.is_blacklisted("active") is True
assert await blacklist.remove_token("missing") is False
stats = await blacklist.get_stats()
assert stats["total_blacklisted"] == 2
assert stats["active_blacklisted"] == 1
assert stats["expired_blacklisted"] == 1
assert await blacklist.cleanup_expired() == 1
assert await blacklist.remove_token("active") is True
await blacklist.start()
assert blacklist._cleanup_task is not None
await blacklist.stop()
assert blacklist._cleanup_task.cancelled()
@pytest.mark.asyncio
async def test_rate_limiter_enforces_window_and_reports_usage() -> None:
limiter = RateLimiter(max_requests=1, time_window=60)
assert await limiter.is_allowed("reader-1") is True
assert await limiter.is_allowed("reader-1") is False
usage = await limiter.get_usage("reader-1")
assert usage == {
"user_id": "reader-1",
"requests_in_window": 1,
"max_requests": 1,
"time_window": 60,
"remaining_requests": 0,
}
limiter._request_history["reader-2"] = [0.0]
assert await limiter.is_allowed("reader-2") is True
@pytest.mark.asyncio
async def test_validator_accepts_valid_claims_and_reports_runtime_stats() -> None:
validator = _validator()
payload = _valid_payload(aud=["another-client", "doris-mcp-clients"])
result = await validator.validate_claims(payload)
assert result == {
"valid": True,
"user_id": "reader-1",
"payload": payload,
}
await validator.revoke_token("revoked", time.time() + 60)
stats = await validator.get_validation_stats()
assert stats["blacklist"]["active_blacklisted"] == 1
assert stats["validation_config"]["verify_signature"] is True
usage = await validator.get_user_rate_limit_info("reader-1")
assert usage["requests_in_window"] == 1
await validator.start()
await validator.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "message"),
[
(_valid_payload(iss="wrong"), "Invalid issuer"),
(_valid_payload(aud="wrong"), "Invalid audience"),
(_valid_payload(aud=["wrong"]), "not in"),
(_valid_payload(exp=None), "Missing 'exp' claim"),
(_valid_payload(exp=1), "Token has expired"),
(_valid_payload(nbf=None), "Missing 'nbf' claim"),
(_valid_payload(nbf=time.time() + 60), "Token not yet valid"),
(_valid_payload(iat=None), "Missing 'iat' claim"),
(_valid_payload(iat=time.time() + 60), "Token issued in the future"),
],
)
async def test_validator_rejects_invalid_registered_claims(
payload: dict[str, object],
message: str,
) -> None:
validator = _validator()
with pytest.raises(ValueError, match=message):
await validator.validate_claims(payload)
@pytest.mark.asyncio
async def test_validator_rejects_revoked_and_rate_limited_tokens() -> None:
validator = _validator()
await validator.blacklist.add_token("token-1", time.time() + 60)
with pytest.raises(ValueError, match="revoked"):
await validator.validate_claims(_valid_payload())
validator = _validator()
validator.rate_limiter.max_requests = 0
with pytest.raises(ValueError, match="Rate limit exceeded"):
await validator.validate_claims(_valid_payload())
def test_validator_accepts_direct_security_config() -> None:
validator = TokenValidator(_security_config())
assert validator.expected_audience == "doris-mcp-clients"
assert validator.expected_issuer == "doris-mcp-test"