|
1 | 1 | import asyncio |
2 | 2 | import os |
| 3 | +import ssl |
3 | 4 | import time |
4 | 5 | from unittest import mock |
5 | 6 | from unittest.mock import MagicMock, patch |
@@ -472,6 +473,116 @@ async def mock_oath2(self, request): |
472 | 473 | assertpy.assert_that(user.has_matching_role(["updater"])).is_false() |
473 | 474 |
|
474 | 475 |
|
| 476 | +# --------------------------------------------------------------------------- |
| 477 | +# JWKS client lifecycle (one lazy client per parser) |
| 478 | +# --------------------------------------------------------------------------- |
| 479 | + |
| 480 | + |
| 481 | +@patch( |
| 482 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 483 | +) |
| 484 | +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") |
| 485 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") |
| 486 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 487 | +def test_oidc_jwks_client_is_lazy_and_reused_per_parser( |
| 488 | + mock_discovery_data, |
| 489 | + mock_jwks_client_cls, |
| 490 | + mock_jwt, |
| 491 | + mock_oauth2, |
| 492 | + oidc_config, |
| 493 | + discovery_data, |
| 494 | +): |
| 495 | + """One JWKS client per parser: built on the first request (not at |
| 496 | + construction, which would move a blocking discovery fetch into server |
| 497 | + startup), reused across requests, and scoped to the parser instance.""" |
| 498 | + mock_discovery_data.return_value = discovery_data |
| 499 | + mock_jwt.return_value = {"preferred_username": "my-name"} |
| 500 | + |
| 501 | + token_parser = OidcTokenParser(auth_config=oidc_config) |
| 502 | + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(0) |
| 503 | + |
| 504 | + for _ in range(3): |
| 505 | + asyncio.run( |
| 506 | + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") |
| 507 | + ) |
| 508 | + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(1) |
| 509 | + |
| 510 | + # A second parser must not see the first parser's client: each parser |
| 511 | + # verifies against the JWKS of its own configured provider. |
| 512 | + other_parser = OidcTokenParser(auth_config=oidc_config) |
| 513 | + asyncio.run(other_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) |
| 514 | + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(2) |
| 515 | + |
| 516 | + |
| 517 | +@pytest.mark.parametrize("verify_ssl", [True, False]) |
| 518 | +@patch( |
| 519 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 520 | +) |
| 521 | +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") |
| 522 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") |
| 523 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 524 | +def test_oidc_jwks_client_ssl_context_follows_config( |
| 525 | + mock_discovery_data, |
| 526 | + mock_jwks_client_cls, |
| 527 | + mock_jwt, |
| 528 | + mock_oauth2, |
| 529 | + verify_ssl, |
| 530 | + discovery_data, |
| 531 | +): |
| 532 | + """The client is built with the discovery JWKS URL and an SSL context |
| 533 | + matching verify_ssl: default configs must keep certificate verification |
| 534 | + on, and verify_ssl=False must be the only way to turn it off.""" |
| 535 | + mock_discovery_data.return_value = discovery_data |
| 536 | + mock_jwt.return_value = {"preferred_username": "my-name"} |
| 537 | + |
| 538 | + token_parser = OidcTokenParser(auth_config=_oidc_config_with(verify_ssl=verify_ssl)) |
| 539 | + asyncio.run(token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) |
| 540 | + |
| 541 | + call = mock_jwks_client_cls.call_args |
| 542 | + assertpy.assert_that(call.args[0]).is_equal_to(discovery_data["jwks_uri"]) |
| 543 | + ssl_ctx = call.kwargs["ssl_context"] |
| 544 | + if verify_ssl: |
| 545 | + assertpy.assert_that(ssl_ctx.verify_mode).is_equal_to(ssl.CERT_REQUIRED) |
| 546 | + assertpy.assert_that(ssl_ctx.check_hostname).is_true() |
| 547 | + else: |
| 548 | + assertpy.assert_that(ssl_ctx.verify_mode).is_equal_to(ssl.CERT_NONE) |
| 549 | + assertpy.assert_that(ssl_ctx.check_hostname).is_false() |
| 550 | + |
| 551 | + |
| 552 | +@patch( |
| 553 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 554 | +) |
| 555 | +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") |
| 556 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") |
| 557 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 558 | +def test_oidc_jwks_client_construction_failure_is_retried( |
| 559 | + mock_discovery_data, |
| 560 | + mock_jwks_client_cls, |
| 561 | + mock_jwt, |
| 562 | + mock_oauth2, |
| 563 | + oidc_config, |
| 564 | + discovery_data, |
| 565 | +): |
| 566 | + """A failed first construction must leave the parser able to retry on |
| 567 | + the next request: the parser is a process singleton, so caching a failed |
| 568 | + or half-built client would wedge authentication until restart.""" |
| 569 | + mock_discovery_data.return_value = discovery_data |
| 570 | + mock_jwt.return_value = {"preferred_username": "my-name"} |
| 571 | + mock_jwks_client_cls.side_effect = [RuntimeError("IdP unreachable"), MagicMock()] |
| 572 | + |
| 573 | + token_parser = OidcTokenParser(auth_config=oidc_config) |
| 574 | + with pytest.raises(RuntimeError): |
| 575 | + asyncio.run( |
| 576 | + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") |
| 577 | + ) |
| 578 | + |
| 579 | + user = asyncio.run( |
| 580 | + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") |
| 581 | + ) |
| 582 | + assertpy.assert_that(user.username).is_equal_to("my-name") |
| 583 | + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(2) |
| 584 | + |
| 585 | + |
475 | 586 | # --------------------------------------------------------------------------- |
476 | 587 | # Optional audience / issuer verification (opt-in via OidcAuthConfig) |
477 | 588 | # --------------------------------------------------------------------------- |
@@ -671,38 +782,6 @@ def test_oidc_default_supports_v1_tokens_against_v2_discovery( |
671 | 782 | assertpy.assert_that(user.roles).is_equal_to(["reader"]) |
672 | 783 |
|
673 | 784 |
|
674 | | -@patch( |
675 | | - "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
676 | | -) |
677 | | -@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") |
678 | | -@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
679 | | -@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") |
680 | | -def test_oidc_jwks_client_is_reused_across_requests( |
681 | | - mock_jwks_client_cls, |
682 | | - mock_discovery_data, |
683 | | - mock_jwt, |
684 | | - mock_oauth2, |
685 | | - oidc_config, |
686 | | - discovery_data, |
687 | | -): |
688 | | - """The JWKS client must be built once per parser, not once per request: |
689 | | - a fresh client starts with a cold JWK-set cache, which forces a full |
690 | | - HTTPS fetch of the JWKS document on every authenticated call.""" |
691 | | - mock_discovery_data.return_value = discovery_data |
692 | | - mock_jwt.return_value = {"preferred_username": "my-name"} |
693 | | - |
694 | | - token_parser = OidcTokenParser(auth_config=oidc_config) |
695 | | - for _ in range(3): |
696 | | - asyncio.run( |
697 | | - token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") |
698 | | - ) |
699 | | - |
700 | | - assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(1) |
701 | | - assertpy.assert_that( |
702 | | - mock_jwks_client_cls.return_value.get_signing_key_from_jwt.call_count |
703 | | - ).is_equal_to(3) |
704 | | - |
705 | | - |
706 | 785 | # TODO RBAC: Move role bindings to a reusable fixture |
707 | 786 | @patch("feast.permissions.auth.kubernetes_token_parser.config.load_incluster_config") |
708 | 787 | @patch("feast.permissions.auth.kubernetes_token_parser.jwt.decode") |
|
0 commit comments