diff --git a/core/src/main/java/org/keycloak/crypto/PublicKeysWrapper.java b/core/src/main/java/org/keycloak/crypto/PublicKeysWrapper.java index 7196607142..283c00ab6d 100644 --- a/core/src/main/java/org/keycloak/crypto/PublicKeysWrapper.java +++ b/core/src/main/java/org/keycloak/crypto/PublicKeysWrapper.java @@ -22,6 +22,7 @@ package org.keycloak.crypto; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -72,4 +73,13 @@ public class PublicKeysWrapper { return potentialMatches.findFirst().orElse(null); } + + /** + * Returns the first key that matches the predicate. + * @param predicate The predicate + * @return The first key that matches the predicate or null + */ + public KeyWrapper getKeyByPredicate(Predicate predicate) { + return keys.stream().filter(predicate).findFirst().orElse(null); + } } diff --git a/docs/documentation/release_notes/topics/24_0_0.adoc b/docs/documentation/release_notes/topics/24_0_0.adoc index b30c1e37a0..32ca9f19a8 100644 --- a/docs/documentation/release_notes/topics/24_0_0.adoc +++ b/docs/documentation/release_notes/topics/24_0_0.adoc @@ -1,3 +1,9 @@ = Keycloak JS using `exports` field The Keycloak JS adapter now uses the https://webpack.js.org/guides/package-exports/[`exports` field] in `package.json`. This improves support for more modern bundlers like Webpack 5 and Vite, but comes with some unavoidable breaking changes. Consult the link:{upgradingguide_link}[{upgradingguide_name}] for more details. + +== Automatic certificate management for SAML identity providers + +The SAML identity providers can now be configured to automatically download the signing certificates from the IDP entity metadata descriptor endpoint. In order to use the new feature the option `Metadata descriptor URL` should be configured in the provider (URL where the IDP metadata information with the certificates is published) and `Use metadata descriptor URL` needs to be `ON`. The certificates are automatically downloaded and cached in the `public-key-storage` SPI from that URL. The certificates can also be reloaded or imported from the admin console, using the action combo in the provider page. + +See the https://www.keycloak.org/docs/latest/server_admin/index.html#saml-v2-0-identity-providers[documentation] for more details about the new options. \ No newline at end of file diff --git a/docs/documentation/server_admin/topics/identity-broker/saml.adoc b/docs/documentation/server_admin/topics/identity-broker/saml.adoc index d7bd9cf8d2..33915d384e 100644 --- a/docs/documentation/server_admin/topics/identity-broker/saml.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/saml.adoc @@ -84,8 +84,16 @@ itself. |Validate Signature |When *ON*, the realm expects SAML requests and responses from the external IDP to be digitally signed. -|Validating X509 Certificate -|The public certificate {project_name} uses to validate the signatures of SAML requests and responses from the external IDP. +|Metadata descriptor URL +|External URL where Identity Provider publishes the `IDPSSODescriptor` metadata. This URL is used to download the Identity Provider certificates when the `Reload keys` or `Import keys` actions are clicked. + +|Use metadata descriptor URL +|When *ON*, the certificates to validate signatures are automatically downloaded from the `Metadata descriptor URL` and cached in {project_name}. The SAML provider can validate signatures in two different ways. If a specific certificate is requested (usually in `POST` binding) and it is not in the cache, certificates are automatically refreshed from the URL. If all certificates are requested to validate the signature (`REDIRECT` binding) the refresh is only done after a max cache time (see https://www.keycloak.org/server/all-provider-config[public-key-storage] spi in the all provider config guide for more information about how the cache works). The cache can also be manually updated using the action `Reload Keys` in the identity provider page. + +When the option is *OFF*, the certificates in `Validating X509 Certificates` are used to validate signatures. + +|Validating X509 Certificates +|The public certificates {project_name} uses to validate the signatures of SAML requests and responses from the external IDP when `Use metadata descriptor URL` is *OFF*. Multiple certificates can be entered separated by comma (`,`). The certificates can be re-imported from the `Metadata descriptor URL` clicking the `Import Keys` action in the identity provider page. The action downloads the current certificates in the metadata endpoint and assigns them to the config in this same option. You need to click `Save` to definitely store the re-imported certificates. |Sign Service Provider Metadata |When *ON*, {project_name} uses the realm's key pair to sign the <<_identity_broker_saml_sp_descriptor, SAML Service Provider Metadata descriptor>>. diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProviderResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProviderResource.java index a2a3e45dbe..688a2ff30a 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProviderResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/IdentityProviderResource.java @@ -83,4 +83,8 @@ public interface IdentityProviderResource { @DELETE @Path("mappers/{id}") void delete(@PathParam("id") String id); -} \ No newline at end of file + + @GET + @Path("reload-keys") + boolean reloadKeys(); +} diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index ecd9085424..2bfe396bbe 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -790,7 +790,7 @@ endpoints=Endpoints roleSaveError=Could not save role\: {{error}} keySize=Key size membershipUserLdapAttributeHelp=Used just if Membership Attribute Type is UID. It is the name of the LDAP attribute on user, which is used for membership mappings. Usually it will be 'uid'. For example if the value of 'Membership User LDAP Attribute' is 'uid' and LDAP group has 'memberUid\: john', then it is expected that particular LDAP user will have attribute 'uid\: john'. -validatingX509CertsHelp=The certificate in PEM format that must be used to check for signatures. Multiple certificates can be entered, separated by comma (,). +validatingX509CertsHelp=The certificate in PEM format that must be used to check for signatures. Multiple certificates can be entered, separated by comma (,). The action "Import keys" can be used to re-import certificates from the "Metadata descriptor URL" (if present) into this option. The configuration should be saved after the import to definitely store the new certificates. samlCapabilityConfig=SAML capabilities accessTokenSignatureAlgorithmHelp=JWA algorithm used for signing access tokens. derFormatted=DER formatted @@ -1008,6 +1008,18 @@ linkAccountTitle=Link account to {{provider}} invalidateRotatedSuccess=Rotated secret successfully removed userSessionAttributeHelp=Name of user session attribute you want to hardcode updateSuccessIdentityProvider=Provider successfully updated +reloadKeys=Reload keys +importKeys=Import keys +useMetadataDescriptorUrl=Use metadata descriptor URL +useMetadataDescriptorUrlHelp=If the switch is on, the certificates to validate signatures will be downloaded and cached from the given "Metadata descriptor URL". The "Reload keys" action can be used to refresh the certificates in the cache. If the switch is off, certificates from "Validating X509 certificates" option are used, they need to be manually updated when changed in the IDP. +metadataDescriptorUrl=Metadata descriptor URL +metadataDescriptorUrlHelp=External URL where Identity Provider publishes the metadata information needed by the client (certificates, keys, other URLs,...). +reloadKeysSuccess=Keys successfully reloaded +reloadKeysError=Error reloading keys. {{error}} +reloadKeysSuccessButFalse=The reload was not executed, maybe the time between request was too short. +importKeysSuccess=Keys successfully re-imported. Please save the provider to store the new certificates. +importKeysError=Error importing keys. {{error}} +importKeysErrorNoSigningCertificate=The option "signingCertificate" is not defined in the metadata. host=Host forbidden_one=Forbidden, permission needed\: backchannelLogoutRevokeOfflineSessions=Backchannel logout revoke offline sessions diff --git a/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx index 321ad61b7b..189ec60fde 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx @@ -59,6 +59,11 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => { name: "config.validateSignature", }); + const useMetadataDescriptorUrl = useWatch({ + control, + name: "config.useMetadataDescriptorUrl", + }); + const principalType = useWatch({ control, name: "config.principalType", @@ -482,14 +487,56 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => { isReadOnly={readOnly} /> {validateSignature === "true" && ( - - + + } + isRequired={useMetadataDescriptorUrl === "true"} + validated={ + errors.config?.metadataDescriptorUrl + ? ValidatedOptions.error + : ValidatedOptions.default + } + fieldId="metadataDescriptorUrl" + helperTextInvalid={t("required")} + > + + + - + /> + {useMetadataDescriptorUrl !== "true" && ( + + + + )} + )} { const { t } = useTranslation(); const { alias: displayName } = useParams<{ alias: string }>(); const [provider, setProvider] = useState(); + const { addAlert, addError } = useAlerts(); + const { setValue, formState, control } = useFormContext(); + + const validateSignature = useWatch({ + control, + name: "config.validateSignature", + }); + + const useMetadataDescriptorUrl = useWatch({ + control, + name: "config.useMetadataDescriptorUrl", + }); + + const metadataDescriptorUrl = useWatch({ + control, + name: "config.metadataDescriptorUrl", + }); useFetch( () => adminClient.identityProviders.findOne({ alias: displayName }), @@ -101,6 +125,41 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => { }, }); + const importSamlKeys = async ( + providerId: string, + metadataDescriptorUrl: string, + ) => { + try { + const result = await adminClient.identityProviders.importFromUrl({ + providerId: providerId, + fromUrl: metadataDescriptorUrl, + }); + if (result.signingCertificate) { + setValue(`config.signingCertificate`, result.signingCertificate); + addAlert(t("importKeysSuccess"), AlertVariant.success); + } else { + addError("importKeysError", t("importKeysErrorNoSigningCertificate")); + } + } catch (error) { + addError("importKeysError", error); + } + }; + + const reloadSamlKeys = async (alias: string) => { + try { + const result = await adminClient.identityProviders.reloadKeys({ + alias: alias, + }); + if (result) { + addAlert(t("reloadKeysSuccess"), AlertVariant.success); + } else { + addAlert(t("reloadKeysSuccessButFalse"), AlertVariant.warning); + } + } catch (error) { + addError("reloadKeysError", error); + } + }; + return ( <> @@ -114,6 +173,40 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => { )} divider={false} dropdownItems={[ + ...(provider?.providerId?.includes("saml") && + validateSignature === "true" && + useMetadataDescriptorUrl === "true" && + metadataDescriptorUrl && + !formState.isDirty && + value + ? [ + reloadSamlKeys(provider.alias!)} + > + {t("reloadKeys")} + , + ] + : provider?.providerId?.includes("saml") && + validateSignature === "true" && + useMetadataDescriptorUrl !== "true" && + metadataDescriptorUrl && + !formState.isDirty + ? [ + + importSamlKeys( + provider.providerId!, + metadataDescriptorUrl, + ) + } + > + {t("importKeys")} + , + ] + : []), + , toggleDeleteDialog()}> {t("delete")} , @@ -249,6 +342,7 @@ export default function DetailSettings() { providerId, }, ); + reset(p); addAlert(t("updateSuccessIdentityProvider"), AlertVariant.success); } catch (error) { addError("updateErrorIdentityProvider", error); diff --git a/js/libs/keycloak-admin-client/src/resources/identityProviders.ts b/js/libs/keycloak-admin-client/src/resources/identityProviders.ts index 79cb8a93df..a3c213c34b 100644 --- a/js/libs/keycloak-admin-client/src/resources/identityProviders.ts +++ b/js/libs/keycloak-admin-client/src/resources/identityProviders.ts @@ -146,6 +146,12 @@ export class IdentityProviders extends Resource<{ realm?: string }> { urlParamKeys: ["alias"], }); + public reloadKeys = this.makeRequest<{ alias: string }, boolean>({ + method: "GET", + path: "/instances/{alias}/reload-keys", + urlParamKeys: ["alias"], + }); + constructor(client: KeycloakAdminClient) { super(client, { path: "/admin/realms/{realm}/identity-provider", diff --git a/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProvider.java b/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProvider.java index 2f278a8801..26c5deb424 100644 --- a/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProvider.java @@ -25,6 +25,8 @@ import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.infinispan.Cache; import org.jboss.logging.Logger; @@ -52,16 +54,19 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi private final Map> tasksInProgress; private final int minTimeBetweenRequests ; + private final int maxCacheTime; - private Set invalidations = new HashSet<>(); + private final Set invalidations = new HashSet<>(); private boolean transactionEnlisted = false; - public InfinispanPublicKeyStorageProvider(KeycloakSession session, Cache keys, Map> tasksInProgress, int minTimeBetweenRequests) { + public InfinispanPublicKeyStorageProvider(KeycloakSession session, Cache keys, Map> tasksInProgress, + int minTimeBetweenRequests, int maxCacheTime) { this.session = session; this.keys = keys; this.tasksInProgress = tasksInProgress; this.minTimeBetweenRequests = minTimeBetweenRequests; + this.maxCacheTime = maxCacheTime; } void addInvalidation(String cacheKey) { @@ -125,7 +130,7 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi @Override public KeyWrapper getPublicKey(String modelKey, String kid, String algorithm, PublicKeyLoader loader) { PublicKeysEntry entry = keys.get(modelKey); - int lastRequestTime = entry==null ? 0 : entry.getLastRequestTime(); + int lastRequestTime = entry == null? 0 : entry.getLastRequestTime(); int currentTime = Time.currentTime(); boolean isSendingRequestAllowed = currentTime > lastRequestTime + minTimeBetweenRequests; @@ -139,28 +144,100 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi } } + PublicKeysEntry updatedEntry = reloadKeys(modelKey, entry, currentTime, loader); + entry = updatedEntry == null? entry : updatedEntry; + KeyWrapper publicKey = entry == null? null : entry.getCurrentKeys().getKeyByKidAndAlg(kid, algorithm); + if (publicKey != null) { + // return a copy of the key to not modify the cached one + return publicKey.cloneKey(); + } + + List availableKids = entry == null? Collections.emptyList() : entry.getCurrentKeys().getKids(); + log.warnf("PublicKey wasn't found in the storage. Requested kid: '%s' . Available kids: '%s'", kid, availableKids); + + return null; + } + + /** + * If the key is found in the cache that is returned straight away. If not in cache, + * the keys are reloaded if allowed by the minTimeBetweenRequests and key + * is searched again. + * + * @param modelKey The model key + * @param predicate The predicate to search the key + * @param loader The loader to reload keys + * @return The key or null + */ + @Override + public KeyWrapper getFirstPublicKey(String modelKey, Predicate predicate, PublicKeyLoader loader) { + PublicKeysEntry entry = keys.get(modelKey); + if (entry != null) { + // if in cache just try to return if found + KeyWrapper key = entry.getCurrentKeys().getKeyByPredicate(predicate); + if (key != null) { + return key.cloneKey(); + } + } + // if not found try a second time if reload allowed by minTimeBetweenRequests + int currentTime = Time.currentTime(); + entry = reloadKeys(modelKey, entry, currentTime, loader); + if (entry != null) { + KeyWrapper key = entry.getCurrentKeys().getKeyByPredicate(predicate); + if (key != null) { + return key.cloneKey(); + } + } + return null; + } + + /** + * return all keys under the model key. The maxCacheTime is used to reload the + * keys from time to time. + * @param modelKey The model key + * @param loader The loader to reload keys id maxCacheTime reached + * @return The keys in the model + */ + @Override + public List getKeys(String modelKey, PublicKeyLoader loader) { + PublicKeysEntry entry = keys.get(modelKey); + int currentTime = Time.currentTime(); + + if (entry == null || currentTime > entry.getLastRequestTime() + maxCacheTime) { + // reload preemptively + PublicKeysEntry updatedEntry = reloadKeys(modelKey, entry, currentTime, loader); + if (updatedEntry != null) { + entry = updatedEntry; + } + } + + return entry == null + ? Collections.emptyList() + : entry.getCurrentKeys().getKeys().stream().map(KeyWrapper::cloneKey).collect(Collectors.toList()); + } + + @Override + public boolean reloadKeys(String modelKey, PublicKeyLoader loader) { + PublicKeysEntry entry = keys.get(modelKey); + int currentTime = Time.currentTime(); + return reloadKeys(modelKey, entry, currentTime, loader) != null; + } + + private PublicKeysEntry reloadKeys(String modelKey, PublicKeysEntry entry, int currentTime, PublicKeyLoader loader) { // Check if we are allowed to send request - if (isSendingRequestAllowed) { + if (entry == null || currentTime > entry.getLastRequestTime() + minTimeBetweenRequests) { WrapperCallable wrapperCallable = new WrapperCallable(modelKey, loader); FutureTask task = new FutureTask<>(wrapperCallable); FutureTask existing = tasksInProgress.putIfAbsent(modelKey, task); if (existing == null) { + log.debugf("Reloading keys for model key '%s'.", modelKey); task.run(); } else { task = existing; } try { - entry = task.get(); - - // Computation finished. Let's see if key is available - KeyWrapper publicKey = entry.getCurrentKeys().getKeyByKidAndAlg(kid, algorithm); - if (publicKey != null) { - // return a copy of the key to not modify the cached one - return publicKey.cloneKey(); - } - + return task.get(); } catch (ExecutionException ee) { throw new RuntimeException("Error when loading public keys: " + ee.getMessage(), ee); } catch (InterruptedException ie) { @@ -172,12 +249,8 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi } } } else { - log.warnf("Won't load the keys for model '%s' . Last request time was %d", modelKey, lastRequestTime); + log.warnf("Won't load the keys for model '%s'. Last request time was %d", modelKey, entry.getLastRequestTime()); } - - List availableKids = entry==null ? Collections.emptyList() : entry.getCurrentKeys().getKids(); - log.warnf("PublicKey wasn't found in the storage. Requested kid: '%s' . Available kids: '%s'", kid, availableKids); - return null; } diff --git a/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProviderFactory.java index 1513f5b3db..fa2350d9b7 100644 --- a/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/keys/infinispan/InfinispanPublicKeyStorageProviderFactory.java @@ -18,6 +18,7 @@ package org.keycloak.keys.infinispan; import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.FutureTask; @@ -36,6 +37,8 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.provider.ProviderEvent; import org.keycloak.provider.ProviderEventListener; @@ -53,11 +56,36 @@ public class InfinispanPublicKeyStorageProviderFactory implements PublicKeyStora private final Map> tasksInProgress = new ConcurrentHashMap<>(); private int minTimeBetweenRequests; + private int maxCacheTime; @Override public PublicKeyStorageProvider create(KeycloakSession session) { lazyInit(session); - return new InfinispanPublicKeyStorageProvider(session, keysCache, tasksInProgress, minTimeBetweenRequests); + return new InfinispanPublicKeyStorageProvider(session, keysCache, tasksInProgress, minTimeBetweenRequests, maxCacheTime); + } + + @Override + public List getConfigMetadata() { + return ProviderConfigurationBuilder.create() + .property() + .name("minTimeBetweenRequests") + .type("int") + .helpText("Minimum interval in seconds between two requests to retrieve the new public keys. " + + "The server will always try to download new public keys when a single key is requested and not found. " + + "However it will avoid the download if the previous refresh was done less than 10 seconds ago (by default). " + + "This behavior is used to avoid DoS attacks against the external keys endpoint.") + .defaultValue(10) + .add() + .property() + .name("maxCacheTime") + .type("int") + .helpText("Maximum interval in seconds that keys are cached when they are retrieved via all keys methods. " + + "When all keys for the entry are retrieved there is no way to detect if a key is missing " + + "(different to the case when the key is retrieved via ID for example). " + + "In that situation this option forces a refresh from time to time. Default 24 hours.") + .defaultValue(24*60*60) + .add() + .build(); } private void lazyInit(KeycloakSession session) { @@ -72,8 +100,15 @@ public class InfinispanPublicKeyStorageProviderFactory implements PublicKeyStora @Override public void init(Config.Scope config) { + // minTimeBetweenRequests is used when getting a key via name or + // predicate to avoid doing calls very sooon when a key is missing minTimeBetweenRequests = config.getInt("minTimeBetweenRequests", 10); - log.debugf("minTimeBetweenRequests is %d", minTimeBetweenRequests); + + // maxCacheTime is used to reload keys when retrieved via all getKeys + // a refresh is ensured for that method from time to time + maxCacheTime = config.getInt("maxCacheTime", 24*60*60); // 24 hours + + log.debugf("minTimeBetweenRequests is %d maxCacheTime is %d", minTimeBetweenRequests, maxCacheTime); } @Override diff --git a/model/infinispan/src/test/java/org/keycloak/keys/infinispan/InfinispanKeyStorageProviderTest.java b/model/infinispan/src/test/java/org/keycloak/keys/infinispan/InfinispanKeyStorageProviderTest.java index efdecc8f80..1dfec03f97 100644 --- a/model/infinispan/src/test/java/org/keycloak/keys/infinispan/InfinispanKeyStorageProviderTest.java +++ b/model/infinispan/src/test/java/org/keycloak/keys/infinispan/InfinispanKeyStorageProviderTest.java @@ -50,6 +50,7 @@ public class InfinispanKeyStorageProviderTest { Cache keys = getKeysCache(); Map> tasksInProgress = new ConcurrentHashMap<>(); int minTimeBetweenRequests = 10; + int maxCacheTime = 600; @Before public void before() { @@ -127,7 +128,7 @@ public class InfinispanKeyStorageProviderTest { @Override public void run() { - InfinispanPublicKeyStorageProvider provider = new InfinispanPublicKeyStorageProvider(null, keys, tasksInProgress, minTimeBetweenRequests); + InfinispanPublicKeyStorageProvider provider = new InfinispanPublicKeyStorageProvider(null, keys, tasksInProgress, minTimeBetweenRequests, maxCacheTime); provider.getPublicKey(modelKey, "kid1", null, new SampleLoader(modelKey)); } diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/util/SAMLMetadataUtil.java b/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/util/SAMLMetadataUtil.java index 99f6c572d7..d94ec4adcd 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/util/SAMLMetadataUtil.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/util/SAMLMetadataUtil.java @@ -16,19 +16,27 @@ */ package org.keycloak.saml.processing.core.saml.v2.util; +import java.io.InputStream; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.function.Function; +import org.keycloak.dom.saml.v2.metadata.EntitiesDescriptorType; +import org.keycloak.dom.saml.v2.metadata.EntityDescriptorType; +import org.keycloak.dom.saml.v2.metadata.IDPSSODescriptorType; import org.keycloak.dom.saml.v2.metadata.KeyDescriptorType; import org.keycloak.dom.saml.v2.metadata.KeyTypes; +import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType; import org.keycloak.dom.saml.v2.metadata.SSODescriptorType; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.processing.core.parsers.saml.SAMLParser; import org.keycloak.saml.processing.core.util.XMLSignatureUtil; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import java.security.cert.X509Certificate; - /** * Deals with SAML2 Metadata * @@ -97,4 +105,52 @@ public class SAMLMetadataUtil { return null; } + + public static EntityDescriptorType parseEntityDescriptorType(InputStream inputStream) throws ParsingException { + Object parsedObject = SAMLParser.getInstance().parse(inputStream); + EntityDescriptorType entityType; + + if (EntitiesDescriptorType.class.isInstance(parsedObject)) { + entityType = (EntityDescriptorType) ((EntitiesDescriptorType) parsedObject).getEntityDescriptor().get(0); + } else { + entityType = (EntityDescriptorType) parsedObject; + } + + return entityType; + } + + public static IDPSSODescriptorType locateIDPSSODescriptorType(EntityDescriptorType entityType) { + return locateSSODescriptorType(entityType, SAMLMetadataUtil::getIDPSSODescriptorType); + } + + public static SPSSODescriptorType locateSPSSODescriptorType(EntityDescriptorType entityType) { + return locateSSODescriptorType(entityType, SAMLMetadataUtil::getSPSSODescriptorType); + } + + private static IDPSSODescriptorType getIDPSSODescriptorType(EntityDescriptorType.EDTDescriptorChoiceType type) { + return type.getIdpDescriptor(); + } + + private static SPSSODescriptorType getSPSSODescriptorType(EntityDescriptorType.EDTDescriptorChoiceType type) { + return type.getSpDescriptor(); + } + + private static T locateSSODescriptorType(EntityDescriptorType entityType, + Function getter) { + List choiceType = entityType.getChoiceType(); + T descriptor = null; + if (!choiceType.isEmpty()) { + + //Metadata documents can contain multiple Descriptors (See ADFS metadata documents) such as RoleDescriptor, SPSSODescriptor, IDPSSODescriptor. + //So we need to loop through to find the correct Descriptor. + for (EntityDescriptorType.EDTChoiceType edtChoiceType : entityType.getChoiceType()) { + List descriptors = edtChoiceType.getDescriptors(); + + if (!descriptors.isEmpty() && descriptors.get(0).getIdpDescriptor() != null) { + descriptor = getter.apply(descriptors.get(0)); + } + } + } + return descriptor; + } } \ No newline at end of file diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/core/util/XMLSignatureUtil.java b/saml-core/src/main/java/org/keycloak/saml/processing/core/util/XMLSignatureUtil.java index 89d6a111fc..4593fd0bd1 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/core/util/XMLSignatureUtil.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/core/util/XMLSignatureUtil.java @@ -88,6 +88,7 @@ import javax.xml.crypto.KeySelector; import javax.xml.crypto.KeySelectorException; import javax.xml.crypto.KeySelectorResult; import javax.xml.crypto.XMLCryptoContext; +import javax.xml.crypto.dom.DOMStructure; import org.keycloak.rotation.KeyLocator; import org.keycloak.saml.common.util.SecurityActions; @@ -729,4 +730,9 @@ public class XMLSignatureUtil { return keyInfoFactory.newKeyInfo(items); } + + public static KeyInfo createKeyInfo(Element keyInfo) throws MarshalException { + KeyInfoFactory keyInfoFactory = fac.getKeyInfoFactory(); + return keyInfoFactory.unmarshalKeyInfo(new DOMStructure(keyInfo)); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java index 739aec1678..e80750f9d6 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java @@ -20,6 +20,7 @@ import org.keycloak.events.EventBuilder; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; @@ -146,4 +147,12 @@ public interface IdentityProvider extends Provi || compatibleIdps.contains(getConfig().getProviderId()); } + /** + * Reload keys for the identity provider if permitted in it.For example OIDC or + * SAML providers will reload the keys from the jwks or metadata endpoint. + * @return true if reloaded, false if not + */ + default boolean reloadKeys() { + return false; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/keys/PublicKeyStorageProvider.java b/server-spi-private/src/main/java/org/keycloak/keys/PublicKeyStorageProvider.java index fd079edf60..770bc8c02a 100644 --- a/server-spi-private/src/main/java/org/keycloak/keys/PublicKeyStorageProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/keys/PublicKeyStorageProvider.java @@ -17,6 +17,8 @@ package org.keycloak.keys; +import java.util.List; +import java.util.function.Predicate; import org.keycloak.crypto.KeyWrapper; import org.keycloak.provider.Provider; @@ -48,4 +50,32 @@ public interface PublicKeyStorageProvider extends Provider { */ KeyWrapper getFirstPublicKey(String modelKey, String algorithm, PublicKeyLoader loader); + /** + * Get the first public key that matches the predicate. Used by SAML when fetching + * a key via the metadata entity descriptor url. + * + * @param modelKey + * @param predicate + * @param loader + * @return The key or null + */ + KeyWrapper getFirstPublicKey(String modelKey, Predicate predicate, PublicKeyLoader loader); + + /** + * Getter for all the keys in the model key. + * + * @param modelKey + * @param loader + * @return + */ + List getKeys(String modelKey, PublicKeyLoader loader); + + /** + * Reloads keys for the model key. + * + * @param modelKey + * @param loader + * @return true if reloaded, false if not + */ + boolean reloadKeys(String modelKey, PublicKeyLoader loader); } diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java index 589dd189d5..d44fb8435b 100755 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java @@ -42,6 +42,7 @@ public class IdentityProviderModel implements Serializable { public static final String CLAIM_FILTER_NAME = "claimFilterName"; public static final String CLAIM_FILTER_VALUE = "claimFilterValue"; public static final String DO_NOT_STORE_USERS = "doNotStoreUsers"; + public static final String METADATA_DESCRIPTOR_URL = "metadataDescriptorUrl"; private String internalId; @@ -302,4 +303,12 @@ public class IdentityProviderModel implements Serializable { public void setClaimFilterValue(String claimFilterValue) { getConfig().put(CLAIM_FILTER_VALUE, claimFilterValue); } + + public String getMetadataDescriptorUrl() { + return getConfig().get(METADATA_DESCRIPTOR_URL); + } + + public void setMetadataDescriptorUrl(String metadataDescriptorUrl) { + getConfig().put(METADATA_DESCRIPTOR_URL, metadataDescriptorUrl); + } } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 581702debf..6951d495bd 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -44,11 +44,15 @@ import org.keycloak.jose.JOSEParser; import org.keycloak.jose.jwe.JWE; import org.keycloak.jose.jwe.JWEException; import org.keycloak.jose.jws.JWSInput; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.keys.PublicKeyStorageUtils; +import org.keycloak.keys.loader.OIDCIdentityProviderPublicKeyLoader; import org.keycloak.keys.loader.PublicKeyStorageManager; import org.keycloak.models.AbstractKeycloakTransaction; import org.keycloak.models.ClientModel; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; @@ -954,4 +958,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider keys = new LinkedList<>(); + if (StringUtil.isNotBlank(config.getMetadataDescriptorUrl()) && config.isUseMetadataDescriptorUrl()) { + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(realm.getId(), config.getInternalId()); + PublicKeyLoader keyLoader = new SamlMetadataPublicKeyLoader(session, config.getMetadataDescriptorUrl()); + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + return new SamlMetadataKeyLocator(modelKey, keyLoader, KeyUse.SIG, keyStorage); + } + List keys = new LinkedList<>(); for (String signingCertificate : config.getSigningCertificates()) { X509Certificate cert = null; try { diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java index aa22893d74..4e9ae9dbe8 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java @@ -40,14 +40,18 @@ import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; import org.keycloak.dom.saml.v2.protocol.ResponseType; import org.keycloak.events.EventBuilder; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.keys.PublicKeyStorageUtils; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder; +import org.keycloak.protocol.saml.SamlMetadataPublicKeyLoader; import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.protocol.saml.SamlService; import org.keycloak.protocol.saml.SamlSessionUtils; @@ -501,4 +505,14 @@ public class SAMLIdentityProvider extends AbstractIdentityProvider parseConfig(KeycloakSession session, InputStream inputStream) { try { - Object parsedObject = SAMLParser.getInstance().parse(inputStream); - EntityDescriptorType entityType; + EntityDescriptorType entityType = SAMLMetadataUtil.parseEntityDescriptorType(inputStream); + IDPSSODescriptorType idpDescriptor = SAMLMetadataUtil.locateIDPSSODescriptorType(entityType); - if (EntitiesDescriptorType.class.isInstance(parsedObject)) { - entityType = (EntityDescriptorType) ((EntitiesDescriptorType) parsedObject).getEntityDescriptor().get(0); - } else { - entityType = (EntityDescriptorType) parsedObject; - } + if (idpDescriptor != null) { + SAMLIdentityProviderConfig samlIdentityProviderConfig = new SAMLIdentityProviderConfig(); + String singleSignOnServiceUrl = null; + boolean postBindingResponse = false; + boolean postBindingLogout = false; + for (EndpointType endpoint : idpDescriptor.getSingleSignOnService()) { + if (endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get())) { + singleSignOnServiceUrl = endpoint.getLocation().toString(); + postBindingResponse = true; + break; + } else if (endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get())) { + singleSignOnServiceUrl = endpoint.getLocation().toString(); + } + } + String singleLogoutServiceUrl = null; + for (EndpointType endpoint : idpDescriptor.getSingleLogoutService()) { + if (postBindingResponse && endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get())) { + singleLogoutServiceUrl = endpoint.getLocation().toString(); + postBindingLogout = true; + break; + } else if (!postBindingResponse && endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get())) { + singleLogoutServiceUrl = endpoint.getLocation().toString(); + break; + } - List choiceType = entityType.getChoiceType(); + } + samlIdentityProviderConfig.setIdpEntityId(entityType.getEntityID()); + samlIdentityProviderConfig.setSingleLogoutServiceUrl(singleLogoutServiceUrl); + samlIdentityProviderConfig.setSingleSignOnServiceUrl(singleSignOnServiceUrl); + samlIdentityProviderConfig.setWantAuthnRequestsSigned(idpDescriptor.isWantAuthnRequestsSigned()); + samlIdentityProviderConfig.setAddExtensionsElementWithKeyInfo(false); + samlIdentityProviderConfig.setValidateSignature(idpDescriptor.isWantAuthnRequestsSigned()); + samlIdentityProviderConfig.setPostBindingResponse(postBindingResponse); + samlIdentityProviderConfig.setPostBindingAuthnRequest(postBindingResponse); + samlIdentityProviderConfig.setPostBindingLogout(postBindingLogout); + samlIdentityProviderConfig.setLoginHint(false); - if (!choiceType.isEmpty()) { - IDPSSODescriptorType idpDescriptor = null; + List nameIdFormatList = idpDescriptor.getNameIDFormat(); + if (nameIdFormatList != null && !nameIdFormatList.isEmpty()) { + samlIdentityProviderConfig.setNameIDPolicyFormat(nameIdFormatList.get(0)); + } - //Metadata documents can contain multiple Descriptors (See ADFS metadata documents) such as RoleDescriptor, SPSSODescriptor, IDPSSODescriptor. - //So we need to loop through to find the IDPSSODescriptor. - for(EntityDescriptorType.EDTChoiceType edtChoiceType : entityType.getChoiceType()) { - List descriptors = edtChoiceType.getDescriptors(); + List keyDescriptor = idpDescriptor.getKeyDescriptor(); + String defaultCertificate = null; - if(!descriptors.isEmpty() && descriptors.get(0).getIdpDescriptor() != null) { - idpDescriptor = descriptors.get(0).getIdpDescriptor(); + if (keyDescriptor != null) { + for (KeyDescriptorType keyDescriptorType : keyDescriptor) { + Element keyInfo = keyDescriptorType.getKeyInfo(); + Element x509KeyInfo = DocumentUtil.getChildElement(keyInfo, new QName("dsig", "X509Certificate")); + + if (KeyTypes.SIGNING.equals(keyDescriptorType.getUse())) { + samlIdentityProviderConfig.addSigningCertificate(x509KeyInfo.getTextContent()); + } else if (KeyTypes.ENCRYPTION.equals(keyDescriptorType.getUse())) { + samlIdentityProviderConfig.setEncryptionPublicKey(x509KeyInfo.getTextContent()); + } else if (keyDescriptorType.getUse() == null) { + defaultCertificate = x509KeyInfo.getTextContent(); + } } } - if (idpDescriptor != null) { - SAMLIdentityProviderConfig samlIdentityProviderConfig = new SAMLIdentityProviderConfig(); - String singleSignOnServiceUrl = null; - boolean postBindingResponse = false; - boolean postBindingLogout = false; - for (EndpointType endpoint : idpDescriptor.getSingleSignOnService()) { - if (endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get())) { - singleSignOnServiceUrl = endpoint.getLocation().toString(); - postBindingResponse = true; - break; - } else if (endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get())){ - singleSignOnServiceUrl = endpoint.getLocation().toString(); - } - } - String singleLogoutServiceUrl = null; - for (EndpointType endpoint : idpDescriptor.getSingleLogoutService()) { - if (postBindingResponse && endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get())) { - singleLogoutServiceUrl = endpoint.getLocation().toString(); - postBindingLogout = true; - break; - } else if (!postBindingResponse && endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get())){ - singleLogoutServiceUrl = endpoint.getLocation().toString(); - break; - } - - } - samlIdentityProviderConfig.setIdpEntityId(entityType.getEntityID()); - samlIdentityProviderConfig.setSingleLogoutServiceUrl(singleLogoutServiceUrl); - samlIdentityProviderConfig.setSingleSignOnServiceUrl(singleSignOnServiceUrl); - samlIdentityProviderConfig.setWantAuthnRequestsSigned(idpDescriptor.isWantAuthnRequestsSigned()); - samlIdentityProviderConfig.setAddExtensionsElementWithKeyInfo(false); - samlIdentityProviderConfig.setValidateSignature(idpDescriptor.isWantAuthnRequestsSigned()); - samlIdentityProviderConfig.setPostBindingResponse(postBindingResponse); - samlIdentityProviderConfig.setPostBindingAuthnRequest(postBindingResponse); - samlIdentityProviderConfig.setPostBindingLogout(postBindingLogout); - samlIdentityProviderConfig.setLoginHint(false); - - List nameIdFormatList = idpDescriptor.getNameIDFormat(); - if (nameIdFormatList != null && !nameIdFormatList.isEmpty()) - samlIdentityProviderConfig.setNameIDPolicyFormat(nameIdFormatList.get(0)); - - List keyDescriptor = idpDescriptor.getKeyDescriptor(); - String defaultCertificate = null; - - if (keyDescriptor != null) { - for (KeyDescriptorType keyDescriptorType : keyDescriptor) { - Element keyInfo = keyDescriptorType.getKeyInfo(); - Element x509KeyInfo = DocumentUtil.getChildElement(keyInfo, new QName("dsig", "X509Certificate")); - - if (KeyTypes.SIGNING.equals(keyDescriptorType.getUse())) { - samlIdentityProviderConfig.addSigningCertificate(x509KeyInfo.getTextContent()); - } else if (KeyTypes.ENCRYPTION.equals(keyDescriptorType.getUse())) { - samlIdentityProviderConfig.setEncryptionPublicKey(x509KeyInfo.getTextContent()); - } else if (keyDescriptorType.getUse() == null) { - defaultCertificate = x509KeyInfo.getTextContent(); - } - } + if (defaultCertificate != null) { + if (samlIdentityProviderConfig.getSigningCertificates().length == 0) { + samlIdentityProviderConfig.addSigningCertificate(defaultCertificate); } - if (defaultCertificate != null) { - if (samlIdentityProviderConfig.getSigningCertificates().length == 0) { - samlIdentityProviderConfig.addSigningCertificate(defaultCertificate); - } - - if (samlIdentityProviderConfig.getEncryptionPublicKey() == null) { - samlIdentityProviderConfig.setEncryptionPublicKey(defaultCertificate); - } + if (samlIdentityProviderConfig.getEncryptionPublicKey() == null) { + samlIdentityProviderConfig.setEncryptionPublicKey(defaultCertificate); } + } - samlIdentityProviderConfig.setEnabledFromMetadata(entityType.getValidUntil() == null + samlIdentityProviderConfig.setEnabledFromMetadata(entityType.getValidUntil() == null || entityType.getValidUntil().toGregorianCalendar().getTime().after(new Date(System.currentTimeMillis()))); - // check for hide on login attribute - if (entityType.getExtensions() != null && entityType.getExtensions().getEntityAttributes() != null) { - for (AttributeType attribute : entityType.getExtensions().getEntityAttributes().getAttribute()) { - if (MACEDIR_ENTITY_CATEGORY.equals(attribute.getName()) + // check for hide on login attribute + if (entityType.getExtensions() != null && entityType.getExtensions().getEntityAttributes() != null) { + for (AttributeType attribute : entityType.getExtensions().getEntityAttributes().getAttribute()) { + if (MACEDIR_ENTITY_CATEGORY.equals(attribute.getName()) && attribute.getAttributeValue().contains(REFEDS_HIDE_FROM_DISCOVERY)) { - samlIdentityProviderConfig.setHideOnLogin(true); - } + samlIdentityProviderConfig.setHideOnLogin(true); } - } - return samlIdentityProviderConfig.getConfig(); } + + return samlIdentityProviderConfig.getConfig(); } } catch (ParsingException pe) { throw new RuntimeException("Could not parse IdP SAML Metadata", pe); diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlAbstractMetadataPublicKeyLoader.java b/services/src/main/java/org/keycloak/protocol/saml/SamlAbstractMetadataPublicKeyLoader.java new file mode 100644 index 0000000000..9eed71103b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlAbstractMetadataPublicKeyLoader.java @@ -0,0 +1,136 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml; + +import java.io.InputStream; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import javax.xml.crypto.MarshalException; +import javax.xml.crypto.XMLStructure; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyName; +import javax.xml.crypto.dsig.keyinfo.X509Data; +import org.jboss.logging.Logger; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.dom.saml.v2.metadata.EntityDescriptorType; +import org.keycloak.dom.saml.v2.metadata.IDPSSODescriptorType; +import org.keycloak.dom.saml.v2.metadata.KeyDescriptorType; +import org.keycloak.dom.saml.v2.metadata.KeyTypes; +import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.saml.processing.core.saml.v2.util.SAMLMetadataUtil; +import org.keycloak.saml.processing.core.util.XMLSignatureUtil; +import org.w3c.dom.Element; + +/** + *

PublicKeyLoader to retrieve keys from a SAML metadata entity endpoint. + * It can be used to load IDP or SP keys. The abstract class does not + * depend on keycloak session.

+ * + * @author rmartinc + */ +public abstract class SamlAbstractMetadataPublicKeyLoader implements PublicKeyLoader { + + private static final Logger logger = Logger.getLogger(SamlAbstractMetadataPublicKeyLoader.class); + private final boolean forIdP; + + public SamlAbstractMetadataPublicKeyLoader(boolean forIdP) { + this.forIdP = forIdP; + } + + protected abstract InputStream openInputStream() throws Exception; + + @Override + public PublicKeysWrapper loadKeys() throws Exception { + InputStream inputStream = openInputStream(); + + List keyDescriptor; + EntityDescriptorType entityType = SAMLMetadataUtil.parseEntityDescriptorType(inputStream); + if (forIdP) { + IDPSSODescriptorType idpDescriptor = SAMLMetadataUtil.locateIDPSSODescriptorType(entityType); + keyDescriptor = idpDescriptor != null? idpDescriptor.getKeyDescriptor() : null; + } else { + SPSSODescriptorType spDescriptor = SAMLMetadataUtil.locateSPSSODescriptorType(entityType); + keyDescriptor = spDescriptor != null? spDescriptor.getKeyDescriptor() : null; + } + + List keys = new ArrayList<>(); + if (keyDescriptor != null) { + for (KeyDescriptorType keyDescriptorType : keyDescriptor) { + Element keyInfoElement = keyDescriptorType.getKeyInfo(); + if (keyInfoElement == null) { + continue; + } + + KeyUse use = null; // TODO: default SIG? Or Both? + if (KeyTypes.SIGNING.equals(keyDescriptorType.getUse())) { + use = KeyUse.SIG; + } else if (KeyTypes.ENCRYPTION.equals(keyDescriptorType.getUse())) { + use = KeyUse.ENC; + } + + try { + KeyInfo keyInfo = XMLSignatureUtil.createKeyInfo(keyInfoElement); + + X509Certificate cert = null; + String kid = null; + for (XMLStructure xs : (List) keyInfo.getContent()) { + if (kid == null && xs instanceof KeyName) { + kid = ((KeyName) xs).getName(); + } else if (cert == null && xs instanceof X509Data) { + for (Object content : ((X509Data) xs).getContent()) { + if (content instanceof X509Certificate) { + cert = ((X509Certificate) content); + // only the first X509Certificate is the signer + // the rest are just part of the chain + break; + } + } + } + // TODO: parse if KeyValue is defined without cert??? + if (kid != null && cert != null) { + break; + } + } + + if (cert != null) { + logger.debugf("Adding certificate %s to the list of public kets", cert.getSubjectX500Principal()); + keys.add(createKeyWrapper(cert, kid, use)); + } + } catch (MarshalException e) { + logger.debugf(e, "Error parsing KeyInfo from metadata endpoint information"); + } + } + } + + return new PublicKeysWrapper(keys); + } + + private KeyWrapper createKeyWrapper(X509Certificate cert, String kid, KeyUse use) { + KeyWrapper key = new KeyWrapper(); + key.setKid(kid != null? kid : cert.getSubjectX500Principal().getName()); + key.setAlgorithm(cert.getPublicKey().getAlgorithm()); + key.setUse(use); + key.setType(cert.getPublicKey().getAlgorithm()); + key.setPublicKey(cert.getPublicKey()); + key.setCertificate(cert); + return key; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataKeyLocator.java b/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataKeyLocator.java new file mode 100644 index 0000000000..f3daa7d23f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataKeyLocator.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml; + +import java.security.Key; +import java.security.KeyManagementException; +import java.security.MessageDigest; +import java.security.cert.CertificateException; +import java.util.Iterator; +import java.util.function.Predicate; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.rotation.KeyLocator; + +/** + *

KeyLocator that caches the keys into a PublicKeyStorageProvider.

+ * + * @author rmartinc + */ +public class SamlMetadataKeyLocator implements KeyLocator { + + private final String modelKey; + private final PublicKeyLoader loader; + private final PublicKeyStorageProvider keyStorage; + private final KeyUse use; + + public SamlMetadataKeyLocator(String modelKey, PublicKeyLoader loader, KeyUse use, PublicKeyStorageProvider keyStorage) { + this.modelKey = modelKey; + this.loader = loader; + this.keyStorage = keyStorage; + this.use = use; + } + + @Override + public Key getKey(String kid) throws KeyManagementException { + if (kid == null) { + return null; + } + // search the key by kid and reload if expired or null + KeyWrapper keyWrapper = keyStorage.getFirstPublicKey(modelKey, sameKidPredicate(kid), loader); + return keyWrapper != null? keyWrapper.getPublicKey() : null; + } + + @Override + public Key getKey(Key key) throws KeyManagementException { + if (key == null) { + return null; + } + // search the key and reload if expired or null + KeyWrapper keyWrapper = keyStorage.getFirstPublicKey(modelKey, sameKeyPredicate(key), loader); + return keyWrapper != null? keyWrapper.getPublicKey() : null; + } + + @Override + public void refreshKeyCache() { + keyStorage.reloadKeys(modelKey, loader); + } + + @Override + public Iterator iterator() { + // force a refresh if a certificate is expired? + return keyStorage.getKeys(modelKey, loader) + .stream() + .filter(k -> isSameUse(k) && isValidCertificate(k)) + .map(KeyWrapper::getPublicKey) + .iterator(); + } + + private Predicate sameKidPredicate(String kid) { + return keyWrapper -> isSameKid(keyWrapper, kid); + } + + private boolean isSameKid(KeyWrapper keyWrapper, String kid) { + String k = keyWrapper.getKid(); + if (k == null) { + return false; + } + return k.equals(kid) && isSameUse(keyWrapper) && isValidCertificate(keyWrapper); + } + + private Predicate sameKeyPredicate(Key key) { + return keyWrapper -> isSameKey(keyWrapper, key); + } + + private boolean isSameKey(KeyWrapper keyWrapper, Key key) { + Key k = keyWrapper.getPublicKey(); + if (k == null) { + return false; + } + return isSameUse(keyWrapper) + && key.getAlgorithm().equals(k.getAlgorithm()) + && MessageDigest.isEqual(k.getEncoded(), key.getEncoded()) + && isValidCertificate(keyWrapper); + } + + private boolean isSameUse(KeyWrapper k) { + if (k == null) { + return false; + } + // if key use is null means it is valid for both uses + return k.getUse() == null || k.getUse().equals(this.use); + } + + private boolean isValidCertificate(KeyWrapper key) { + if (key == null || key.getCertificate() == null) { + return false; + } + try { + key.getCertificate().checkValidity(); + return true; + } catch (CertificateException e) { + return false; + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataPublicKeyLoader.java b/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataPublicKeyLoader.java new file mode 100644 index 0000000000..5b1617f1b5 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlMetadataPublicKeyLoader.java @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml; + +import java.io.InputStream; +import org.jboss.logging.Logger; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.models.KeycloakSession; + +/** + *

PublicKeyLoader to retrieve keys from a SAML metadata entity endpoint. + * It can be used to load IDP or SP keys.

+ * + * @author rmartinc + */ +public class SamlMetadataPublicKeyLoader extends SamlAbstractMetadataPublicKeyLoader { + + private static final Logger logger = Logger.getLogger(SamlMetadataPublicKeyLoader.class); + private final KeycloakSession session; + private final String metadataUrl; + + public SamlMetadataPublicKeyLoader(KeycloakSession session, String metadataUrl) { + this(session, metadataUrl, true); + } + + public SamlMetadataPublicKeyLoader(KeycloakSession session, String metadataUrl, boolean forIdP) { + super(forIdP); + this.session = session; + this.metadataUrl = metadataUrl; + } + + @Override + protected InputStream openInputStream() throws Exception { + logger.debugf("loading keys from metadata endpoint %s", metadataUrl); + return session.getProvider(HttpClientProvider.class).get(metadataUrl); + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java index 98e4f72fc2..6799e90bd3 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java @@ -48,6 +48,7 @@ import org.keycloak.representations.idm.IdentityProviderMapperTypeRepresentation import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.ManagementPermissionReference; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; @@ -64,6 +65,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; import java.util.Collections; import java.util.Map; import java.util.Objects; @@ -495,4 +497,17 @@ public class IdentityProviderResource { return new ManagementPermissionReference(); } } + + @GET + @Path("reload-keys") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.IDENTITY_PROVIDERS) + @Operation(summary = "Reaload keys for the identity provider if the provider supports it, \"true\" is returned if reload was performed, \"false\" if not.") + public boolean reloadKeys() { + this.auth.realm().requireManageIdentityProviders(); + IdentityProviderFactory providerFactory = IdentityBrokerService.getIdentityProviderFactory(session, identityProviderModel); + IdentityProvider provider = providerFactory.create(session, identityProviderModel); + return provider.reloadKeys(); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java index 31928aa8ed..4b5640afb7 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java @@ -159,6 +159,8 @@ public class IdentityProvidersResource { IdentityProviderFactory providerFactory = getProviderFactoryById(providerId); Map config; config = providerFactory.parseConfig(session, inputStream); + // add the URL just if needed by the identity provider + config.put(IdentityProviderModel.METADATA_DESCRIPTOR_URL, from); return config; } finally { try { diff --git a/services/src/test/java/org/keycloak/protocol/saml/SamlMetadataKeyLocatorTest.java b/services/src/test/java/org/keycloak/protocol/saml/SamlMetadataKeyLocatorTest.java new file mode 100644 index 0000000000..9e79dc13c1 --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/saml/SamlMetadataKeyLocatorTest.java @@ -0,0 +1,226 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyManagementException; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.rotation.KeyLocator; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.processing.core.util.XMLSignatureUtil; + +/** + * + * @author rmartinc + */ +public class SamlMetadataKeyLocatorTest { + + private static final String EXPIRED_CERT = "MIIDQTCCAimgAwIBAgIUT8qwq3DECizGLB2tQAaaNSGAVLgwDQYJKoZIhvcNAQELBQAwMDEuMCwGA1UEAwwlaHR0cDovL2xvY2FsaG9zdDo4MDgwL3NhbGVzLXBvc3Qtc2lnLzAeFw0yMzAxMjcxNjAwMDBaFw0yMzAxMjgxNjAwMDBaMDAxLjAsBgNVBAMMJWh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9zYWxlcy1wb3N0LXNpZy8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDdIwQZjDffQJL75c/QqqgXPZR7NTFkCGQu/kL3eF/kU8bD1Ck+aKakZyw3xELLgMNg4atu4kt3waSgEKSanvFOpAzH+etS/MMIBYBRKfGcFWAKyr0pukjmx1pw4d3SgQj2lB1FDvVGP62Kl4i34XLxLXtuSkYFiNCTfF26wxfwT0tHTiSynQL2jaa9f5TRAKsXwepUII72Awkk04Zqi3trf5BpNac2s+C6Ey4eAnouWzI5Rg0VDDmt3GzxXPaY6wga9afUSb9z4oJwyW1MiE6ENjfNbdmsUvdXCriRNDviO71CnWrLJA44maKDosubfUtC9Ac9BaRjutFyn1UExE9xAgMBAAGjUzBRMB0GA1UdDgQWBBR4R5i1kWMxzzdQ3TdgI/MuNLChSDAfBgNVHSMEGDAWgBR4R5i1kWMxzzdQ3TdgI/MuNLChSDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAacI/f9YFVTUCGXfh/FCVBQI20bgOs9D6IpIhN8L5kEnY6Ox5t00b9G5Bz64alK3WMR3DdhTEpufX8IMFpMlme/JnnOQXkfmIvzbev4iIKxcKFvS8qNXav8PVxwDApuzgxEq/XZCtFXhDS3q1jGRmlOr+MtQdCNQuJmxy7kOoFPY+UYjhSXTZVrCyFI0LYJQfcZ69bYXd+5h1U3UsN4ZvsBgnrz/IhhadaCtTZVtvyr/uzHiJpqT99VO9/7lwh2zL8ihPyOUVDjdYxYyCi+BHLRB+udnVAfo7t3fbxMi1gV9xVcYaqTJgSArsYM8mxv8p5mhTa8TJknzs4V3Dm+PHs"; + + private static final String DESCRIPTOR + = "" + + "" + + "" + + "" + + "keycloak" + + "" + + "MIICyzCCAbOgAwIBAgIILXNek+GBwlgwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAxMIa2V5Y2xvYWswIBcNMjMxMTIzMTU0NTUxWhgPMjA1MTA0MTAxNTQ1NTFaMBMxETAPBgNVBAMTCGtleWNsb2FrMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwQVrjErn551TZPkHindw0UzfpkGlj6Isy2WgzRwdjzE4GT26xQYJqG0M1Qt1KN1AUQOyRLhgW2aLn3dYa6fjBnyFiOHCwTUyd/HZPrXFYv81yMQ99vfAoZctjUcFhzKyuDdkWWnqkblEg/viQ/aXP2Gv0Glhx9TE/cxYfAqX5ecfklAz1CTlfh3BpF41fZZglE3k14h4fYWsBqdRIOaFDjcnCp6uePFEOXRew8a5itIP9SJHEwDsSPtjjkOX/kpr98AYmculBa/bxlCEJd8hm4hD272OdoCBsjj5v1DrQ4FL4plD0F0r9VmcWIISWV4cY49cIt2jj08daKAs6b5mEwIDAQABoyEwHzAdBgNVHQ4EFgQUj2pqC0EoVS6al/4sqg+bST3deWwwDQYJKoZIhvcNAQELBQADggEBAL25DtFsext/fhIh6GiSlo+sCBKXj1FKd6hoHGFTi7vcQpk8+8JVVhSCUgE9IxgyuLGZqDplR+x5Vr+i/kVoWTT0/esCF58K1uEp4mOd1Rt92K7IJCXnAhXMB8Atm85sxkiAl8uy5JkGyGek4mdQRomm+m4Xb7o+PgLtrQpFOKACc4CbaAcR1gixhZ06Z8Y5gG/s7l/LaU8YJ1ijtj55buS7KOe/j30GMV+So6HDx59e6jblEZewA10GmcwWO8fy/gI4odUWTG/0rwpZij9NeLwWI2lBjvUxP+inhbemCMob8J/cEndkTUjaeQsC8Dck72jkQa7LdkgFQe4B9nxnz+8=" + + "" + + "" + + "" + + "" + + "" + + "keycloak2" + + "" + + "MIICzjCCAbagAwIBAgIJAIGXzrijFn9HMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMTCWtleWNsb2FrMjAgFw0yMzExMjMxNjI3MDNaGA8yMDUxMDQxMDE2MjcwM1owFDESMBAGA1UEAxMJa2V5Y2xvYWsyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs53pde5xBfaXZHVWahcdGjwfZxHDmdh/EeF0GsSPU8z36+1R9T/xVJpP6xZgmNpqh09EHSBYRXRsPh1EsJ/cCLtGYWt0HMYRCSBVkwBQQq+xwjuTrLNllroc1QBOOUbe0V3cLbKVZLebdsD+K/hNz/K3lZB6BIb82y6GoiEcAZ57+EwUg0dfRPphMEHDPuggp0gWT5TPm47U6TeE3MKk6WzMgTZjLkHuuqOksBwTIT3y5Q5RFGsydnv5szlfWp8UEQjN6tHAZNlyDYqL9r/CuWmGolkd09JoFfXnbpLNiMciDcBpxZi1RhZijVXx9pg4xdU6J76wYfL2vLuYjhQqlwIDAQABoyEwHzAdBgNVHQ4EFgQUWHGU4mBOKQ/1kI9OhLVJdozBnkYwDQYJKoZIhvcNAQELBQADggEBAJV/5MVxAIh8nfpnNmyNNSosF5bauda74+z5SWyPZlvLBf3GdsG+MQQ0ApE+ZjtMH1X2E8t1dfCdwVv94rbBiDUS+hRIqFkgQgq6y/1+IEagi6epBT/mmebW0oM034gFu5+XzmH+U3F/ifjVWV61CmMAWfpn7poioesWSucOq+TwHtVBOCazly+fZVJgmJd6IZ8rqLiso8Bd6OS0tyU5/lFZ3iz1CQB9WQV+X0sF68KVcIJBw/mQ2HMN3G21M4Xa1ZZggzV70JpsMaaHPmJjCZ8OhbqTthZCY3dLJgy+96WMGq8zuhbULs5GNA8mt52GAq1Kw6r/bYFG+PEqYQNxPDM=" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" + + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + + "" + + "" + + "" + + "" + + "" + + ""; + + // test PublicKeyStorageProvider that just loads the keys in every call + private static class TestPublicKeyStorageProvider implements PublicKeyStorageProvider { + + private PublicKeysWrapper load(PublicKeyLoader loader) { + try { + return loader.loadKeys(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Override + public KeyWrapper getPublicKey(String modelKey, String kid, String algorithm, PublicKeyLoader loader) { + return load(loader).getKeyByKidAndAlg(kid, algorithm); + } + + @Override + public KeyWrapper getFirstPublicKey(String modelKey, String algorithm, PublicKeyLoader loader) { + return getFirstPublicKey(modelKey, k -> algorithm.equals(k.getAlgorithm()), loader); + } + + @Override + public KeyWrapper getFirstPublicKey(String modelKey, Predicate predicate, PublicKeyLoader loader) { + return load(loader).getKeyByPredicate(predicate); + } + + @Override + public List getKeys(String modelKey, PublicKeyLoader loader) { + return load(loader).getKeys(); + } + + @Override + public boolean reloadKeys(String modelKey, PublicKeyLoader loader) { + return false; + } + + @Override + public void close() { + // no-op + } + } + + // PublicKeyLoader from the metadata descriptor string + private static class TestSamlMetadataPublicKeyLoader extends SamlAbstractMetadataPublicKeyLoader { + + private final String descriptor; + + public TestSamlMetadataPublicKeyLoader(String descriptor, boolean forIdP) { + super(forIdP); + this.descriptor = descriptor; + } + + @Override + protected InputStream openInputStream() throws Exception { + return new ByteArrayInputStream(descriptor.getBytes(StandardCharsets.UTF_8)); + } + } + + @Test + public void testCertificatesSign() throws KeyManagementException { + PublicKeyStorageProvider keyStorage = new TestPublicKeyStorageProvider(); + PublicKeyLoader loader = new TestSamlMetadataPublicKeyLoader(DESCRIPTOR, true); + + KeyLocator keyLocator = new SamlMetadataKeyLocator("test", loader, KeyUse.SIG, keyStorage); + + Key keycloak = keyLocator.getKey("keycloak"); + Assert.assertNotNull(keycloak); + Assert.assertEquals(keycloak, keyLocator.getKey(keycloak)); + + Key keycloak2 = keyLocator.getKey("keycloak2"); + Assert.assertNotNull(keycloak2); + Assert.assertEquals(keycloak2, keyLocator.getKey(keycloak2)); + + MatcherAssert.assertThat(StreamSupport.stream(keyLocator.spliterator(), false).collect(Collectors.toList()), + Matchers.containsInAnyOrder(keycloak, keycloak2)); + + keyLocator = new SamlMetadataKeyLocator("test", loader, KeyUse.ENC, keyStorage); + + Assert.assertNull(keyLocator.getKey("keycloak")); + Assert.assertNull(keyLocator.getKey(keycloak)); + + Assert.assertNull(keyLocator.getKey("keycloak2")); + Assert.assertNull(keyLocator.getKey(keycloak2)); + + Assert.assertFalse(keyLocator.iterator().hasNext()); + } + + @Test + public void testCertificatesUseNull() throws KeyManagementException { + PublicKeyStorageProvider keyStorage = new TestPublicKeyStorageProvider(); + // both certificates are use null + String desc = DESCRIPTOR.replaceAll("", ""); + PublicKeyLoader loader = new TestSamlMetadataPublicKeyLoader(desc, true); + KeyLocator keyLocator = new SamlMetadataKeyLocator("test", loader, KeyUse.SIG, keyStorage); + + Key keycloak = keyLocator.getKey("keycloak"); + Assert.assertNotNull(keycloak); + Assert.assertEquals(keycloak, keyLocator.getKey(keycloak)); + + Key keycloak2 = keyLocator.getKey("keycloak2"); + Assert.assertNotNull(keycloak2); + Assert.assertEquals(keycloak2, keyLocator.getKey(keycloak2)); + + MatcherAssert.assertThat(StreamSupport.stream(keyLocator.spliterator(), false).collect(Collectors.toList()), + Matchers.containsInAnyOrder(keycloak, keycloak2)); + + keyLocator = new SamlMetadataKeyLocator("test", loader, KeyUse.ENC, keyStorage); + + keycloak = keyLocator.getKey("keycloak"); + Assert.assertNotNull(keycloak); + Assert.assertEquals(keycloak, keyLocator.getKey(keycloak)); + + keycloak2 = keyLocator.getKey("keycloak2"); + Assert.assertNotNull(keycloak2); + Assert.assertEquals(keycloak2, keyLocator.getKey(keycloak2)); + + MatcherAssert.assertThat(StreamSupport.stream(keyLocator.spliterator(), false).collect(Collectors.toList()), + Matchers.containsInAnyOrder(keycloak, keycloak2)); + } + + @Test + public void testCertificatesExpired() throws KeyManagementException, ProcessingException { + PublicKeyStorageProvider keyStorage = new TestPublicKeyStorageProvider(); + // first certificate keycloak is changed to the expired one + String desc = DESCRIPTOR.replaceFirst("[^<]+", "" + EXPIRED_CERT +""); + PublicKeyLoader loader = new TestSamlMetadataPublicKeyLoader(desc, true); + + KeyLocator keyLocator = new SamlMetadataKeyLocator("test", loader, KeyUse.SIG, keyStorage); + + Key keycloak = keyLocator.getKey("keycloak"); + Assert.assertNull(keycloak); + X509Certificate keycloakExp = XMLSignatureUtil.getX509CertificateFromKeyInfoString(EXPIRED_CERT); + Assert.assertNull(keyLocator.getKey(keycloakExp.getPublicKey())); + + Key keycloak2 = keyLocator.getKey("keycloak2"); + Assert.assertNotNull(keycloak2); + Assert.assertEquals(keycloak2, keyLocator.getKey(keycloak2)); + + MatcherAssert.assertThat(StreamSupport.stream(keyLocator.spliterator(), false).collect(Collectors.toList()), + Matchers.containsInAnyOrder(keycloak2)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlMetadataSignedBrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlMetadataSignedBrokerTest.java new file mode 100644 index 0000000000..46067e7a20 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlMetadataSignedBrokerTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.broker; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import java.io.Closeable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.broker.saml.SAMLIdentityProviderConfig; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.crypto.Algorithm; +import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; +import org.keycloak.keys.KeyProvider; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.protocol.saml.SamlConfigAttributes; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.saml.SignatureAlgorithm; +import org.keycloak.saml.common.constants.JBossSAMLConstants; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; +import org.keycloak.testsuite.saml.AbstractSamlTest; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.IdentityProviderAttributeUpdater; +import org.keycloak.testsuite.util.KeyUtils; +import org.keycloak.testsuite.util.SamlClient; +import org.keycloak.testsuite.util.SamlClientBuilder; +import org.keycloak.testsuite.util.saml.SamlDocumentStepBuilder; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import static org.keycloak.testsuite.util.Matchers.bodyHC; + +/** + * + * @author rmartinc + */ +public class KcSamlMetadataSignedBrokerTest extends AbstractBrokerTest { + + public class KcSamlMetadataSignedBrokerConfiguration extends KcSamlBrokerConfiguration { + + @Override + public List createProviderClients() { + List clientRepresentationList = super.createProviderClients(); + + String consumerCert = KeyUtils.findActiveSigningKey(adminClient.realm(consumerRealmName()), Algorithm.RS256).getCertificate(); + MatcherAssert.assertThat(consumerCert, Matchers.notNullValue()); + + for (ClientRepresentation client : clientRepresentationList) { + client.setClientAuthenticatorType("client-secret"); + client.setSurrogateAuthRequired(false); + + Map attributes = client.getAttributes(); + if (attributes == null) { + attributes = new HashMap<>(); + client.setAttributes(attributes); + } + + attributes.put(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true"); + attributes.put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true"); + attributes.put(SamlConfigAttributes.SAML_SIGNATURE_ALGORITHM, SignatureAlgorithm.RSA_SHA512.name()); + attributes.put(SamlConfigAttributes.SAML_SIGNING_CERTIFICATE_ATTRIBUTE, consumerCert); + } + + return clientRepresentationList; + } + + @Override + public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) { + IdentityProviderRepresentation result = super.setUpIdentityProvider(syncMode); + + Map config = result.getConfig(); + + config.put(SAMLIdentityProviderConfig.VALIDATE_SIGNATURE, "true"); + config.put(SAMLIdentityProviderConfig.WANT_AUTHN_REQUESTS_SIGNED, "true"); + config.put(SAMLIdentityProviderConfig.USE_METADATA_DESCRIPTOR_URL, "true"); + config.put(SAMLIdentityProviderConfig.METADATA_DESCRIPTOR_URL, + BrokerTestTools.getProviderRoot() + "/auth/realms/" + providerRealmName() + "/protocol/saml/descriptor"); + + return result; + } + } + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return new KcSamlMetadataSignedBrokerConfiguration(); + } + + @Test + public void testPostLoginUsingDefaultKeyName() throws Exception { + // do initial login with the current key + doSamlPostLogin(Status.OK.getStatusCode(), "Update Account Information", this::identityDocument); + + // rotate the key and do not allow refresh <30 it should fail + rotateKeys(Algorithm.RS256, "rsa-generated"); + doSamlPostLogin(Status.BAD_REQUEST.getStatusCode(), "Invalid signature in response from identity provider", this::identityDocument); + + // ofsset to allow the refresh of the key + setTimeOffset(35); + doSamlPostLogin(Status.OK.getStatusCode(), "Update Account Information", this::identityDocument); + } + + @Test + public void testPostLoginUsingOnlyX09Data() throws Exception { + // do initial login with the current key + doSamlPostLogin(Status.OK.getStatusCode(), "Update Account Information", this::removeKeyNameFromSignature); + + // rotate the key and do not allow refresh <30 it should fail + rotateKeys(Algorithm.RS256, "rsa-generated"); + doSamlPostLogin(Status.BAD_REQUEST.getStatusCode(), "Invalid signature in response from identity provider", this::removeKeyNameFromSignature); + + // ofsset to allow the refresh of the key + setTimeOffset(35); + doSamlPostLogin(Status.OK.getStatusCode(), "Update Account Information", this::removeKeyNameFromSignature); + } + + @Test + public void testRedirectLogin() throws Exception { + try (Closeable clientUpdater = ClientAttributeUpdater.forClient(adminClient, bc.providerRealmName(), bc.getIDPClientIdInProviderRealm()) + .setAttribute(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "false") + .update(); + Closeable idpUpdater = new IdentityProviderAttributeUpdater(identityProviderResource) + .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_AUTHN_REQUEST, "false") + .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_RESPONSE, "false") + .update()) { + // do initial login with the current key + doSamlRedirectLogin(Status.OK.getStatusCode(), "Update Account Information"); + + // rotate keys it should fail + rotateKeys(Algorithm.RS256, "rsa-generated"); + doSamlRedirectLogin(Status.BAD_REQUEST.getStatusCode(), "Invalid signature in response from identity provider"); + + // offset of 35 is not enough (POST require iteration of keys) + setTimeOffset(35); + doSamlRedirectLogin(Status.BAD_REQUEST.getStatusCode(), "Invalid signature in response from identity provider."); + + // offset more than one day + setTimeOffset(24*60*60 + 5); + doSamlRedirectLogin(Status.OK.getStatusCode(), "Update Account Information"); + + // rotate keys it should fail again + rotateKeys(Algorithm.RS256, "rsa-generated"); + doSamlRedirectLogin(Status.BAD_REQUEST.getStatusCode(), "Invalid signature in response from identity provider"); + + // manually refresh after 1d plus 20s (15s more min refresh is 10s) + setTimeOffset(24*60*60 + 20); + Assert.assertTrue(adminClient.realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias()).reloadKeys()); + doSamlRedirectLogin(Status.OK.getStatusCode(), "Update Account Information"); + } + } + + private Document identityDocument(Document doc) { + return doc; + } + + private Document removeKeyNameFromSignature(Document doc) { + NodeList nodes = doc.getElementsByTagNameNS(JBossSAMLURIConstants.XMLDSIG_NSURI.get(), JBossSAMLConstants.KEY_INFO.get()); + if (nodes != null && nodes.getLength() > 0) { + Element keyInfo = (Element) nodes.item(0); + nodes = keyInfo.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (JBossSAMLURIConstants.XMLDSIG_NSURI.get().equals(node.getNamespaceURI()) + && "KeyName".equals(node.getLocalName())) { + keyInfo.removeChild(node); + break; + } + } + } + return doc; + } + + private void doSamlPostLogin(int statusCode, String expectedString, SamlDocumentStepBuilder.Saml2DocumentTransformer transformer) + throws ProcessingException, ConfigurationException, ParsingException { + AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST, + BrokerTestTools.getConsumerRoot() + "/sales-post/saml", null); + Document doc = SAML2Request.convert(loginRep); + new SamlClientBuilder() + .authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, SamlClient.Binding.POST) + .build() // Request to consumer IdP + .login().idp(bc.getIDPAlias()).build() + .processSamlResponse(SamlClient.Binding.POST).build() // AuthnRequest to producer IdP + .login().user(bc.getUserLogin(), bc.getUserPassword()).build() + .processSamlResponse(SamlClient.Binding.POST) // Response from producer IdP + .transformDocument(transformer) + .build() + // first-broker flow: if valid request, it displays an update profile page on consumer realm + .execute(currentResponse -> { + Assert.assertEquals(statusCode, currentResponse.getStatusLine().getStatusCode()); + MatcherAssert.assertThat(currentResponse, bodyHC(Matchers.containsString(expectedString))); + }); + } + + private void doSamlRedirectLogin(int statusCode, String expectedString) throws ProcessingException, ConfigurationException, ParsingException { + AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST, + BrokerTestTools.getConsumerRoot() + "/sales-post/saml", null); + Document doc = SAML2Request.convert(loginRep); + new SamlClientBuilder() + .authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, SamlClient.Binding.REDIRECT) + .build() // Request to consumer IdP + .login().idp(bc.getIDPAlias()).build() + .processSamlResponse(SamlClient.Binding.REDIRECT).build() // AuthnRequest to producer IdP + .login().user(bc.getUserLogin(), bc.getUserPassword()).build() + .processSamlResponse(SamlClient.Binding.REDIRECT) // Response from producer IdP + .build() + // first-broker flow: if valid request, it displays an update profile page on consumer realm + .execute(currentResponse -> { + Assert.assertEquals(statusCode, currentResponse.getStatusLine().getStatusCode()); + MatcherAssert.assertThat(currentResponse, bodyHC(Matchers.containsString(expectedString))); + }); + } + + private ComponentRepresentation createComponentRep(String algorithm, String providerId, String realmId) { + ComponentRepresentation keys = new ComponentRepresentation(); + keys.setName("generated"); + keys.setProviderType(KeyProvider.class.getName()); + keys.setProviderId(providerId); + keys.setParentId(realmId); + keys.setConfig(new MultivaluedHashMap<>()); + keys.getConfig().putSingle("priority", Long.toString(System.currentTimeMillis())); + keys.getConfig().putSingle("algorithm", algorithm); + return keys; + } + + private void rotateKeys(String algorithm, String providerId) { + RealmResource providerRealm = adminClient.realm(bc.providerRealmName()); + String activeKid = providerRealm.keys().getKeyMetadata().getActive().get(algorithm); + + // Rotate public keys on the parent broker + String realmId = providerRealm.toRepresentation().getId(); + ComponentRepresentation keys = createComponentRep(algorithm, providerId, realmId); + try (Response response = providerRealm.components().add(keys)) { + Assert.assertEquals(201, response.getStatus()); + } + + String updatedActiveKid = providerRealm.keys().getKeyMetadata().getActive().get(algorithm); + Assert.assertNotEquals(activeKid, updatedActiveKid); + } +}