diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index f784f72437f..cb107b103f0 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -93,6 +93,8 @@ public class Profile { CLIENT_AUTH_FEDERATED("Authenticates client based on assertions issued by identity provider", Type.EXPERIMENTAL), + SPIFFE("SPIFFE trust relationship provider", Type.EXPERIMENTAL), + // Check if kerberos is available in underlying JVM and auto-detect if feature should be enabled or disabled by default based on that KERBEROS("Kerberos", Type.DEFAULT, 1, () -> KerberosJdkProvider.getProvider().isKerberosAvailable()), diff --git a/core/src/main/java/org/keycloak/crypto/KeyUse.java b/core/src/main/java/org/keycloak/crypto/KeyUse.java index ddf6d06c523..f88943f3e3b 100644 --- a/core/src/main/java/org/keycloak/crypto/KeyUse.java +++ b/core/src/main/java/org/keycloak/crypto/KeyUse.java @@ -19,7 +19,8 @@ package org.keycloak.crypto; public enum KeyUse { SIG("sig"), - ENC("enc"); + ENC("enc"), + JWT_SVID("jwt-svid"); private String specName; diff --git a/core/src/main/java/org/keycloak/crypto/KeyWrapper.java b/core/src/main/java/org/keycloak/crypto/KeyWrapper.java index fb15c3c5904..3be4265305f 100644 --- a/core/src/main/java/org/keycloak/crypto/KeyWrapper.java +++ b/core/src/main/java/org/keycloak/crypto/KeyWrapper.java @@ -16,26 +16,14 @@ */ package org.keycloak.crypto; -import java.util.HashMap; -import java.util.List; import javax.crypto.SecretKey; import java.security.Key; import java.security.cert.X509Certificate; import java.util.ArrayList; -import java.util.Map; +import java.util.List; public class KeyWrapper { - /** - * A repository for the default algorithms by key type. - */ - private static final Map DEFAULT_ALGORITHM_BY_TYPE = new HashMap<>(); - - static { - //backwards compatibility: RSA keys without "alg" field set are considered RS256 - DEFAULT_ALGORITHM_BY_TYPE.put(KeyType.RSA, Algorithm.RS256); - } - private String providerId; private long providerPriority; private String kid; @@ -85,18 +73,34 @@ public class KeyWrapper { } /** - *

Returns the value of the optional {@code alg} claim. If not defined, a default is returned depending on the - * key type as per {@code kty} claim. + *

Returns the value of the optional {@code alg} claim. If not defined, a default is + * inferred for some algorithms. * *

For keys of type {@link KeyType#RSA}, the default algorithm is {@link Algorithm#RS256} as this is the default * algorithm recommended by OIDC specs. * + *

For keys of type {@link KeyType#EC}, {@link Algorithm#ES256}, {@link Algorithm#ES384}, or {@link Algorithm#ES512} + * is returned based on the curve * * @return the algorithm set or a default based on the key type. */ public String getAlgorithmOrDefault() { - if (algorithm == null) { - return DEFAULT_ALGORITHM_BY_TYPE.get(type); + if (algorithm == null && type != null) { + switch (type) { + case KeyType.EC: + if (curve != null) { + switch (curve) { + case "P-256": + return Algorithm.ES256; + case "P-384": + return Algorithm.ES384; + case "P-512": + return Algorithm.ES512; + } + } + case KeyType.RSA: + return Algorithm.RS256; + } } return algorithm; } diff --git a/core/src/main/java/org/keycloak/jose/jwk/JSONWebKeySet.java b/core/src/main/java/org/keycloak/jose/jwk/JSONWebKeySet.java index 1949c57eef4..f7432a66b92 100644 --- a/core/src/main/java/org/keycloak/jose/jwk/JSONWebKeySet.java +++ b/core/src/main/java/org/keycloak/jose/jwk/JSONWebKeySet.java @@ -17,11 +17,13 @@ package org.keycloak.jose.jwk; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; /** * @author Stian Thorgersen */ +@JsonIgnoreProperties(ignoreUnknown = true) public class JSONWebKeySet { @JsonProperty("keys") diff --git a/core/src/main/java/org/keycloak/jose/jwk/JWK.java b/core/src/main/java/org/keycloak/jose/jwk/JWK.java index eee4401fc49..e093befe052 100755 --- a/core/src/main/java/org/keycloak/jose/jwk/JWK.java +++ b/core/src/main/java/org/keycloak/jose/jwk/JWK.java @@ -45,9 +45,14 @@ public class JWK { public static final String SHA256_509_THUMBPRINT = "x5t#S256"; + /** + * This duplicates {@link org.keycloak.crypto.KeyUse}, which should be used instead when possible + */ + @Deprecated public enum Use { SIG("sig"), - ENCRYPTION("enc"); + ENCRYPTION("enc"), + JWT_SVID("jwt-svid"); private String str; diff --git a/core/src/test/java/org/keycloak/util/JWKSUtilsTest.java b/core/src/test/java/org/keycloak/util/JWKSUtilsTest.java index b769a87e0f8..b9aaa59818b 100644 --- a/core/src/test/java/org/keycloak/util/JWKSUtilsTest.java +++ b/core/src/test/java/org/keycloak/util/JWKSUtilsTest.java @@ -168,7 +168,7 @@ public abstract class JWKSUtilsTest { key = keyWrappersForUse.getKeyByKidAndAlg(kidEC2, null); assertNotNull(key); - assertNull(key.getAlgorithmOrDefault()); + assertEquals("ES384", key.getAlgorithmOrDefault()); assertEquals(KeyUse.SIG, key.getUse()); assertEquals(kidEC2, key.getKid()); assertEquals("EC", key.getType()); diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionContext.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionContext.java index 29b95c070d0..5389ac11b57 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionContext.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ClientAssertionContext.java @@ -2,14 +2,16 @@ package org.keycloak.broker.provider; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; import org.keycloak.representations.JsonWebToken; public interface ClientAssertionContext { + RealmModel getRealm(); + ClientModel getClient(); String getAssertionType(); JWSInput getJwsInput(); JsonWebToken getToken(); - ClientModel getClient(); boolean isFailure(); String getError(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index a65f1f31b3d..8d5894a3e4c 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -882,7 +882,15 @@ public class RepresentationToModel { identityProviderModel.setAddReadTokenRoleOnCreate(representation.isAddReadTokenRoleOnCreate()); updateOrganizationBroker(representation, session); identityProviderModel.setOrganizationId(representation.getOrganizationId()); - identityProviderModel.setConfig(removeEmptyString(representation.getConfig())); + + // Merge config from the identity provider model in case the provider sets some default config + Map repConfig = removeEmptyString(representation.getConfig()); + if (repConfig != null && !repConfig.isEmpty()) { + if (identityProviderModel.getConfig() == null) { + identityProviderModel.setConfig(new HashMap<>()); + } + identityProviderModel.getConfig().putAll(repConfig); + } String flowAlias = representation.getFirstBrokerLoginFlowAlias(); if (flowAlias == null || flowAlias.trim().isEmpty()) { 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 9532af28b3a..4a322481dc5 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 @@ -10,11 +10,13 @@ import org.keycloak.authentication.ConfigurableAuthenticatorFactory; import org.keycloak.broker.provider.ClientAssertionContext; import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.spiffe.SpiffeConstants; import org.keycloak.common.Profile; import org.keycloak.events.Details; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.JsonWebToken; @@ -37,7 +39,7 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator new ProviderConfigProperty(JWT_CREDENTIAL_ISSUER_KEY, "Identity provider", "Issuer of the client assertion", ProviderConfigProperty.STRING_TYPE, null) ); - private static final Set SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT); + private static final Set SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT, SpiffeConstants.CLIENT_ASSERTION_TYPE); @Override public String getId() { @@ -69,7 +71,7 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator ClientAssertionIdentityProvider identityProvider = lookupIdentityProvider(context, client); - ClientAssertionContext clientAssertionContext = new DefaultClientAssertionContext(client, clientAssertionType, jws, token); + ClientAssertionContext clientAssertionContext = new DefaultClientAssertionContext(context.getRealm(), client, clientAssertionType, jws, token); if (identityProvider.verifyClientAssertion(clientAssertionContext)) { context.success(); } else { @@ -155,19 +157,31 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator private static class DefaultClientAssertionContext implements ClientAssertionContext { + private final RealmModel realm; private final ClientModel client; private final String assertionType; private final JWSInput jwsInput; private final JsonWebToken token; private String error; - public DefaultClientAssertionContext(ClientModel client, String assertionType, JWSInput jwsInput, JsonWebToken token) { + public DefaultClientAssertionContext(RealmModel realm, ClientModel client, String assertionType, JWSInput jwsInput, JsonWebToken token) { + this.realm = realm; this.client = client; this.assertionType = assertionType; this.jwsInput = jwsInput; this.token = token; } + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public ClientModel getClient() { + return client; + } + @Override public String getAssertionType() { return assertionType; @@ -183,11 +197,6 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator return token; } - @Override - public ClientModel getClient() { - return client; - } - @Override public boolean isFailure() { return error != null; diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeBundleEndpointLoader.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeBundleEndpointLoader.java new file mode 100644 index 00000000000..3a92a48dba7 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeBundleEndpointLoader.java @@ -0,0 +1,27 @@ +package org.keycloak.broker.spiffe; + +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.JWKSHttpUtils; +import org.keycloak.util.JWKSUtils; + +public class SpiffeBundleEndpointLoader implements PublicKeyLoader { + + private final KeycloakSession session; + private final String bundleEndpoint; + + public SpiffeBundleEndpointLoader(KeycloakSession session, String bundleEndpoint) { + this.session = session; + this.bundleEndpoint = bundleEndpoint; + } + + @Override + public PublicKeysWrapper loadKeys() throws Exception { + JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, bundleEndpoint); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.JWT_SVID); + } + +} diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeConstants.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeConstants.java new file mode 100644 index 00000000000..80774855678 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeConstants.java @@ -0,0 +1,7 @@ +package org.keycloak.broker.spiffe; + +public interface SpiffeConstants { + + String CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; + +} diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProvider.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProvider.java new file mode 100644 index 00000000000..3eb15971477 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProvider.java @@ -0,0 +1,190 @@ +package org.keycloak.broker.spiffe; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.jboss.logging.Logger; +import org.keycloak.broker.provider.AuthenticationRequest; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ClientAssertionContext; +import org.keycloak.broker.provider.ClientAssertionIdentityProvider; +import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.provider.IdentityProviderDataMarshaller; +import org.keycloak.common.util.Time; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.events.EventBuilder; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.keys.PublicKeyStorageUtils; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.Urls; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * Implementation for https://datatracker.ietf.org/doc/draft-schwenkschuster-oauth-spiffe-client-auth/ + * + * Main differences for SPIFFE JWT SVIDs and regular client assertions: + *

+ */ +public class SpiffeIdentityProvider implements IdentityProvider, ClientAssertionIdentityProvider { + + private static final Logger LOGGER = Logger.getLogger(SpiffeIdentityProvider.class); + + private final KeycloakSession session; + private final SpiffeIdentityProviderConfig config; + + public SpiffeIdentityProvider(KeycloakSession session, SpiffeIdentityProviderConfig config) { + this.session = session; + this.config = config; + } + + @Override + public SpiffeIdentityProviderConfig getConfig() { + return config; + } + + @Override + public boolean verifyClientAssertion(ClientAssertionContext context) { + if (!context.getAssertionType().equals(SpiffeConstants.CLIENT_ASSERTION_TYPE)) { + return false; + } + + String trustedDomain = config.getTrustDomain(); + + if (!verifySignature(context)) { + return context.failure("Invalid signature"); + } + + JsonWebToken token = context.getToken(); + + URI uri = URI.create(token.getSubject()); + if (!uri.getScheme().equals("spiffe")) { + return context.failure("Not a SPIFFE ID"); + } + + if (!uri.getRawAuthority().equals(trustedDomain)) { + return context.failure("Invalid trust-domain"); + } + + String expectedAudience = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), session.getContext().getRealm().getName()); + int allowedClockSkew = config.getAllowedClockSkew(); + + if (token.getExp() == null || token.getExp() <= 0) { + return context.failure("Token does not contain an expiration"); + } + + if (!(token.getAudience().length == 1 && token.getAudience()[0].equals(expectedAudience))) { + return context.failure("Invalid audience"); + } + + if (!token.isActive(allowedClockSkew)) { + return context.failure("Token not active"); + } + if (token.getIat() != null && token.getIat() > 0 && token.getIat() - allowedClockSkew > Time.currentTime()) { + return context.failure("Token was issued in the future"); + } + + return true; + } + + private boolean verifySignature(ClientAssertionContext context) { + + try { + String bundleEndpoint = config.getBundleEndpoint(); + JWSInput jws = context.getJwsInput(); + JWSHeader header = jws.getHeader(); + String kid = header.getKeyId(); + String alg = header.getRawAlgorithm(); + + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(context.getRealm().getId(), config.getInternalId()); + + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, new SpiffeBundleEndpointLoader(session, bundleEndpoint)); + + 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 close() { + } + + @Override + public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, BrokeredIdentityContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) { + throw new UnsupportedOperationException(); + } + + @Override + public Response performLogin(AuthenticationRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public Response retrieveToken(KeycloakSession session, FederatedIdentityModel identity) { + throw new UnsupportedOperationException(); + } + + @Override + public void backchannelLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { + throw new UnsupportedOperationException(); + } + + @Override + public Response keycloakInitiatedBrowserLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { + throw new UnsupportedOperationException(); + } + + @Override + public Response export(UriInfo uriInfo, RealmModel realm, String format) { + throw new UnsupportedOperationException(); + } + + @Override + public IdentityProviderDataMarshaller getMarshaller() { + throw new UnsupportedOperationException(); + } +} diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java new file mode 100644 index 00000000000..680fcb77e22 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderConfig.java @@ -0,0 +1,63 @@ +package org.keycloak.broker.spiffe; + +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IdentityProviderShowInAccountConsole; +import org.keycloak.models.RealmModel; + +import java.util.regex.Pattern; + +import static org.keycloak.common.util.UriUtils.checkUrl; + +public class SpiffeIdentityProviderConfig extends IdentityProviderModel { + + public static final String TRUST_DOMAIN_KEY = "trustDomain"; + public static final String BUNDLE_ENDPOINT_KEY = "bundleEndpoint"; + + private static final Pattern TRUST_DOMAIN_PATTERN = Pattern.compile("[a-z0-9.\\-_]*"); + + public SpiffeIdentityProviderConfig() { + getConfig().put(IdentityProviderModel.SHOW_IN_ACCOUNT_CONSOLE, IdentityProviderShowInAccountConsole.NEVER.name()); + } + + public SpiffeIdentityProviderConfig(IdentityProviderModel model) { + super(model); + } + + @Override + public boolean isHideOnLogin() { + return true; + } + + 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; + } + } + + public String getTrustDomain() { + return getConfig().get(TRUST_DOMAIN_KEY); + } + + public String getBundleEndpoint() { + return getConfig().get(BUNDLE_ENDPOINT_KEY); + } + + @Override + public void validate(RealmModel realm) { + super.validate(realm); + + String trustDomain = getTrustDomain(); + if (trustDomain == null || !TRUST_DOMAIN_PATTERN.matcher(trustDomain).matches()) { + throw new IllegalArgumentException("Invalid trust domain name"); + } + + checkUrl(realm.getSslRequired(), getBundleEndpoint(), BUNDLE_ENDPOINT_KEY); + } +} diff --git a/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java new file mode 100644 index 00000000000..6774153d3d8 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/spiffe/SpiffeIdentityProviderFactory.java @@ -0,0 +1,46 @@ +package org.keycloak.broker.spiffe; + +import org.keycloak.Config; +import org.keycloak.broker.provider.AbstractIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +import java.util.Map; + +public class SpiffeIdentityProviderFactory extends AbstractIdentityProviderFactory implements EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "spiffe"; + + @Override + public String getName() { + return "SPIFFE"; + } + + @Override + public SpiffeIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new SpiffeIdentityProvider(session, new SpiffeIdentityProviderConfig(model)); + } + + @Override + public Map parseConfig(KeycloakSession session, String configString) { + throw new UnsupportedOperationException(); + } + + @Override + public IdentityProviderModel createConfig() { + return new SpiffeIdentityProviderConfig(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.SPIFFE); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory index ef9d27f7455..aaef408013c 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -18,4 +18,5 @@ org.keycloak.broker.oidc.OIDCIdentityProviderFactory org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory org.keycloak.broker.saml.SAMLIdentityProviderFactory -org.keycloak.broker.oauth.OAuth2IdentityProviderFactory \ No newline at end of file +org.keycloak.broker.oauth.OAuth2IdentityProviderFactory +org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory \ No newline at end of file diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthIdentityProviderConfig.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthIdentityProviderConfig.java new file mode 100644 index 00000000000..91af7492805 --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthIdentityProviderConfig.java @@ -0,0 +1,8 @@ +package org.keycloak.testframework.oauth; + +public class DefaultOAuthIdentityProviderConfig implements OAuthIdentityProviderConfig { + @Override + public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { + return config; + } +} 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 8bac0a27f89..61f1f03b41b 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 @@ -9,7 +9,6 @@ import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.def.DefaultCryptoProvider; -import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.jose.jws.JWSBuilder; @@ -22,14 +21,18 @@ import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.spec.ECGenParameterSpec; +import java.util.HashMap; +import java.util.Map; public class OAuthIdentityProvider { private final HttpServer httpServer; private final OAuthIdentityProviderKeys keys; + private final OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config; - public OAuthIdentityProvider(HttpServer httpServer) { + public OAuthIdentityProvider(HttpServer httpServer, OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config) { + this.config = config; if (!CryptoIntegration.isInitialised()) { CryptoIntegration.setProvider(new DefaultCryptoProvider()); } @@ -37,7 +40,7 @@ public class OAuthIdentityProvider { this.httpServer = httpServer; httpServer.createContext("/idp/jwks", new JwksHttpHandler()); - keys = new OAuthIdentityProviderKeys(); + keys = new OAuthIdentityProviderKeys(config); } public String encodeToken(JsonWebToken token) { @@ -49,7 +52,7 @@ public class OAuthIdentityProvider { } public OAuthIdentityProviderKeys createKeys() { - return new OAuthIdentityProviderKeys(); + return new OAuthIdentityProviderKeys(config); } public void close() { @@ -75,19 +78,29 @@ public class OAuthIdentityProvider { private final String jwksString; - public OAuthIdentityProviderKeys() { + public OAuthIdentityProviderKeys(OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config) { try { + KeyUse keyUse = config.spiffe() ? KeyUse.JWT_SVID : KeyUse.SIG; + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1"); keyPairGenerator.initialize(ecSpec); KeyPair keyPair = keyPairGenerator.generateKeyPair(); JWK jwk = JWKBuilder.create().ec(keyPair.getPublic()); - jwk.setAlgorithm("ES256"); - jwk.setPublicKeyUse(KeyUse.SIG.getSpecName()); + if (!config.spiffe()) { + jwk.setAlgorithm("ES256"); + } + jwk.setPublicKeyUse(keyUse.getSpecName()); + + Map jwks = new HashMap<>(); + jwks.put("keys", new JWK[] { jwk }); + + if (config.spiffe()) { + jwks.put("spiffe_sequence", 1); + jwks.put("spiffe_refresh_hint", 300); + } - JSONWebKeySet jwks = new JSONWebKeySet(); - jwks.setKeys(new JWK[] { jwk }); jwksString = JsonSerialization.writeValueAsString(jwks); keyWrapper = new KeyWrapper(); diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfig.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfig.java new file mode 100644 index 00000000000..23e08bb293f --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfig.java @@ -0,0 +1,7 @@ +package org.keycloak.testframework.oauth; + +public interface OAuthIdentityProviderConfig { + + OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config); + +} 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 new file mode 100644 index 00000000000..bb57e540aec --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderConfigBuilder.java @@ -0,0 +1,19 @@ +package org.keycloak.testframework.oauth; + +public class OAuthIdentityProviderConfigBuilder { + + private boolean spiffe; + + public OAuthIdentityProviderConfigBuilder spiffe() { + spiffe = true; + return this; + } + + public OAuthIdentityProviderConfiguration build() { + return new OAuthIdentityProviderConfiguration(spiffe); + } + + public record OAuthIdentityProviderConfiguration(boolean spiffe) { + } + +} diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderSupplier.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderSupplier.java index 87c07818e17..2a783c91a2a 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderSupplier.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProviderSupplier.java @@ -4,6 +4,7 @@ import com.sun.net.httpserver.HttpServer; import org.keycloak.testframework.injection.InstanceContext; import org.keycloak.testframework.injection.RequestedInstance; import org.keycloak.testframework.injection.Supplier; +import org.keycloak.testframework.injection.SupplierHelpers; import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; public class OAuthIdentityProviderSupplier implements Supplier { @@ -11,12 +12,21 @@ public class OAuthIdentityProviderSupplier implements Supplier instanceContext) { HttpServer httpServer = instanceContext.getDependency(HttpServer.class); - return new OAuthIdentityProvider(httpServer); + OAuthIdentityProviderConfig config = SupplierHelpers.getInstance(instanceContext.getAnnotation().config()); + OAuthIdentityProviderConfigBuilder configBuilder = new OAuthIdentityProviderConfigBuilder(); + OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration configuration = config.configure(configBuilder).build(); + + return new OAuthIdentityProvider(httpServer, configuration); + } + + @Override + public void close(InstanceContext instanceContext) { + instanceContext.getValue().close(); } @Override public boolean compatible(InstanceContext a, RequestedInstance b) { - return true; + return a.getAnnotation().equals(b.getAnnotation()); } } diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectOAuthIdentityProvider.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectOAuthIdentityProvider.java index 0183e592596..59c972ab152 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectOAuthIdentityProvider.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectOAuthIdentityProvider.java @@ -1,6 +1,8 @@ package org.keycloak.testframework.oauth.annotations; import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.oauth.DefaultOAuthIdentityProviderConfig; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -13,4 +15,6 @@ public @interface InjectOAuthIdentityProvider { LifeCycle lifecycle() default LifeCycle.GLOBAL; + Class config() default DefaultOAuthIdentityProviderConfig.class; + } diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthFromKeycloakTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthFromKeycloakTest.java index b22d34524fa..5fb29528c46 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthFromKeycloakTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthFromKeycloakTest.java @@ -23,6 +23,8 @@ import java.util.List; @KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class) public class FederatedClientAuthFromKeycloakTest { + private static final String IDP_ALIAS = "keycloak-idp"; + @InjectRealm(config = InternalRealmConfig.class) ManagedRealm internalRealm; @@ -50,7 +52,7 @@ public class FederatedClientAuthFromKeycloakTest { return realm.identityProvider( IdentityProviderBuilder.create() .providerId(OIDCIdentityProviderFactory.PROVIDER_ID) - .alias("external") + .alias(IDP_ALIAS) .setAttribute("issuer", "http://localhost:8080/realms/external") .setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true") .setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true") @@ -67,7 +69,7 @@ public class FederatedClientAuthFromKeycloakTest { return client.clientId("myclient") .serviceAccountsEnabled(true) .authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID) - .attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, "external"); + .attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS); } } diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthTest.java index 9f7ee871fe6..3d19ace1616 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/FederatedClientAuthTest.java @@ -30,7 +30,7 @@ import java.util.UUID; @KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class) public class FederatedClientAuthTest { - private static final String IDP_ALIAS = "myidp"; + private static final String IDP_ALIAS = "external-idp"; private static final String CLIENT_ID = "myclient"; diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/IdentityProviderUpdater.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/IdentityProviderUpdater.java new file mode 100644 index 00000000000..66920352f52 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/IdentityProviderUpdater.java @@ -0,0 +1,27 @@ +package org.keycloak.tests.client.authentication.external; + +import org.keycloak.admin.client.resource.IdentityProviderResource; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RepresentationUtils; + +public class IdentityProviderUpdater { + + public static void updateWithRollback(ManagedRealm realm, String alias, IdentityProviderUpdate update) { + IdentityProviderResource resource = realm.admin().identityProviders().get(alias); + + IdentityProviderRepresentation original = resource.toRepresentation(); + IdentityProviderRepresentation updated = RepresentationUtils.clone(original); + update.update(updated); + resource.update(updated); + + realm.cleanup().add(r -> r.identityProviders().get(alias).update(original)); + } + + public interface IdentityProviderUpdate { + + void update(IdentityProviderRepresentation rep); + + } + +} 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 new file mode 100644 index 00000000000..ac446c571d4 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/SpiffeClientAuthTest.java @@ -0,0 +1,207 @@ +package org.keycloak.tests.client.authentication.external; + +import jakarta.ws.rs.core.Response; +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.OAuth2Constants; +import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator; +import org.keycloak.broker.spiffe.SpiffeConstants; +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.representations.JsonWebToken; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.OAuthIdentityProvider; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; +import org.keycloak.testframework.realm.ClientConfig; +import org.keycloak.testframework.realm.ClientConfigBuilder; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testsuite.util.IdentityProviderBuilder; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import java.util.UUID; + +@KeycloakIntegrationTest(config = SpiffeClientAuthTest.SpiffeServerConfig.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +public class SpiffeClientAuthTest { + + private static final String IDP_ALIAS = "spiffe-idp"; + + private static final String CLIENT_ID = "spiffe://mytrust-domain/myclient"; + + @InjectRealm(config = ExernalClientAuthRealmConfig.class) + protected ManagedRealm realm; + + @InjectClient(config = ExernalClientAuthClientConfig.class) + protected ManagedClient client; + + @InjectOAuthClient + OAuthClient oAuthClient; + + @InjectOAuthIdentityProvider(config = SpiffeIdpConfig.class) + OAuthIdentityProvider identityProvider; + + @Test + public void testInvalidSignature() { + OAuthIdentityProvider.OAuthIdentityProviderKeys keys = identityProvider.createKeys(); + String jws = identityProvider.encodeToken(createDefaultToken(), keys); + Assertions.assertFalse(doClientGrant(jws)); + } + + @Test + public void testValidToken() { + Assertions.assertTrue(doClientGrant(createDefaultToken())); + } + + @Test + public void testInvalidConfig() { + testInvalidConfig("with-port:8080", "https://localhost"); + testInvalidConfig("spiffe://with-spiffe-scheme", "https://localhost"); + testInvalidConfig("valid", "invalid-url"); + } + + @Test + public void testInvalidTrustDomain() { + IdentityProviderUpdater.updateWithRollback(realm, IDP_ALIAS, rep -> { + rep.getConfig().put(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "different-domain"); + }); + + Assertions.assertFalse(doClientGrant(createDefaultToken())); + } + + @Test + public void testValidInvalidAssertionType() { + String jws = identityProvider.encodeToken(createDefaultToken()); + AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT).send(); + Assertions.assertFalse(response.isSuccess()); + } + + @Test + public void testInvalidAud() { + JsonWebToken token = createDefaultToken(); + token.audience("invalid"); + Assertions.assertFalse(doClientGrant(token)); + } + + @Test + public void testMultipleAud() { + JsonWebToken token = createDefaultToken(); + token.audience(token.getAudience()[0], "invalid"); + Assertions.assertFalse(doClientGrant(token)); + } + + @Test + public void testInvalidNbf() { + JsonWebToken token = createDefaultToken(); + token.nbf((long) (Time.currentTime() + 60)); + Assertions.assertFalse(doClientGrant(token)); + } + + @Test + public void testExpired() { + JsonWebToken token = createDefaultToken(); + token.exp((long) (Time.currentTime() - 30)); + Assertions.assertFalse(doClientGrant(token)); + } + + @Test + public void testMissingExp() { + JsonWebToken token = createDefaultToken(); + token.exp(null); + Assertions.assertFalse(doClientGrant(token)); + } + + @Test + public void testReuse() { + JsonWebToken token = createDefaultToken(); + token.id(UUID.randomUUID().toString()); + Assertions.assertTrue(doClientGrant(token)); + Assertions.assertTrue(doClientGrant(token)); + } + + private boolean doClientGrant(JsonWebToken token) { + String jws = identityProvider.encodeToken(token); + return doClientGrant(jws); + } + + private boolean doClientGrant(String jws) { + AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws, SpiffeConstants.CLIENT_ASSERTION_TYPE).send(); + return response.isSuccess(); + } + + private JsonWebToken createDefaultToken() { + JsonWebToken token = new JsonWebToken(); + token.id(null); + token.audience(oAuthClient.getEndpoints().getIssuer()); + token.exp((long) (Time.currentTime() + 300)); + token.subject(CLIENT_ID); + return token; + } + + private void testInvalidConfig(String trustDomain, String bundleEndpoint) { + IdentityProviderRepresentation idp = IdentityProviderBuilder.create().providerId(SpiffeIdentityProviderFactory.PROVIDER_ID) + .alias("another") + .setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, trustDomain) + .setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, bundleEndpoint).build(); + + try (Response r = realm.admin().identityProviders().create(idp)) { + Assertions.assertEquals(400, r.getStatus()); + } + } + + public static class SpiffeServerConfig extends ClientAuthIdpServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return super.configure(config).features(Profile.Feature.SPIFFE); + } + } + + public static class SpiffeIdpConfig implements OAuthIdentityProviderConfig { + + @Override + public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { + return config.spiffe(); + } + } + + public static class ExernalClientAuthRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + return realm.identityProvider( + IdentityProviderBuilder.create() + .providerId(SpiffeIdentityProviderFactory.PROVIDER_ID) + .alias(IDP_ALIAS) + .setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "mytrust-domain") + .setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, "http://127.0.0.1:8500/idp/jwks") + .build()); + } + } + + public static class ExernalClientAuthClientConfig implements ClientConfig { + + @Override + public ClientConfigBuilder configure(ClientConfigBuilder client) { + return client.clientId(CLIENT_ID) + .serviceAccountsEnabled(true) + .authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID) + .attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS); + } + } + +}