|
1 | 1 | import asyncio |
2 | 2 | import os |
| 3 | +import time |
3 | 4 | from unittest import mock |
4 | 5 | from unittest.mock import MagicMock, patch |
5 | 6 |
|
6 | 7 | import assertpy |
| 8 | +import jwt |
7 | 9 | import pytest |
8 | 10 | from starlette.authentication import ( |
9 | 11 | AuthenticationError, |
10 | 12 | ) |
11 | 13 |
|
12 | 14 | from feast.permissions.auth.kubernetes_token_parser import KubernetesTokenParser |
13 | 15 | from feast.permissions.auth.oidc_token_parser import OidcTokenParser |
| 16 | +from feast.permissions.auth_model import OidcAuthConfig |
14 | 17 | from feast.permissions.user import User |
15 | 18 |
|
16 | 19 | _CLIENT_ID = "test" |
@@ -469,6 +472,205 @@ async def mock_oath2(self, request): |
469 | 472 | assertpy.assert_that(user.has_matching_role(["updater"])).is_false() |
470 | 473 |
|
471 | 474 |
|
| 475 | +# --------------------------------------------------------------------------- |
| 476 | +# Optional audience / issuer verification (opt-in via OidcAuthConfig) |
| 477 | +# --------------------------------------------------------------------------- |
| 478 | + |
| 479 | + |
| 480 | +def _oidc_config_with(**overrides) -> OidcAuthConfig: |
| 481 | + return OidcAuthConfig( |
| 482 | + auth_discovery_url="https://localhost:8080/realms/master/.well-known/openid-configuration", |
| 483 | + client_id=_CLIENT_ID, |
| 484 | + type="oidc", |
| 485 | + **overrides, |
| 486 | + ) |
| 487 | + |
| 488 | + |
| 489 | +@pytest.fixture(scope="module") |
| 490 | +def rsa_keypair() -> tuple: |
| 491 | + """A real RSA keypair, so the aud/iss tests exercise the real ``jwt.decode``.""" |
| 492 | + from cryptography.hazmat.primitives import serialization |
| 493 | + from cryptography.hazmat.primitives.asymmetric import rsa |
| 494 | + |
| 495 | + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) |
| 496 | + private_pem = private_key.private_bytes( |
| 497 | + encoding=serialization.Encoding.PEM, |
| 498 | + format=serialization.PrivateFormat.PKCS8, |
| 499 | + encryption_algorithm=serialization.NoEncryption(), |
| 500 | + ) |
| 501 | + public_pem = private_key.public_key().public_bytes( |
| 502 | + encoding=serialization.Encoding.PEM, |
| 503 | + format=serialization.PublicFormat.SubjectPublicKeyInfo, |
| 504 | + ) |
| 505 | + return private_pem, public_pem |
| 506 | + |
| 507 | + |
| 508 | +def _make_token(private_pem: bytes, claims: dict) -> str: |
| 509 | + now = int(time.time()) |
| 510 | + return jwt.encode( |
| 511 | + {"iat": now, "exp": now + 300, **claims}, private_pem, algorithm="RS256" |
| 512 | + ) |
| 513 | + |
| 514 | + |
| 515 | +@pytest.mark.parametrize( |
| 516 | + "audience,issuer", |
| 517 | + [ |
| 518 | + (None, None), |
| 519 | + ("api://feast-server", None), |
| 520 | + (None, "https://idp.example.com/realm"), |
| 521 | + ("api://feast-server", "https://idp.example.com/realm"), |
| 522 | + ], |
| 523 | +) |
| 524 | +@patch( |
| 525 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 526 | +) |
| 527 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") |
| 528 | +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") |
| 529 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 530 | +def test_oidc_decode_verification_options_follow_config( |
| 531 | + mock_discovery_data, |
| 532 | + mock_jwt, |
| 533 | + mock_signing_key, |
| 534 | + mock_oauth2, |
| 535 | + audience, |
| 536 | + issuer, |
| 537 | + discovery_data, |
| 538 | + signing_key, |
| 539 | +): |
| 540 | + """The verified decode enables aud/iss checks exactly when the config |
| 541 | + provides expected values, and stays permissive otherwise.""" |
| 542 | + mock_signing_key.return_value = signing_key |
| 543 | + mock_discovery_data.return_value = discovery_data |
| 544 | + mock_jwt.return_value = {"preferred_username": "my-name"} |
| 545 | + |
| 546 | + token_parser = OidcTokenParser( |
| 547 | + auth_config=_oidc_config_with(audience=audience, issuer=issuer) |
| 548 | + ) |
| 549 | + asyncio.run(token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) |
| 550 | + |
| 551 | + verified_calls = [ |
| 552 | + c |
| 553 | + for c in mock_jwt.call_args_list |
| 554 | + if c.kwargs.get("options", {}).get("verify_signature") is not False |
| 555 | + ] |
| 556 | + assertpy.assert_that(verified_calls).is_length(1) |
| 557 | + kwargs = verified_calls[0].kwargs |
| 558 | + assertpy.assert_that(kwargs["options"]["verify_aud"]).is_equal_to( |
| 559 | + audience is not None |
| 560 | + ) |
| 561 | + assertpy.assert_that(kwargs["options"]["verify_iss"]).is_equal_to( |
| 562 | + issuer is not None |
| 563 | + ) |
| 564 | + assertpy.assert_that(kwargs["audience"]).is_equal_to( |
| 565 | + audience if audience is not None else "account" |
| 566 | + ) |
| 567 | + assertpy.assert_that(kwargs["issuer"]).is_equal_to(issuer) |
| 568 | + |
| 569 | + |
| 570 | +@pytest.mark.parametrize( |
| 571 | + "config_kwargs,claims,should_authenticate", |
| 572 | + [ |
| 573 | + # Opt-in audience: match accepted, mismatch and missing rejected. |
| 574 | + ({"audience": "api://feast-server"}, {"aud": "api://feast-server"}, True), |
| 575 | + ({"audience": "api://feast-server"}, {"aud": "api://another-app"}, False), |
| 576 | + ({"audience": "api://feast-server"}, {}, False), |
| 577 | + # Opt-in issuer: match accepted, mismatch rejected. |
| 578 | + ( |
| 579 | + {"issuer": "https://idp.example.com/expected"}, |
| 580 | + {"iss": "https://idp.example.com/expected"}, |
| 581 | + True, |
| 582 | + ), |
| 583 | + ( |
| 584 | + {"issuer": "https://idp.example.com/expected"}, |
| 585 | + {"iss": "https://idp.example.com/other"}, |
| 586 | + False, |
| 587 | + ), |
| 588 | + # Default config: neither claim is verified, so a token minted for a |
| 589 | + # different resource still authenticates (pre-existing behavior). |
| 590 | + ({}, {"aud": "api://another-app", "iss": "https://idp.example.com/any"}, True), |
| 591 | + ], |
| 592 | +) |
| 593 | +@patch( |
| 594 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 595 | +) |
| 596 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") |
| 597 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 598 | +def test_oidc_audience_issuer_verification_end_to_end( |
| 599 | + mock_discovery_data, |
| 600 | + mock_signing_key, |
| 601 | + mock_oauth2, |
| 602 | + config_kwargs, |
| 603 | + claims, |
| 604 | + should_authenticate, |
| 605 | + discovery_data, |
| 606 | + rsa_keypair, |
| 607 | +): |
| 608 | + """Real RS256-signed tokens through the real ``jwt.decode``: opt-in checks |
| 609 | + reject mismatched aud/iss and the default stays permissive.""" |
| 610 | + private_pem, public_pem = rsa_keypair |
| 611 | + mock_discovery_data.return_value = discovery_data |
| 612 | + key = MagicMock() |
| 613 | + key.key = public_pem |
| 614 | + mock_signing_key.return_value = key |
| 615 | + |
| 616 | + token = _make_token(private_pem, {"preferred_username": "my-name", **claims}) |
| 617 | + token_parser = OidcTokenParser(auth_config=_oidc_config_with(**config_kwargs)) |
| 618 | + |
| 619 | + if should_authenticate: |
| 620 | + user = asyncio.run( |
| 621 | + token_parser.user_details_from_access_token(access_token=token) |
| 622 | + ) |
| 623 | + assertpy.assert_that(user).is_type_of(User) |
| 624 | + if isinstance(user, User): |
| 625 | + assertpy.assert_that(user.username).is_equal_to("my-name") |
| 626 | + else: |
| 627 | + with pytest.raises(AuthenticationError): |
| 628 | + asyncio.run(token_parser.user_details_from_access_token(access_token=token)) |
| 629 | + |
| 630 | + |
| 631 | +@patch( |
| 632 | + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" |
| 633 | +) |
| 634 | +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") |
| 635 | +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") |
| 636 | +def test_oidc_default_supports_v1_tokens_against_v2_discovery( |
| 637 | + mock_discovery_data, |
| 638 | + mock_signing_key, |
| 639 | + mock_oauth2, |
| 640 | + discovery_data, |
| 641 | + rsa_keypair, |
| 642 | +): |
| 643 | + """Pins the Entra ID v1-token-against-v2-discovery setup: with no expected |
| 644 | + audience or issuer configured, a v1.0-shaped app-only token (issuer under |
| 645 | + ``sts.windows.net``, ``api://`` audience, ``appid`` identity) validates |
| 646 | + against a v2.0-style discovery document, because discovery is used only to |
| 647 | + source the JWKS signing keys. A future strict-by-default change would |
| 648 | + break real deployments and must fail here first.""" |
| 649 | + private_pem, public_pem = rsa_keypair |
| 650 | + mock_discovery_data.return_value = discovery_data |
| 651 | + key = MagicMock() |
| 652 | + key.key = public_pem |
| 653 | + mock_signing_key.return_value = key |
| 654 | + |
| 655 | + token = _make_token( |
| 656 | + private_pem, |
| 657 | + { |
| 658 | + "iss": "https://sts.windows.net/11111111-2222-3333-4444-555555555555/", |
| 659 | + "aud": "api://66666666-7777-8888-9999-000000000000", |
| 660 | + "appid": "client-app-id", |
| 661 | + "roles": ["reader"], |
| 662 | + }, |
| 663 | + ) |
| 664 | + token_parser = OidcTokenParser(auth_config=_oidc_config_with()) |
| 665 | + |
| 666 | + user = asyncio.run(token_parser.user_details_from_access_token(access_token=token)) |
| 667 | + |
| 668 | + assertpy.assert_that(user).is_type_of(User) |
| 669 | + if isinstance(user, User): |
| 670 | + assertpy.assert_that(user.username).is_equal_to("client-app-id") |
| 671 | + assertpy.assert_that(user.roles).is_equal_to(["reader"]) |
| 672 | + |
| 673 | + |
472 | 674 | # TODO RBAC: Move role bindings to a reusable fixture |
473 | 675 | @patch("feast.permissions.auth.kubernetes_token_parser.config.load_incluster_config") |
474 | 676 | @patch("feast.permissions.auth.kubernetes_token_parser.jwt.decode") |
|
0 commit comments