mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
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:
parent
091b57c1e4
commit
2a78bc67d7
17
core/src/main/java/org/keycloak/util/Strings.java
Normal file
17
core/src/main/java/org/keycloak/util/Strings.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
----
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -16,7 +16,7 @@ export const SpiffeSettings = () => {
|
||||
/>
|
||||
|
||||
<TextControl
|
||||
name="config.issuer"
|
||||
name="config.trustDomain"
|
||||
label={t("spiffeTrustDomain")}
|
||||
labelIcon={t("spiffeTrustDomainHelp")}
|
||||
rules={{
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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) {}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user