blob: 7cb93a6f0848866b5ce86340c9305341d5655fc8 [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.
import pytest
from doris_mcp_server.auth.oauth_provider import OAuthAuthenticationProvider
from doris_mcp_server.auth.oauth_token_validation import (
OAuthAccessTokenContext,
OAuthAccessTokenValidationError,
)
from doris_mcp_server.auth.oauth_types import OAuthState, OAuthTokens, OAuthUserInfo
from doris_mcp_server.utils.config import DorisConfig
from doris_mcp_server.utils.security import SecurityLevel
ISSUER = "https://issuer.example.test"
RESOURCE = "https://mcp.example.test/mcp"
def _token_context(subject="user-1") -> OAuthAccessTokenContext:
return OAuthAccessTokenContext(
issuer=ISSUER,
resource=RESOURCE,
audiences=(RESOURCE,),
scopes=("resource:read", "tool:list"),
subject=subject,
client_id="caller-client",
token_id="token-1",
expires_at=200,
)
class _FlowOAuthClient:
def __init__(self, *, token_subject="user-1", user_subject="user-1"):
self.events = []
self.token_subject = token_subject
self.user_subject = user_subject
async def exchange_code_for_tokens(self, code, state):
self.events.append(("exchange", code, state))
return (
OAuthTokens(
access_token="callback-access",
refresh_token="callback-refresh",
),
OAuthState(state=state),
)
async def refresh_tokens(self, refresh_token):
self.events.append(("refresh", refresh_token))
return OAuthTokens(access_token="refreshed-access")
async def introspect_access_token(self, access_token):
self.events.append(("introspect", access_token))
return _token_context(self.token_subject)
async def get_user_info(self, tokens):
self.events.append(("userinfo", tokens.access_token))
return OAuthUserInfo(
sub=self.user_subject,
email="user@example.test",
roles=["oauth_user"],
)
def _provider(client) -> OAuthAuthenticationProvider:
provider = object.__new__(OAuthAuthenticationProvider)
provider.config = DorisConfig()
provider.config.semantic.oauth_tools_enabled = True
provider.config.semantic.oauth_resources_enabled = True
provider.enabled = True
provider.oauth_client = client
return provider
@pytest.mark.asyncio
@pytest.mark.parametrize("flow", ["callback", "bearer", "refresh"])
async def test_every_external_oauth_entry_validates_before_userinfo(flow):
client = _FlowOAuthClient()
provider = _provider(client)
if flow == "callback":
context = await provider.handle_callback("code-1", "state-1")
assert client.events == [
("exchange", "code-1", "state-1"),
("introspect", "callback-access"),
("userinfo", "callback-access"),
]
elif flow == "bearer":
context = await provider.authenticate_with_token("bearer-access")
assert client.events == [
("introspect", "bearer-access"),
("userinfo", "bearer-access"),
]
else:
context, access_token = await provider.refresh_authentication("refresh-1")
assert access_token == "refreshed-access"
assert client.events == [
("refresh", "refresh-1"),
("introspect", "refreshed-access"),
("userinfo", "refreshed-access"),
]
assert context.user_id == "user-1"
assert context.token_id == "token-1"
assert context.token == ""
assert context.auth_method == "external_oauth"
assert context.oauth_client_id == "caller-client"
assert context.oauth_scopes == ["resource:read", "tool:list"]
assert context.oauth_issuer == ISSUER
assert context.oauth_resource == RESOURCE
assert context.oauth_audiences == [RESOURCE]
assert context.pool_key == "global"
assert context.semantic_tools_enabled is True
assert context.semantic_resources_enabled is True
@pytest.mark.asyncio
async def test_failed_introspection_never_calls_userinfo():
class RejectingOAuthClient(_FlowOAuthClient):
async def introspect_access_token(self, access_token):
self.events.append(("introspect", access_token))
raise OAuthAccessTokenValidationError(
"invalid_token",
"inactive",
)
client = RejectingOAuthClient()
provider = _provider(client)
with pytest.raises(
OAuthAccessTokenValidationError,
match="inactive",
) as exc_info:
await provider.authenticate_with_token("rejected-access")
assert exc_info.value.error == "invalid_token"
assert exc_info.value.status_code == 401
assert client.events == [("introspect", "rejected-access")]
@pytest.mark.asyncio
async def test_userinfo_subject_must_match_introspected_subject():
client = _FlowOAuthClient(
token_subject="token-user",
user_subject="userinfo-user",
)
provider = _provider(client)
with pytest.raises(
OAuthAccessTokenValidationError,
match="userinfo subject does not match",
) as exc_info:
await provider.authenticate_with_token("access-1")
assert exc_info.value.error == "invalid_token"
@pytest.mark.asyncio
async def test_external_oauth_authorization_uses_configured_role_mappings():
provider = _provider(_FlowOAuthClient())
provider.config.security.oauth_role_security_levels = {
"analyst": "confidential",
"executive": "secret",
}
provider.config.security.oauth_role_permissions = {
"analyst": ["query_database", "read_data"],
"suspended": [],
}
provider.config.security.oauth_default_permissions = []
user_info = OAuthUserInfo(
sub="user-1",
roles=["Analyst", "Executive"],
)
assert (
await provider._determine_security_level(user_info)
is SecurityLevel.SECRET
)
assert await provider._map_permissions(user_info.roles) == [
"query_database",
"read_data",
]
assert await provider._map_permissions(["unknown"]) == []
assert await provider._map_permissions(["suspended"]) == []
@pytest.mark.asyncio
async def test_external_oauth_trusted_domain_requires_verified_email():
provider = _provider(_FlowOAuthClient())
provider.config.security.oauth_trusted_domains = ["example.test"]
provider.config.security.oauth_trusted_domain_security_level = "confidential"
provider.config.security.oauth_default_security_level = "public"
verified_user = OAuthUserInfo(
sub="verified",
email="user@example.test",
email_verified=True,
)
unverified_user = OAuthUserInfo(
sub="unverified",
email="user@example.test",
email_verified=False,
)
assert (
await provider._determine_security_level(verified_user)
is SecurityLevel.CONFIDENTIAL
)
assert (
await provider._determine_security_level(unverified_user)
is SecurityLevel.PUBLIC
)