8000 Allow configuring "Revoke Refresh Token" per client (#16422) by thomasdarimont · Pull Request #51798 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
12a1ccf
Allow configuring "Revoke Refresh Token" per client (#16422)
thomasdarimont Aug 18, 2026
533cf83
Add admin console test for per-client refresh token revocation override
thomasdarimont Aug 18, 2026
075f3d0
Fix "Revert" in client Advanced settings breaking subsequent save
thomasdarimont Aug 18, 2026
68a94b2
Guard acr.loa.map JSON parsing when resetting the advanced tab form
thomasdarimont Aug 24, 2026
0792e6c
Address review feedback for client-level refresh token revocation
thomasdarimont Aug 24, 2026
da5f681
Revise fix for acr.loa.map handling in AdvancedTab.tsx
thomasdarimont Aug 25, 2026
b82dd04
Address second round of review feedback for client-level refresh toke…
thomasdarimont Aug 25, 2026
6c73d69
Use double quotes in client validation message to satisfy theme verifier
thomasdarimont Aug 25, 2026
1b80aee
Address third round of review feedback for client-level refresh token…
thomasdarimont Aug 28, 2026
76e3cce
Align client-level refresh token revocation control with TokenLifespa…
thomasdarimont Aug 29, 2026
51140ab
Normalize malformed refresh token max reuse override on client form s…
thomasdarimont Aug 29, 2026
acb5a3c
Reject refresh token max reuse overrides above Integer.MAX_VALUE in c…
thomasdarimont Sep 14, 2026
3b7e399
Address review findings on per-client refresh token revocation
thomasdarimont Sep 16, 2026
89867c4
Honour client-level refresh token revocation in the OID4VCI refresh t…
thomasdarimont Sep 16, 2026
b8a7526
Move release note for Refresh token revocation configurable per clien…
thomasdarimont Nov 3, 2026
217eaf7
Honour client-level refresh token revocation in the revocation endpoi…
thomasdarimont Nov 3, 2026
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
4 changes: 4 additions & 0 deletions docs/documentation/release_notes/topics/27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ This protects brokered logins against assertion replay, in particular in IdP-ini
How long a consumed assertion is remembered is bounded by the new *OneTimeUse maximum age* setting of the SAML identity provider, which defaults to 1 hour.
See the upgrading guide for details.

== Refresh token revocation configurable per client

The realm settings *Revoke Refresh Token* and *Refresh Token Max Reuse* can now be overridden per client in the *Advanced settings* section of the client. This allows enabling refresh token rotation only for clients that need it, or exempting specific clients when rotation is enabled for the realm. When no client override is set, the realm setting is used.

== Credential ID in credential update events

The `UPDATE_CREDENTIAL` events emitted by built-in required actions now carry the `credential_id` detail when a credential was created or updated in the {project_name} credential store, as the `REMOVE_CREDENTIAL` event already did. The detail is omitted when an external user-storage provider handles the update because there is no {project_name} credential ID to report.
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ image:images/client-advanced-settings-oidc.png[Advanced Settings]
|Client Offline Session Max
|This setting allows you to configure a shorter offline session max lifespan for the client. The lifespan is the maximum time before {project_name} revokes the corresponding offline token. This option needs <<_offline-session-max-limited,Offline Session Max Limited>> enabled globally in the realm, and defaults to <<_offline-session-max,Offline Session Max>>.

|Revoke Refresh Token
|The value overrides the realm option <<_revoke-refresh-token,Revoke Refresh Token>> for this client. When set to *Enabled*, refresh tokens issued to this client are rotated on use and their reuse is limited by *Refresh Token Max Reuse*, regardless of the realm setting. When set to *Disabled*, refresh tokens issued to this client can be reused without restriction, even if revocation is enabled for the realm. When not set, the realm setting is used.

|Refresh Token Max Reuse
|The value overrides the realm option with the same name for this client. This option is only available when *Revoke Refresh Token* is set to *Enabled* for the client and is preset with the realm value. When *Revoke Refresh Token* is not set for the client, the realm value is used. When *Revoke Refresh Token* is *Disabled* for the client, refresh tokens can be reused without restriction and this option has no effect.

|===

[[_mapping-acr-to-loa-client]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ image:images/tokens-tab.png[Tokens Tab]

[[_revoke-refresh-token]]
|Revoke Refresh Token
|When *Enabled*, {project_name} revokes refresh tokens and issues another token that the client must use. This action applies to OIDC clients performing the refresh token flow.
|When *Enabled*, {project_name} revokes refresh tokens and issues another token that the client must use. This action applies to OIDC clients performing the refresh token flow. This setting and the related *Refresh Token Max Reuse* can be overridden per client in the <<_client_advanced_settings_oidc,Advanced Settings>> of the client.

|Access Token Lifespan
|When {project_name} creates an OIDC access token, this value controls the lifetime of the token.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ editAttribute=Edit attribute
webAuthnPolicyRpEntityNameHelp=Human-readable server name as WebAuthn Relying Party
postBrokerLoginFlowAlias=Post login flow
refreshTokenMaxReuse=Refresh Token Max Reuse
refreshTokenMaxReuseShort=Max reuse
partialExportHeaderText=Partial export allows you to export realm configuration and other associated resources into a JSON file.
clientScopes=Client scopes
missingClientScope=Missing client scope
Expand Down Expand Up @@ -2257,6 +2258,7 @@ deleteResourceConfirm=If you delete this resource, some permissions will be affe
httpPostBindingResponse=HTTP-POST binding response
artifactBindingResponse=ARTIFACT binding response
tokenLifespan.inherited=Inherits from realm settings
inheritsFromRealmSettings=Inherits from realm settings ({{value}})
saveEvents=Save events
issuer=Issuer
policyEnforcementModeHelp=The policy enforcement mode dictates how policies are enforced when evaluating authorization requests. 'Enforcing' means requests are denied by default even when there is no policy associated with a given resource. 'Permissive' means requests are allowed even when there is no policy associated with a given resource. 'Disabled' completely disables the evaluation of policies and allows access to any resource.
Expand Down Expand Up @@ -2598,6 +2600,7 @@ signature-algorithm=JWA algorithm, which the client needs to use when signing a
advanced=Advanced
initialCounter=Initial counter
revokeRefreshTokenHelp=If enabled, a refresh token can only be used up to 'Refresh Token Max Reuse' and it is revoked when a different token is used. Otherwise, refresh tokens are not revoked when used and can be used multiple times.
revokeRefreshTokenClientHelp=Overrides the realm setting 'Revoke Refresh Token' for this client. If enabled, a refresh token issued to this client can only be reused up to the given 'Refresh Token Max Reuse' and it is revoked when a different token is used. If disabled, refresh tokens issued to this client are not revoked when used and can be used multiple times. If not set, the realm settings are used.
nameField=Name
ownerManagedAccessHelp=If enabled, access to this resource can be managed by the resource owner.
useLowerCaseBearerTypeHelp=If this is on, token responses will be set the with the type "bearer" in lower-case. By default, the server sets the type as "Bearer" as defined by RFC6750. This switch is deprecated and might be removed in future version.
Expand Down
35 changes: 33 additions & 2 deletions js/apps/admin-ui/src/clients/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ScrollForm } from "@keycloak/keycloak-ui-shared";
import type { AddAlertFunction } from "@keycloak/keycloak-ui-shared";
import { convertAttributeNameToForm, toUpperCase } from "../util";
import {
convertAttributeNameToForm,
normalizeBooleanOverride,
normalizeNonNegativeIntegerOverride,
toUpperCase,
} from "../util";
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
import type { FormFields, SaveOptions } from "./ClientDetails";
import { AdvancedSettings } from "./advanced/AdvancedSettings";
Expand Down Expand Up @@ -47,6 +52,30 @@ export const parseResult = (
}
};

// The ACR to LoA mapping is stored as a JSON string but edited as key/value pairs (see setupForm in ClientDetails)
const toFormValue = (name: string, value?: string) => {
if (name === "acr.loa.map") {
if (!value) {
return [];
}
try {
return Object.entries(JSON.parse(value)).map(([key, value]) => ({
key,
value,
}));
} catch {
return [];
}
}
if (name === "revoke.refresh.token") {
return normalizeBooleanOverride(value);
}
if (name === "refresh.token.max.reuse") {
return normalizeNonNegativeIntegerOverride(value);
}
return value || "";
};

export type AdvancedProps = {
save: (options?: SaveOptions) => void;
client: ClientRepresentation;
Expand All @@ -69,7 +98,7 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
for (const name of names) {
setValue(
convertAttributeNameToForm<FormFields>(`attributes.${name}`),
attributes?.[name] || "",
toFormValue(name, attributes?.[name]),
);
}
};
Expand Down Expand Up @@ -191,6 +220,8 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
"client.session.max.lifespan",
"client.offline.session.idle.timeout",
"client.offline.session.max.lifespan",
"revoke.refresh.token",
"refresh.token.max.reuse",
"dpop.bound.access.tokens",
"tls.client.certificate.bound.access.tokens",
"require.pushed.authorization.requests",
Expand Down
20 changes: 20 additions & 0 deletions js/apps/admin-ui/src/clients/ClientDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
convertFormValuesToObject,
convertToFormValues,
exportClient,
normalizeBooleanOverride,
normalizeNonNegativeIntegerOverride,
} from "../util";
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
import { useParams } from "../utils/useParams";
Expand Down Expand Up @@ -361,6 +363,24 @@ export default function ClientDetails() {
),
);
}
if (client.attributes?.["revoke.refresh.token"]) {
form.setValue(
convertAttributeNameToForm<FormFields>(
"attributes.revoke.refresh.token",
),
normalizeBooleanOverride(client.attributes["revoke.refresh.token"]),
Comment thread
thomasdarimont marked this conversation as resolved.
Resolved
);
}
if (client.attributes?.["refresh.token.max.reuse"]) {
form.setValue(
convertAttributeNameToForm<FormFields>(
"attributes.refresh.token.max.reuse",
),
normalizeNonNegativeIntegerOverride(
client.attributes["refresh.token.max.reuse"],
),
);
}
// reset dirty as for reason it is not resetting
form.reset(form.getValues(), { keepDirty: false });
};
Expand Down
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TimeSelector } from "../../components/time-selector/TimeSelector";
import { useRealm } from "../../context/realm-context/RealmContext";
import { convertAttributeNameToForm } from "../../util";
import { FormFields } from "../ClientDetails";
import { RefreshTokenRevocation } from "./RefreshTokenRevocation";
import { TokenLifespan } from "./TokenLifespan";
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";

Expand Down Expand Up @@ -189,6 +190,7 @@ export const AdvancedSettings = ({
units={["minute", "day", "hour"]}
/>
)}
<RefreshTokenRevocation />
<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(
"attributes.tls.client.certificate.bound.access.tokens",
Expand Down
169 changes: 169 additions & 0 deletions js/apps/admin-ui/src/clients/advanced/RefreshTokenRevocation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { HelpItem } from "@keycloak/keycloak-ui-shared";
import {
FormGroup,
MenuToggle,
NumberInput,
Select,
SelectList,
SelectOption,
Split,
SplitItem,
} from "@patternfly/react-core";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useRealm } from "../../context/realm-context/RealmContext";
import { convertAttributeNameToForm } from "../../util";
import { FormFields } from "../ClientDetails";

const REVOKE_REFRESH_TOKEN = convertAttributeNameToForm<FormFields>(
"attributes.revoke.refresh.token",
);
const REFRESH_TOKEN_MAX_REUSE = convertAttributeNameToForm<FormFields>(
"attributes.refresh.token.max.reuse",
);

const ENABLED = "true";
const DISABLED = "false";
const INHERITED = "";

const toNumber = (value: unknown, fallback: number) => {
const number = Number(value);
return value === "" || value == null || isNaN(number) ? fallback : number;
};

/**
* Client-level override of the realm settings "Revoke Refresh Token" and "Refresh Token Max Reuse",
* laid out like {@link TokenLifespan}: a selector to inherit, enable or disable revocation and an inline
* input for the max reuse that is only shown when revocation is enabled for the client. When revocation
* is not explicitly enabled, both attributes are cleared so the realm settings apply.
*/
export const RefreshTokenRevocation = () => {
const { t } = useTranslation();
const { realmRepresentation: realm } = useRealm();
const { control, setValue, getValues } = useFormContext();
const [open, setOpen] = useState(false);

const realmRevokeRefreshToken = realm.revokeRefreshToken ?? false;
const realmMaxReuse = realm.refreshTokenMaxReuse ?? 0;

const options = [
{
value: INHERITED,
label: t("inheritsFromRealmSettings", {
value: t(realmRevokeRefreshToken ? "enabled" : "disabled"),
}),
},
{ value: ENABLED, label: t("enabled") },
{ value: DISABLED, label: t("disabled") },
];
// Only explicit "true"/"false" are overrides, anything else inherits the realm setting (same as the server side)
const labelFor = (value?: string) =>
(options.find((option) => option.value === value) ?? options[0]).label;

return (
<FormGroup
label={t("revokeRefreshToken")}
fieldId="revokeRefreshToken"
labelIcon={
<HelpItem
helpText={t("revokeRefreshTokenClientHelp")}
fieldLabelId="revokeRefreshToken"
/>
}
data-testid="revoke-refresh-token"
>
<Controller
name={REVOKE_REFRESH_TOKEN}
defaultValue={INHERITED}
control={control}
render={({ field }) => {
const isEnabled = field.value === ENABLED;
return (
<Split hasGutter>
<SplitItem>
<Select
toggle={(ref) => (
<MenuToggle
ref={ref}
id="revokeRefreshToken"
=> setOpen(!open)}
isExpanded={open}
>
{labelFor(field.value)}
</MenuToggle>
)}
isOpen={open}
=> setOpen(isOpen)}
value) => {
field.onChange(value);
// The max reuse is an override only while revocation is enabled for the client;
// seed it with the realm value when enabling, like TokenLifespan does with the lifespan
setValue(
REFRESH_TOKEN_MAX_REUSE,
value === ENABLED
? toNumber(
getValues(REFRESH_TOKEN_MAX_REUSE),
realmMaxReuse,
)
: INHERITED,
Comment thread
Copilot marked this conversation as resolved.
Resolved
{ shouldDirty: true },
);
setOpen(false);
}}
selected={field.value}
>
<SelectList>
{options.map(({ value, label }) => (
<SelectOption key={value} value={value}>
{label}
</SelectOption>
))}
</SelectList>
</Select>
</SplitItem>
<SplitItem hidden={!isEnabled}>
<Controller
name={REFRESH_TOKEN_MAX_REUSE}
defaultValue={INHERITED}
control={control}
render={({ field: maxReuse }) => {
const value = toNumber(maxReuse.value, realmMaxReuse);
const update = (newValue: number) =>
maxReuse.onChange(Math.max(0, newValue));
return (
<NumberInput
id="refreshTokenMaxReuse"
inputAriaLabel={t("refreshTokenMaxReuse")}
inputProps={{ "data-testid": "refreshTokenMaxReuse" }}
unit={
<span style={{ whiteSpace: "nowrap" }}>
{t("refreshTokenMaxReuseShort")}
</span>
}
unitPosition="before"
min={0}
value={value}
isDisabled={!isEnabled}
=> update(value + 1)}
=> update(value - 1)}
=>
update(
toNumber(
(event.target as HTMLInputElement).value,
0,
),
)
}
/>
);
}}
/>
</SplitItem>
</Split>
);
}}
/>
</FormGroup>
);
};
36 changes: 36 additions & 0 deletions js/apps/admin-ui/src/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
convertAttributeNameToForm,
convertFormValuesToObject,
convertToFormValues,
normalizeNonNegativeIntegerOverride,
resolveDisplayName,
} from "./util";

Expand Down Expand Up @@ -175,3 +176,38 @@ describe("resolveDisplayName", () => {
expect(resolveDisplayName(mockT, "", "my-realm")).toBe("my-realm");
});
});

describe("normalizeNonNegativeIntegerOverride", () => {
it("keeps non-negative integers within Java's Integer range", () => {
expect(normalizeNonNegativeIntegerOverride("0")).toBe("0");
expect(normalizeNonNegativeIntegerOverride(" 42 ")).toBe("42");
expect(normalizeNonNegativeIntegerOverride(7)).toBe("7");
expect(normalizeNonNegativeIntegerOverride("2147483647")).toBe(
"2147483647",
);
});

it("canonicalizes spellings accepted by Java's Integer.parseInt", () => {
expect(normalizeNonNegativeIntegerOverride("+1")).toBe("1");
expect(normalizeNonNegativeIntegerOverride("-0")).toBe("0");
expect(normalizeNonNegativeIntegerOverride("007")).toBe("7");
expect(normalizeNonNegativeIntegerOverride("+2147483647")).toBe(
"2147483647",
);
});

it("treats values above Java's Integer.MAX_VALUE as unset", () => {
expect(normalizeNonNegativeIntegerOverride("2147483648")).toBe("");
expect(normalizeNonNegativeIntegerOverride("99999999999999999999")).toBe(
"",
);
});

it("treats non-integer values as unset", () => {
expect(normalizeNonNegativeIntegerOverride(undefined)).toBe("");
expect(normalizeNonNegativeIntegerOverride("")).toBe("");
expect(normalizeNonNegativeIntegerOverride("-1")).toBe("");
expect(normalizeNonNegativeIntegerOverride("1.5")).toBe("");
expect(normalizeNonNegativeIntegerOverride("abc")).toBe("");
});
});
Loading
Loading
0