Skip to content

Commit 78ebcfe

Browse files
larrysingleton007jyejare
authored andcommitted
feat: Add optional OIDC token audience and issuer verification (feast-dev#6670)
* feat: Add optional OIDC token audience and issuer verification The OIDC token parser verifies signature (JWKS via discovery) and expiry, but never the audience or issuer: any validly-signed, unexpired token from the configured IdP authenticates regardless of which resource it was minted for, leaving RBAC role matching as the only gate. Add two optional OidcAuthConfig fields, audience and issuer, both unset by default. When set, the corresponding claim must match or the token is rejected at authentication; when unset, the decode options are identical to before, so existing deployments are unaffected. Opt-in rather than strict-by-default because IdPs commonly mint tokens whose claims differ from the discovery metadata: Entra ID issues v1.0 tokens (iss under sts.windows.net, api:// audience) that are validated against a v2.0 discovery URL, which works because discovery is used only to source JWKS keys. A dedicated test pins that setup so it cannot silently regress. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> * docs: Document optional OIDC audience and issuer verification Cover the new audience and issuer options in the OIDC authorization page, including the caveat that expected values must match the token's claims rather than the discovery document (the Entra ID v1.0-token case). Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --------- Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com>
1 parent 6646139 commit 78ebcfe

4 files changed

Lines changed: 244 additions & 4 deletions

File tree

‎docs/getting-started/components/authz_manager.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The server, in turn, uses the same OIDC server to validate the token and extract
4545
Some assumptions are made in the OIDC server configuration:
4646
* The OIDC token refers to a client with roles matching the RBAC roles of the configured `Permission`s (*)
4747
* The roles are exposed in the access token under `resource_access.<client_id>.roles` (Keycloak) or in the top-level `roles` claim (Entra ID app roles). Roles found in both are merged.
48-
* The JWT token is expected to have a verified signature and not be expired. The Feast OIDC token parser logic validates for `verify_signature` and `verify_exp` so make sure that the given OIDC provider is configured to meet these requirements.
48+
* The JWT token is expected to have a verified signature and not be expired. The Feast OIDC token parser logic validates for `verify_signature` and `verify_exp` so make sure that the given OIDC provider is configured to meet these requirements. The token's audience and issuer claims are **not** verified by default; both checks can be enabled with the `audience` and `issuer` options (see [Server-Side Configuration](#server-side-configuration)).
4949
* The username is read from the first of `preferred_username`, `upn`, `azp`, `appid`, `sub` present in the token. Entra ID client-credentials (app-only) tokens carry no user claim, so they authenticate as the calling application.
5050
* For `GroupBasedPolicy` support, the `groups` claim should be present in the access token (requires a "Group Membership" protocol mapper in Keycloak).
5151
* **Entra ID limitation**: Group claims use object IDs (GUIDs) instead of names, and are omitted entirely when a user exceeds the group overage threshold. GroupBasedPolicy must reference GUIDs and cannot be used for principals with large group memberships.
@@ -105,6 +105,23 @@ auth:
105105
Setting `verify_ssl: false` disables TLS certificate verification for all OIDC provider communication (discovery, JWKS, token endpoint). Only use this in development or internal environments where you accept the security risk.
106106
{% endhint %}
107107

108+
By default the server verifies only the token's signature and expiry: any validly-signed, unexpired token from the configured provider is accepted regardless of the audience it was minted for, and authorization (role matching) is the only remaining gate. For defense in depth, set `audience` and/or `issuer` to additionally require a matching `aud` / `iss` claim:
109+
110+
```yaml
111+
auth:
112+
type: oidc
113+
client_id: _CLIENT_ID_
114+
auth_discovery_url: https://login.example.com/.well-known/openid-configuration
115+
audience: api://feast-feature-server
116+
issuer: https://login.example.com/realms/master
117+
```
118+
119+
A token whose `aud` (or `iss`) claim does not match is rejected at authentication. The two options are independent; leave one unset to skip that check.
120+
121+
{% hint style="warning" %}
122+
Set these to the values your IdP puts **in the token itself**, which are not always the ones in the discovery document. For example, Microsoft Entra ID commonly issues v1.0 tokens (`iss: https://sts.windows.net/<tenant-id>/`, `aud: api://<app-id-uri>`) even when `auth_discovery_url` points at the v2.0 endpoint. That setup keeps working with these options unset, or set to the v1.0 values — but copying the v2.0 issuer from the discovery document would reject every v1.0 token.
123+
{% endhint %}
124+
108125
#### Client-Side Configuration
109126

110127
The client supports multiple token source modes. The SDK resolves tokens in the following priority order:

‎sdk/python/feast/permissions/auth/oidc_token_parser.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,15 @@ def _is_ssl_error(exc: BaseException) -> bool:
116116
return False
117117

118118
def _decode_token(self, access_token: str) -> dict:
119-
"""Fetch the JWKS signing key and decode + verify the JWT."""
119+
"""Fetch the JWKS signing key and decode + verify the JWT.
120+
121+
Signature and expiry are always verified. Audience and issuer are
122+
verified only when ``audience`` / ``issuer`` are set on
123+
``OidcAuthConfig``; both default to off, because the claim values a
124+
provider puts in the token can legitimately differ from its discovery
125+
metadata (e.g. Entra ID v1.0 tokens validated against a v2.0
126+
discovery document).
127+
"""
120128
optional_custom_headers = {"User-agent": "custom-user-agent"}
121129
ssl_ctx = ssl.create_default_context()
122130
if not self._auth_config.verify_ssl:
@@ -132,15 +140,21 @@ def _decode_token(self, access_token: str) -> dict:
132140
ssl_context=ssl_ctx,
133141
)
134142
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
143+
expected_audience = self._auth_config.audience
144+
expected_issuer = self._auth_config.issuer
135145
return jwt.decode(
136146
access_token,
137147
signing_key.key,
138148
algorithms=["RS256"],
139-
audience="account",
149+
# "account" preserves the historical Keycloak-shaped default; it
150+
# is inert while verify_aud is off.
151+
audience=expected_audience if expected_audience is not None else "account",
152+
issuer=expected_issuer,
140153
options={
141-
"verify_aud": False,
154+
"verify_aud": expected_audience is not None,
142155
"verify_signature": True,
143156
"verify_exp": True,
157+
"verify_iss": expected_issuer is not None,
144158
},
145159
leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew
146160
)

‎sdk/python/feast/permissions/auth_model.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ class OidcAuthConfig(AuthConfig):
4040
ui_client_id: Optional[str] = None
4141
verify_ssl: bool = True
4242
ca_cert_path: str = ""
43+
# When set, incoming tokens must carry a matching `aud` / `iss` claim;
44+
# when left unset (the default), the corresponding claim is not verified.
45+
# Set these to the values your IdP puts in the token itself, which may
46+
# differ from the discovery document (e.g. Entra ID v1.0 tokens validated
47+
# against a v2.0 discovery URL).
48+
audience: Optional[str] = None
49+
issuer: Optional[str] = None
4350

4451

4552
class OidcClientAuthConfig(OidcAuthConfig):

‎sdk/python/tests/unit/permissions/auth/test_token_parser.py‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import asyncio
22
import os
3+
import time
34
from unittest import mock
45
from unittest.mock import MagicMock, patch
56

67
import assertpy
8+
import jwt
79
import pytest
810
from starlette.authentication import (
911
AuthenticationError,
1012
)
1113

1214
from feast.permissions.auth.kubernetes_token_parser import KubernetesTokenParser
1315
from feast.permissions.auth.oidc_token_parser import OidcTokenParser
16+
from feast.permissions.auth_model import OidcAuthConfig
1417
from feast.permissions.user import User
1518

1619
_CLIENT_ID = "test"
@@ -469,6 +472,205 @@ async def mock_oath2(self, request):
469472
assertpy.assert_that(user.has_matching_role(["updater"])).is_false()
470473

471474

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+
472674
# TODO RBAC: Move role bindings to a reusable fixture
473675
@patch("feast.permissions.auth.kubernetes_token_parser.config.load_incluster_config")
474676
@patch("feast.permissions.auth.kubernetes_token_parser.jwt.decode")

0 commit comments

Comments
 (0)