Refactoring around federated client authenticator to better handling lookup of IdPs and clients. Also, introducing updates to documentation. (#44325)

Closes #44253
Closes #42987
Closes #44063

Signed-off-by: stianst <stianst@gmail.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@gmx.net>
This commit is contained in:
Stian Thorgersen 2025-11-22 12:53:22 +01:00 committed by GitHub
parent 091b57c1e4
commit 2a78bc67d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 460 additions and 131 deletions

View File

@ -0,0 +1,17 @@
package org.keycloak.util;
public class Strings {
private Strings() {
}
/**
* Returns true if string is null, empty, or only contains spaces
* @param str
* @return
*/
public static Boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}
}

View File

@ -18,6 +18,25 @@ include::../../topics/templates/techpreview.adoc[]
A Kubernetes identity provider supports authenticating clients with Kubernetes service account tokens.
It depends on the preview feature `client-auth-federated`.
The default issuer URL for a Kubernetes cluster is `\https://kubernetes.default.svc.cluster.local`. You can discover
this value by decoding a service account token to retrieve the value of the `iss` claim.
Keycloak must be able to invoke the endpoint `<ISSUER>/.well-known/openid-configuration` and additionally the
JWKS endpoint returned in the well-known configuration. By default, these endpoints require authentication with a
service account token. ${project_name} will automatically use the token from `/var/run/secrets/kubernetes.io/serviceaccount/token`
if available and the token issuer matches the configured issuer.
Each identity provider must have a unique issuer. Each client must also have a unique subject identifier for each
issuer. As the subject identifier is built from the namespace and service account name, each client must have its own
service account if multiple clients share a namespace.
As a security best practice, do not use the `default` service account in a namespace, as it is shared with all Pods in a namespace.
Instead, create an individual service account for each client.
Kubernetes service account tokens can be reused multiple times. As a security best practice, reduce the expiration time
and make sure tokens can not be intercepted in communication between the client and {project_name}.
.Procedure
. Click *Identity Providers* in the menu.
. From the `Add provider` list, select `Kubernetes`.
@ -31,8 +50,8 @@ It depends on the preview feature `client-auth-federated`.
|Alias
|The alias for the identity provider is used to link a client to the provider
|Kubernetes JWKS URL
|The Kubernetes JWKS URL when accessing an external Kubernetes cluster. The JWKS endpoint must not require authentication. If the Kubernetes JWKS URL is not defined, it defaults to `+https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}/openid/v1/jwks+` with Token-based authentication using `/var/run/secrets/kubernetes.io/serviceaccount/token`.
|Kubernetes Issuer URL
|The issuer URL of service account tokens. The URL `<ISSUER>.well-known/openid-configuration` must be available to {project_name})
|===
+
@ -42,14 +61,6 @@ It depends on the preview feature `client-auth-federated`.
.. As *Identity provider*, enter the alias of the Kubernetes identity provider added in step 3.
.. As *Federated Subject*, enter the subject identifier as issued by Kubernetes. This is usually `system:serviceaccount:<namespace>:<serviceaccount>`.
+
[NOTE]
====
Each client must have a unique subject identifier within a realm.
As the subject identifier is built from the namespace and service account name, each client must have its own service account if multiple clients share a namespace.
As a security best practice, do not use the `default` service account in a namespace, as it is shared with all Pods in a namespace.
Instead, create an individual service account for each client.
====
. For the Pod in Kubernetes add a service account:
+
@ -76,11 +87,6 @@ spec:
. Maximum time allowed by Kubernetes and {project_name} is 3600 seconds
--
+
[NOTE]
====
Kubernetes service account tokens can be reused multiple times.
As a security best practice, reduce the expiration time.
====
To verify your setup, assuming the client has a service account configured:
@ -97,7 +103,6 @@ cat /var/run/secrets/tokens/my-aud-token
curl -k https://example.com:8443/realms/<realm>/protocol/openid-connect/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=system:serviceaccount:<namespace>:<serviceaccount> \
--data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion=<token>
----

View File

@ -8,6 +8,11 @@ include::../../topics/templates/techpreview.adoc[]
A SPIFFE identity provider supports authenticating clients with SPIFFE JWT SVIDs.
Each client must have a unique subject identifier (SPIFFE ID) for each realm.
SPIFFE JWT SVIDs can be re-used multiple times. As a security best practice, reduce the expiration time
and make sure tokens can not be intercepted in communication between the client and Keycloak.
.Procedure
. Click *Identity Providers* in the menu.
. From the `Add provider` list, select `SPIFFE`.

View File

@ -48,6 +48,12 @@ To revert to the previoius behavior and to accept non-normalized URLs, set the o
Notable changes may include internal behavior changes that prevent common misconfigurations, bugs that are fixed, or changes to simplify running {project_name}.
=== SPIFFE Identity Provider configuration changed
The SPIFFE Identity Provider preview feature now uses the `trustDomain` configuration instead of `issuer`. This change
was done to make it more explicit that a `trustDomain` is configured and not the `iss` of the token, which is
usually not set in SPIFFE JWT SVIDs.
=== `session_state` and `sid` are no longer UUIDs
In OpenID connect, there are several places where the protocol shares a `session_state` and a `sid`.

View File

@ -958,8 +958,8 @@ addKubernetesProvider=Add Kubernetes provider
spiffeTrustDomain=SPIFFE Trust Domain
spiffeTrustDomainHelp=Use a URL starting with 'spiffe://' followed by a domain name. For example, 'spiffe://acme.com'.
spiffeBundleEndpoint=SPIFFE Bundle or OIDC JWKs endpoint
kubernetesJWKSURL=Kubernetes JWKS URL
kubernetesJWKSURLHelp=Use Kubernetes JWKS URL when accessing an external Kubernetes cluster. The JWKS endpoint must not require authentication
kubernetesIssuerUrlHelp=The issuer of the Kubernetes service account tokens
kubernetesIssuerUrl=Kubernetes Issuer URL
permission=Permission
saveEventListeners=Save Event Listeners
capabilityConfig=Capability config

View File

@ -16,9 +16,9 @@ export const KubernetesSettings = () => {
/>
<TextControl
name="config.jwksUrl"
labelIcon={t("kubernetesJWKSURLHelp")}
label={t("kubernetesJWKSURL")}
name="config.issuer"
labelIcon={t("kubernetesIssuerUrlHelp")}
label={t("kubernetesIssuerUrl")}
/>
</>
);

View File

@ -16,7 +16,7 @@ export const SpiffeSettings = () => {
/>
<TextControl
name="config.issuer"
name="config.trustDomain"
label={t("spiffeTrustDomain")}
labelIcon={t("spiffeTrustDomainHelp")}
rules={{

View File

@ -18,7 +18,7 @@ test.describe.serial("Kubernetes identity provider test", () => {
await createKubernetesProvider(
page,
"kubernetes",
"https://kubernetes.myorg.com/openid/v1/jwks",
"https://kubernetes.myorg.com",
);
await assertNotificationMessage(
@ -30,8 +30,8 @@ test.describe.serial("Kubernetes identity provider test", () => {
await clickTableRowItem(page, "kubernetes");
await page
.getByTestId("config.jwksUrl")
.fill("https://kubernetes.myorg2.com/openid/v1/jwks");
.getByTestId("config.issuer")
.fill("https://kubernetes2.myorg.com");
await clickSaveButton(page);

View File

@ -50,7 +50,7 @@ export async function createSPIFFEProvider(
bundleEndpoint: string,
) {
await clickProviderCard(page, providerName);
await page.getByTestId("config.issuer").fill(trustDomain);
await page.getByTestId("config.trustDomain").fill(trustDomain);
await page.getByTestId("config.bundleEndpoint").fill(bundleEndpoint);
await clickAddButton(page);
}
@ -58,10 +58,10 @@ export async function createSPIFFEProvider(
export async function createKubernetesProvider(
page: Page,
providerName: string,
jwksUrl: string,
issuerUrl: string,
) {
await clickProviderCard(page, providerName);
await page.getByTestId("config.jwksUrl").fill(jwksUrl);
await page.getByTestId("config.issuer").fill(issuerUrl);
await clickAddButton(page);
}

View File

@ -30,7 +30,7 @@ test.describe.serial("SPIFFE identity provider test", () => {
await goToIdentityProviders(page);
await clickTableRowItem(page, "Spiffe");
await page.getByTestId("config.issuer").fill("spiffe://mytrust2");
await page.getByTestId("config.trustDomain").fill("spiffe://mytrust2");
await page.getByTestId("config.bundleEndpoint").fill("https://mytrust2");
await clickSaveButton(page);

View File

@ -28,12 +28,15 @@ import io.quarkus.test.junit.QuarkusTest;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.operator.controllers.KeycloakServiceDependentResource;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.UnsupportedSpec;
import org.keycloak.operator.testsuite.apiserver.DisabledIfApiServerTest;
import org.keycloak.operator.testsuite.utils.TrustAllSSLContext;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -117,6 +120,7 @@ public class KeycloakKubernetesJwtTest extends BaseOperatorTest {
IdentityProviderRepresentation idp = new IdentityProviderRepresentation();
idp.setAlias("kubernetes");
idp.setProviderId("kubernetes");
idp.getConfig().put("issuer", "https://kubernetes.default.svc.cluster.local");
keycloak.realm("test").identityProviders().create(idp).close();
}
@ -142,7 +146,6 @@ public class KeycloakKubernetesJwtTest extends BaseOperatorTest {
url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"--data-urlencode", "grant_type=client_credentials",
"--data-urlencode", "client_id=system:serviceaccount:" + namespace + ":default",
"--data-urlencode", "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"--data-urlencode", "client_assertion=" + token
};

View File

@ -0,0 +1,23 @@
package org.keycloak.broker.provider;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
public interface ClientAssertionIdentityProviderFactory {
default ClientAssertionStrategy getClientAssertionStrategy() {
return null;
}
interface ClientAssertionStrategy {
boolean isSupportedAssertionType(String assertionType);
LookupResult lookup(ClientAuthenticationFlowContext context) throws Exception;
}
record LookupResult(ClientModel clientModel, IdentityProviderModel identityProviderModel) {}
}

View File

@ -0,0 +1,43 @@
package org.keycloak.authentication.authenticators.client;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.broker.provider.ClientAssertionIdentityProviderFactory;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
public class DefaultClientAssertionStrategy implements ClientAssertionIdentityProviderFactory.ClientAssertionStrategy {
@Override
public boolean isSupportedAssertionType(String assertionType) {
return OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT.equals(assertionType);
}
@Override
public ClientAssertionIdentityProviderFactory.LookupResult lookup(ClientAuthenticationFlowContext context) throws Exception {
ClientAssertionState clientAssertionState = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
AlternativeLookupProvider lookupProvider = context.getSession().getProvider(AlternativeLookupProvider.class);
String issuer = clientAssertionState.getToken().getIssuer();
String federatedClientId = clientAssertionState.getToken().getSubject();
IdentityProviderModel identityProvider = lookupProvider.lookupIdentityProviderFromIssuer(context.getSession(), issuer);
if (identityProvider == null) {
return null;
}
ClientModel client = lookupProvider.lookupClientFromClientAttributes(
context.getSession(),
Map.of(
FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, federatedClientId,
FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, identityProvider.getAlias()
)
);
return new ClientAssertionIdentityProviderFactory.LookupResult(client, identityProvider);
}
}

View File

@ -1,23 +1,25 @@
package org.keycloak.authentication.authenticators.client;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.authentication.ConfigurableAuthenticatorFactory;
import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.spiffe.SpiffeConstants;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.broker.provider.ClientAssertionIdentityProviderFactory;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.common.Profile;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
@ -51,13 +53,25 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
.add()
.build();
private static final Set<String> SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT, SpiffeConstants.CLIENT_ASSERTION_TYPE);
private final List<ClientAssertionIdentityProviderFactory.ClientAssertionStrategy> strategies = new LinkedList<>();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public void postInit(KeycloakSessionFactory factory) {
factory.getProviderFactoriesStream(IdentityProvider.class)
.filter(ClientAssertionIdentityProviderFactory.class::isInstance)
.map(ClientAssertionIdentityProviderFactory.class::cast)
.map(ClientAssertionIdentityProviderFactory::getClientAssertionStrategy)
.filter(Objects::nonNull)
.forEach(strategies::add);
strategies.add(new DefaultClientAssertionStrategy());
}
@Override
public void authenticateClient(ClientAuthenticationFlowContext context) {
try {
@ -70,25 +84,18 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
return;
}
if (!SUPPORTED_ASSERTION_TYPES.contains(clientAssertionState.getClientAssertionType())) {
ClientAssertionIdentityProviderFactory.ClientAssertionStrategy strategy = findStrategy(clientAssertionState.getClientAssertionType());
if (strategy == null) {
return;
}
AlternativeLookupProvider lookupProvider = context.getSession().getProvider(AlternativeLookupProvider.class);
String federatedClientId = clientAssertionState.getToken().getSubject();
ClientModel client = lookupProvider.lookupClientFromClientAttributes(
context.getSession(),
Map.of(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, federatedClientId));
if (client == null) return;
String idpAlias = client.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY);
IdentityProviderModel identityProviderModel = context.getSession().identityProviders().getByAlias(idpAlias);
ClientAssertionIdentityProvider identityProvider = getClientAssertionIdentityProvider(context.getSession(), identityProviderModel);
if (identityProvider == null) return;
ClientAssertionIdentityProviderFactory.LookupResult lookup = strategy.lookup(context);
if (lookup == null || lookup.identityProviderModel() == null || lookup.clientModel() == null) {
return;
}
ClientAssertionIdentityProvider<?> identityProvider = getClientAssertionIdentityProvider(context.getSession(), lookup.identityProviderModel());
ClientModel client = lookup.clientModel();
clientAssertionState.setClient(client);
if (!PROVIDER_ID.equals(client.getClientAuthenticatorType())) return;
@ -104,7 +111,11 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
}
}
private ClientAssertionIdentityProvider getClientAssertionIdentityProvider(KeycloakSession session, IdentityProviderModel identityProviderModel) {
private ClientAssertionIdentityProviderFactory.ClientAssertionStrategy findStrategy(String assertionType) {
return strategies.stream().filter(c -> c.isSupportedAssertionType(assertionType)).findFirst().orElse(null);
}
private ClientAssertionIdentityProvider<?> getClientAssertionIdentityProvider(KeycloakSession session, IdentityProviderModel identityProviderModel) {
if (identityProviderModel == null) {
return null;
}

View File

@ -22,12 +22,10 @@ public class KubernetesIdentityProvider implements ClientAssertionIdentityProvid
private final KeycloakSession session;
private final KubernetesIdentityProviderConfig config;
private final String globalJwksUrl;
public KubernetesIdentityProvider(KeycloakSession session, KubernetesIdentityProviderConfig config, String globalJwksUrl) {
public KubernetesIdentityProvider(KeycloakSession session, KubernetesIdentityProviderConfig config) {
this.session = session;
this.config = config;
this.globalJwksUrl = globalJwksUrl;
}
@Override
@ -46,7 +44,7 @@ public class KubernetesIdentityProvider implements ClientAssertionIdentityProvid
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(validator.getContext().getRealm().getId(), config.getInternalId());
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, new KubernetesJwksEndpointLoader(session, globalJwksUrl, getConfig().getJwksUrl()));
KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, new KubernetesJwksEndpointLoader(session, config.getIssuer()));
SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, alg);
if (signatureProvider == null) {

View File

@ -2,13 +2,15 @@ package org.keycloak.broker.kubernetes;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import org.keycloak.util.Strings;
import static org.keycloak.common.util.UriUtils.checkUrl;
public class KubernetesIdentityProviderConfig extends IdentityProviderModel {
public static final String ISSUER = OIDCIdentityProviderConfig.ISSUER;
public static final String JWKS_URL = OIDCIdentityProviderConfig.JWKS_URL;
public KubernetesIdentityProviderConfig() {
}
@ -20,10 +22,6 @@ public class KubernetesIdentityProviderConfig extends IdentityProviderModel {
return getConfig().get(ISSUER);
}
public String getJwksUrl() {
return getConfig().get(JWKS_URL);
}
public int getAllowedClockSkew() {
String allowedClockSkew = getConfig().get(ALLOWED_CLOCK_SKEW);
if (allowedClockSkew == null || allowedClockSkew.isEmpty()) {
@ -42,4 +40,14 @@ public class KubernetesIdentityProviderConfig extends IdentityProviderModel {
return true;
}
@Override
public void validate(RealmModel realm) {
super.validate(realm);
String issuer = getIssuer();
if (Strings.isEmpty(issuer)) {
throw new IllegalArgumentException(ISSUER + " is required");
}
checkUrl(realm.getSslRequired(), issuer, ISSUER);
}
}

View File

@ -9,15 +9,10 @@ import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
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";
@ -25,16 +20,7 @@ public class KubernetesIdentityProviderFactory extends AbstractIdentityProviderF
@Override
public KubernetesIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new KubernetesIdentityProvider(session, new KubernetesIdentityProviderConfig(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";
}
return new KubernetesIdentityProvider(session, new KubernetesIdentityProviderConfig(model));
}
@Override

View File

@ -3,61 +3,77 @@ package org.keycloak.broker.kubernetes;
import java.io.File;
import java.nio.charset.StandardCharsets;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.crypto.PublicKeysWrapper;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
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.jboss.logging.Logger;
import static org.keycloak.broker.kubernetes.KubernetesConstants.SERVICE_ACCOUNT_TOKEN_PATH;
public class KubernetesJwksEndpointLoader implements PublicKeyLoader {
private static final Logger logger = Logger.getLogger(KubernetesJwksEndpointLoader.class);
private final KeycloakSession session;
private final String issuer;
private final boolean authenticate;
private final String endpoint;
public KubernetesJwksEndpointLoader(KeycloakSession session, String globalEndpoint, String providerEndpoint) {
public KubernetesJwksEndpointLoader(KeycloakSession session, String issuer) {
this.session = session;
if (globalEndpoint == null && providerEndpoint == null) {
throw new RuntimeException("Not running on Kubernetes and Kubernetes JWKS endpoint not set");
}
if (globalEndpoint != null && (providerEndpoint == null || providerEndpoint.isEmpty() || globalEndpoint.equals(providerEndpoint))) {
this.endpoint = globalEndpoint;
authenticate = true;
} else {
this.endpoint = providerEndpoint;
authenticate = false;
}
this.issuer = issuer;
}
@Override
public PublicKeysWrapper loadKeys() throws Exception {
CloseableHttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient();
HttpGet httpGet = new HttpGet(endpoint);
SimpleHttp simpleHttp = SimpleHttp.create(session);
httpGet.setHeader(HttpHeaders.ACCEPT, "application/jwk-set+json");
String token = getToken(issuer);
if (authenticate) {
String token = FileUtils.readFileToString(new File(SERVICE_ACCOUNT_TOKEN_PATH), StandardCharsets.UTF_8);
httpGet.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
String wellKnownEndpoint = issuer + "/.well-known/openid-configuration";
SimpleHttpRequest wellKnownReqest = simpleHttp.doGet(wellKnownEndpoint).acceptJson();
if (token != null) {
wellKnownReqest.auth(token);
}
String jwksUri = wellKnownReqest.asJson(OIDCConfigurationRepresentation.class).getJwksUri();
SimpleHttpRequest jwksRequest = simpleHttp.doGet(jwksUri).header(HttpHeaders.ACCEPT, "application/jwk-set+json");
if (token != null) {
jwksRequest.auth(token);
}
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
JSONWebKeySet jwks = JsonSerialization.readValue(response.getEntity().getContent(), JSONWebKeySet.class);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG);
JSONWebKeySet jwks = jwksRequest.asJson(JSONWebKeySet.class);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG);
}
private String getToken(String issuer) {
try {
File file = new File(SERVICE_ACCOUNT_TOKEN_PATH);
if (!file.exists()) {
return null;
}
String token = FileUtils.readFileToString(file, StandardCharsets.UTF_8);
JsonWebToken jwt = new JWSInput(token).readJsonContent(JsonWebToken.class);
if (jwt.getIssuer().equals(issuer)) {
logger.trace("Including service account token in request");
return token;
} else {
logger.debug("Not including service account token due to issuer missmatch");
}
} catch (Exception e) {
logger.warn("Failed to read service account token file", e);
}
return null;
}
}

View File

@ -0,0 +1,36 @@
package org.keycloak.broker.spiffe;
import java.util.Map;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.authentication.authenticators.client.ClientAssertionState;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.provider.ClientAssertionIdentityProviderFactory;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
public class SpiffeClientAssertionStrategy implements ClientAssertionIdentityProviderFactory.ClientAssertionStrategy {
@Override
public boolean isSupportedAssertionType(String assertionType) {
return SpiffeConstants.CLIENT_ASSERTION_TYPE.equals(assertionType);
}
@Override
public ClientAssertionIdentityProviderFactory.LookupResult lookup(ClientAuthenticationFlowContext context) throws Exception {
ClientAssertionState clientAssertionState = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
AlternativeLookupProvider lookupProvider = context.getSession().getProvider(AlternativeLookupProvider.class);
String federatedClientId = clientAssertionState.getToken().getSubject();
ClientModel client = lookupProvider.lookupClientFromClientAttributes(
context.getSession(),
Map.of(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, federatedClientId));
IdentityProviderModel identityProvider = context.getSession().identityProviders().getByAlias(
client.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY));
return new ClientAssertionIdentityProviderFactory.LookupResult(client, identityProvider);
}
}

View File

@ -10,6 +10,7 @@ import static org.keycloak.common.util.UriUtils.checkUrl;
public class SpiffeIdentityProviderConfig extends IdentityProviderModel {
public static final String BUNDLE_ENDPOINT_KEY = "bundleEndpoint";
public static final String TRUST_DOMAIN_KEY = "trustDomain";
private static final Pattern TRUST_DOMAIN_PATTERN = Pattern.compile("spiffe://[a-z0-9.\\-_]*");
@ -34,7 +35,7 @@ public class SpiffeIdentityProviderConfig extends IdentityProviderModel {
}
public String getTrustDomain() {
return getConfig().get(ISSUER);
return getConfig().get(TRUST_DOMAIN_KEY);
}
public String getBundleEndpoint() {

View File

@ -4,12 +4,13 @@ import java.util.Map;
import org.keycloak.Config;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.broker.provider.ClientAssertionIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public class SpiffeIdentityProviderFactory extends AbstractIdentityProviderFactory<SpiffeIdentityProvider> implements EnvironmentDependentProviderFactory {
public class SpiffeIdentityProviderFactory extends AbstractIdentityProviderFactory<SpiffeIdentityProvider> implements EnvironmentDependentProviderFactory, ClientAssertionIdentityProviderFactory {
public static final String PROVIDER_ID = "spiffe";
@ -43,4 +44,9 @@ public class SpiffeIdentityProviderFactory extends AbstractIdentityProviderFacto
return Profile.isFeatureEnabled(Profile.Feature.SPIFFE);
}
@Override
public ClientAssertionStrategy getClientAssertionStrategy() {
return new SpiffeClientAssertionStrategy();
}
}

View File

@ -41,6 +41,11 @@ public class RealmConfigBuilder {
return this;
}
public RealmConfigBuilder client(ClientRepresentation client) {
rep.setClients(Collections.combine(rep.getClients(), client));
return this;
}
public ClientConfigBuilder addClient(String clientId) {
ClientRepresentation client = new ClientRepresentation();
rep.setClients(Collections.combine(rep.getClients(), client));

View File

@ -18,6 +18,7 @@ import org.keycloak.crypto.def.DefaultCryptoProvider;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;
@ -41,6 +42,7 @@ public class OAuthIdentityProvider {
}
this.httpServer = httpServer;
httpServer.createContext("/idp/.well-known/openid-configuration", new WellKnownHandler());
httpServer.createContext("/idp/jwks", new JwksHttpHandler());
keys = new OAuthIdentityProviderKeys(config);
@ -67,14 +69,38 @@ public class OAuthIdentityProvider {
}
public void close() {
httpServer.removeContext("/idp/.well-known/openid-configuration");
httpServer.removeContext("/idp/jwks");
}
public class WellKnownHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
OIDCConfigurationRepresentation oidcConfig = new OIDCConfigurationRepresentation();
oidcConfig.setJwksUri("http://127.0.0.1:8500/idp/jwks");
String oidcConfigString = JsonSerialization.writeValueAsString(oidcConfig);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, oidcConfigString.length());
OutputStream outputStream = exchange.getResponseBody();
outputStream.write(oidcConfigString.getBytes(StandardCharsets.UTF_8));
outputStream.close();
}
}
public class JwksHttpHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json");
boolean kubernetes = OAuthIdentityProviderConfigBuilder.Mode.KUBERNETES.equals(config.mode());
if (kubernetes) {
exchange.getResponseHeaders().add("Content-Type", "application/jwk-set+json");
} else {
exchange.getResponseHeaders().add("Content-Type", "application/json");
}
exchange.sendResponseHeaders(200, keys.getJwksString().length());
OutputStream outputStream = exchange.getResponseBody();
outputStream.write(keys.getJwksString().getBytes(StandardCharsets.UTF_8));
@ -93,7 +119,9 @@ public class OAuthIdentityProvider {
public OAuthIdentityProviderKeys(OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config) {
try {
KeyUse keyUse = config.spiffe() ? KeyUse.JWT_SVID : KeyUse.SIG;
boolean spiffe = OAuthIdentityProviderConfigBuilder.Mode.SPIFFE.equals(config.mode());
KeyUse keyUse = spiffe ? KeyUse.JWT_SVID : KeyUse.SIG;
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1");
@ -101,7 +129,7 @@ public class OAuthIdentityProvider {
KeyPair keyPair = keyPairGenerator.generateKeyPair();
JWK jwk = JWKBuilder.create().ec(keyPair.getPublic());
if (!config.spiffe()) {
if (!spiffe) {
jwk.setAlgorithm("ES256");
}
if (config.jwkUse()) {
@ -113,7 +141,7 @@ public class OAuthIdentityProvider {
Map<String, Object> jwks = new HashMap<>();
jwks.put("keys", new JWK[] { jwk });
if (config.spiffe()) {
if (spiffe) {
jwks.put("spiffe_sequence", 1);
jwks.put("spiffe_refresh_hint", 300);
}

View File

@ -2,11 +2,16 @@ package org.keycloak.testframework.oauth;
public class OAuthIdentityProviderConfigBuilder {
private boolean spiffe;
private Mode mode = Mode.DEFAULT;
private boolean jwkUse = true;
public OAuthIdentityProviderConfigBuilder spiffe() {
spiffe = true;
mode = Mode.SPIFFE;
return this;
}
public OAuthIdentityProviderConfigBuilder kubernetes() {
mode = Mode.KUBERNETES;
return this;
}
@ -16,10 +21,16 @@ public class OAuthIdentityProviderConfigBuilder {
}
public OAuthIdentityProviderConfiguration build() {
return new OAuthIdentityProviderConfiguration(spiffe, jwkUse);
return new OAuthIdentityProviderConfiguration(mode, jwkUse);
}
public record OAuthIdentityProviderConfiguration(boolean spiffe, boolean jwkUse) {
public record OAuthIdentityProviderConfiguration(Mode mode, boolean jwkUse) {
}
public enum Mode {
DEFAULT,
SPIFFE,
KUBERNETES
}
}

View File

@ -68,7 +68,7 @@ public class IdentityProviderTypeTest {
.identityProvider(IdentityProviderBuilder.create()
.providerId(SpiffeIdentityProviderFactory.PROVIDER_ID)
.alias("myspiffe")
.setAttribute(IdentityProviderModel.ISSUER, "spiffe://mytrust")
.setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "spiffe://mytrust")
.setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, "https://myendpoint")
.build())
.identityProvider(IdentityProviderBuilder.create()

View File

@ -0,0 +1,120 @@
package org.keycloak.tests.client.authentication.external;
import java.util.UUID;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.util.Time;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectEvents;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.Events;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class)
public class FederatedClientAuthConflictsTest {
@InjectOAuthIdentityProvider
OAuthIdentityProvider identityProvider;
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm realm;
@InjectOAuthClient
OAuthClient oAuthClient;
@InjectEvents
Events events;
@Test
public void testDuplicatedIssuers() {
createIdp("idp1", "http://127.0.0.1:8500");
createIdp("idp2", "http://127.0.0.1:8500");
ClientRepresentation clientRep = createClient("myclient", "external1", "idp1");
// Should pass as the first matching IdP by alias is always used
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(createDefaultToken("external1", "http://127.0.0.1:8500")).send();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("myclient", events.poll().getClientId());
clientRep.getAttributes().put(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, "idp2");
realm.admin().clients().get(clientRep.getId()).update(clientRep);
// Should fail since it's using the second IdP
response = oAuthClient.clientCredentialsGrantRequest().clientJwt(createDefaultToken("external1", "http://127.0.0.1:8500")).send();
Assertions.assertFalse(response.isSuccess());
Assertions.assertNull(events.poll().getClientId());
}
@Test
public void testDuplicatedClientExternalId() {
createIdp("idp1", "http://127.0.0.1:8500/one");
createIdp("idp2", "http://127.0.0.1:8500/two");
createClient("myclient1", "external1", "idp1");
createClient("myclient2", "external1", "idp2");
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(createDefaultToken("external1", "http://127.0.0.1:8500/one")).send();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("myclient1", events.poll().getClientId());
response = oAuthClient.clientCredentialsGrantRequest().clientJwt(createDefaultToken("external1", "http://127.0.0.1:8500/two")).send();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("myclient2", events.poll().getClientId());
}
private String createDefaultToken(String externalClientId, String issuer) {
JsonWebToken token = new JsonWebToken();
token.id(UUID.randomUUID().toString());
token.issuer(issuer);
token.audience(oAuthClient.getEndpoints().getIssuer());
token.exp((long) (Time.currentTime() + 300));
token.subject(externalClientId);
return identityProvider.encodeToken(token);
}
private IdentityProviderRepresentation createIdp(String alias, String issuer) {
IdentityProviderRepresentation rep = IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(alias)
.setAttribute(IdentityProviderModel.ISSUER, issuer)
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")
.build();
rep.setInternalId(ApiUtil.getCreatedId(realm.admin().identityProviders().create(rep)));
return rep;
}
private ClientRepresentation createClient(String clientId, String externalClientId, String idpAlias) {
ClientRepresentation rep = ClientConfigBuilder.create().clientId(clientId)
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, idpAlias)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, externalClientId)
.build();
rep.setId(ApiUtil.getCreatedId(realm.admin().clients().create(rep)));
return rep;
}
}

View File

@ -3,7 +3,6 @@ package org.keycloak.tests.client.authentication.external;
import java.util.UUID;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.kubernetes.KubernetesIdentityProviderConfig;
import org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
@ -12,12 +11,12 @@ import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
@ -33,17 +32,14 @@ public class KubernetesClientAuthTest extends AbstractBaseClientAuthTest {
static final String INTERNAL_CLIENT_ID = "myclient";
static final String EXTERNAL_CLIENT_ID = "system:serviceaccount:mynamespace:myserviceaccount";
static final String IDP_ALIAS = "kubernetes-idp";
static final String ISSUER = "https://kubernetes.default.svc.cluster.local";
static final String ISSUER = "http://127.0.0.1:8500/idp";
@InjectRealm(config = ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@InjectOAuthIdentityProvider
@InjectOAuthIdentityProvider(config = KubernetesIdpConfig.class)
OAuthIdentityProvider identityProvider;
@InjectTimeOffSet
TimeOffSet timeOffSet;
public KubernetesClientAuthTest() {
super(ISSUER, INTERNAL_CLIENT_ID, EXTERNAL_CLIENT_ID);
}
@ -116,7 +112,6 @@ public class KubernetesClientAuthTest extends AbstractBaseClientAuthTest {
.providerId(KubernetesIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute(IdentityProviderModel.ISSUER, ISSUER)
.setAttribute(KubernetesIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")
.build());
realm.addClient(INTERNAL_CLIENT_ID)
@ -129,4 +124,12 @@ public class KubernetesClientAuthTest extends AbstractBaseClientAuthTest {
}
}
public static class KubernetesIdpConfig implements OAuthIdentityProviderConfig {
@Override
public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) {
return config.kubernetes();
}
}
}

View File

@ -6,7 +6,6 @@ import org.keycloak.broker.spiffe.SpiffeIdentityProviderConfig;
import org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
@ -67,7 +66,7 @@ public class SpiffeClientAuthTest extends AbstractBaseClientAuthTest {
@Test
public void testInvalidTrustDomain() {
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(IdentityProviderModel.ISSUER, "spiffe://different-domain");
rep.getConfig().put(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "spiffe://different-domain");
});
JsonWebToken jwt = createDefaultToken();
@ -136,7 +135,7 @@ public class SpiffeClientAuthTest extends AbstractBaseClientAuthTest {
IdentityProviderBuilder.create()
.providerId(SpiffeIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute(IdentityProviderModel.ISSUER, TRUST_DOMAIN)
.setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, TRUST_DOMAIN)
.setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, BUNDLE_ENDPOINT)
.build());

View File

@ -9,7 +9,6 @@ import org.keycloak.admin.client.resource.IdentityProvidersResource;
import org.keycloak.broker.spiffe.SpiffeIdentityProviderConfig;
import org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectSimpleHttp;
@ -66,7 +65,7 @@ public class SpiffeConfigTest {
IdentityProviderRepresentation createdRep = realm.admin().identityProviders().get(rep.getAlias()).toRepresentation();
Assertions.assertTrue(createdRep.isEnabled());
MatcherAssert.assertThat(createdRep.getConfig(), Matchers.equalTo(Map.of("bundleEndpoint", "https://localhost", "issuer", "spiffe://test")));
MatcherAssert.assertThat(createdRep.getConfig(), Matchers.equalTo(Map.of("bundleEndpoint", "https://localhost", "trustDomain", "spiffe://test")));
Assertions.assertNull(createdRep.getUpdateProfileFirstLoginMode());
Assertions.assertNull(createdRep.getFirstBrokerLoginFlowAlias());
@ -112,7 +111,7 @@ public class SpiffeConfigTest {
private IdentityProviderRepresentation createConfig(String alias, String trustDomain, String bundleEndpoint) {
return IdentityProviderBuilder.create().providerId(SpiffeIdentityProviderFactory.PROVIDER_ID)
.alias(alias)
.setAttribute(IdentityProviderModel.ISSUER, trustDomain)
.setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, trustDomain)
.setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, bundleEndpoint).build();
}