|
18 | 18 | MCPAuthAuthServerException, |
19 | 19 | MCPAuthConfigException, |
20 | 20 | MCPAuthTokenVerificationExceptionCode, |
| 21 | + MCPAuthBearerAuthException, |
21 | 22 | ) |
| 23 | +import pydantic |
22 | 24 |
|
23 | 25 |
|
24 | 26 | class TestHandleBearerAuth: |
@@ -58,6 +60,16 @@ def test_should_throw_error_if_issuer_is_not_a_valid_url( |
58 | 60 | auth_info, |
59 | 61 | ) |
60 | 62 |
|
| 63 | + def test_should_throw_error_if_issuer_is_not_string_or_callable( |
| 64 | + self, auth_info: ContextVar[AuthInfo | None] |
| 65 | + ): |
| 66 | + with pytest.raises(pydantic.ValidationError): |
| 67 | + create_bearer_auth( |
| 68 | + lambda _: None, # type: ignore |
| 69 | + BearerAuthConfig(issuer=123), # type: ignore |
| 70 | + auth_info, |
| 71 | + ) |
| 72 | + |
61 | 73 |
|
62 | 74 | @pytest.mark.asyncio |
63 | 75 | class TestHandleBearerAuthMiddleware: |
@@ -287,6 +299,7 @@ async def test_should_respond_with_error_if_audience_does_not_match( |
287 | 299 | ], |
288 | 300 | ): |
289 | 301 | mock_verify = MagicMock() |
| 302 | + assert isinstance(auth_config[1].issuer, str) |
290 | 303 | mock_verify.return_value = AuthInfo( |
291 | 304 | issuer=auth_config[1].issuer, |
292 | 305 | client_id="client-id", |
@@ -333,6 +346,7 @@ async def test_should_respond_with_error_if_audience_does_not_match_array_case( |
333 | 346 | ], |
334 | 347 | ): |
335 | 348 | mock_verify = MagicMock() |
| 349 | + assert isinstance(auth_config[1].issuer, str) |
336 | 350 | mock_verify.return_value = AuthInfo( |
337 | 351 | issuer=auth_config[1].issuer, |
338 | 352 | client_id="client-id", |
@@ -379,6 +393,7 @@ async def test_should_respond_with_error_if_required_scopes_are_not_present( |
379 | 393 | ], |
380 | 394 | ): |
381 | 395 | mock_verify = MagicMock() |
| 396 | + assert isinstance(auth_config[1].issuer, str) |
382 | 397 | mock_verify.return_value = AuthInfo( |
383 | 398 | issuer=auth_config[1].issuer, |
384 | 399 | client_id="client-id", |
@@ -643,3 +658,99 @@ async def test_should_show_error_details_for_bearer_auth_error(self): |
643 | 658 | }, |
644 | 659 | } |
645 | 660 | mock_verify.assert_called_once_with("valid-token") |
| 661 | + |
| 662 | + @pytest.mark.asyncio |
| 663 | + async def test_should_include_resource_metadata_on_401_bearer_error( |
| 664 | + self, |
| 665 | + auth_info: ContextVar[AuthInfo | None], |
| 666 | + ): |
| 667 | + """ |
| 668 | + Test that the WWW-Authenticate header includes the resource_metadata URI when a |
| 669 | + Bearer auth error (401) occurs and a resource is specified. |
| 670 | + """ |
| 671 | + config = BearerAuthConfig( |
| 672 | + issuer="https://correct-issuer.com", |
| 673 | + resource="https://my-api.com", |
| 674 | + ) |
| 675 | + mock_verify = MagicMock() |
| 676 | + mock_verify.return_value = AuthInfo( |
| 677 | + issuer="https://wrong-issuer.com", |
| 678 | + client_id="client-id", |
| 679 | + scopes=[], |
| 680 | + token="valid-token", |
| 681 | + subject="subject-id", |
| 682 | + audience=None, |
| 683 | + claims={}, |
| 684 | + ) |
| 685 | + |
| 686 | + MiddlewareClass = create_bearer_auth(mock_verify, config, auth_info) |
| 687 | + middleware = MiddlewareClass(app=MagicMock()) |
| 688 | + |
| 689 | + request = Request( |
| 690 | + scope={ |
| 691 | + "type": "http", |
| 692 | + "headers": [(b"authorization", b"Bearer valid-token")], |
| 693 | + "method": "GET", |
| 694 | + "path": "/", |
| 695 | + } |
| 696 | + ) |
| 697 | + |
| 698 | + response = await middleware.dispatch(request, MagicMock()) |
| 699 | + |
| 700 | + assert response.status_code == 401 |
| 701 | + assert "WWW-Authenticate" in response.headers |
| 702 | + assert ( |
| 703 | + 'resource_metadata="https://my-api.com/.well-known/oauth-protected-resource"' |
| 704 | + in response.headers["WWW-Authenticate"] |
| 705 | + ) |
| 706 | + |
| 707 | + async def test_should_respond_with_error_if_callable_issuer_fails( |
| 708 | + self, |
| 709 | + auth_config: tuple[ |
| 710 | + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] |
| 711 | + ], |
| 712 | + ): |
| 713 | + """Test that an error is returned if a callable issuer fails validation.""" |
| 714 | + mock_verify = MagicMock() |
| 715 | + mock_verify.return_value = AuthInfo( |
| 716 | + issuer="https://some-issuer.com", |
| 717 | + client_id="client-id", |
| 718 | + scopes=[], |
| 719 | + token="valid-token", |
| 720 | + audience=None, |
| 721 | + subject="subject-id", |
| 722 | + claims={}, |
| 723 | + ) |
| 724 | + |
| 725 | + def failing_issuer_validator(issuer: str): |
| 726 | + raise MCPAuthBearerAuthException(BearerAuthExceptionCode.INVALID_ISSUER) |
| 727 | + |
| 728 | + config = BearerAuthConfig( |
| 729 | + issuer=failing_issuer_validator, |
| 730 | + resource="https://my-api.com", |
| 731 | + ) |
| 732 | + |
| 733 | + MiddlewareClass = create_bearer_auth(mock_verify, config, auth_config[2]) |
| 734 | + middleware = MiddlewareClass(app=MagicMock()) |
| 735 | + |
| 736 | + request = Request( |
| 737 | + scope={ |
| 738 | + "type": "http", |
| 739 | + "headers": [(b"authorization", b"Bearer valid-token")], |
| 740 | + "method": "GET", |
| 741 | + "path": "/", |
| 742 | + } |
| 743 | + ) |
| 744 | + |
| 745 | + response = await middleware.dispatch(request, MagicMock()) |
| 746 | + |
| 747 | + assert response.status_code == 401 |
| 748 | + assert "WWW-Authenticate" in response.headers |
| 749 | + assert ( |
| 750 | + f'error="{BearerAuthExceptionCode.INVALID_ISSUER.value}"' |
| 751 | + in response.headers["WWW-Authenticate"] |
| 752 | + ) |
| 753 | + assert ( |
| 754 | + 'resource_metadata="https://my-api.com/.well-known/oauth-protected-resource"' |
| 755 | + in response.headers["WWW-Authenticate"] |
| 756 | + ) |
0 commit comments