diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java index 0bbf7fac6e6c..7dfefdfb31cb 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java @@ -11,6 +11,7 @@ public class FederatedJWTClientValidator extends AbstractJWTClientValidator { private final String expectedTokenIssuer; private final int allowedClockSkew; private final boolean reusePermitted; + private int maximumExpirationTime = 300; public FederatedJWTClientValidator(ClientAuthenticationFlowContext context, SignatureValidator signatureValidator, String expectedTokenIssuer, int allowedClockSkew, boolean reusePermitted) throws Exception { super(context, signatureValidator, null); @@ -41,7 +42,11 @@ protected int getAllowedClockSkew() { @Override protected int getMaximumExpirationTime() { - return 300; // TODO Hard-coded for now, but should be configurable + return maximumExpirationTime; + } + + public void setMaximumExpirationTime(int maximumExpirationTime) { + this.maximumExpirationTime = maximumExpirationTime; } @Override 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 b1e1a105844a..56d2dd9705b5 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java @@ -1,96 +1,72 @@ package org.keycloak.broker.kubernetes; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriInfo; -import org.keycloak.broker.oidc.OIDCIdentityProvider; -import org.keycloak.broker.provider.AuthenticationRequest; -import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.broker.provider.IdentityProviderDataMarshaller; +import org.jboss.logging.Logger; +import org.keycloak.authentication.ClientAuthenticationFlowContext; +import org.keycloak.authentication.authenticators.client.AbstractJWTClientValidator; +import org.keycloak.authentication.authenticators.client.FederatedJWTClientValidator; +import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.crypto.KeyWrapper; -import org.keycloak.events.EventBuilder; +import org.keycloak.crypto.SignatureProvider; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.keys.PublicKeyStorageProvider; import org.keycloak.keys.PublicKeyStorageUtils; -import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.UserSessionModel; -import org.keycloak.sessions.AuthenticationSessionModel; -public class KubernetesIdentityProvider extends OIDCIdentityProvider { +import java.nio.charset.StandardCharsets; +public class KubernetesIdentityProvider implements ClientAssertionIdentityProvider { + + private static final Logger LOGGER = Logger.getLogger(KubernetesIdentityProvider.class); + + private final KeycloakSession session; + private final KubernetesIdentityProviderConfig config; private final String globalJwksUrl; public KubernetesIdentityProvider(KeycloakSession session, KubernetesIdentityProviderConfig config, String globalJwksUrl) { - super(session, config); + this.session = session; + this.config = config; this.globalJwksUrl = globalJwksUrl; } - protected KeyWrapper getIdentityProviderKeyWrapper(JWSInput jws) { - JWSHeader header = jws.getHeader(); - String kid = header.getKeyId(); - String alg = header.getRawAlgorithm(); - - String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), getConfig().getInternalId()); - - PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); - return keyStorage.getPublicKey(modelKey, kid, alg, new KubernetesJwksEndpointLoader(session, globalJwksUrl, getConfig().getJwksUrl())); - } - - @Override - public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, BrokeredIdentityContext context) { - throw new UnsupportedOperationException(); - } - @Override - public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context) { - throw new UnsupportedOperationException(); + public boolean verifyClientAssertion(ClientAuthenticationFlowContext context) throws Exception { + FederatedJWTClientValidator validator = new FederatedJWTClientValidator(context, this::verifySignature, config.getIssuer(), config.getAllowedClockSkew(), true); + validator.setMaximumExpirationTime(3600); // Kubernetes defaults to 1 hour (https://kubernetes.io/docs/concepts/storage/projected-volumes/#serviceaccounttoken) + return validator.validate(); } - @Override - public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) { - throw new UnsupportedOperationException(); + private boolean verifySignature(AbstractJWTClientValidator validator) { + try { + JWSInput jws = validator.getState().getJws(); + JWSHeader header = jws.getHeader(); + String kid = header.getKeyId(); + String alg = header.getRawAlgorithm(); + + 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())); + + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, alg); + if (signatureProvider == null) { + LOGGER.debugf("Failed to verify token, signature provider not found for algorithm %s", alg); + return false; + } + + return signatureProvider.verifier(publicKey).verify(jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jws.getSignature()); + } catch (Exception e) { + LOGGER.debug("Failed to verify token signature", e); + return false; + } } @Override - public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) { - throw new UnsupportedOperationException(); + public KubernetesIdentityProviderConfig getConfig() { + return config; } @Override - public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) { - throw new UnsupportedOperationException(); - } - - @Override - public Response performLogin(AuthenticationRequest request) { - throw new UnsupportedOperationException(); - } + public void close() { - @Override - public Response retrieveToken(KeycloakSession session, FederatedIdentityModel identity) { - throw new UnsupportedOperationException(); - } - - @Override - public void backchannelLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { - throw new UnsupportedOperationException(); - } - - @Override - public Response keycloakInitiatedBrowserLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { - throw new UnsupportedOperationException(); - } - - @Override - public Response export(UriInfo uriInfo, RealmModel realm, String format) { - throw new UnsupportedOperationException(); - } - - @Override - public IdentityProviderDataMarshaller getMarshaller() { - throw new UnsupportedOperationException(); } } diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java index fddd5c6ad1a5..7df261b342c6 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java @@ -2,18 +2,39 @@ import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.models.IdentityProviderModel; -import org.keycloak.models.IdentityProviderShowInAccountConsole; -public class KubernetesIdentityProviderConfig extends OIDCIdentityProviderConfig { +public class KubernetesIdentityProviderConfig extends IdentityProviderModel { + + public static final String ISSUER = OIDCIdentityProviderConfig.ISSUER; + + public static final String JWKS_URL = OIDCIdentityProviderConfig.JWKS_URL; public KubernetesIdentityProviderConfig() { - this(null); } public KubernetesIdentityProviderConfig(IdentityProviderModel model) { super(model); - setHideOnLogin(true); - getConfig().put(IdentityProviderModel.SHOW_IN_ACCOUNT_CONSOLE, IdentityProviderShowInAccountConsole.NEVER.name()); + } + + public String getIssuer() { + 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()) { + return 0; + } + try { + return Integer.parseInt(getConfig().get(ALLOWED_CLOCK_SKEW)); + } catch (NumberFormatException e) { + // ignore it and use default + return 0; + } } @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 1e2abc12ebb6..e0db6b70039f 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesJwksEndpointLoader.java @@ -33,7 +33,7 @@ public KubernetesJwksEndpointLoader(KeycloakSession session, String globalEndpoi throw new RuntimeException("Not running on Kubernetes and Kubernetes JWKS endpoint not set"); } - if (providerEndpoint == null || providerEndpoint.isEmpty() || globalEndpoint.equals(providerEndpoint)) { + if (globalEndpoint != null && !globalEndpoint.isEmpty() && globalEndpoint.equals(providerEndpoint)) { this.endpoint = globalEndpoint; authenticate = true; } else { 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 new file mode 100644 index 000000000000..89e2c4620966 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/KubernetesClientAuthTest.java @@ -0,0 +1,131 @@ +package org.keycloak.tests.client.authentication.external; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +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; +import org.keycloak.models.IdentityProviderModel; +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.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; + +import java.util.UUID; + +@KeycloakIntegrationTest(config = KubernetesClientAuthTest.KubernetesServerConfig.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +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"; + + @InjectRealm(config = ExernalClientAuthRealmConfig.class) + protected ManagedRealm realm; + + @InjectOAuthIdentityProvider + OAuthIdentityProvider identityProvider; + + @InjectTimeOffSet + TimeOffSet timeOffSet; + + public KubernetesClientAuthTest() { + super(ISSUER, INTERNAL_CLIENT_ID, EXTERNAL_CLIENT_ID); + } + + @Override + protected OAuthIdentityProvider getIdentityProvider() { + return identityProvider; + } + + @Test + public void testKeysCached() { + Assertions.assertTrue(doClientGrant(createDefaultToken()).isSuccess()); + int keysRequestCount = identityProvider.getKeysRequestCount(); + Assertions.assertTrue(doClientGrant(createDefaultToken()).isSuccess()); + Assertions.assertEquals(keysRequestCount, identityProvider.getKeysRequestCount()); + } + + @Test + public void testInvalidIssuer() { + JsonWebToken jwt = createDefaultToken(); + jwt.issuer("https://invalid"); + assertFailure(doClientGrant(jwt)); + assertFailure(null, "https://invalid", jwt.getSubject(), jwt.getId(), "client_not_found", events.poll()); + } + + @Test + public void testOldIAt() { + JsonWebToken jwt = createDefaultToken(); + jwt.iat((long) (Time.currentTime() - 3550)); + assertSuccess(internalClientId, doClientGrant(jwt)); + assertSuccess(internalClientId, jwt.getId(), expectedTokenIssuer, externalClientId, events.poll()); + } + + @Test + public void testReuse() { + JsonWebToken jwt = createDefaultToken(); + assertSuccess(internalClientId, doClientGrant(jwt)); + assertSuccess(internalClientId, jwt.getId(), expectedTokenIssuer, externalClientId, events.poll()); + assertSuccess(internalClientId, doClientGrant(jwt)); + assertSuccess(internalClientId, jwt.getId(), expectedTokenIssuer, externalClientId, events.poll()); + } + + @Override + protected JsonWebToken createDefaultToken() { + JsonWebToken token = new JsonWebToken(); + token.id(UUID.randomUUID().toString()); + token.issuer(ISSUER); + token.audience(oAuthClient.getEndpoints().getIssuer()); + token.nbf((long) (Time.currentTime())); + token.exp((long) (Time.currentTime() + 300)); + token.iat((long) (Time.currentTime() - 300)); + token.subject(EXTERNAL_CLIENT_ID); + return token; + } + + public static class KubernetesServerConfig extends ClientAuthIdpServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return super.configure(config).features(Profile.Feature.KUBERNETES_SERVICE_ACCOUNTS); + } + } + + public static class ExernalClientAuthRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + realm.identityProvider( + IdentityProviderBuilder.create() + .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) + .serviceAccountsEnabled(true) + .authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID) + .attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS) + .attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, EXTERNAL_CLIENT_ID); + + return realm; + } + } + +}