Skip to content

Commit bcd3bd3

Browse files
committed
feat: Lightweight SA token validation for OIDC auth — TokenReview only, no RBAC queries
Replace KubernetesTokenParser delegation with a lightweight _validate_k8s_sa_token_and_extract_namespace() method in OidcTokenParser. Validates SA tokens via TokenReview API and extracts namespace from the authenticated identity. No RoleBinding/ClusterRoleBinding queries needed, so the server SA only requires tokenreviews/create permission. Also updates OIDC auth documentation with token priority, verify_ssl, groups claim, and multi-token support sections. Made-with: Cursor Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
1 parent 0f4064c commit bcd3bd3

File tree

5 files changed

+124
-72
lines changed

5 files changed

+124
-72
lines changed

docs/getting-started/components/authz_manager.md

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,52 +40,87 @@ auth:
4040
With OIDC authorization, the Feast client proxies retrieve the JWT token from an OIDC server (or [Identity Provider](https://openid.net/developers/how-connect-works/))
4141
and append it in every request to a Feast server, using an [Authorization Bearer Token](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#bearer).
4242

43-
The server, in turn, uses the same OIDC server to validate the token and extract the user roles from the token itself.
43+
The server, in turn, uses the same OIDC server to validate the token and extract user details — including username, roles, and groups — from the token itself.
4444

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 (*)
47-
* The roles are exposed in the access token that is passed to the server
47+
* The roles are exposed in the access token under `resource_access.<client_id>.roles`
4848
* 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.
49-
* The preferred_username should be part of the JWT token claim.
50-
49+
* The `preferred_username` should be part of the JWT token claim.
50+
* For `GroupBasedPolicy` support, the `groups` claim should be present in the access token (requires a "Group Membership" protocol mapper in Keycloak).
5151

5252
(*) 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
5353
must be exactly the same.
5454

55-
For example, the access token for a client `app` of a user with `reader` role should have the following `resource_access` section:
55+
For example, the access token for a client `app` of a user with `reader` role and membership in the `data-team` group should have the following claims:
5656
```json
5757
{
58+
"preferred_username": "alice",
5859
"resource_access": {
5960
"app": {
6061
"roles": [
6162
"reader"
6263
]
6364
}
64-
}
65+
},
66+
"groups": [
67+
"data-team"
68+
]
6569
}
6670
```
6771

68-
An example of feast OIDC authorization configuration on the server side is the following:
72+
#### Server-Side Configuration
73+
74+
The server requires `auth_discovery_url` and `client_id` to validate incoming JWT tokens via JWKS:
6975
```yaml
7076
project: my-project
7177
auth:
7278
type: oidc
73-
client_id: _CLIENT_ID__
79+
client_id: _CLIENT_ID_
7480
auth_discovery_url: _OIDC_SERVER_URL_/realms/master/.well-known/openid-configuration
7581
...
7682
```
7783

78-
In case of client configuration, the following settings username, password and client_secret must be added to specify the current user:
84+
When the OIDC provider uses a self-signed or untrusted TLS certificate (e.g. internal Keycloak on OpenShift), set `verify_ssl` to `false` to disable certificate verification:
85+
```yaml
86+
auth:
87+
type: oidc
88+
client_id: _CLIENT_ID_
89+
auth_discovery_url: https://keycloak.internal/realms/master/.well-known/openid-configuration
90+
verify_ssl: false
91+
```
92+
93+
{% hint style="warning" %}
94+
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.
95+
{% endhint %}
96+
97+
#### Client-Side Configuration
98+
99+
The client supports multiple token source modes. The SDK resolves tokens in the following priority order:
100+
101+
1. **Intra-communication token** — internal server-to-server calls (via `INTRA_COMMUNICATION_BASE64` env var)
102+
2. **`token`** — a static JWT string provided directly in the configuration
103+
3. **`token_env_var`** — the name of an environment variable containing the JWT
104+
4. **`client_secret`** — fetches a token from the OIDC provider using client credentials or ROPC flow (requires `auth_discovery_url` and `client_id`)
105+
5. **`FEAST_OIDC_TOKEN`** — default fallback environment variable
106+
6. **Kubernetes service account token** — read from `/var/run/secrets/kubernetes.io/serviceaccount/token` when running inside a pod
107+
108+
**Token passthrough** (for use with external token providers like [kube-authkit](https://github.com/opendatahub-io/kube-authkit)):
109+
```yaml
110+
project: my-project
111+
auth:
112+
type: oidc
113+
token_env_var: FEAST_OIDC_TOKEN
114+
```
115+
116+
Or with a bare `type: oidc` (no other fields) — the SDK falls back to the `FEAST_OIDC_TOKEN` environment variable or a mounted Kubernetes service account token:
79117
```yaml
118+
project: my-project
80119
auth:
81120
type: oidc
82-
...
83-
username: _USERNAME_
84-
password: _PASSWORD_
85-
client_secret: _CLIENT_SECRET__
86121
```
87122

88-
Below is an example of feast full OIDC client auth configuration:
123+
**Client credentials / ROPC flow** (existing behavior, unchanged):
89124
```yaml
90125
project: my-project
91126
auth:
@@ -97,6 +132,12 @@ auth:
97132
auth_discovery_url: http://localhost:8080/realms/master/.well-known/openid-configuration
98133
```
99134

135+
When using client credentials or ROPC flows, the `verify_ssl` setting also applies to the discovery and token endpoint requests.
136+
137+
#### Multi-Token Support (OIDC + Kubernetes Service Account)
138+
139+
When the Feast server is configured with OIDC auth and deployed on Kubernetes, the `OidcTokenParser` can handle both Keycloak JWT tokens and Kubernetes service account tokens. Incoming tokens that contain a `kubernetes.io` claim are validated via the Kubernetes Token Access Review API and the namespace is extracted from the authenticated identity — no RBAC queries are performed, so the server service account only needs `tokenreviews/create` permission. All other tokens follow the standard OIDC/Keycloak JWKS validation path. This enables `NamespaceBasedPolicy` enforcement for service account tokens while using `GroupBasedPolicy` and `RoleBasedPolicy` for OIDC user tokens.
140+
100141
### Kubernetes RBAC Authorization
101142
With Kubernetes RBAC Authorization, the client uses the service account token as the authorizarion bearer token, and the
102143
server fetches the associated roles from the Kubernetes RBAC resources. Feast supports advanced authorization by extracting user groups and namespaces from Kubernetes tokens, enabling fine-grained access control beyond simple role matching. This is achieved by leveraging Kubernetes Token Access Review, which allows Feast to determine the groups and namespaces associated with a user or service account.

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

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from starlette.authentication import (
1212
AuthenticationError,
1313
)
14+
from kubernetes import client, config
1415

1516
from feast.permissions.auth.token_parser import TokenParser
1617
from feast.permissions.auth_model import OidcAuthConfig
@@ -25,21 +26,16 @@ class OidcTokenParser(TokenParser):
2526
A ``TokenParser`` to use an OIDC server to retrieve the user details.
2627
Server settings are retrieved from the ``auth`` configuration of the Feature store.
2728
28-
When running inside Kubernetes, an optional ``k8s_parser`` can be supplied.
2929
Incoming tokens that contain a ``kubernetes.io`` claim (i.e. Kubernetes
30-
service-account tokens) are delegated to the K8s parser, while all other
31-
tokens follow the standard OIDC/Keycloak JWKS validation path.
30+
service-account tokens) are handled via a lightweight TokenReview that
31+
extracts only the namespace — no RBAC queries needed. All other tokens
32+
follow the standard OIDC/Keycloak JWKS validation path.
3233
"""
3334

3435
_auth_config: OidcAuthConfig
3536

36-
def __init__(
37-
self,
38-
auth_config: OidcAuthConfig,
39-
k8s_parser: Optional[TokenParser] = None,
40-
):
37+
def __init__(self, auth_config: OidcAuthConfig):
4138
self._auth_config = auth_config
42-
self._k8s_parser = k8s_parser
4339
self.oidc_discovery_service = OIDCDiscoveryService(
4440
self._auth_config.auth_discovery_url,
4541
verify_ssl=self._auth_config.verify_ssl,
@@ -147,11 +143,11 @@ async def user_details_from_access_token(self, access_token: str) -> User:
147143
if user:
148144
return user
149145

150-
if self._k8s_parser and self._is_kubernetes_token(access_token):
146+
if self._is_kubernetes_token(access_token):
151147
logger.debug(
152-
"Detected kubernetes.io claim — delegating to KubernetesTokenParser"
148+
"Detected kubernetes.io claim — validating via TokenReview"
153149
)
154-
return await self._k8s_parser.user_details_from_access_token(access_token)
150+
return await self._validate_k8s_sa_token_and_extract_namespace(access_token)
155151

156152
# Standard OIDC / Keycloak flow
157153
try:
@@ -182,6 +178,38 @@ async def user_details_from_access_token(self, access_token: str) -> User:
182178
logger.exception("Exception while parsing the token:")
183179
raise AuthenticationError("Invalid token.")
184180

181+
@staticmethod
182+
async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> User:
183+
"""Validate a K8s SA token via TokenReview and extract the namespace.
184+
185+
Lightweight alternative to full KubernetesTokenParser — only validates
186+
the token and extracts the namespace from the authenticated identity.
187+
No RBAC queries (RoleBindings, ClusterRoleBindings) are performed,
188+
so the server SA needs only ``tokenreviews/create`` permission.
189+
"""
190+
config.load_incluster_config()
191+
auth_v1 = client.AuthenticationV1Api()
192+
193+
token_review = client.V1TokenReview(
194+
spec=client.V1TokenReviewSpec(token=access_token)
195+
)
196+
response = auth_v1.create_token_review(token_review)
197+
198+
if not response.status.authenticated:
199+
raise AuthenticationError(
200+
f"Kubernetes token validation failed: {response.status.error}"
201+
)
202+
203+
username = getattr(response.status.user, "username", "") or ""
204+
namespaces = []
205+
if username.startswith("system:serviceaccount:") and username.count(":") >= 3:
206+
namespaces.append(username.split(":")[2])
207+
208+
logger.info(
209+
f"SA token validated — user: {username}, namespaces: {namespaces}"
210+
)
211+
return User(username=username, roles=[], groups=[], namespaces=namespaces)
212+
185213
def _get_intra_comm_user(self, access_token: str) -> Optional[User]:
186214
intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64")
187215

sdk/python/feast/permissions/server/utils.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,21 +121,7 @@ def init_auth_manager(
121121
token_parser = KubernetesTokenParser()
122122
elif auth_type == AuthManagerType.OIDC:
123123
assert isinstance(auth_config, OidcAuthConfig)
124-
k8s_parser = None
125-
try:
126-
from feast.permissions.auth.kubernetes_token_parser import (
127-
KubernetesTokenParser,
128-
)
129-
130-
k8s_parser = KubernetesTokenParser()
131-
logger.info(
132-
"K8s API available — SA token support enabled for OIDC auth"
133-
)
134-
except Exception as e:
135-
logger.info(f"K8s API unavailable ({e}) — OIDC-only token parsing")
136-
token_parser = OidcTokenParser(
137-
auth_config=auth_config, k8s_parser=k8s_parser
138-
)
124+
token_parser = OidcTokenParser(auth_config=auth_config)
139125
else:
140126
raise ValueError(f"Unmanaged authorization manager type {auth_type}")
141127

sdk/python/tests/unit/permissions/auth/server/mock_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ async def mock_oath2(self, request):
3232
}
3333
monkeypatch.setattr(
3434
"feast.permissions.client.oidc_authentication_client_manager.requests.get",
35-
lambda url: discovery_response,
35+
lambda url, verify=True: discovery_response,
3636
)
3737
token_response = Mock(spec=Response)
3838
token_response.status_code = 200
3939
token_response.json.return_value = {"access_token": "my-token"}
4040
monkeypatch.setattr(
4141
"feast.permissions.client.oidc_authentication_client_manager.requests.post",
42-
lambda url, data, headers: token_response,
42+
lambda url, data, headers, verify=True: token_response,
4343
)
4444

4545
monkeypatch.setattr(

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

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
import os
33
from unittest import mock
4-
from unittest.mock import AsyncMock, MagicMock, patch
4+
from unittest.mock import MagicMock, patch
55

66
import assertpy
77
import pytest
@@ -398,48 +398,48 @@ def test_k8s_inter_server_comm(
398398
# ---------------------------------------------------------------------------
399399

400400

401-
@patch(
402-
"feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__"
403-
)
404-
@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt")
405401
@patch("feast.permissions.auth.oidc_token_parser.jwt.decode")
406402
@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data")
407-
def test_oidc_parser_routes_sa_token_to_k8s_parser(
408-
mock_discovery_data, mock_jwt_decode, mock_signing_key, mock_oauth2, oidc_config
403+
def test_oidc_parser_handles_sa_token_via_token_review(
404+
mock_discovery_data, mock_jwt_decode, oidc_config
409405
):
410-
"""When a token contains kubernetes.io claim, it should be routed to the K8s parser."""
406+
"""When a token contains kubernetes.io claim, _handle_sa_token is called (not the OIDC JWKS path)."""
411407
mock_discovery_data.return_value = {
412408
"authorization_endpoint": "https://localhost:8080/auth",
413409
"token_endpoint": "https://localhost:8080/token",
414410
"jwks_uri": "https://localhost:8080/certs",
415411
}
416412

413+
mock_jwt_decode.return_value = {
414+
"kubernetes.io": {"namespace": "feast"},
415+
"sub": "system:serviceaccount:feast:feast",
416+
}
417+
417418
sa_user = User(
418-
username="feast:feast",
419+
username="system:serviceaccount:feast:feast",
419420
roles=[],
420-
groups=["system:serviceaccounts:feast"],
421+
groups=[],
421422
namespaces=["feast"],
422423
)
423424

424-
k8s_parser = MagicMock()
425-
k8s_parser.user_details_from_access_token = AsyncMock(return_value=sa_user)
425+
token_parser = OidcTokenParser(auth_config=oidc_config)
426426

427-
# jwt.decode is patched globally — the unverified decode inside the parser
428-
# returns a payload with kubernetes.io claim
429-
mock_jwt_decode.return_value = {
430-
"kubernetes.io": {"namespace": "feast"},
431-
"sub": "system:serviceaccount:feast:feast",
432-
}
427+
with patch.object(
428+
token_parser,
429+
"_validate_k8s_sa_token_and_extract_namespace",
430+
return_value=sa_user,
431+
) as mock_handle:
432+
user = asyncio.run(
433+
token_parser.user_details_from_access_token(access_token="sa-token")
434+
)
435+
mock_handle.assert_called_once_with("sa-token")
433436

434-
token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser)
435-
user = asyncio.run(
436-
token_parser.user_details_from_access_token(access_token="sa-token")
437+
assertpy.assert_that(user.username).is_equal_to(
438+
"system:serviceaccount:feast:feast"
437439
)
438-
439-
k8s_parser.user_details_from_access_token.assert_called_once_with("sa-token")
440-
assertpy.assert_that(user.username).is_equal_to("feast:feast")
441440
assertpy.assert_that(user.namespaces).is_equal_to(["feast"])
442-
mock_signing_key.assert_not_called()
441+
assertpy.assert_that(user.roles).is_equal_to([])
442+
assertpy.assert_that(user.groups).is_equal_to([])
443443

444444

445445
@patch(
@@ -469,14 +469,11 @@ def test_oidc_parser_routes_keycloak_token_normally(
469469
}
470470
mock_jwt_decode.return_value = keycloak_payload
471471

472-
k8s_parser = MagicMock()
473-
474-
token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser)
472+
token_parser = OidcTokenParser(auth_config=oidc_config)
475473
user = asyncio.run(
476474
token_parser.user_details_from_access_token(access_token="keycloak-jwt")
477475
)
478476

479-
k8s_parser.user_details_from_access_token.assert_not_called()
480477
assertpy.assert_that(user.username).is_equal_to("testuser")
481478
assertpy.assert_that(user.roles).is_equal_to(["reader"])
482479
assertpy.assert_that(user.groups).is_equal_to(["data-team"])

0 commit comments

Comments
 (0)