From 2a78bc67d7f507738268986bbaca58999a18e1da Mon Sep 17 00:00:00 2001 From: Stian Thorgersen Date: Sat, 22 Nov 2025 12:53:22 +0100 Subject: [PATCH] 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 Co-authored-by: Alexander Schwartz --- .../main/java/org/keycloak/util/Strings.java | 17 +++ .../topics/identity-broker/kubernetes.adoc | 37 +++--- .../topics/identity-broker/spiffe.adoc | 5 + .../topics/changes/changes-26_5_0.adoc | 6 + .../admin/messages/messages_en.properties | 4 +- .../add/KubernetesSettings.tsx | 6 +- .../identity-providers/add/SpiffeSettings.tsx | 2 +- .../identity-providers/kubernetes.spec.ts | 6 +- .../admin-ui/test/identity-providers/main.ts | 6 +- .../test/identity-providers/spiffe.spec.ts | 2 +- .../KeycloakKubernetesJwtTest.java | 5 +- ...lientAssertionIdentityProviderFactory.java | 23 ++++ .../DefaultClientAssertionStrategy.java | 43 +++++++ .../FederatedJWTClientAuthenticator.java | 51 +++++--- .../KubernetesIdentityProvider.java | 6 +- .../KubernetesIdentityProviderConfig.java | 20 ++- .../KubernetesIdentityProviderFactory.java | 16 +-- .../KubernetesJwksEndpointLoader.java | 76 ++++++----- .../spiffe/SpiffeClientAssertionStrategy.java | 36 ++++++ .../spiffe/SpiffeIdentityProviderConfig.java | 3 +- .../spiffe/SpiffeIdentityProviderFactory.java | 8 +- .../realm/RealmConfigBuilder.java | 5 + .../oauth/OAuthIdentityProvider.java | 36 +++++- .../OAuthIdentityProviderConfigBuilder.java | 19 ++- .../IdentityProviderTypeTest.java | 2 +- .../FederatedClientAuthConflictsTest.java | 120 ++++++++++++++++++ .../external/KubernetesClientAuthTest.java | 21 +-- .../external/SpiffeClientAuthTest.java | 5 +- .../external/SpiffeConfigTest.java | 5 +- 29 files changed, 460 insertions(+), 131 deletions(-) create mode 100644 core/src/main/java/org/keycloak/util/Strings.java create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionIdentityProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/client/DefaultClientAssertionStrategy.java create mode 100644 services/src/main/java/org/keycloak/broker/spiffe/SpiffeClientAssertionStrategy.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthConflictsTest.java diff --git a/core/src/main/java/org/keycloak/util/Strings.java b/core/src/main/java/org/keycloak/util/Strings.java new file mode 100644 index 00000000000..31df4083678 --- /dev/null +++ b/core/src/main/java/org/keycloak/util/Strings.java @@ -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(); + } + +} diff --git a/docs/documentation/server_admin/topics/identity-broker/kubernetes.adoc b/docs/documentation/server_admin/topics/identity-broker/kubernetes.adoc index f32b504baa5..1ae205b5e34 100644 --- a/docs/documentation/server_admin/topics/identity-broker/kubernetes.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/kubernetes.adoc @@ -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 `/.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 `.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::`. + -[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//protocol/openid-connect/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode grant_type=client_credentials \ - --data-urlencode client_id=system:serviceaccount:: \ --data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ --data-urlencode client_assertion= ---- diff --git a/docs/documentation/server_admin/topics/identity-broker/spiffe.adoc b/docs/documentation/server_admin/topics/identity-broker/spiffe.adoc index 8fa1cfcd7ac..cb89eb8f595 100644 --- a/docs/documentation/server_admin/topics/identity-broker/spiffe.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/spiffe.adoc @@ -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`. diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index 6331cb420db..71adb4a297e 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -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`. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 920e1ffa51c..3e0d2ed433e 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -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 diff --git a/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx index 824abaa427e..0e2dfe8663d 100644 --- a/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx @@ -16,9 +16,9 @@ export const KubernetesSettings = () => { /> ); diff --git a/js/apps/admin-ui/src/identity-providers/add/SpiffeSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/SpiffeSettings.tsx index 82ba0f2c563..5a46a77def2 100644 --- a/js/apps/admin-ui/src/identity-providers/add/SpiffeSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/SpiffeSettings.tsx @@ -16,7 +16,7 @@ export const SpiffeSettings = () => { /> { 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); diff --git a/js/apps/admin-ui/test/identity-providers/main.ts b/js/apps/admin-ui/test/identity-providers/main.ts index 718f2dbfca6..629d99feaf1 100644 --- a/js/apps/admin-ui/test/identity-providers/main.ts +++ b/js/apps/admin-ui/test/identity-providers/main.ts @@ -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); } diff --git a/js/apps/admin-ui/test/identity-providers/spiffe.spec.ts b/js/apps/admin-ui/test/identity-providers/spiffe.spec.ts index 687711f72d7..64d5e602b27 100644 --- a/js/apps/admin-ui/test/identity-providers/spiffe.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/spiffe.spec.ts @@ -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); diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakKubernetesJwtTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakKubernetesJwtTest.java index 5b5705f8094..fe6485df3c9 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakKubernetesJwtTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakKubernetesJwtTest.java @@ -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 }; diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionIdentityProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionIdentityProviderFactory.java new file mode 100644 index 00000000000..36ab1590a7f --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionIdentityProviderFactory.java @@ -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) {} + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/DefaultClientAssertionStrategy.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/DefaultClientAssertionStrategy.java new file mode 100644 index 00000000000..0bb9ef54a5c --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/DefaultClientAssertionStrategy.java @@ -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); + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java index 11562eb96db..c548a9e2783 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java @@ -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 SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT, SpiffeConstants.CLIENT_ASSERTION_TYPE); + private final List 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; } diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java index 38bdd0c47aa..b367982788a 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java @@ -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) { diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java index 9078857e5fb..b8968968562 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java @@ -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); + } } diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java index a1d7e4463e8..45ddd151a62 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java @@ -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 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 diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java index 13730cb9ff9..6c2f7c3f8d4 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java @@ -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; } } diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeClientAssertionStrategy.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeClientAssertionStrategy.java new file mode 100644 index 00000000000..2298c4b6e97 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeClientAssertionStrategy.java @@ -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); + } + +} diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java index c579d194139..217348b7328 100644 --- a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java @@ -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() { diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java index e2b658f6f57..933af260603 100644 --- a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java @@ -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 implements EnvironmentDependentProviderFactory { +public class SpiffeIdentityProviderFactory extends AbstractIdentityProviderFactory 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(); + } + } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java index 23859dc2a34..2a807bbacb6 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java @@ -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)); diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProvider.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProvider.java index d14b7f2b653..c496727f2da 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProvider.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProvider.java @@ -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 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); } diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfigBuilder.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfigBuilder.java index a9ad49bd9c0..a642b6266ab 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfigBuilder.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfigBuilder.java @@ -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 } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/identityprovider/IdentityProviderTypeTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/identityprovider/IdentityProviderTypeTest.java index 998c7847538..0a5d202d96d 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/identityprovider/IdentityProviderTypeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/identityprovider/IdentityProviderTypeTest.java @@ -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() diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthConflictsTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthConflictsTest.java new file mode 100644 index 00000000000..67b7113dc32 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthConflictsTest.java @@ -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; + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/KubernetesClientAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/KubernetesClientAuthTest.java index 1719365e9b7..9079e6a7cfa 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/KubernetesClientAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/KubernetesClientAuthTest.java @@ -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(); + } + } + } diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeClientAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeClientAuthTest.java index c09de8b5883..f3232c1ebc5 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeClientAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeClientAuthTest.java @@ -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()); diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeConfigTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeConfigTest.java index f6ca3dca899..94eb790b935 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeConfigTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeConfigTest.java @@ -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(); }