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:
parent
3bc028fe2d
commit
16afecd6b4
28 changed files with 1401 additions and 130 deletions
|
@ -22,6 +22,7 @@ package org.keycloak.crypto;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@ -72,4 +73,13 @@ public class PublicKeysWrapper {
|
||||||
|
|
||||||
return potentialMatches.findFirst().orElse(null);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
= Keycloak JS using `exports` field
|
= 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.
|
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.
|
|
@ -84,8 +84,16 @@ itself.
|
||||||
|Validate Signature
|
|Validate Signature
|
||||||
|When *ON*, the realm expects SAML requests and responses from the external IDP to be digitally signed.
|
|When *ON*, the realm expects SAML requests and responses from the external IDP to be digitally signed.
|
||||||
|
|
||||||
|Validating X509 Certificate
|
|Metadata descriptor URL
|
||||||
|The public certificate {project_name} uses to validate the signatures of SAML requests and responses from the external IDP.
|
|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
|
|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>>.
|
|When *ON*, {project_name} uses the realm's key pair to sign the <<_identity_broker_saml_sp_descriptor, SAML Service Provider Metadata descriptor>>.
|
||||||
|
|
|
@ -83,4 +83,8 @@ public interface IdentityProviderResource {
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("mappers/{id}")
|
@Path("mappers/{id}")
|
||||||
void delete(@PathParam("id") String id);
|
void delete(@PathParam("id") String id);
|
||||||
}
|
|
||||||
|
@GET
|
||||||
|
@Path("reload-keys")
|
||||||
|
boolean reloadKeys();
|
||||||
|
}
|
||||||
|
|
|
@ -790,7 +790,7 @@ endpoints=Endpoints
|
||||||
roleSaveError=Could not save role\: {{error}}
|
roleSaveError=Could not save role\: {{error}}
|
||||||
keySize=Key size
|
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'.
|
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
|
samlCapabilityConfig=SAML capabilities
|
||||||
accessTokenSignatureAlgorithmHelp=JWA algorithm used for signing access tokens.
|
accessTokenSignatureAlgorithmHelp=JWA algorithm used for signing access tokens.
|
||||||
derFormatted=DER formatted
|
derFormatted=DER formatted
|
||||||
|
@ -1008,6 +1008,18 @@ linkAccountTitle=Link account to {{provider}}
|
||||||
invalidateRotatedSuccess=Rotated secret successfully removed
|
invalidateRotatedSuccess=Rotated secret successfully removed
|
||||||
userSessionAttributeHelp=Name of user session attribute you want to hardcode
|
userSessionAttributeHelp=Name of user session attribute you want to hardcode
|
||||||
updateSuccessIdentityProvider=Provider successfully updated
|
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
|
host=Host
|
||||||
forbidden_one=Forbidden, permission needed\:
|
forbidden_one=Forbidden, permission needed\:
|
||||||
backchannelLogoutRevokeOfflineSessions=Backchannel logout revoke offline sessions
|
backchannelLogoutRevokeOfflineSessions=Backchannel logout revoke offline sessions
|
||||||
|
|
|
@ -59,6 +59,11 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => {
|
||||||
name: "config.validateSignature",
|
name: "config.validateSignature",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const useMetadataDescriptorUrl = useWatch({
|
||||||
|
control,
|
||||||
|
name: "config.useMetadataDescriptorUrl",
|
||||||
|
});
|
||||||
|
|
||||||
const principalType = useWatch({
|
const principalType = useWatch({
|
||||||
control,
|
control,
|
||||||
name: "config.principalType",
|
name: "config.principalType",
|
||||||
|
@ -482,14 +487,56 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => {
|
||||||
isReadOnly={readOnly}
|
isReadOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{validateSignature === "true" && (
|
{validateSignature === "true" && (
|
||||||
<FormGroupField label="validatingX509Certs">
|
<>
|
||||||
<KeycloakTextArea
|
<FormGroup
|
||||||
id="validatingX509Certs"
|
label={t("metadataDescriptorUrl")}
|
||||||
data-testid="validatingX509Certs"
|
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}
|
isReadOnly={readOnly}
|
||||||
{...register("config.signingCertificate")}
|
/>
|
||||||
></KeycloakTextArea>
|
{useMetadataDescriptorUrl !== "true" && (
|
||||||
</FormGroupField>
|
<FormGroupField label="validatingX509Certs">
|
||||||
|
<KeycloakTextArea
|
||||||
|
id="validatingX509Certs"
|
||||||
|
data-testid="validatingX509Certs"
|
||||||
|
isReadOnly={readOnly}
|
||||||
|
{...register("config.signingCertificate")}
|
||||||
|
></KeycloakTextArea>
|
||||||
|
</FormGroupField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<SwitchField
|
<SwitchField
|
||||||
field="config.signSpMetadata"
|
field="config.signSpMetadata"
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
Divider,
|
Divider,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
DropdownSeparator,
|
||||||
Form,
|
Form,
|
||||||
PageSection,
|
PageSection,
|
||||||
Tab,
|
Tab,
|
||||||
|
@ -13,7 +14,13 @@ import {
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useMemo, useState } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ScrollForm } from "ui-shared";
|
import { ScrollForm } from "ui-shared";
|
||||||
|
@ -79,6 +86,23 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { alias: displayName } = useParams<{ alias: string }>();
|
const { alias: displayName } = useParams<{ alias: string }>();
|
||||||
const [provider, setProvider] = useState<IdentityProviderRepresentation>();
|
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(
|
useFetch(
|
||||||
() => adminClient.identityProviders.findOne({ alias: displayName }),
|
() => 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DisableConfirm />
|
<DisableConfirm />
|
||||||
|
@ -114,6 +173,40 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
|
||||||
)}
|
)}
|
||||||
divider={false}
|
divider={false}
|
||||||
dropdownItems={[
|
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()}>
|
<DropdownItem key="delete" onClick={() => toggleDeleteDialog()}>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</DropdownItem>,
|
</DropdownItem>,
|
||||||
|
@ -249,6 +342,7 @@ export default function DetailSettings() {
|
||||||
providerId,
|
providerId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
reset(p);
|
||||||
addAlert(t("updateSuccessIdentityProvider"), AlertVariant.success);
|
addAlert(t("updateSuccessIdentityProvider"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("updateErrorIdentityProvider", error);
|
addError("updateErrorIdentityProvider", error);
|
||||||
|
|
|
@ -146,6 +146,12 @@ export class IdentityProviders extends Resource<{ realm?: string }> {
|
||||||
urlParamKeys: ["alias"],
|
urlParamKeys: ["alias"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public reloadKeys = this.makeRequest<{ alias: string }, boolean>({
|
||||||
|
method: "GET",
|
||||||
|
path: "/instances/{alias}/reload-keys",
|
||||||
|
urlParamKeys: ["alias"],
|
||||||
|
});
|
||||||
|
|
||||||
constructor(client: KeycloakAdminClient) {
|
constructor(client: KeycloakAdminClient) {
|
||||||
super(client, {
|
super(client, {
|
||||||
path: "/admin/realms/{realm}/identity-provider",
|
path: "/admin/realms/{realm}/identity-provider",
|
||||||
|
|
|
@ -25,6 +25,8 @@ import java.util.Set;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.FutureTask;
|
import java.util.concurrent.FutureTask;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.infinispan.Cache;
|
import org.infinispan.Cache;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
@ -52,16 +54,19 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi
|
||||||
private final Map<String, FutureTask<PublicKeysEntry>> tasksInProgress;
|
private final Map<String, FutureTask<PublicKeysEntry>> tasksInProgress;
|
||||||
|
|
||||||
private final int minTimeBetweenRequests ;
|
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;
|
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.session = session;
|
||||||
this.keys = keys;
|
this.keys = keys;
|
||||||
this.tasksInProgress = tasksInProgress;
|
this.tasksInProgress = tasksInProgress;
|
||||||
this.minTimeBetweenRequests = minTimeBetweenRequests;
|
this.minTimeBetweenRequests = minTimeBetweenRequests;
|
||||||
|
this.maxCacheTime = maxCacheTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addInvalidation(String cacheKey) {
|
void addInvalidation(String cacheKey) {
|
||||||
|
@ -125,7 +130,7 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi
|
||||||
@Override
|
@Override
|
||||||
public KeyWrapper getPublicKey(String modelKey, String kid, String algorithm, PublicKeyLoader loader) {
|
public KeyWrapper getPublicKey(String modelKey, String kid, String algorithm, PublicKeyLoader loader) {
|
||||||
PublicKeysEntry entry = keys.get(modelKey);
|
PublicKeysEntry entry = keys.get(modelKey);
|
||||||
int lastRequestTime = entry==null ? 0 : entry.getLastRequestTime();
|
int lastRequestTime = entry == null? 0 : entry.getLastRequestTime();
|
||||||
int currentTime = Time.currentTime();
|
int currentTime = Time.currentTime();
|
||||||
boolean isSendingRequestAllowed = currentTime > lastRequestTime + minTimeBetweenRequests;
|
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
|
// Check if we are allowed to send request
|
||||||
if (isSendingRequestAllowed) {
|
if (entry == null || currentTime > entry.getLastRequestTime() + minTimeBetweenRequests) {
|
||||||
WrapperCallable wrapperCallable = new WrapperCallable(modelKey, loader);
|
WrapperCallable wrapperCallable = new WrapperCallable(modelKey, loader);
|
||||||
FutureTask<PublicKeysEntry> task = new FutureTask<>(wrapperCallable);
|
FutureTask<PublicKeysEntry> task = new FutureTask<>(wrapperCallable);
|
||||||
FutureTask<PublicKeysEntry> existing = tasksInProgress.putIfAbsent(modelKey, task);
|
FutureTask<PublicKeysEntry> existing = tasksInProgress.putIfAbsent(modelKey, task);
|
||||||
|
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
|
log.debugf("Reloading keys for model key '%s'.", modelKey);
|
||||||
task.run();
|
task.run();
|
||||||
} else {
|
} else {
|
||||||
task = existing;
|
task = existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entry = task.get();
|
return 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (ExecutionException ee) {
|
} catch (ExecutionException ee) {
|
||||||
throw new RuntimeException("Error when loading public keys: " + ee.getMessage(), ee);
|
throw new RuntimeException("Error when loading public keys: " + ee.getMessage(), ee);
|
||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
|
@ -172,12 +249,8 @@ public class InfinispanPublicKeyStorageProvider implements PublicKeyStorageProvi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
package org.keycloak.keys.infinispan;
|
package org.keycloak.keys.infinispan;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.FutureTask;
|
import java.util.concurrent.FutureTask;
|
||||||
|
@ -36,6 +37,8 @@ import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.provider.EnvironmentDependentProviderFactory;
|
import org.keycloak.provider.EnvironmentDependentProviderFactory;
|
||||||
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
|
import org.keycloak.provider.ProviderConfigurationBuilder;
|
||||||
import org.keycloak.provider.ProviderEvent;
|
import org.keycloak.provider.ProviderEvent;
|
||||||
import org.keycloak.provider.ProviderEventListener;
|
import org.keycloak.provider.ProviderEventListener;
|
||||||
|
|
||||||
|
@ -53,11 +56,36 @@ public class InfinispanPublicKeyStorageProviderFactory implements PublicKeyStora
|
||||||
private final Map<String, FutureTask<PublicKeysEntry>> tasksInProgress = new ConcurrentHashMap<>();
|
private final Map<String, FutureTask<PublicKeysEntry>> tasksInProgress = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private int minTimeBetweenRequests;
|
private int minTimeBetweenRequests;
|
||||||
|
private int maxCacheTime;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PublicKeyStorageProvider create(KeycloakSession session) {
|
public PublicKeyStorageProvider create(KeycloakSession session) {
|
||||||
lazyInit(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) {
|
private void lazyInit(KeycloakSession session) {
|
||||||
|
@ -72,8 +100,15 @@ public class InfinispanPublicKeyStorageProviderFactory implements PublicKeyStora
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Config.Scope config) {
|
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);
|
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
|
@Override
|
||||||
|
|
|
@ -50,6 +50,7 @@ public class InfinispanKeyStorageProviderTest {
|
||||||
Cache<String, PublicKeysEntry> keys = getKeysCache();
|
Cache<String, PublicKeysEntry> keys = getKeysCache();
|
||||||
Map<String, FutureTask<PublicKeysEntry>> tasksInProgress = new ConcurrentHashMap<>();
|
Map<String, FutureTask<PublicKeysEntry>> tasksInProgress = new ConcurrentHashMap<>();
|
||||||
int minTimeBetweenRequests = 10;
|
int minTimeBetweenRequests = 10;
|
||||||
|
int maxCacheTime = 600;
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void before() {
|
public void before() {
|
||||||
|
@ -127,7 +128,7 @@ public class InfinispanKeyStorageProviderTest {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
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));
|
provider.getPublicKey(modelKey, "kid1", null, new SampleLoader(modelKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,19 +16,27 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.saml.processing.core.saml.v2.util;
|
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.KeyDescriptorType;
|
||||||
import org.keycloak.dom.saml.v2.metadata.KeyTypes;
|
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.dom.saml.v2.metadata.SSODescriptorType;
|
||||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||||
import org.keycloak.saml.common.exceptions.ConfigurationException;
|
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.common.exceptions.ProcessingException;
|
||||||
|
import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
|
||||||
import org.keycloak.saml.processing.core.util.XMLSignatureUtil;
|
import org.keycloak.saml.processing.core.util.XMLSignatureUtil;
|
||||||
import org.w3c.dom.Element;
|
import org.w3c.dom.Element;
|
||||||
import org.w3c.dom.Node;
|
import org.w3c.dom.Node;
|
||||||
import org.w3c.dom.NodeList;
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
import java.security.cert.X509Certificate;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deals with SAML2 Metadata
|
* Deals with SAML2 Metadata
|
||||||
*
|
*
|
||||||
|
@ -97,4 +105,52 @@ public class SAMLMetadataUtil {
|
||||||
|
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -88,6 +88,7 @@ import javax.xml.crypto.KeySelector;
|
||||||
import javax.xml.crypto.KeySelectorException;
|
import javax.xml.crypto.KeySelectorException;
|
||||||
import javax.xml.crypto.KeySelectorResult;
|
import javax.xml.crypto.KeySelectorResult;
|
||||||
import javax.xml.crypto.XMLCryptoContext;
|
import javax.xml.crypto.XMLCryptoContext;
|
||||||
|
import javax.xml.crypto.dom.DOMStructure;
|
||||||
import org.keycloak.rotation.KeyLocator;
|
import org.keycloak.rotation.KeyLocator;
|
||||||
import org.keycloak.saml.common.util.SecurityActions;
|
import org.keycloak.saml.common.util.SecurityActions;
|
||||||
|
|
||||||
|
@ -729,4 +730,9 @@ public class XMLSignatureUtil {
|
||||||
|
|
||||||
return keyInfoFactory.newKeyInfo(items);
|
return keyInfoFactory.newKeyInfo(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static KeyInfo createKeyInfo(Element keyInfo) throws MarshalException {
|
||||||
|
KeyInfoFactory keyInfoFactory = fac.getKeyInfoFactory();
|
||||||
|
return keyInfoFactory.unmarshalKeyInfo(new DOMStructure(keyInfo));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.models.FederatedIdentityModel;
|
import org.keycloak.models.FederatedIdentityModel;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.ModelException;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
|
@ -146,4 +147,12 @@ public interface IdentityProvider<C extends IdentityProviderModel> extends Provi
|
||||||
|| compatibleIdps.contains(getConfig().getProviderId());
|
|| 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
|
|
||||||
package org.keycloak.keys;
|
package org.keycloak.keys;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import org.keycloak.crypto.KeyWrapper;
|
import org.keycloak.crypto.KeyWrapper;
|
||||||
import org.keycloak.provider.Provider;
|
import org.keycloak.provider.Provider;
|
||||||
|
|
||||||
|
@ -48,4 +50,32 @@ public interface PublicKeyStorageProvider extends Provider {
|
||||||
*/
|
*/
|
||||||
KeyWrapper getFirstPublicKey(String modelKey, String algorithm, PublicKeyLoader loader);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ public class IdentityProviderModel implements Serializable {
|
||||||
public static final String CLAIM_FILTER_NAME = "claimFilterName";
|
public static final String CLAIM_FILTER_NAME = "claimFilterName";
|
||||||
public static final String CLAIM_FILTER_VALUE = "claimFilterValue";
|
public static final String CLAIM_FILTER_VALUE = "claimFilterValue";
|
||||||
public static final String DO_NOT_STORE_USERS = "doNotStoreUsers";
|
public static final String DO_NOT_STORE_USERS = "doNotStoreUsers";
|
||||||
|
public static final String METADATA_DESCRIPTOR_URL = "metadataDescriptorUrl";
|
||||||
|
|
||||||
private String internalId;
|
private String internalId;
|
||||||
|
|
||||||
|
@ -302,4 +303,12 @@ public class IdentityProviderModel implements Serializable {
|
||||||
public void setClaimFilterValue(String claimFilterValue) {
|
public void setClaimFilterValue(String claimFilterValue) {
|
||||||
getConfig().put(CLAIM_FILTER_VALUE, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,11 +44,15 @@ import org.keycloak.jose.JOSEParser;
|
||||||
import org.keycloak.jose.jwe.JWE;
|
import org.keycloak.jose.jwe.JWE;
|
||||||
import org.keycloak.jose.jwe.JWEException;
|
import org.keycloak.jose.jwe.JWEException;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
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.keys.loader.PublicKeyStorageManager;
|
||||||
import org.keycloak.models.AbstractKeycloakTransaction;
|
import org.keycloak.models.AbstractKeycloakTransaction;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.FederatedIdentityModel;
|
import org.keycloak.models.FederatedIdentityModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.ModelException;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
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);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,6 +101,12 @@ import java.util.function.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.util.stream.Collectors;
|
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.protocol.saml.SamlPrincipalType;
|
||||||
import org.keycloak.rotation.HardcodedKeyLocator;
|
import org.keycloak.rotation.HardcodedKeyLocator;
|
||||||
import org.keycloak.rotation.KeyLocator;
|
import org.keycloak.rotation.KeyLocator;
|
||||||
|
@ -253,8 +259,14 @@ public class SAMLEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected KeyLocator getIDPKeyLocator() {
|
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()) {
|
for (String signingCertificate : config.getSigningCertificates()) {
|
||||||
X509Certificate cert = null;
|
X509Certificate cert = null;
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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.LogoutRequestType;
|
||||||
import org.keycloak.dom.saml.v2.protocol.ResponseType;
|
import org.keycloak.dom.saml.v2.protocol.ResponseType;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
|
import org.keycloak.keys.PublicKeyStorageProvider;
|
||||||
|
import org.keycloak.keys.PublicKeyStorageUtils;
|
||||||
import org.keycloak.models.FederatedIdentityModel;
|
import org.keycloak.models.FederatedIdentityModel;
|
||||||
import org.keycloak.models.IdentityProviderMapperModel;
|
import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
import org.keycloak.models.KeyManager;
|
import org.keycloak.models.KeyManager;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.ModelException;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
||||||
|
import org.keycloak.protocol.saml.SamlMetadataPublicKeyLoader;
|
||||||
import org.keycloak.protocol.saml.SamlProtocol;
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
import org.keycloak.protocol.saml.SamlService;
|
import org.keycloak.protocol.saml.SamlService;
|
||||||
import org.keycloak.protocol.saml.SamlSessionUtils;
|
import org.keycloak.protocol.saml.SamlSessionUtils;
|
||||||
|
@ -501,4 +505,14 @@ public class SAMLIdentityProvider extends AbstractIdentityProvider<SAMLIdentityP
|
||||||
public IdentityProviderDataMarshaller getMarshaller() {
|
public IdentityProviderDataMarshaller getMarshaller() {
|
||||||
return new SAMLDataMarshaller();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,11 +21,11 @@ import static org.keycloak.common.util.UriUtils.checkUrl;
|
||||||
import org.keycloak.common.enums.SslRequired;
|
import org.keycloak.common.enums.SslRequired;
|
||||||
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
|
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.protocol.saml.SamlPrincipalType;
|
import org.keycloak.protocol.saml.SamlPrincipalType;
|
||||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||||
import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
|
import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
|
||||||
|
import org.keycloak.utils.StringUtil;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Pedro Igor
|
* @author Pedro Igor
|
||||||
|
@ -64,6 +64,7 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
|
||||||
public static final String ALLOW_CREATE = "allowCreate";
|
public static final String ALLOW_CREATE = "allowCreate";
|
||||||
public static final String ATTRIBUTE_CONSUMING_SERVICE_INDEX = "attributeConsumingServiceIndex";
|
public static final String ATTRIBUTE_CONSUMING_SERVICE_INDEX = "attributeConsumingServiceIndex";
|
||||||
public static final String ATTRIBUTE_CONSUMING_SERVICE_NAME = "attributeConsumingServiceName";
|
public static final String ATTRIBUTE_CONSUMING_SERVICE_NAME = "attributeConsumingServiceName";
|
||||||
|
public static final String USE_METADATA_DESCRIPTOR_URL = "useMetadataDescriptorUrl";
|
||||||
|
|
||||||
public SAMLIdentityProviderConfig() {
|
public SAMLIdentityProviderConfig() {
|
||||||
}
|
}
|
||||||
|
@ -397,12 +398,32 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
|
||||||
return getConfig().get(ATTRIBUTE_CONSUMING_SERVICE_NAME);
|
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
|
@Override
|
||||||
public void validate(RealmModel realm) {
|
public void validate(RealmModel realm) {
|
||||||
SslRequired sslRequired = realm.getSslRequired();
|
SslRequired sslRequired = realm.getSslRequired();
|
||||||
|
|
||||||
checkUrl(sslRequired, getSingleLogoutServiceUrl(), SINGLE_LOGOUT_SERVICE_URL);
|
checkUrl(sslRequired, getSingleLogoutServiceUrl(), SINGLE_LOGOUT_SERVICE_URL);
|
||||||
checkUrl(sslRequired, getSingleSignOnServiceUrl(), SINGLE_SIGN_ON_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
|
//transient name id format is not accepted together with principaltype SubjectnameId
|
||||||
if (JBossSAMLURIConstants.NAMEID_FORMAT_TRANSIENT.get().equals(getNameIDPolicyFormat()) && SamlPrincipalType.SUBJECT == getPrincipalType())
|
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");
|
throw new IllegalArgumentException("Can not have Transient NameID Policy Format together with SUBJECT Principal Type");
|
||||||
|
|
|
@ -26,10 +26,8 @@ import javax.xml.namespace.QName;
|
||||||
|
|
||||||
import org.keycloak.Config.Scope;
|
import org.keycloak.Config.Scope;
|
||||||
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
|
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.assertion.AttributeType;
|
||||||
import org.keycloak.dom.saml.v2.metadata.EndpointType;
|
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.EntityDescriptorType;
|
||||||
import org.keycloak.dom.saml.v2.metadata.IDPSSODescriptorType;
|
import org.keycloak.dom.saml.v2.metadata.IDPSSODescriptorType;
|
||||||
import org.keycloak.dom.saml.v2.metadata.KeyDescriptorType;
|
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.constants.JBossSAMLURIConstants;
|
||||||
import org.keycloak.saml.common.exceptions.ParsingException;
|
import org.keycloak.saml.common.exceptions.ParsingException;
|
||||||
import org.keycloak.saml.common.util.DocumentUtil;
|
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.keycloak.saml.validators.DestinationValidator;
|
||||||
import org.w3c.dom.Element;
|
import org.w3c.dom.Element;
|
||||||
|
|
||||||
|
@ -73,115 +71,94 @@ public class SAMLIdentityProviderFactory extends AbstractIdentityProviderFactory
|
||||||
@Override
|
@Override
|
||||||
public Map<String, String> parseConfig(KeycloakSession session, InputStream inputStream) {
|
public Map<String, String> parseConfig(KeycloakSession session, InputStream inputStream) {
|
||||||
try {
|
try {
|
||||||
Object parsedObject = SAMLParser.getInstance().parse(inputStream);
|
EntityDescriptorType entityType = SAMLMetadataUtil.parseEntityDescriptorType(inputStream);
|
||||||
EntityDescriptorType entityType;
|
IDPSSODescriptorType idpDescriptor = SAMLMetadataUtil.locateIDPSSODescriptorType(entityType);
|
||||||
|
|
||||||
if (EntitiesDescriptorType.class.isInstance(parsedObject)) {
|
if (idpDescriptor != null) {
|
||||||
entityType = (EntityDescriptorType) ((EntitiesDescriptorType) parsedObject).getEntityDescriptor().get(0);
|
SAMLIdentityProviderConfig samlIdentityProviderConfig = new SAMLIdentityProviderConfig();
|
||||||
} else {
|
String singleSignOnServiceUrl = null;
|
||||||
entityType = (EntityDescriptorType) parsedObject;
|
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()) {
|
List<String> nameIdFormatList = idpDescriptor.getNameIDFormat();
|
||||||
IDPSSODescriptorType idpDescriptor = null;
|
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.
|
List<KeyDescriptorType> keyDescriptor = idpDescriptor.getKeyDescriptor();
|
||||||
//So we need to loop through to find the IDPSSODescriptor.
|
String defaultCertificate = null;
|
||||||
for(EntityDescriptorType.EDTChoiceType edtChoiceType : entityType.getChoiceType()) {
|
|
||||||
List<EntityDescriptorType.EDTDescriptorChoiceType> descriptors = edtChoiceType.getDescriptors();
|
|
||||||
|
|
||||||
if(!descriptors.isEmpty() && descriptors.get(0).getIdpDescriptor() != null) {
|
if (keyDescriptor != null) {
|
||||||
idpDescriptor = descriptors.get(0).getIdpDescriptor();
|
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) {
|
if (defaultCertificate != null) {
|
||||||
SAMLIdentityProviderConfig samlIdentityProviderConfig = new SAMLIdentityProviderConfig();
|
if (samlIdentityProviderConfig.getSigningCertificates().length == 0) {
|
||||||
String singleSignOnServiceUrl = null;
|
samlIdentityProviderConfig.addSigningCertificate(defaultCertificate);
|
||||||
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.getEncryptionPublicKey() == null) {
|
||||||
if (samlIdentityProviderConfig.getSigningCertificates().length == 0) {
|
samlIdentityProviderConfig.setEncryptionPublicKey(defaultCertificate);
|
||||||
samlIdentityProviderConfig.addSigningCertificate(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())));
|
|| entityType.getValidUntil().toGregorianCalendar().getTime().after(new Date(System.currentTimeMillis())));
|
||||||
|
|
||||||
// check for hide on login attribute
|
// check for hide on login attribute
|
||||||
if (entityType.getExtensions() != null && entityType.getExtensions().getEntityAttributes() != null) {
|
if (entityType.getExtensions() != null && entityType.getExtensions().getEntityAttributes() != null) {
|
||||||
for (AttributeType attribute : entityType.getExtensions().getEntityAttributes().getAttribute()) {
|
for (AttributeType attribute : entityType.getExtensions().getEntityAttributes().getAttribute()) {
|
||||||
if (MACEDIR_ENTITY_CATEGORY.equals(attribute.getName())
|
if (MACEDIR_ENTITY_CATEGORY.equals(attribute.getName())
|
||||||
&& attribute.getAttributeValue().contains(REFEDS_HIDE_FROM_DISCOVERY)) {
|
&& attribute.getAttributeValue().contains(REFEDS_HIDE_FROM_DISCOVERY)) {
|
||||||
samlIdentityProviderConfig.setHideOnLogin(true);
|
samlIdentityProviderConfig.setHideOnLogin(true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return samlIdentityProviderConfig.getConfig();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return samlIdentityProviderConfig.getConfig();
|
||||||
}
|
}
|
||||||
} catch (ParsingException pe) {
|
} catch (ParsingException pe) {
|
||||||
throw new RuntimeException("Could not parse IdP SAML Metadata", pe);
|
throw new RuntimeException("Could not parse IdP SAML Metadata", pe);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -48,6 +48,7 @@ import org.keycloak.representations.idm.IdentityProviderMapperTypeRepresentation
|
||||||
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
||||||
import org.keycloak.representations.idm.ManagementPermissionReference;
|
import org.keycloak.representations.idm.ManagementPermissionReference;
|
||||||
import org.keycloak.services.ErrorResponse;
|
import org.keycloak.services.ErrorResponse;
|
||||||
|
import org.keycloak.services.resources.IdentityBrokerService;
|
||||||
import org.keycloak.services.resources.KeycloakOpenAPI;
|
import org.keycloak.services.resources.KeycloakOpenAPI;
|
||||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
||||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
|
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.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.core.Response.Status;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
@ -495,4 +497,17 @@ public class IdentityProviderResource {
|
||||||
return new ManagementPermissionReference();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -159,6 +159,8 @@ public class IdentityProvidersResource {
|
||||||
IdentityProviderFactory providerFactory = getProviderFactoryById(providerId);
|
IdentityProviderFactory providerFactory = getProviderFactoryById(providerId);
|
||||||
Map<String, String> config;
|
Map<String, String> config;
|
||||||
config = providerFactory.parseConfig(session, inputStream);
|
config = providerFactory.parseConfig(session, inputStream);
|
||||||
|
// add the URL just if needed by the identity provider
|
||||||
|
config.put(IdentityProviderModel.METADATA_DESCRIPTOR_URL, from);
|
||||||
return config;
|
return config;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue