Skip to content
15 changes: 13 additions & 2 deletions docs/getting-started/components/authz_manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ The server, in turn, uses the same OIDC server to validate the token and extract

Some assumptions are made in the OIDC server configuration:
* The OIDC token refers to a client with roles matching the RBAC roles of the configured `Permission`s (*)
* The roles are exposed in the access token under `resource_access.<client_id>.roles`
* 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.
* 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 `preferred_username` should be part of the JWT token claim.
* 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.
* For `GroupBasedPolicy` support, the `groups` claim should be present in the access token (requires a "Group Membership" protocol mapper in Keycloak).
* **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.

(*) Please note that **the role match is case-sensitive**, e.g. the name of the role in the OIDC server and in the `Permission` configuration
must be exactly the same.
Expand All @@ -69,6 +70,16 @@ For example, the access token for a client `app` of a user with `reader` role an
}
```

A Microsoft Entra ID (Azure AD) client-credentials (app-only) token has no user claim; the application authenticates as itself, and its app roles arrive in the top-level `roles` claim:
```json
{
"azp": "11111111-2222-3333-4444-555555555555",
"roles": [
"reader"
]
}
```

#### Server-Side Configuration

The server requires `auth_discovery_url` and `client_id` to validate incoming JWT tokens via JWKS:
Expand Down
34 changes: 25 additions & 9 deletions sdk/python/feast/permissions/auth/oidc_token_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,29 @@ async def _validate_token(self, access_token: str):

@staticmethod
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).
"""Extract the username from the decoded JWT, or raise if the token
carries none of the recognised identity claims.

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.
A claim present with a null or non-string value is skipped rather than
returned, so it never shadows a usable later claim. Because ``sub`` is a
mandatory string JWT claim, the raise below fires only for a malformed
token that provides none of the five as a string.
"""
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"):
value = data.get(claim)
if isinstance(value, str):
return value
logger.debug(
f"No usable username claim; token claims present: {list(data.keys())}"
)
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 +206,10 @@ 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 for every issuer, preserving order and dropping duplicates.
roles = list(dict.fromkeys(roles + self._extract_claim(data, "roles")))
groups = self._extract_claim(data, "groups")

logger.info(
Expand Down
19 changes: 19 additions & 0 deletions sdk/python/tests/unit/permissions/auth/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import Dict
from unittest.mock import MagicMock

import pytest
from kubernetes import client

Expand Down Expand Up @@ -62,6 +65,22 @@ def oidc_config() -> OidcAuthConfig:
)


@pytest.fixture
def discovery_data() -> Dict[str, str]:
return {
"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",
}


@pytest.fixture
def signing_key() -> MagicMock:
key = MagicMock()
key.key = "a-key"
return key


@pytest.fixture(
scope="module",
params=[
Expand Down
Loading
Loading