Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 13 additions & 3 deletions docs/getting-started/components/authz_manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ 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.
* For `GroupBasedPolicy` support, the `groups` claim should be present in the access token (requires a "Group Membership" protocol mapper in Keycloak).
* 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 emits group object IDs (GUIDs) in this claim rather than names, and omits it once a principal exceeds Entra's group overage limit, so on Entra a `GroupBasedPolicy` must reference those IDs and cannot rely on the claim for very 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 +69,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
31 changes: 22 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,26 @@ 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
raise AuthenticationError(
"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 +203,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
18 changes: 18 additions & 0 deletions sdk/python/tests/unit/permissions/auth/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock

import pytest
from kubernetes import client

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


@pytest.fixture
def discovery_data() -> dict:
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