blob: 61dfeffa346f1e198917efae988d3e718905d3bb [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.
"""JWT lifecycle coverage for supported authentication runtime behavior."""
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import jwt
import pytest
from doris_mcp_server.auth import jwt_manager as jwt_manager_module
from doris_mcp_server.auth.jwt_manager import JWTManager
JWT_SECRET = "test-only-jwt-signing-key-with-sufficient-length"
def _security_config() -> SimpleNamespace:
return SimpleNamespace(
jwt_algorithm="HS256",
jwt_issuer="doris-mcp-test",
jwt_audience="doris-mcp-clients",
jwt_access_token_expiry=300,
jwt_refresh_token_expiry=600,
enable_token_refresh=True,
enable_token_revocation=True,
jwt_verify_signature=True,
jwt_require_exp=True,
jwt_require_iat=True,
jwt_require_nbf=False,
jwt_verify_audience=True,
jwt_verify_issuer=True,
jwt_leeway=0,
)
def _manager(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[JWTManager, SimpleNamespace, SimpleNamespace]:
key_manager = SimpleNamespace(
key_rotation_interval=0,
initialize=AsyncMock(return_value=True),
get_private_key=Mock(return_value=JWT_SECRET),
get_public_key=Mock(return_value=JWT_SECRET),
is_key_expired=AsyncMock(return_value=False),
rotate_keys=AsyncMock(),
get_key_info=AsyncMock(return_value={"key_id": "test-key"}),
export_public_key_pem=AsyncMock(return_value="test-public-key"),
)
async def validate_claims(payload: dict) -> dict:
return {"valid": True, "user_id": payload.get("sub"), "payload": payload}
validator = SimpleNamespace(
start=AsyncMock(),
stop=AsyncMock(),
validate_claims=AsyncMock(side_effect=validate_claims),
revoke_token=AsyncMock(),
get_validation_stats=AsyncMock(return_value={"validated": 1}),
)
monkeypatch.setattr(
jwt_manager_module,
"KeyManager",
Mock(return_value=key_manager),
)
monkeypatch.setattr(
jwt_manager_module,
"TokenValidator",
Mock(return_value=validator),
)
manager = JWTManager(SimpleNamespace(security=_security_config()))
return manager, key_manager, validator
@pytest.mark.asyncio
async def test_jwt_manager_initializes_and_shuts_down_owned_tasks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, key_manager, validator = _manager(monkeypatch)
assert await manager.initialize() is True
key_manager.initialize.assert_awaited_once()
validator.start.assert_awaited_once()
manager._key_rotation_task = asyncio.create_task(asyncio.sleep(60))
await manager.shutdown()
assert manager._key_rotation_task.cancelled()
validator.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_jwt_manager_full_token_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, _, validator = _manager(monkeypatch)
user = {
"user_id": "reader-1",
"roles": ["reader"],
"permissions": ["read_data"],
"security_level": "internal",
}
tokens = await manager.generate_tokens(user, {"tenant": "tenant-a"})
assert tokens["token_type"] == "Bearer"
assert tokens["user_id"] == "reader-1"
assert tokens["refresh_token"]
validated = await manager.validate_token(tokens["access_token"])
assert validated["payload"]["tenant"] == "tenant-a"
refreshed = await manager.refresh_token(tokens["refresh_token"])
assert refreshed["user_id"] == "reader-1"
token_info = await manager.get_token_info(tokens["access_token"])
assert token_info["sub"] == "reader-1"
assert token_info["token_type"] == "access"
assert token_info["is_expired"] is False
revocable = jwt.encode(
{"jti": "revocable", "exp": int(time.time()) + 60},
JWT_SECRET,
algorithm="HS256",
)
assert await manager.revoke_token(revocable) is True
validator.revoke_token.assert_awaited_once_with(
"revocable",
pytest.approx(time.time() + 60, abs=2),
)
assert await manager.get_public_key_info() == {
"algorithm": "HS256",
"public_key_pem": "test-public-key",
"key_info": {"key_id": "test-key"},
}
stats = await manager.get_manager_stats()
assert stats["jwt_config"]["enable_refresh"] is True
assert stats["key_manager"] == {"key_id": "test-key"}
assert stats["validator"] == {"validated": 1}
@pytest.mark.asyncio
async def test_jwt_manager_rejects_invalid_lifecycle_inputs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, key_manager, _ = _manager(monkeypatch)
current_time = int(time.time())
wrong_type = jwt.encode(
{
"iss": manager.issuer,
"aud": manager.audience,
"iat": current_time,
"exp": current_time + 60,
"token_type": "refresh",
},
JWT_SECRET,
algorithm="HS256",
)
with pytest.raises(ValueError, match="Invalid token type"):
await manager.validate_token(wrong_type)
expired = jwt.encode(
{
"iss": manager.issuer,
"aud": manager.audience,
"iat": current_time - 120,
"exp": current_time - 60,
"token_type": "access",
},
JWT_SECRET,
algorithm="HS256",
)
with pytest.raises(ValueError, match="Token has expired"):
await manager.validate_token(expired)
with pytest.raises(ValueError, match="Invalid token"):
await manager.validate_token("not-a-jwt")
key_manager.get_public_key.return_value = None
with pytest.raises(ValueError, match="verification key is not initialized"):
await manager.validate_token(wrong_type)
key_manager.get_public_key.return_value = JWT_SECRET
key_manager.get_private_key.return_value = None
with pytest.raises(RuntimeError, match="signing key is not initialized"):
await manager.generate_tokens({"user_id": "reader-1"})
key_manager.get_private_key.return_value = JWT_SECRET
manager.enable_refresh = False
with pytest.raises(ValueError, match="refresh is disabled"):
await manager.refresh_token("unused")
manager.enable_revocation = False
assert await manager.revoke_token("unused") is False
manager.enable_revocation = True
missing_claims = jwt.encode(
{"exp": current_time + 60},
JWT_SECRET,
algorithm="HS256",
)
assert await manager.revoke_token(missing_claims) is False
assert await manager.revoke_token("not-a-jwt") is False
with pytest.raises(jwt.InvalidTokenError):
await manager.decode_token_unsafe("not-a-jwt")
@pytest.mark.asyncio
async def test_jwt_manager_initialization_failure_paths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, key_manager, validator = _manager(monkeypatch)
key_manager.initialize.return_value = False
assert await manager.initialize() is False
validator.start.assert_not_awaited()
key_manager.initialize.side_effect = RuntimeError("key store unavailable")
assert await manager.initialize() is False
validator.stop.side_effect = RuntimeError("validator shutdown failed")
await manager.shutdown()
@pytest.mark.asyncio
async def test_jwt_manager_rotates_expired_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, key_manager, _ = _manager(monkeypatch)
key_manager.is_key_expired.return_value = True
async def cancel_after_iteration(_delay: float) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(jwt_manager_module.asyncio, "sleep", cancel_after_iteration)
await manager._auto_key_rotation()
key_manager.rotate_keys.assert_awaited_once()