blob: c8157e5e76dca25782f1b47c638c987cc7f3df83 [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 time
from urllib.parse import parse_qs, urlparse
import pytest
from doris_mcp_server.auth.oauth_client import OAuthClient
from doris_mcp_server.utils.config import (
DorisConfig,
_mark_source,
normalize_effective_auth_config,
)
ISSUER = "https://issuer.example.test"
RESOURCE = "https://mcp.example.test/mcp"
class _FakeResponse:
def __init__(self, status, payload):
self.status = status
self.payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
async def json(self):
return self.payload
def raise_for_status(self):
if self.status >= 400:
raise RuntimeError(f"HTTP {self.status}")
class _FakeSession:
def __init__(self, *, post_responses=None, get_responses=None):
self.post_responses = list(post_responses or [])
self.get_responses = list(get_responses or [])
self.post_calls = []
self.get_calls = []
def post(self, url, **kwargs):
self.post_calls.append((url, kwargs))
status, payload = self.post_responses.pop(0)
return _FakeResponse(status, payload)
def get(self, url, **kwargs):
self.get_calls.append((url, kwargs))
status, payload = self.get_responses.pop(0)
return _FakeResponse(status, payload)
def _external_oauth_config() -> DorisConfig:
config = DorisConfig()
config.security.enable_oauth_auth = True
_mark_source(config, "enable_oauth_auth", "test")
config.security.oauth_provider = "custom"
config.security.oauth_client_id = "oauth-client"
config.security.oauth_client_secret = "oauth-secret"
config.security.oauth_redirect_uri = "http://localhost:3000/auth/callback"
config.security.oauth_authorization_endpoint = f"{ISSUER}/authorize"
config.security.oauth_token_endpoint = f"{ISSUER}/token"
config.security.oauth_introspection_endpoint = f"{ISSUER}/introspect"
config.security.oauth_userinfo_endpoint = f"{ISSUER}/userinfo"
config.security.oauth_issuer = ISSUER
config.security.oauth_resource = RESOURCE
config.security.oauth_audience = RESOURCE
config.security.oauth_scopes = [
"tool:list",
"resource:list",
"resource:read",
]
config.security.oauth_required_scopes = ["tool:list", "resource:read"]
normalize_effective_auth_config(config)
return config
def test_authorization_request_includes_resource_indicator():
client = OAuthClient(_external_oauth_config())
authorization_url, _ = client.build_authorization_url()
query = parse_qs(urlparse(authorization_url).query)
assert query["resource"] == [RESOURCE]
assert query["scope"] == ["tool:list resource:list resource:read"]
@pytest.mark.asyncio
async def test_token_exchange_and_refresh_preserve_resource_binding():
session = _FakeSession(
post_responses=[
(200, {"access_token": "access-1", "refresh_token": "refresh-1"}),
(200, {"access_token": "access-2"}),
]
)
client = OAuthClient(_external_oauth_config())
client._session = session
_, oauth_state = client.build_authorization_url()
tokens, _ = await client.exchange_code_for_tokens(
"code-1",
oauth_state.state,
)
refreshed = await client.refresh_tokens(tokens.refresh_token)
assert tokens.access_token == "access-1"
assert refreshed.access_token == "access-2"
assert session.post_calls[0][1]["data"]["resource"] == RESOURCE
assert session.post_calls[1][1]["data"]["resource"] == RESOURCE
@pytest.mark.asyncio
async def test_introspection_uses_confidential_client_and_returns_context():
session = _FakeSession(
post_responses=[
(
200,
{
"active": True,
"iss": ISSUER,
"aud": RESOURCE,
"scope": (
"tool:list resource:list resource:read unconfigured:admin"
),
"sub": "user-1",
"client_id": "caller-client",
"jti": "token-1",
"exp": int(time.time()) + 3600,
},
)
]
)
client = OAuthClient(_external_oauth_config())
client._session = session
context = await client.introspect_access_token("access-secret")
endpoint, kwargs = session.post_calls[0]
assert endpoint == f"{ISSUER}/introspect"
assert kwargs["data"] == {
"token": "access-secret",
"token_type_hint": "access_token",
}
assert kwargs["auth"].login == "oauth-client"
assert kwargs["auth"].password == "oauth-secret"
assert context.subject == "user-1"
assert context.audiences == (RESOURCE,)
assert context.scopes == (
"resource:list",
"resource:read",
"tool:list",
)
assert "unconfigured:admin" not in context.scopes
@pytest.mark.asyncio
async def test_discovery_document_must_match_configured_issuer():
session = _FakeSession(
get_responses=[
(
200,
{
"issuer": "https://other-issuer.example.test",
"authorization_endpoint": f"{ISSUER}/authorize",
"token_endpoint": f"{ISSUER}/token",
},
)
]
)
client = OAuthClient(_external_oauth_config())
client.provider_config.discovery_url = f"{ISSUER}/.well-known/oauth"
client._session = session
with pytest.raises(ValueError, match="issuer does not match"):
await client._discover_oidc_endpoints()