Skip to content

Commit

Permalink
Allow automatic download of SAML certificates in the identity provider
Browse files Browse the repository at this point in the history
Closes keycloak#24424

Signed-off-by: rmartinc <rmartinc@redhat.com>
  • Loading branch information
rmartinc authored and mposolda committed Nov 29, 2023
1 parent 3bc028f commit 16afecd
Show file tree
Hide file tree
Showing 28 changed files with 1,402 additions and 131 deletions.
10 changes: 10 additions & 0 deletions core/src/main/java/org/keycloak/crypto/PublicKeysWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand Down Expand Up @@ -72,4 +73,13 @@ public KeyWrapper getKeyByKidAndAlg(String kid, String alg) {

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);
}
}
6 changes: 6 additions & 0 deletions docs/documentation/release_notes/topics/24_0_0.adoc
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
= Keycloak JS using `exports` field

The Keycloak JS adapter now uses the https://webpack.js.org/guides/package-exports/[`exports` field] in `package.json`. This improves support for more modern bundlers like Webpack 5 and Vite, but comes with some unavoidable breaking changes. Consult the link:{upgradingguide_link}[{upgradingguide_name}] for more details.

== Automatic certificate management for SAML identity providers

The SAML identity providers can now be configured to automatically download the signing certificates from the IDP entity metadata descriptor endpoint. In order to use the new feature the option `Metadata descriptor URL` should be configured in the provider (URL where the IDP metadata information with the certificates is published) and `Use metadata descriptor URL` needs to be `ON`. The certificates are automatically downloaded and cached in the `public-key-storage` SPI from that URL. The certificates can also be reloaded or imported from the admin console, using the action combo in the provider page.

See the https://www.keycloak.org/docs/latest/server_admin/index.html#saml-v2-0-identity-providers[documentation] for more details about the new options.
12 changes: 10 additions & 2 deletions docs/documentation/server_admin/topics/identity-broker/saml.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,16 @@ itself.
|Validate Signature
|When *ON*, the realm expects SAML requests and responses from the external IDP to be digitally signed.

|Validating X509 Certificate
|The public certificate {project_name} uses to validate the signatures of SAML requests and responses from the external IDP.
|Metadata descriptor URL
|External URL where Identity Provider publishes the `IDPSSODescriptor` metadata. This URL is used to download the Identity Provider certificates when the `Reload keys` or `Import keys` actions are clicked.

|Use metadata descriptor URL
|When *ON*, the certificates to validate signatures are automatically downloaded from the `Metadata descriptor URL` and cached in {project_name}. The SAML provider can validate signatures in two different ways. If a specific certificate is requested (usually in `POST` binding) and it is not in the cache, certificates are automatically refreshed from the URL. If all certificates are requested to validate the signature (`REDIRECT` binding) the refresh is only done after a max cache time (see https://www.keycloak.org/server/all-provider-config[public-key-storage] spi in the all provider config guide for more information about how the cache works). The cache can also be manually updated using the action `Reload Keys` in the identity provider page.

When the option is *OFF*, the certificates in `Validating X509 Certificates` are used to validate signatures.

|Validating X509 Certificates
|The public certificates {project_name} uses to validate the signatures of SAML requests and responses from the external IDP when `Use metadata descriptor URL` is *OFF*. Multiple certificates can be entered separated by comma (`,`). The certificates can be re-imported from the `Metadata descriptor URL` clicking the `Import Keys` action in the identity provider page. The action downloads the current certificates in the metadata endpoint and assigns them to the config in this same option. You need to click `Save` to definitely store the re-imported certificates.

|Sign Service Provider Metadata
|When *ON*, {project_name} uses the realm's key pair to sign the <<_identity_broker_saml_sp_descriptor, SAML Service Provider Metadata descriptor>>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ public interface IdentityProviderResource {
@DELETE
@Path("mappers/{id}")
void delete(@PathParam("id") String id);
}

@GET
@Path("reload-keys")
boolean reloadKeys();
}
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ endpoints=Endpoints
roleSaveError=Could not save role\: {{error}}
keySize=Key size
membershipUserLdapAttributeHelp=Used just if Membership Attribute Type is UID. It is the name of the LDAP attribute on user, which is used for membership mappings. Usually it will be 'uid'. For example if the value of 'Membership User LDAP Attribute' is 'uid' and LDAP group has 'memberUid\: john', then it is expected that particular LDAP user will have attribute 'uid\: john'.
validatingX509CertsHelp=The certificate in PEM format that must be used to check for signatures. Multiple certificates can be entered, separated by comma (,).
validatingX509CertsHelp=The certificate in PEM format that must be used to check for signatures. Multiple certificates can be entered, separated by comma (,). The action "Import keys" can be used to re-import certificates from the "Metadata descriptor URL" (if present) into this option. The configuration should be saved after the import to definitely store the new certificates.
samlCapabilityConfig=SAML capabilities
accessTokenSignatureAlgorithmHelp=JWA algorithm used for signing access tokens.
derFormatted=DER formatted
Expand Down Expand Up @@ -1008,6 +1008,18 @@ linkAccountTitle=Link account to {{provider}}
invalidateRotatedSuccess=Rotated secret successfully removed
userSessionAttributeHelp=Name of user session attribute you want to hardcode
updateSuccessIdentityProvider=Provider successfully updated
reloadKeys=Reload keys
importKeys=Import keys
useMetadataDescriptorUrl=Use metadata descriptor URL
useMetadataDescriptorUrlHelp=If the switch is on, the certificates to validate signatures will be downloaded and cached from the given "Metadata descriptor URL". The "Reload keys" action can be used to refresh the certificates in the cache. If the switch is off, certificates from "Validating X509 certificates" option are used, they need to be manually updated when changed in the IDP.
metadataDescriptorUrl=Metadata descriptor URL
metadataDescriptorUrlHelp=External URL where Identity Provider publishes the metadata information needed by the client (certificates, keys, other URLs,...).
reloadKeysSuccess=Keys successfully reloaded
reloadKeysError=Error reloading keys. {{error}}
reloadKeysSuccessButFalse=The reload was not executed, maybe the time between request was too short.
importKeysSuccess=Keys successfully re-imported. Please save the provider to store the new certificates.
importKeysError=Error importing keys. {{error}}
importKeysErrorNoSigningCertificate=The option "signingCertificate" is not defined in the metadata.
host=Host
forbidden_one=Forbidden, permission needed\:
backchannelLogoutRevokeOfflineSessions=Backchannel logout revoke offline sessions
Expand Down
61 changes: 54 additions & 7 deletions js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => {
name: "config.validateSignature",
});

const useMetadataDescriptorUrl = useWatch({
control,
name: "config.useMetadataDescriptorUrl",
});

const principalType = useWatch({
control,
name: "config.principalType",
Expand Down Expand Up @@ -482,14 +487,56 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => {
isReadOnly={readOnly}
/>
{validateSignature === "true" && (
<FormGroupField label="validatingX509Certs">
<KeycloakTextArea
id="validatingX509Certs"
data-testid="validatingX509Certs"
<>
<FormGroup
label={t("metadataDescriptorUrl")}
labelIcon={
<HelpItem
helpText={t("metadataDescriptorUrlHelp")}
fieldLabelId="metadataDescriptorUrl"
/>
}
isRequired={useMetadataDescriptorUrl === "true"}
validated={
errors.config?.metadataDescriptorUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
fieldId="metadataDescriptorUrl"
helperTextInvalid={t("required")}
>
<KeycloakTextInput
type="url"
id="metadataDescriptorUrl"
data-testid="metadataDescriptorUrl"
isReadOnly={readOnly}
validated={
errors.config?.metadataDescriptorUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
{...register("config.metadataDescriptorUrl", {
required: useMetadataDescriptorUrl === "true",
})}
/>
</FormGroup>
<SwitchField
field="config.useMetadataDescriptorUrl"
label="useMetadataDescriptorUrl"
data-testid="useMetadataDescriptorUrl"
isReadOnly={readOnly}
{...register("config.signingCertificate")}
></KeycloakTextArea>
</FormGroupField>
/>
{useMetadataDescriptorUrl !== "true" && (
<FormGroupField label="validatingX509Certs">
<KeycloakTextArea
id="validatingX509Certs"
data-testid="validatingX509Certs"
isReadOnly={readOnly}
{...register("config.signingCertificate")}
></KeycloakTextArea>
</FormGroupField>
)}
</>
)}
<SwitchField
field="config.signSpMetadata"
Expand Down
96 changes: 95 additions & 1 deletion js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import {
ButtonVariant,
Divider,
DropdownItem,
DropdownSeparator,
Form,
PageSection,
Tab,
TabTitleText,
ToolbarItem,
} from "@patternfly/react-core";
import { useMemo, useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import {
Controller,
FormProvider,
useForm,
useFormContext,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ScrollForm } from "ui-shared";
Expand Down Expand Up @@ -79,6 +86,23 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
const { t } = useTranslation();
const { alias: displayName } = useParams<{ alias: string }>();
const [provider, setProvider] = useState<IdentityProviderRepresentation>();
const { addAlert, addError } = useAlerts();
const { setValue, formState, control } = useFormContext();

const validateSignature = useWatch({
control,
name: "config.validateSignature",
});

const useMetadataDescriptorUrl = useWatch({
control,
name: "config.useMetadataDescriptorUrl",
});

const metadataDescriptorUrl = useWatch({
control,
name: "config.metadataDescriptorUrl",
});

useFetch(
() => adminClient.identityProviders.findOne({ alias: displayName }),
Expand All @@ -101,6 +125,41 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
},
});

const importSamlKeys = async (
providerId: string,
metadataDescriptorUrl: string,
) => {
try {
const result = await adminClient.identityProviders.importFromUrl({
providerId: providerId,
fromUrl: metadataDescriptorUrl,
});
if (result.signingCertificate) {
setValue(`config.signingCertificate`, result.signingCertificate);
addAlert(t("importKeysSuccess"), AlertVariant.success);
} else {
addError("importKeysError", t("importKeysErrorNoSigningCertificate"));
}
} catch (error) {
addError("importKeysError", error);
}
};

const reloadSamlKeys = async (alias: string) => {
try {
const result = await adminClient.identityProviders.reloadKeys({
alias: alias,
});
if (result) {
addAlert(t("reloadKeysSuccess"), AlertVariant.success);
} else {
addAlert(t("reloadKeysSuccessButFalse"), AlertVariant.warning);
}
} catch (error) {
addError("reloadKeysError", error);
}
};

return (
<>
<DisableConfirm />
Expand All @@ -114,6 +173,40 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
)}
divider={false}
dropdownItems={[
...(provider?.providerId?.includes("saml") &&
validateSignature === "true" &&
useMetadataDescriptorUrl === "true" &&
metadataDescriptorUrl &&
!formState.isDirty &&
value
? [
<DropdownItem
key="reloadKeys"
onClick={() => reloadSamlKeys(provider.alias!)}
>
{t("reloadKeys")}
</DropdownItem>,
]
: provider?.providerId?.includes("saml") &&
validateSignature === "true" &&
useMetadataDescriptorUrl !== "true" &&
metadataDescriptorUrl &&
!formState.isDirty
? [
<DropdownItem
key="importKeys"
onClick={() =>
importSamlKeys(
provider.providerId!,
metadataDescriptorUrl,
)
}
>
{t("importKeys")}
</DropdownItem>,
]
: []),
<DropdownSeparator key="separator" />,
<DropdownItem key="delete" onClick={() => toggleDeleteDialog()}>
{t("delete")}
</DropdownItem>,
Expand Down Expand Up @@ -249,6 +342,7 @@ export default function DetailSettings() {
providerId,
},
);
reset(p);
addAlert(t("updateSuccessIdentityProvider"), AlertVariant.success);
} catch (error) {
addError("updateErrorIdentityProvider", error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ export class IdentityProviders extends Resource<{ realm?: string }> {
urlParamKeys: ["alias"],
});

public reloadKeys = this.makeRequest<{ alias: string }, boolean>({
method: "GET",
path: "/instances/{alias}/reload-keys",
urlParamKeys: ["alias"],
});

constructor(client: KeycloakAdminClient) {
super(client, {
path: "/admin/realms/{realm}/identity-provider",
Expand Down
Loading

0 comments on commit 16afecd

Please sign in to comment.