Experimental Kube service accounts identity provider

Closes #37600

Signed-off-by: stianst <stianst@gmail.com>
Signed-off-by: Ryan Emerson <remerson@ibm.com>
Co-authored-by: Ryan Emerson <remerson@ibm.com>
This commit is contained in:
Stian Thorgersen
2025-09-23 00:11:24 +02:00
committed by GitHub
parent e789e3213f
commit f72482bfd2
6 changed files with 233 additions and 1 deletions

View File

@@ -95,6 +95,8 @@ public class Profile {
SPIFFE("SPIFFE trust relationship provider", Type.PREVIEW),
KUBERNETES_SERVICE_ACCOUNTS("Kubernetes service accounts trust relationship provider", Type.EXPERIMENTAL),
// Check if kerberos is available in underlying JVM and auto-detect if feature should be enabled or disabled by default based on that
KERBEROS("Kerberos", Type.DEFAULT, 1, () -> KerberosJdkProvider.getProvider().isKerberosAvailable()),

View File

@@ -0,0 +1,9 @@
package org.keycloak.broker.kubernetes;
public interface KubernetesConstants {
String KUBERNETES_SERVICE_HOST_KEY = "KUBERNETES_SERVICE_HOST";
String KUBERNETES_SERVICE_PORT_HTTPS_KEY = "KUBERNETES_SERVICE_PORT_HTTPS";
String SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
}

View File

@@ -0,0 +1,97 @@
package org.keycloak.broker.kubernetes;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProviderDataMarshaller;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.EventBuilder;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.sessions.AuthenticationSessionModel;
public class KubernetesIdentityProvider extends OIDCIdentityProvider {
private final String globalJwksUrl;
public KubernetesIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config, String globalJwksUrl) {
super(session, config);
this.globalJwksUrl = globalJwksUrl;
}
protected KeyWrapper getIdentityProviderKeyWrapper(JWSInput jws) {
JWSHeader header = jws.getHeader();
String kid = header.getKeyId();
String alg = header.getRawAlgorithm();
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), getConfig().getInternalId());
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
return keyStorage.getPublicKey(modelKey, kid, alg, new KubernetesJwksEndpointLoader(session, globalJwksUrl, getConfig().getJwksUrl()));
}
@Override
public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, BrokeredIdentityContext context) {
throw new UnsupportedOperationException();
}
@Override
public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context) {
throw new UnsupportedOperationException();
}
@Override
public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) {
throw new UnsupportedOperationException();
}
@Override
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) {
throw new UnsupportedOperationException();
}
@Override
public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) {
throw new UnsupportedOperationException();
}
@Override
public Response performLogin(AuthenticationRequest request) {
throw new UnsupportedOperationException();
}
@Override
public Response retrieveToken(KeycloakSession session, FederatedIdentityModel identity) {
throw new UnsupportedOperationException();
}
@Override
public void backchannelLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) {
throw new UnsupportedOperationException();
}
@Override
public Response keycloakInitiatedBrowserLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) {
throw new UnsupportedOperationException();
}
@Override
public Response export(UriInfo uriInfo, RealmModel realm, String format) {
throw new UnsupportedOperationException();
}
@Override
public IdentityProviderDataMarshaller getMarshaller() {
throw new UnsupportedOperationException();
}
}

View File

@@ -0,0 +1,61 @@
package org.keycloak.broker.kubernetes;
import org.keycloak.Config;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import java.util.Map;
import static org.keycloak.broker.kubernetes.KubernetesConstants.KUBERNETES_SERVICE_HOST_KEY;
import static org.keycloak.broker.kubernetes.KubernetesConstants.KUBERNETES_SERVICE_PORT_HTTPS_KEY;
public class KubernetesIdentityProviderFactory extends AbstractIdentityProviderFactory<KubernetesIdentityProvider> implements EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "kubernetes";
private String globalJwksUrl;
@Override
public String getName() {
return "Kubernetes";
}
@Override
public KubernetesIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new KubernetesIdentityProvider(session, new OIDCIdentityProviderConfig(model), globalJwksUrl);
}
@Override
public void init(Config.Scope config) {
String kubernetesServiceHost = System.getenv(KUBERNETES_SERVICE_HOST_KEY);
String kubernetesServicePortHttps = System.getenv(KUBERNETES_SERVICE_PORT_HTTPS_KEY);
if (kubernetesServiceHost != null && kubernetesServicePortHttps != null) {
globalJwksUrl = "https://" + kubernetesServiceHost + ":" + kubernetesServicePortHttps + "/openid/v1/jwks";
}
}
@Override
public Map<String, String> parseConfig(KeycloakSession session, String configString) {
throw new UnsupportedOperationException();
}
@Override
public IdentityProviderModel createConfig() {
return new OIDCIdentityProviderConfig();
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.KUBERNETES_SERVICE_ACCOUNTS);
}
}

View File

@@ -0,0 +1,62 @@
package org.keycloak.broker.kubernetes;
import org.apache.commons.io.FileUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.crypto.PublicKeysWrapper;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.models.KeycloakSession;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
import java.io.File;
import java.nio.charset.StandardCharsets;
import static org.keycloak.broker.kubernetes.KubernetesConstants.SERVICE_ACCOUNT_TOKEN_PATH;
public class KubernetesJwksEndpointLoader implements PublicKeyLoader {
private final KeycloakSession session;
private final boolean authenticate;
private final String endpoint;
public KubernetesJwksEndpointLoader(KeycloakSession session, String globalEndpoint, String providerEndpoint) {
this.session = session;
if (globalEndpoint == null && providerEndpoint == null) {
throw new RuntimeException("Not running on Kubernetes and Kubernetes JWKS endpoint not set");
}
if (providerEndpoint == null || providerEndpoint.isEmpty() || globalEndpoint.equals(providerEndpoint)) {
this.endpoint = globalEndpoint;
authenticate = true;
} else {
this.endpoint = providerEndpoint;
authenticate = false;
}
}
@Override
public PublicKeysWrapper loadKeys() throws Exception {
CloseableHttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient();
HttpGet httpGet = new HttpGet(endpoint);
httpGet.setHeader(HttpHeaders.ACCEPT, "application/jwk-set+json");
if (authenticate) {
String token = FileUtils.readFileToString(new File(SERVICE_ACCOUNT_TOKEN_PATH), StandardCharsets.UTF_8);
httpGet.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
JSONWebKeySet jwks = JsonSerialization.readValue(response.getEntity().getContent(), JSONWebKeySet.class);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG);
}
}
}

View File

@@ -19,4 +19,5 @@ org.keycloak.broker.oidc.OIDCIdentityProviderFactory
org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory
org.keycloak.broker.saml.SAMLIdentityProviderFactory
org.keycloak.broker.oauth.OAuth2IdentityProviderFactory
org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory
org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory
org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory