Allow automatic download of SAML certificates in the identity provider

Closes https://github.com/keycloak/keycloak/issues/24424

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2023-11-06 11:26:13 +01:00 committed by Marek Posolda
parent 3bc028fe2d
commit 16afecd6b4
28 changed files with 1401 additions and 130 deletions

View file

@ -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<KeyWrapper> predicate) {
return keys.stream().filter(predicate).findFirst().orElse(null);
}
}

View file

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

View file

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

View file

@ -83,4 +83,8 @@ public interface IdentityProviderResource {
@DELETE
@Path("mappers/{id}")
void delete(@PathParam("id") String id);
}
@GET
@Path("reload-keys")
boolean reloadKeys();
}

View file

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

View file

@ -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" && (
<FormGroupField label="validatingX509Certs">
<KeycloakTextArea
id="validatingX509Certs"
data-testid="validatingX509Certs"
<>
<FormGroup
label={t("metadataDescriptorUrl")}
labelIcon={
<HelpItem
helpText={t("metadataDescriptorUrlHelp")}
fieldLabelId="metadataDescriptorUrl"
/>
}
isRequired={useMetadataDescriptorUrl === "true"}
validated={
errors.config?.metadataDescriptorUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
fieldId="metadataDescriptorUrl"
helperTextInvalid={t("required")}
>
<KeycloakTextInput
type="url"
id="metadataDescriptorUrl"
data-testid="metadataDescriptorUrl"
isReadOnly={readOnly}
validated={
errors.config?.metadataDescriptorUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
{...register("config.metadataDescriptorUrl", {
required: useMetadataDescriptorUrl === "true",
})}
/>
</FormGroup>
<SwitchField
field="config.useMetadataDescriptorUrl"
label="useMetadataDescriptorUrl"
data-testid="useMetadataDescriptorUrl"
isReadOnly={readOnly}
{...register("config.signingCertificate")}
></KeycloakTextArea>
</FormGroupField>
/>
{useMetadataDescriptorUrl !== "true" && (
<FormGroupField label="validatingX509Certs">
<KeycloakTextArea
id="validatingX509Certs"
data-testid="validatingX509Certs"
isReadOnly={readOnly}
{...register("config.signingCertificate")}
></KeycloakTextArea>
</FormGroupField>
)}
</>
)}
<SwitchField
field="config.signSpMetadata"

View file

@ -6,6 +6,7 @@ import {
ButtonVariant,
Divider,
DropdownItem,
DropdownSeparator,
Form,
PageSection,
Tab,
@ -13,7 +14,13 @@ import {
ToolbarItem,
} from "@patternfly/react-core";
import { useMemo, useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import {
Controller,
FormProvider,
useForm,
useFormContext,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ScrollForm } from "ui-shared";
@ -79,6 +86,23 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
const { t } = useTranslation();
const { alias: displayName } = useParams<{ alias: string }>();
const [provider, setProvider] = useState<IdentityProviderRepresentation>();
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 (
<>
<DisableConfirm />
@ -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
? [
<DropdownItem
key="reloadKeys"
onClick={() => reloadSamlKeys(provider.alias!)}
>
{t("reloadKeys")}
</DropdownItem>,
]
: provider?.providerId?.includes("saml") &&
validateSignature === "true" &&
useMetadataDescriptorUrl !== "true" &&
metadataDescriptorUrl &&
!formState.isDirty
? [
<DropdownItem
key="importKeys"
onClick={() =>
importSamlKeys(
provider.providerId!,
metadataDescriptorUrl,
)
}
>
{t("importKeys")}
</DropdownItem>,
]
: []),
<DropdownSeparator key="separator" />,
<DropdownItem key="delete" onClick={() => toggleDeleteDialog()}>
{t("delete")}
</DropdownItem>,
@ -249,6 +342,7 @@ export default function DetailSettings() {
providerId,
},
);
reset(p);
addAlert(t("updateSuccessIdentityProvider"), AlertVariant.success);
} catch (error) {
addError("updateErrorIdentityProvider", error);

View file

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

View file

@ -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<String, FutureTask<PublicKeysEntry>> tasksInProgress;
private final int minTimeBetweenRequests ;
private final int maxCacheTime;
private Set<String> invalidations = new HashSet<>();
private final Set<String> invalidations = new HashSet<>();
private boolean transactionEnlisted = false;
public InfinispanPublicKeyStorageProvider(KeycloakSession session, Cache<String, PublicKeysEntry> keys, Map<String, FutureTask<PublicKeysEntry>> tasksInProgress, int minTimeBetweenRequests) {
public InfinispanPublicKeyStorageProvider(KeycloakSession session, Cache<String, PublicKeysEntry> keys, Map<String, FutureTask<PublicKeysEntry>> 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<String> 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<KeyWrapper> 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<KeyWrapper> 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<PublicKeysEntry> task = new FutureTask<>(wrapperCallable);
FutureTask<PublicKeysEntry> 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<String> 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;
}

View file

@ -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<String, FutureTask<PublicKeysEntry>> 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<ProviderConfigProperty> 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

View file

@ -50,6 +50,7 @@ public class InfinispanKeyStorageProviderTest {
Cache<String, PublicKeysEntry> keys = getKeysCache();
Map<String, FutureTask<PublicKeysEntry>> 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));
}

View file

@ -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> T locateSSODescriptorType(EntityDescriptorType entityType,
Function<EntityDescriptorType.EDTDescriptorChoiceType, T> getter) {
List<EntityDescriptorType.EDTChoiceType> 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<EntityDescriptorType.EDTDescriptorChoiceType> descriptors = edtChoiceType.getDescriptors();
if (!descriptors.isEmpty() && descriptors.get(0).getIdpDescriptor() != null) {
descriptor = getter.apply(descriptors.get(0));
}
}
}
return descriptor;
}
}

View file

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

View file

@ -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<C extends IdentityProviderModel> 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;
}
}

View file

@ -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<KeyWrapper> predicate, PublicKeyLoader loader);
/**
* Getter for all the keys in the model key.
*
* @param modelKey
* @param loader
* @return
*/
List<KeyWrapper> 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);
}

View file

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

View file

@ -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<OIDCIde
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid nonce", Response.Status.BAD_REQUEST);
}
}
@Override
public boolean reloadKeys() {
if (getConfig().isEnabled() && getConfig().isUseJwksUrl()) {
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), getConfig().getInternalId());
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
return keyStorage.reloadKeys(modelKey, new OIDCIdentityProviderPublicKeyLoader(session, getConfig()));
}
return false;
}
}

View file

@ -101,6 +101,12 @@ import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.keycloak.crypto.KeyUse;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
import org.keycloak.protocol.saml.SamlMetadataKeyLocator;
import org.keycloak.protocol.saml.SamlMetadataPublicKeyLoader;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.rotation.HardcodedKeyLocator;
import org.keycloak.rotation.KeyLocator;
@ -253,8 +259,14 @@ public class SAMLEndpoint {
}
protected KeyLocator getIDPKeyLocator() {
List<Key> 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<Key> keys = new LinkedList<>();
for (String signingCertificate : config.getSigningCertificates()) {
X509Certificate cert = null;
try {

View file

@ -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<SAMLIdentityP
public IdentityProviderDataMarshaller getMarshaller() {
return new SAMLDataMarshaller();
}
@Override
public boolean reloadKeys() {
if (getConfig().isEnabled() && getConfig().isUseMetadataDescriptorUrl()) {
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), getConfig().getInternalId());
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
return keyStorage.reloadKeys(modelKey, new SamlMetadataPublicKeyLoader(session, getConfig().getMetadataDescriptorUrl()));
}
return false;
}
}

View file

@ -21,11 +21,11 @@ import static org.keycloak.common.util.UriUtils.checkUrl;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
import org.keycloak.utils.StringUtil;
/**
* @author Pedro Igor
@ -64,6 +64,7 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
public static final String ALLOW_CREATE = "allowCreate";
public static final String ATTRIBUTE_CONSUMING_SERVICE_INDEX = "attributeConsumingServiceIndex";
public static final String ATTRIBUTE_CONSUMING_SERVICE_NAME = "attributeConsumingServiceName";
public static final String USE_METADATA_DESCRIPTOR_URL = "useMetadataDescriptorUrl";
public SAMLIdentityProviderConfig() {
}
@ -397,12 +398,32 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
return getConfig().get(ATTRIBUTE_CONSUMING_SERVICE_NAME);
}
public void setUseMetadataDescriptorUrl(Boolean useDescriptorUrl) {
if (useDescriptorUrl == null || !useDescriptorUrl) {
getConfig().remove(USE_METADATA_DESCRIPTOR_URL);
} else {
getConfig().put(USE_METADATA_DESCRIPTOR_URL, Boolean.TRUE.toString());
}
}
public boolean isUseMetadataDescriptorUrl() {
return Boolean.parseBoolean(getConfig().get(USE_METADATA_DESCRIPTOR_URL));
}
@Override
public void validate(RealmModel realm) {
SslRequired sslRequired = realm.getSslRequired();
checkUrl(sslRequired, getSingleLogoutServiceUrl(), SINGLE_LOGOUT_SERVICE_URL);
checkUrl(sslRequired, getSingleSignOnServiceUrl(), SINGLE_SIGN_ON_SERVICE_URL);
if (StringUtil.isNotBlank(getMetadataDescriptorUrl())) {
checkUrl(sslRequired, getMetadataDescriptorUrl(), METADATA_DESCRIPTOR_URL);
}
if (isUseMetadataDescriptorUrl()) {
if (StringUtil.isBlank(getMetadataDescriptorUrl())) {
throw new IllegalArgumentException(USE_METADATA_DESCRIPTOR_URL + " needs a non-empty URL for " + METADATA_DESCRIPTOR_URL);
}
}
//transient name id format is not accepted together with principaltype SubjectnameId
if (JBossSAMLURIConstants.NAMEID_FORMAT_TRANSIENT.get().equals(getNameIDPolicyFormat()) && SamlPrincipalType.SUBJECT == getPrincipalType())
throw new IllegalArgumentException("Can not have Transient NameID Policy Format together with SUBJECT Principal Type");

View file

@ -26,10 +26,8 @@ import javax.xml.namespace.QName;
import org.keycloak.Config.Scope;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.common.util.Time;
import org.keycloak.dom.saml.v2.assertion.AttributeType;
import org.keycloak.dom.saml.v2.metadata.EndpointType;
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;
@ -39,7 +37,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.exceptions.ParsingException;
import org.keycloak.saml.common.util.DocumentUtil;
import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
import org.keycloak.saml.processing.core.saml.v2.util.SAMLMetadataUtil;
import org.keycloak.saml.validators.DestinationValidator;
import org.w3c.dom.Element;
@ -73,115 +71,94 @@ public class SAMLIdentityProviderFactory extends AbstractIdentityProviderFactory
@Override
public Map<String, String> 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<EntityDescriptorType.EDTChoiceType> 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<String> 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<EntityDescriptorType.EDTDescriptorChoiceType> descriptors = edtChoiceType.getDescriptors();
List<KeyDescriptorType> 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<String> nameIdFormatList = idpDescriptor.getNameIDFormat();
if (nameIdFormatList != null && !nameIdFormatList.isEmpty())
samlIdentityProviderConfig.setNameIDPolicyFormat(nameIdFormatList.get(0));
List<KeyDescriptorType> 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);

View file

@ -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;
/**
* <p>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.</p>
*
* @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<KeyDescriptorType> 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<KeyWrapper> 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<XMLStructure>) 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;
}
}

View file

@ -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;
/**
* <p>KeyLocator that caches the keys into a PublicKeyStorageProvider.</p>
*
* @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<Key> 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<KeyWrapper> 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<KeyWrapper> 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;
}
}
}

View file

@ -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;
/**
* <p>PublicKeyLoader to retrieve keys from a SAML metadata entity endpoint.
* It can be used to load IDP or SP keys.</p>
*
* @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);
}
}

View file

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

View file

@ -159,6 +159,8 @@ public class IdentityProvidersResource {
IdentityProviderFactory providerFactory = getProviderFactoryById(providerId);
Map<String, String> 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 {

View file

@ -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
= "<md:EntityDescriptor xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\" entityID=\"http://localhost:8080/realms/keycloak\">"
+ "<md:IDPSSODescriptor WantAuthnRequestsSigned=\"true\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">"
+ "<md:KeyDescriptor use=\"signing\">"
+ "<ds:KeyInfo>"
+ "<ds:KeyName>keycloak</ds:KeyName>"
+ "<ds:X509Data>"
+ "<ds:X509Certificate>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=</ds:X509Certificate>"
+ "</ds:X509Data>"
+ "</ds:KeyInfo>"
+ "</md:KeyDescriptor>"
+ "<md:KeyDescriptor use=\"signing\">"
+ "<ds:KeyInfo>"
+ "<ds:KeyName>keycloak2</ds:KeyName>"
+ "<ds:X509Data>"
+ "<ds:X509Certificate>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=</ds:X509Certificate>"
+ "</ds:X509Data>"
+ "</ds:KeyInfo>"
+ "</md:KeyDescriptor>"
+ "<md:ArtifactResolutionService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:SOAP\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml/resolve\" index=\"0\"></md:ArtifactResolutionService>"
+ "<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleLogoutService>"
+ "<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleLogoutService>"
+ "<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleLogoutService>"
+ "<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:SOAP\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleLogoutService>"
+ "<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>"
+ "<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>"
+ "<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>"
+ "<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>"
+ "<md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleSignOnService>"
+ "<md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleSignOnService>"
+ "<md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:SOAP\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleSignOnService>"
+ "<md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact\" Location=\"http://localhost:8080/realms/keycloak/protocol/saml\"></md:SingleSignOnService>"
+ "</md:IDPSSODescriptor>"
+ "</md:EntityDescriptor>";
// 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<KeyWrapper> predicate, PublicKeyLoader loader) {
return load(loader).getKeyByPredicate(predicate);
}
@Override
public List<KeyWrapper> 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("<md:KeyDescriptor use=\"signing\">", "<md:KeyDescriptor>");
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("<ds:X509Certificate>[^<]+</ds:X509Certificate>", "<ds:X509Certificate>" + EXPIRED_CERT +"</ds:X509Certificate>");
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));
}
}

View file

@ -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<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> 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<String, String> 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<String, String> 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);
}
}