Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ URL that the Relying Party Client provides to the End-User to read about how the

URL that the Relying Party Client provides to the End-User to read about the Relying Party's terms of service.

*Per-client signing key selection*

By default, {project_name} signs tokens using the realm's active signing key. You can override this behavior for individual clients by selecting a specific signing key in the *Fine Grain OpenID Connect Configuration* section. This is useful for <<_rotating-keys-gradually,rotating keys gradually>>.

The following signing key fields are available:

* *Access token signing key* — Used for access tokens.
* *ID token signing key* — Used for ID tokens and logout tokens.
* *User info signing key* — Used for signed UserInfo responses.
* *Authorization response signing key* — Used for JARM (JWT Secured Authorization Response Mode) responses.

[NOTE]
====
If the selected key is passive, disabled, not found, or does not match the signature algorithm, {project_name} falls back to the realm's active signing key.
====

[WARNING]
====
Make sure the selected signing key matches the signature algorithm configured for that token type. If you set a signing key for one token type, consider whether other token types using the same algorithm should also be configured.
====

[[_jwe-id-token-encryption]]
*Signed and Encrypted ID Token Support*

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ This option is used when {project_name} server and adapter provide the IDP and S
We recommend the use of some more secure algorithm instead of `*_SHA1`. Also, with `*_SHA1` algorithms, verifying signatures
do not work if the SAML client runs on Java 17 or higher.

*SAML signing key*:: Select a specific realm key to sign the SAML messages that {project_name} signs for this client. This applies to every server-signed message for the client, including login responses and assertions, logout requests and responses (frontchannel and backchannel), and artifact responses. If not set, the realm's active signing key is used. This setting is useful for <<_rotating-keys-gradually,rotating keys gradually>>.
+
[NOTE]
====
If the selected key is passive, disabled, or not found, {project_name} falls back to the realm's active signing key.
====

*SAML Signature Key Name*:: Signed SAML documents sent using POST binding contain the identification of the signing key in the *KeyName* element. This action can be controlled by the *SAML Signature Key Name* option. This option controls the contents of the *Keyname*.
+
--
Expand Down
16 changes: 16 additions & 0 deletions docs/documentation/server_admin/topics/realms/keys.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ The frequency of deleting old keys is a tradeoff between security and making sur

Rotating keys also applies to offline tokens. To make sure they are updated, the applications need to refresh the tokens before the old keys are removed.

[[_rotating-keys-gradually]]
==== Rotating keys gradually with per-client signing key selection

{project_name} supports selecting a specific signing key for each client. This feature enables gradual key rotation, where you can control the timing of key changes per client instead of switching all clients at once.

.Procedure
. Create a new key provider with *Active* set to *On* and a *Priority* lower than the current active key. The new key is active but will not be used as the realm's active signing key because its priority is lower.
. Open the client you want to migrate first.
* For OIDC clients, click the *Advanced* tab and select the new key in the signing key fields under *Fine Grain OpenID Connect Configuration* (for example, *Access token signing key*, *ID token signing key*).
* For SAML clients, select the new key in the *SAML signing key* field on the *Settings* tab.
. Verify that the client works correctly with the new key.
. Repeat for additional clients until all clients are verified.
. Promote the new key to the realm's active signing key by increasing its *Priority* above the old key.
. Set the old key to passive, and after sufficient time has passed, disable or delete it.
. Optionally, remove the per-client signing key settings from each client so they use the realm's active signing key.

==== Adding a generated key pair

Use this procedure to generate a key pair including a self-signed certificate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4183,3 +4183,25 @@ scimApiEnabledHelp=If enabled, exposes realm resources through an API based on t
excludedDomains=Excluded domains
excludedDomainsHelp=A comma-separated list of domains to skip automatic redirection. You can use wildcard domains like `*.example.com` to exclude all subdomains of `example.com`. For instance, `*.example.com, example.com`.
clearInputField=Clear input field

# Per-client signing key selection (shared)
signingKeyUseRealmActive=Use default (realm's active signing key)
signingKeyActive=Active
signingKeyPassive=Passive
signingKeyDisabled=Disabled
signingKeyNotFound=Not found
signingKeyPriority=priority

# Per-client signing key selection (SAML)
signingKeySamlLabel=SAML signing key
signingKeySamlHelp=Select a specific key to sign the SAML messages that the server signs for this client, including login responses and assertions, logout requests and responses, and artifact responses. If not set, the realm's active signing key will be used. Only RS256 keys are listed.

# Per-client signing key selection (OIDC)
signingKeyAccessTokenLabel=Access token signing key
signingKeyAccessTokenHelp=Select a specific key to sign access tokens. If not set, the realm's active signing key will be used. Only asymmetric keys are listed (HMAC keys are excluded). Make sure the selected key matches the signature algorithm configured for that token type. If you set a signing key here, consider setting one for other token types that use the same algorithm.
signingKeyIdTokenLabel=ID token signing key
signingKeyIdTokenHelp=Select a specific key to sign ID tokens and logout tokens. If not set, the realm's active signing key will be used. Only asymmetric keys are listed (HMAC keys are excluded). Make sure the selected key matches the signature algorithm configured for that token type. If you set a signing key here, consider setting one for other token types that use the same algorithm.
signingKeyUserInfoLabel=User info signing key
signingKeyUserInfoHelp=Select a specific key to sign user info responses. If not set, the realm's active signing key will be used. Only asymmetric keys are listed (HMAC keys are excluded). Make sure the selected key matches the signature algorithm configured for that token type. If you set a signing key here, consider setting one for other token types that use the same algorithm.
signingKeyAuthorizationLabel=Authorization response signing key
signingKeyAuthorizationHelp=Select a specific key to sign authorization responses (JARM). If not set, the realm's active signing key will be used. Only asymmetric keys are listed (HMAC keys are excluded). Make sure the selected key matches the signature algorithm configured for that token type. If you set a signing key here, consider setting one for other token types that use the same algorithm.
4 changes: 4 additions & 0 deletions js/apps/admin-ui/src/clients/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,15 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
"policyUri",
"tosUri",
"access.token.signed.response.alg",
"access.token.signed.response.kid",
"access.token.header.type.rfc9068",
"id.token.signed.response.alg",
"id.token.signed.response.kid",
"id.token.encrypted.response.alg",
"id.token.encrypted.response.enc",
"id.token.as.detached.signature",
"user.info.response.signature.alg",
"user.info.response.signature.kid",
"user.info.encrypted.response.alg",
"user.info.encrypted.response.enc",
"request.object.signature.alg",
Expand All @@ -114,6 +117,7 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
"request.object.required",
"request.uris",
"authorization.signed.response.alg",
"authorization.signed.response.kid",
"authorization.encrypted.response.alg",
"authorization.encrypted.response.enc",
]);
Expand Down
9 changes: 9 additions & 0 deletions js/apps/admin-ui/src/clients/add/SamlSignature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { SelectControl, TextControl } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { SigningKeySelect } from "../../components/signing-key/SigningKeySelect";
import { convertAttributeNameToForm } from "../../util";
import { FormFields } from "../ClientDetails";
import { Toggle } from "./SamlConfig";
Expand Down Expand Up @@ -96,6 +97,14 @@ export const SamlSignature = () => {
}}
options={[...KEYNAME_TRANSFORMER]}
/>
<SigningKeySelect
name={convertAttributeNameToForm<FormFields>(
"attributes.saml.server.signature.kid",
)}
protocol="saml"
label={t("signingKeySamlLabel")}
labelIcon={t("signingKeySamlHelp")}
/>
<SelectControl
name="attributes.saml_signature_canonicalization_method"
label={t("canonicalization")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import { HelpItem, SelectControl } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput";
import { SigningKeySelect } from "../../components/signing-key/SigningKeySelect";
import { useRealmKeys } from "../../components/signing-key/useRealmKeys";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { convertAttributeNameToForm, sortProviders } from "../../util";
import { FormFields } from "../ClientDetails";
Expand All @@ -29,6 +31,10 @@ export const FineGrainOpenIdConnect = ({
const cekManagementProviders = providers?.cekmanagement.providers;
const signatureProviders = providers?.signature.providers;

// The four selectors below share the same realm keys; fetch once here and pass them down
// so opening this tab issues a single realm-keys request instead of one per selector.
const { realmKeys, canViewRealmKeys } = useRealmKeys();

const convert = (list: { [index: string]: ProviderRepresentation }) =>
sortProviders(list).map((i) => ({ key: i, value: i }));

Expand Down Expand Up @@ -60,6 +66,16 @@ export const FineGrainOpenIdConnect = ({
}}
options={prependEmpty(clientSignatureProviders!)}
/>
<SigningKeySelect
name={convertAttributeNameToForm<FormFields>(
"attributes.access.token.signed.response.kid",
)}
protocol="openid-connect"
realmKeys={realmKeys}
canViewRealmKeys={canViewRealmKeys}
label={t("signingKeyAccessTokenLabel")}
labelIcon={t("signingKeyAccessTokenHelp")}
/>
Comment thread
wadahiro marked this conversation as resolved.
<FormGroup
label={t("useRfc9068AccessTokenType")}
fieldId="useRfc9068AccessTokenType"
Expand Down Expand Up @@ -100,6 +116,16 @@ export const FineGrainOpenIdConnect = ({
}}
options={prependEmpty(clientSignatureProviders!)}
/>
<SigningKeySelect
name={convertAttributeNameToForm<FormFields>(
"attributes.id.token.signed.response.kid",
)}
protocol="openid-connect"
realmKeys={realmKeys}
canViewRealmKeys={canViewRealmKeys}
label={t("signingKeyIdTokenLabel")}
labelIcon={t("signingKeyIdTokenHelp")}
/>
<SelectControl
name={convertAttributeNameToForm<FormFields>(
"attributes.id.token.encrypted.response.alg",
Expand Down Expand Up @@ -162,6 +188,16 @@ export const FineGrainOpenIdConnect = ({
}}
options={prependEmpty(signatureProviders!)}
/>
<SigningKeySelect
name={convertAttributeNameToForm<FormFields>(
"attributes.user.info.response.signature.kid",
)}
protocol="openid-connect"
realmKeys={realmKeys}
canViewRealmKeys={canViewRealmKeys}
label={t("signingKeyUserInfoLabel")}
labelIcon={t("signingKeyUserInfoHelp")}
/>
<SelectControl
name={convertAttributeNameToForm<FormFields>(
"attributes.user.info.encrypted.response.alg",
Expand Down Expand Up @@ -266,6 +302,16 @@ export const FineGrainOpenIdConnect = ({
}}
options={prependEmpty(signatureProviders!)}
/>
<SigningKeySelect
name={convertAttributeNameToForm<FormFields>(
"attributes.authorization.signed.response.kid",
)}
protocol="openid-connect"
realmKeys={realmKeys}
canViewRealmKeys={canViewRealmKeys}
label={t("signingKeyAuthorizationLabel")}
labelIcon={t("signingKeyAuthorizationHelp")}
/>
<SelectControl
name={convertAttributeNameToForm<FormFields>(
"attributes.authorization.encrypted.response.alg",
Expand Down
145 changes: 145 additions & 0 deletions js/apps/admin-ui/src/components/signing-key/SigningKeySelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { SelectControl } from "@keycloak/keycloak-ui-shared";
import type { KeyMetadataRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/keyMetadataRepresentation";
import { useRealmKeys } from "./useRealmKeys";

type SigningKeySelectProps = {
name: string;
protocol: "openid-connect" | "saml";
label: string;
labelIcon: string;
// When several selectors share one parent (the OIDC advanced tab renders four), the
// parent fetches the realm keys once with useRealmKeys() and passes them in to avoid
// duplicate requests. Omit for standalone usage (e.g. the single SAML selector), in which
// case the component fetches them itself. `undefined` means "not provided"; an empty array
// means "fetched, but the realm has no keys".
realmKeys?: KeyMetadataRepresentation[];
canViewRealmKeys?: boolean;
};

// The kid is a long opaque string; a short prefix is appended to the label so the
// selected option (the collapsed toggle only renders the label, not the description)
// still shows which key is chosen. The full kid is repeated as the option description.
const KID_LABEL_LENGTH = 12;
const truncateKid = (kid: string) =>
kid.length > KID_LABEL_LENGTH ? `${kid.slice(0, KID_LABEL_LENGTH)}…` : kid;

// Order within a protocol's key list: group by algorithm, then active keys first
// (highest-priority active is the realm's default for that algorithm), then by
// priority descending so the effective signing key surfaces at the top of its group.
const STATUS_ORDER: Record<string, number> = {
ACTIVE: 0,
PASSIVE: 1,
DISABLED: 2,
};

function formatKeyOption(
kid: string,
algorithm: string | undefined,
status: string | undefined,
priority: number | undefined,
t: (key: string) => string,
): string {
const alg = algorithm ?? "unknown";
const shortKid = truncateKid(kid);
const prio = `${t("signingKeyPriority")} ${priority ?? 0}`;
switch (status) {
case "ACTIVE":
return `${alg} (${t("signingKeyActive")}, ${prio}) - ${shortKid}`;
case "PASSIVE":
return `${alg} (${t("signingKeyPassive")}, ${prio}) - ${shortKid}`;
case "DISABLED":
return `${alg} (${t("signingKeyDisabled")}, ${prio}) - ${shortKid}`;
default:
return `${t("signingKeyNotFound")} - ${shortKid}`;
}
}

export const SigningKeySelect = ({
name,
protocol,
label,
labelIcon,
realmKeys: providedRealmKeys,
canViewRealmKeys: providedCanViewRealmKeys,
}: SigningKeySelectProps) => {
const { t } = useTranslation();
const currentValue = useWatch({ name });

// Fetch internally only when a parent hasn't already provided the keys. When the admin
// can't list the realm keys, the selector degrades to read-only and preserves the
// currently configured value.
const fetched = useRealmKeys(providedRealmKeys === undefined);
const realmKeys = providedRealmKeys ?? fetched.realmKeys;
const canViewRealmKeys = providedCanViewRealmKeys ?? fetched.canViewRealmKeys;

const toOption = (
kid: string,
alg?: string,
status?: string,
priority?: number,
) => ({
key: kid,
value: formatKeyOption(kid, alg, status, priority, t),
// Full kid shown as the option description (second line); the label carries a short prefix.
description: kid,
});

const isSigningKey = (k: KeyMetadataRepresentation) =>
k.kid &&
k.algorithm &&
k.use === "SIG" &&
["ACTIVE", "PASSIVE", "DISABLED"].includes(k.status!);

// SAML: only RS256 keys are supported (server-side key lookup uses Algorithm.RS256)
// OIDC: exclude HMAC (OCT) keys as they are derived from client secrets, not realm keys
const isKeyTypeAllowed = (k: KeyMetadataRepresentation) =>
protocol === "saml" ? k.algorithm === "RS256" : k.type !== "OCT";

const filtered = realmKeys
.filter((k) => isSigningKey(k) && isKeyTypeAllowed(k))
.sort((a, b) => {
const byAlgorithm = (a.algorithm ?? "").localeCompare(b.algorithm ?? "");
if (byAlgorithm !== 0) return byAlgorithm;
const byStatus =
(STATUS_ORDER[a.status!] ?? 99) - (STATUS_ORDER[b.status!] ?? 99);
if (byStatus !== 0) return byStatus;
return (b.providerPriority ?? 0) - (a.providerPriority ?? 0);
})
.map((k) => toOption(k.kid!, k.algorithm, k.status, k.providerPriority));

// If the configured key is not in the eligible list, it is either deleted or exists but
// is ineligible for this protocol (wrong type/algorithm). Either way the server will not
// use it — it falls back to the realm's active key — so render it as "Not found" rather
// than surfacing its real (misleading) status metadata as if it were usable.
const notFound = (() => {
if (!currentValue || filtered.some((o) => o.key === currentValue))
return [];
return [toOption(currentValue)];
})();

// Without realm access the key list can't be fetched, so keep only the realm-active
// option plus the currently configured key (shown as its raw kid) to preserve the value.
const keyOptions = canViewRealmKeys
? [
{ key: "", value: t("signingKeyUseRealmActive") },
...filtered,
...notFound,
]
: [
{ key: "", value: t("signingKeyUseRealmActive") },
...(currentValue ? [{ key: currentValue, value: currentValue }] : []),
];

return (
<SelectControl
name={name}
label={label}
labelIcon={labelIcon}
controller={{ defaultValue: "" }}
options={keyOptions}
isDisabled={!canViewRealmKeys}
/>
);
};
Loading
Loading