Visitar URL original
fix: Support Entra ID (Azure AD) token claims in OIDC auth by larrysingleton007 · Pull Request #6631 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix: Support Entra ID (Azure AD) token claims in OIDC auth
The OIDC token parser was written against Keycloak's token shape, which
rejected Microsoft Entra ID tokens in two independent ways.

Username extraction accepted only preferred_username or upn. Entra
client-credentials (app-only) tokens carry neither, so every
machine-to-machine caller failed authentication. Fall back to the calling
application's own identity when no human claim is present: azp on v2
tokens, appid on v1, then sub, which every issuer sets. A token with none
of the five claims still raises AuthenticationError.

Roles were read only from Keycloak's nested
resource_access.<client_id>.roles. Entra emits app roles in the top-level
roles claim, so the extracted list was always empty and RoleBasedPolicy
could never match. Merge the top-level claim into the existing
extraction, preserving order and dropping duplicates.

Both changes are additive, so Keycloak behavior is unchanged: the
username fallbacks only fire when preferred_username and upn are both
absent, and the merge only adds roles. Token validation is untouched.

Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com>
  • Loading branch information
larrysingleton007 authored and ntkathole committed Jul 23, 2026
commit 5e9c95b635af03c219e3035dd88f88709ed74449
23 changes: 16 additions & 7 deletions sdk/python/feast/permissions/auth/oidc_token_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,18 @@ async def _validate_token(self, access_token: str):
def _extract_username_or_raise_error(data: dict) -> str:
"""Extract the username from the decoded JWT. Raises if missing — identity is mandatory.

Checks ``preferred_username`` first (Keycloak default), then falls back
to ``upn`` (Azure AD / Entra ID).
Human identity claims take precedence: ``preferred_username`` (Keycloak
default), then ``upn`` (Azure AD / Entra ID). Client-credentials
(app-only) tokens carry neither, so the calling application's own
identity is used instead: ``azp`` (Entra ID v2 tokens), ``appid``
(Entra ID v1 tokens), and finally ``sub``, which every issuer sets.
"""
if "preferred_username" in data:
return data["preferred_username"]
if "upn" in data:
return data["upn"]
for claim in ("preferred_username", "upn", "azp", "appid", "sub"):
if claim in data:
return data[claim]
raise AuthenticationError(
Comment thread
larrysingleton007 marked this conversation as resolved.
"Missing preferred_username or upn field in access token."
"Missing username claim in access token: expected one of "
"preferred_username, upn, azp, appid or sub."
)

@staticmethod
Expand Down Expand Up @@ -194,6 +197,12 @@ async def user_details_from_access_token(self, access_token: str) -> User:
if self._auth_config.client_id
else []
)
# Entra ID emits app roles in the top-level `roles` claim instead of
# Keycloak's nested `resource_access.<client_id>.roles`. Merge both
# shapes, preserving order and dropping duplicates.
roles = roles + [
r for r in self._extract_claim(data, "roles") if r not in roles
]
groups = self._extract_claim(data, "groups")

logger.info(
Expand Down
150 changes: 150 additions & 0 deletions sdk/python/tests/unit/permissions/auth/test_token_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,156 @@ def test_oidc_token_extracts_groups_and_roles(
assertpy.assert_that(user.has_matching_group(["banking-admin"])).is_true()


@pytest.mark.parametrize(
"token_claims, expected_username",
[
# Human identity claims keep precedence over the application claims.
(
{
"preferred_username": "my-name",
"upn": "my-name@example.com",
"azp": "my-client-app",
},
"my-name",
),
(
{"upn": "my-name@example.com", "azp": "my-client-app"},
"my-name@example.com",
),
# Entra ID client-credentials (app-only) tokens carry no human claim.
(
{"azp": "my-client-app", "appid": "my-v1-client-app", "sub": "my-subject"},
"my-client-app",
),
({"appid": "my-v1-client-app", "sub": "my-subject"}, "my-v1-client-app"),
({"sub": "my-subject"}, "my-subject"),
],
)
@patch(
"feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__"
)
@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt")
@patch("feast.permissions.auth.oidc_token_parser.jwt.decode")
@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data")
def test_oidc_token_username_claim_precedence(
mock_discovery_data,
mock_jwt,
mock_signing_key,
mock_oauth2,
token_claims,
expected_username,
oidc_config,
):
signing_key = MagicMock()
signing_key.key = "a-key"
mock_signing_key.return_value = signing_key

mock_discovery_data.return_value = {
"authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth",
"token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token",
"jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs",
}

mock_jwt.return_value = token_claims

access_token = "aaa-bbb-ccc"
token_parser = OidcTokenParser(auth_config=oidc_config)
user = asyncio.run(
token_parser.user_details_from_access_token(access_token=access_token)
)

assertpy.assert_that(user).is_type_of(User)
if isinstance(user, User):
assertpy.assert_that(user.username).is_equal_to(expected_username)


@patch(
"feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__"
)
@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt")
@patch("feast.permissions.auth.oidc_token_parser.jwt.decode")
@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data")
def test_oidc_token_without_any_username_claim_fails(
mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config
):
signing_key = MagicMock()
signing_key.key = "a-key"
mock_signing_key.return_value = signing_key

mock_discovery_data.return_value = {
"authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth",
"token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token",
"jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs",
}

mock_jwt.return_value = {"resource_access": {_CLIENT_ID: {"roles": ["reader"]}}}

access_token = "aaa-bbb-ccc"
token_parser = OidcTokenParser(auth_config=oidc_config)
with pytest.raises(AuthenticationError):
asyncio.run(
token_parser.user_details_from_access_token(access_token=access_token)
)


@pytest.mark.parametrize(
"token_claims, expected_roles",
[
# Entra ID app roles arrive in the top-level `roles` claim.
({"roles": ["reader", "writer"]}, ["reader", "writer"]),
# Keycloak's nested claim keeps working on its own.
({"resource_access": {_CLIENT_ID: {"roles": ["reader"]}}}, ["reader"]),
# Both shapes present: merged, in order, without duplicates.
(
{
"resource_access": {_CLIENT_ID: {"roles": ["reader", "writer"]}},
"roles": ["writer", "admin"],
},
["reader", "writer", "admin"],
),
],
)
@patch(
"feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__"
)
@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt")
@patch("feast.permissions.auth.oidc_token_parser.jwt.decode")
@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data")
def test_oidc_token_merges_top_level_and_resource_access_roles(
mock_discovery_data,
mock_jwt,
mock_signing_key,
mock_oauth2,
token_claims,
expected_roles,
oidc_config,
):
signing_key = MagicMock()
signing_key.key = "a-key"
mock_signing_key.return_value = signing_key

mock_discovery_data.return_value = {
"authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth",
"token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token",
"jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs",
}

mock_jwt.return_value = {"preferred_username": "my-name", **token_claims}

access_token = "aaa-bbb-ccc"
token_parser = OidcTokenParser(auth_config=oidc_config)
user = asyncio.run(
token_parser.user_details_from_access_token(access_token=access_token)
)

assertpy.assert_that(user).is_type_of(User)
if isinstance(user, User):
assertpy.assert_that(user.roles).is_equal_to(expected_roles)
for role in expected_roles:
assertpy.assert_that(user.has_matching_role([role])).is_true()
assertpy.assert_that(user.has_matching_role(["updater"])).is_false()


@patch(
"feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__"
)
Expand Down