blob: c3edcd8d6acdd6560a9bb0722e67746917cce4b4 [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_token_validation import (
ExternalOAuthTokenValidator,
OAuthAccessTokenValidationError,
)
ISSUER_A = "https://issuer-a.example.test"
ISSUER_B = "https://issuer-b.example.test"
RESOURCE_A = "https://mcp-a.example.test/mcp"
RESOURCE_B = "https://mcp-b.example.test/mcp"
ALLOWED_SCOPES = ("tool:list", "resource:list", "resource:read")
REQUIRED_SCOPES = ("tool:list", "resource:read")
def _validator() -> ExternalOAuthTokenValidator:
return ExternalOAuthTokenValidator(
issuer=ISSUER_A,
resource=RESOURCE_A,
audience=RESOURCE_A,
allowed_scopes=ALLOWED_SCOPES,
required_scopes=REQUIRED_SCOPES,
)
def _claims(
*,
issuer: str = ISSUER_A,
resource: str = RESOURCE_A,
scopes: tuple[str, ...] = REQUIRED_SCOPES,
) -> dict[str, object]:
return {
"active": True,
"iss": issuer,
"aud": [resource],
"resource": resource,
"scope": " ".join(scopes),
"sub": "user-1",
"client_id": "client-1",
"jti": "token-1",
"exp": 200,
}
@pytest.mark.parametrize("issuer", [ISSUER_A, ISSUER_B])
@pytest.mark.parametrize("resource", [RESOURCE_A, RESOURCE_B])
@pytest.mark.parametrize(
("scope_case", "scopes", "scope_is_valid", "expected_scopes"),
[
("insufficient", ("tool:list",), False, ()),
("minimum", REQUIRED_SCOPES, True, tuple(sorted(REQUIRED_SCOPES))),
(
"superset",
(*ALLOWED_SCOPES, "unconfigured:admin"),
True,
tuple(sorted(ALLOWED_SCOPES)),
),
],
)
def test_two_issuer_two_resource_three_scope_matrix_is_least_privilege(
issuer,
resource,
scope_case,
scopes,
scope_is_valid,
expected_scopes,
):
claims = _claims(issuer=issuer, resource=resource, scopes=scopes)
should_accept = issuer == ISSUER_A and resource == RESOURCE_A and scope_is_valid
if not should_accept:
with pytest.raises(OAuthAccessTokenValidationError) as exc_info:
_validator().validate(claims, current_time=100)
if issuer != ISSUER_A or resource != RESOURCE_A:
assert exc_info.value.error == "invalid_token"
else:
assert scope_case == "insufficient"
assert exc_info.value.error == "insufficient_scope"
return
context = _validator().validate(claims, current_time=100)
assert context.issuer == ISSUER_A
assert context.resource == RESOURCE_A
assert context.audiences == (RESOURCE_A,)
assert context.scopes == expected_scopes
assert "unconfigured:admin" not in context.scopes
assert context.subject == "user-1"
assert context.client_id == "client-1"
assert context.token_id == "token-1"
assert context.expires_at == 200
@pytest.mark.parametrize(
("claim", "value", "error"),
[
("active", False, "invalid_token"),
("exp", 100, "invalid_token"),
("exp", float("nan"), "invalid_token"),
("nbf", 101, "invalid_token"),
("sub", "", "invalid_token"),
],
)
def test_invalid_lifecycle_or_identity_claims_fail_closed(claim, value, error):
claims = _claims()
claims[claim] = value
with pytest.raises(OAuthAccessTokenValidationError) as exc_info:
_validator().validate(claims, current_time=100)
assert exc_info.value.error == error
def test_resource_claim_cannot_override_a_valid_audience():
claims = _claims()
claims["resource"] = RESOURCE_B
with pytest.raises(
OAuthAccessTokenValidationError,
match="resource mismatch",
):
_validator().validate(claims, current_time=100)
def test_rfc7662_response_without_nonstandard_resource_uses_audience_binding():
claims = _claims()
del claims["resource"]
context = _validator().validate(claims, current_time=100)
assert context.resource == RESOURCE_A