Experimental SPIFFE identity provider (#42314)

Closes #42313

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen 2025-09-04 14:48:18 +02:00 committed by GitHub
parent fc467f48c8
commit 320ea5a9a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 709 additions and 45 deletions

View File

@ -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()),

View File

@ -19,7 +19,8 @@ package org.keycloak.crypto;
public enum KeyUse {
SIG("sig"),
ENC("enc");
ENC("enc"),
JWT_SVID("jwt-svid");
private String specName;

View File

@ -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<String, String> 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 {
}
/**
* <p>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.
* <p>Returns the value of the optional {@code alg} claim. If not defined, a default is
* inferred for some algorithms.
*
* <p>For keys of type {@link KeyType#RSA}, the default algorithm is {@link Algorithm#RS256} as this is the default
* algorithm recommended by OIDC specs.
*
* <p>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;
}

View File

@ -17,11 +17,13 @@
package org.keycloak.jose.jwk;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class JSONWebKeySet {
@JsonProperty("keys")

View File

@ -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;

View File

@ -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());

View File

@ -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();

View File

@ -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<String, String> 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()) {

View File

@ -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<String> SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT);
private static final Set<String> 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;

View File

@ -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);
}
}

View File

@ -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";
}

View File

@ -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:
* <ul>
* <li><code>jwt-spiffe</code> client assertion type</li>
* <li><code>iss</code> claim is optional, uses SPIFFE IDs, which includes trust domain instead</li>
* <li><code>jti</code> claim is optional, and SPIFFE vendors re-use/cache tokens</li>
* <li><code>sub</code> is a SPIFFE ID with the syntax <code>spiffe://trust-domain/workload-identity</code></li>
* <li>Keys are fetched from a SPIFFE bundle endpoint, where the JWKS has additional SPIFFE specific fields (<code>spiffe_sequence</code> and <code>spiffe_refresh_hint</code>, the JWK does not set the <code>alg></code></li>
* </ul>
*/
public class SpiffeIdentityProvider implements IdentityProvider<SpiffeIdentityProviderConfig>, 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();
}
}

View File

@ -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);
}
}

View File

@ -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<SpiffeIdentityProvider> 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<String, String> 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);
}
}

View File

@ -18,4 +18,5 @@
org.keycloak.broker.oidc.OIDCIdentityProviderFactory
org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory
org.keycloak.broker.saml.SAMLIdentityProviderFactory
org.keycloak.broker.oauth.OAuth2IdentityProviderFactory
org.keycloak.broker.oauth.OAuth2IdentityProviderFactory
org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory

View File

@ -0,0 +1,8 @@
package org.keycloak.testframework.oauth;
public class DefaultOAuthIdentityProviderConfig implements OAuthIdentityProviderConfig {
@Override
public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) {
return config;
}
}

View File

@ -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<String, Object> 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();

View File

@ -0,0 +1,7 @@
package org.keycloak.testframework.oauth;
public interface OAuthIdentityProviderConfig {
OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config);
}

View File

@ -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) {
}
}

View File

@ -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<OAuthIdentityProvider, InjectOAuthIdentityProvider> {
@ -11,12 +12,21 @@ public class OAuthIdentityProviderSupplier implements Supplier<OAuthIdentityProv
@Override
public OAuthIdentityProvider getValue(InstanceContext<OAuthIdentityProvider, InjectOAuthIdentityProvider> 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<OAuthIdentityProvider, InjectOAuthIdentityProvider> instanceContext) {
instanceContext.getValue().close();
}
@Override
public boolean compatible(InstanceContext<OAuthIdentityProvider, InjectOAuthIdentityProvider> a, RequestedInstance<OAuthIdentityProvider, InjectOAuthIdentityProvider> b) {
return true;
return a.getAnnotation().equals(b.getAnnotation());
}
}

View File

@ -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<? extends OAuthIdentityProviderConfig> config() default DefaultOAuthIdentityProviderConfig.class;
}

View File

@ -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);
}
}

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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);
}
}
}