You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Signed JWT - Federated (Kubernetes Service Accounts) feedback
#48130
When integrating the new "Signed JWT - Federated" Client Authenticator for Kubernetes Service Accounts into UDS Core, we encountered a few corner cases that are worth discussing:
The Client ID validation seems too tight, which is a blocker for using the Keycloak Terraform Provider
The feature misses the entire use case for Managed Kubernetes Clusters and centralized Authorization Servers/IdPs
The FederatedJWTClientValidator and AbstractJWTClientValidator encapsulate methods too tightly, which makes them non-extensible
The KubernetesJwksEndpointLoader lacks configuration (or extension points) to enable/disable attaching tokens to the requests
I put all the necessary details along with proposed solutions below. We managed to get all the bits working in the UDS Core platform (see uds-identity-config/pull/838) but we ended up forking many aspects of the original implementation.
@ahus1@stianst@ryanemerson@sschu I'd love to get your thoughts on these. I'm more than happy to contribute these features back to upstream, but before I start creating tickets and PRs, I wanted to ensure that's the proper path forward.
Authentication of the client is optional, as described in Section 3.2.1 of OAuth 2.0 [RFC6749] and consequently, the "client_id" is only needed when a form of client authentication that relies on the parameter is used.
B. For client authentication, the subject MUST be the "client_id" of the OAuth client.
Kubernetes doesn't follow the above convention and sets the sub claim to the Service Account (e.g. system:serviceaccount:my-namespace:my-serviceaccount). So if we wanted to follow the spec in this case, we'd need to use the Service Account names as Client ID. This is not feasible for many environments as Clients such as system:serviceaccount:my-namespace:my-serviceaccount are no longer human-readable.
I propose a different approach — relax Client ID validation for the Kubernetes case. This approach has already been proposed in:
Managed Kubernetes clusters, such as EKS or AKS, use a centralized IdP for both user authentication and issuing Service Account Tokens. When the --oidc-* settings are used in the Kubernetes API Server, it no longer issues tokens but rather delegates this to a different IdP. At the same time, the JWKS URL remains the same and valid, so this integration doesn't affect token signature validation. See the commands below:
This doesn't play well with the implementation, which strongly assumes that Kubernetes will issue all the tokens. In this case, it's not Kubernetes — it's the OIDC service from EKS.
Also related to this, in a multi-cloud environment, services like UDS can be deployed on many clouds at the same time. So the implementation needs to be flexible enough to do dynamic issuer lookup before validating the JWT.
In essence, we implemented an alternative ClientAssertionStrategy. At first, it tries the default strategy (that assumes Kubernetes issues tokens), but when it fails, it performs an alternative lookup (this can probably be optimized further, but here's what we have):
// 1. Try standard issuer-based lookup (handles k3d and matching-issuer cases)LookupResultdefaultResult = defaultStrategy.lookup(context);
if (defaultResult != null
&& defaultResult.identityProviderModel() != null
&& defaultResult.identityProviderModel().isEnabled()
&& defaultResult.clientModel() != null) {
LOGGER.debug("Standard issuer-based lookup succeeded");
returndefaultResult;
}
// 2. Fallback: client-first lookup for managed K8s clusters where issuer doesn't matchLOGGER.debug("Standard issuer-based lookup failed, falling back to client-first lookup");
ClientAssertionStatestate = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
Stringsubject = state != null && state.getToken() != null ? state.getToken().getSubject() : null;
if (subject == null) {
LOGGER.debug("Fallback lookup skipped because client assertion subject is missing");
returnnull;
}
varidpStorage = context.getSession().identityProviders();
returncontext.getRealm().getClientsStream()
.filter(c -> subject.equals(c.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY)))
.map(matchingClient -> {
StringidpAlias = matchingClient.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY);
if (idpAlias == null) {
returnnull;
}
IdentityProviderModelidp = idpStorage.getByAlias(idpAlias);
if (idp != null
&& UDSKubernetesIdentityProviderFactory.PROVIDER_ID.equals(idp.getProviderId())
&& idp.isEnabled()) {
LOGGER.debugf("Fallback lookup matched client '%s' to IdP '%s'",
matchingClient.getClientId(), idpAlias);
returnnewLookupResult(matchingClient, idp);
}
returnnull;
})
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
The implementation also requires a custom IdP that is almost identical to the KubernetesIdentityProvider but uses an additional lookup for the issuer:
publicbooleanverifyClientAssertion(ClientAuthenticationFlowContext<
10000
/span> context) throwsException {
StringdiscoveredIssuer = discoverIssuer(config.getIssuer());
if (discoveredIssuer == null) {
ResponsechallengeResponse = ClientAuthUtil.errorResponse(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), "server_error", "Failed to discover OIDC issuer from Kubernetes API server");
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challengeResponse);
returnfalse;
}
UDSFederatedJWTClientValidatorvalidator = newUDSFederatedJWTClientValidator(
context, this::verifySignature, discoveredIssuer, config.getAllowedClockSkew(), true
);
validator.setMaximumExpirationTime(3600);
returnvalidator.validate();
}
StringdiscoverIssuer(StringkubeApiServerUrl) {
// The final implementation should probably use an Infinispan Cache here. Stringcached = ISSUER_CACHE.get(kubeApiServerUrl);
if (cached != null) {
LOGGER.debugf("Using cached OIDC issuer '%s' for K8s API server '%s'", cached, kubeApiServerUrl);
returncached;
}
Stringdiscovered = fetchIssuerFromOidc(kubeApiServerUrl);
if (discovered == null) {
// Discovery failed -- don't cache, fail the request and let the client retry.// We don't want any fallback in this case as it may produce inconsistent results.returnnull;
}
// putIfAbsent is atomic -- if another thread raced us, use their resultStringexisting = ISSUER_CACHE.putIfAbsent(kubeApiServerUrl, discovered);
returnexisting != null ? existing : discovered;
}
privateStringfetchIssuerFromOidc(StringkubeApiServerUrl) {
StringwellKnownEndpoint = kubeApiServerUrl + "/.well-known/openid-configuration";
try {
StringsaToken = KubernetesUtils.readServiceAccountToken();
SimpleHttpsimpleHttp = SimpleHttp.create(session);
varrequest = simpleHttp.doGet(wellKnownEndpoint).acceptJson();
if (saToken != null) {
request.auth(saToken);
}
try (SimpleHttpResponseresponse = request.asResponse()) {
intstatus = response.getStatus();
if (status < 200 || status >= 300) {
LOGGER.errorf("OIDC discovery from '%s' returned HTTP %d",
wellKnownEndpoint, status);
returnnull;
}
OIDCConfigurationRepresentationoidcConfig = JsonSerialization.readValue(
response.asString(), OIDCConfigurationRepresentation.class);
StringdiscoveredIssuer = oidcConfig.getIssuer();
if (discoveredIssuer == null || discoveredIssuer.isEmpty()) {
LOGGER.errorf("OIDC discovery from '%s' returned empty issuer",
wellKnownEndpoint);
returnnull;
}
if (!discoveredIssuer.startsWith("https://")) {
LOGGER.errorf("OIDC discovery from '%s' returned non-HTTPS issuer '%s'",
wellKnownEndpoint, discoveredIssuer);
returnnull;
}
LOGGER.infof("Discovered OIDC issuer '%s' for K8s API server '%s'", discoveredIssuer, kubeApiServerUrl);
returndiscoveredIssuer;
}
} catch (Exceptione) {
LOGGER.errorf(e, "Failed to discover OIDC issuer from '%s'", wellKnownEndpoint);
returnnull;
}
}
The above piece of code has been implemented in a very defensive way. We believe it can be unified and squashed into one discovery call that should satisfy both cases (managed Kubernetes clusters and local ones).
The most complicated aspect of this is testing. Doing it properly, requires deploying Keycloak on EKS/AKS (and potentially others) which leads to this discussion: #47669
The FederatedJWTClientValidator and AbstractJWTClientValidator encapsulate methods too tightly, which makes them non-extensible
When working on integrating Tofu with the "Signed JWT - Federated" Client Authenticator, we could not easily bypass the Client ID validation by providing our own validateClient method. Unfortunately, all the methods in AbstractJWTClientValidator are private, which requires copy-pasting them into our codebase and maintaining the changes.
A much better approach would be to change all the method signatures to protected, enabling custom implementations to override only certain aspects of the token validation.
The KubernetesJwksEndpointLoader lacks configuration (or extension points) to enable/disable attaching tokens to the requests
The KubernetesJwksEndpointLoader prevents sending the token on issuer mismatch:
if (jwt.getIssuer().equals(issuer)) {
logger.trace("Including service account token in request");
returntoken;
} else {
logger.debug("Not including service account token due to issuer missmatch");
}
This approach makes a lot of sense to avoid sending tokens to fake services prepared by an attacker. It also works great when the issuer is known, but in case of dynamic configurations (like managed Kubernetes clusters - multi cloud environmens), that is not always the case. If we decide to implement a more dynamic approach, we will need a configuration switch that allows always including the token when probing the OIDC Discovery endpoint (which requires a token for managed cloud offerings).
Managed Kubernetes clusters, such as EKS or AKS, use a centralized IdP for both user authentication and issuing Service Account Tokens.
That is an interesting point. However, this seems to be specific to AWS/EKS. For Azure/AKS this is definitely not the case. For AKS, the tokens are issued by Kubernetes: https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer#get-the-discovery-document. I also successfully tested the current implementation with service account tokens from AKS.
In my opinion, this is more an issue of the client, which sets the client ID when using a bearer token for authentication. So, in this specific case, I think this should be addressed in the Terraform provider.
The Quarkus OIDC client, for example, seems to handle this correctly and does not send the client ID when a client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer token is used.
But I see your point. At the beginning, I also had a hard time understanding why it was not working because of the strict client-id validation.
Yes, I do believe we have two use cases - when Kubernetes issues all the tokens (Minikube, K3d, K3s etc) and when there's an external Authorization Server that does it (EKS, probably others).
I also agree with you on the Client ID - I think it's primary an early adoption problem. But still - Keycloak seems to enforce this containt without good reason, so the fix can actually be applied both sides.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Summary
When integrating the new "Signed JWT - Federated" Client Authenticator for Kubernetes Service Accounts into UDS Core, we encountered a few corner cases that are worth discussing:
FederatedJWTClientValidatorandAbstractJWTClientValidatorencapsulate methods too tightly, which makes them non-extensibleKubernetesJwksEndpointLoaderlacks configuration (or extension points) to enable/disable attaching tokens to the requestsI put all the necessary details along with proposed solutions below. We managed to get all the bits working in the UDS Core platform (see uds-identity-config/pull/838) but we ended up forking many aspects of the original implementation.
@ahus1 @stianst @ryanemerson @sschu I'd love to get your thoughts on these. I'm more than happy to contribute these features back to upstream, but before I start creating tickets and PRs, I wanted to ensure that's the proper path forward.
Client ID validation
As described in keycloak/terraform-provider-keycloak#1542 and #48024, tools like the Keycloak Terraform Provider attach Client ID to each authentication request by default. The JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants RFC also highlights that with Client Assertions, sending the client_id is optional:
Kubernetes doesn't follow the above convention and sets the
subclaim to the Service Account (e.g.system:serviceaccount:my-namespace:my-serviceaccount). So if we wanted to follow the spec in this case, we'd need to use the Service Account names as Client ID. This is not feasible for many environments as Clients such assystem:serviceaccount:my-namespace:my-serviceaccountare no longer human-readable.I propose a different approach — relax Client ID validation for the Kubernetes case. This approach has already been proposed in:
client_idoptional for Signed JWT authentication terraform-provider-keycloak#1552client_idparameter validation for federated client assertions #48026Managed Kubernetes Clusters and centralized IdPs
Managed Kubernetes clusters, such as EKS or AKS, use a centralized IdP for both user authentication and issuing Service Account Tokens. When the
--oidc-*settings are used in the Kubernetes API Server, it no longer issues tokens but rather delegates this to a different IdP. At the same time, the JWKS URL remains the same and valid, so this integration doesn't affect token signature validation. See the commands below:The
jwks_urias well as Kube's JWKS endpoint return the same keys.Tokens issued by Kubernetes (and mounted to the Pods) in this scenario use a different issuer:
This doesn't play well with the implementation, which strongly assumes that Kubernetes will issue all the tokens. In this case, it's not Kubernetes — it's the OIDC service from EKS.
Also related to this, in a multi-cloud environment, services like UDS can be deployed on many clouds at the same time. So the implementation needs to be flexible enough to do dynamic issuer lookup before validating the JWT.
We're currently working on a patch for UDS Core to accommodate this scenario here: defenseunicorns/uds-identity-config#838
In essence, we implemented an alternative
ClientAssertionStrategy. At first, it tries the default strategy (that assumes Kubernetes issues tokens), but when it fails, it performs an alternative lookup (this can probably be optimized further, but here's what we have):The implementation also requires a custom IdP that is almost identical to the
KubernetesIdentityProviderbut uses an additional lookup for the issuer:The above piece of code has been implemented in a very defensive way. We believe it can be unified and squashed into one discovery call that should satisfy both cases (managed Kubernetes clusters and local ones).
The most complicated aspect of this is testing. Doing it properly, requires deploying Keycloak on EKS/AKS (and potentially others) which leads to this discussion: #47669
The
FederatedJWTClientValidatorandAbstractJWTClientValidatorencapsulate methods too tightly, which makes them non-extensibleWhen working on integrating Tofu with the "Signed JWT - Federated" Client Authenticator, we could not easily bypass the Client ID validation by providing our own
validateClientmethod. Unfortunately, all the methods inAbstractJWTClientValidatorare private, which requires copy-pasting them into our codebase and maintaining the changes.A much better approach would be to change all the method signatures to
protected, enabling custom implementations to override only certain aspects of the token validation.The
KubernetesJwksEndpointLoaderlacks configuration (or extension points) to enable/disable attaching tokens to the requestsThe
KubernetesJwksEndpointLoaderprevents sending the token on issuer mismatch:This approach makes a lot of sense to avoid sending tokens to fake services prepared by an attacker. It also works great when the issuer is known, but in case of dynamic configurations (like managed Kubernetes clusters - multi cloud environmens), that is not always the case. If we decide to implement a more dynamic approach, we will need a configuration switch that allows always including the token when probing the OIDC Discovery endpoint (which requires a token for managed cloud offerings).
All reactions
Replies: 1 comment · 1 reply
Uh oh!
There was an error while loading. Please reload this page.
That is an interesting point. However, this seems to be specific to AWS/EKS. For Azure/AKS this is definitely not the case. For AKS, the tokens are issued by Kubernetes: https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer#get-the-discovery-document. I also successfully tested the current implementation with service account tokens from AKS.
In my opinion, this is more an issue of the client, which sets the client ID when using a bearer token for authentication. So, in this specific case, I think this should be addressed in the Terraform provider.
The Quarkus OIDC client, for example, seems to handle this correctly and does not send the client ID when a client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer token is used.
But I see your point. At the beginning, I also had a hard time understanding why it was not working because of the strict client-id validation.
All reactions
Uh oh!
There was an error while loading. Please reload this page.
Yes, I do believe we have two use cases - when Kubernetes issues all the tokens (Minikube, K3d, K3s etc) and when there's an external Authorization Server that does it (EKS, probably others).
I also agree with you on the Client ID - I think it's primary an early adoption problem. But still - Keycloak seems to enforce this containt without good reason, so the fix can actually be applied both sides.
All reactions