diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 1a97680ba17..18141841401 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -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()), diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesConstants.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesConstants.java new file mode 100644 index 00000000000..57f73195c07 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesConstants.java @@ -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"; + +} diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java new file mode 100644 index 00000000000..b925990c3b0 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java @@ -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(); + } +} diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java new file mode 100644 index 00000000000..f5f33af46aa --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java @@ -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 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 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); + } + +} diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java new file mode 100644 index 00000000000..1e2abc12ebb --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java @@ -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); + } + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory index aaef408013c..fb7e8413efe 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -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 \ No newline at end of file +org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory +org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory \ No newline at end of file