Allow setting if both 'client_id' and 'id_token_hint' params should be sent in logout requests

Closes #27281

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-02-26 12:29:38 -03:00
parent c18c4bbeb8
commit 0c91fceaad
7 changed files with 152 additions and 4 deletions

View file

@ -3065,3 +3065,7 @@ error-invalid-multivalued-size=Attribute {{0}} must have at least {{1}} and at m
multivalued=Multivalued
multivaluedHelp=If this attribute supports multiple values. This setting is an indicator and does not enable any validation
to the attribute. For that, make sure to use any of the built-in validators to properly validate the size and the values.
sendIdTokenOnLogout=Send 'id_token_hint' in logout requests
sendIdTokenOnLogoutHelp=If the 'id_token_hint' parameter should be sent in logout requests.
sendClientIdOnLogout=Send 'client_id' in logout requests
sendClientIdOnLogoutHelp=If the 'client_id' parameter should be sent in logout requests.

View file

@ -163,6 +163,19 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => {
data-testid="backchannelLogout"
isReadOnly={readOnly}
/>
<SwitchField
field="config.sendIdTokenOnLogout"
label="sendIdTokenOnLogout"
data-testid="sendIdTokenOnLogout"
defaultValue={"true"}
isReadOnly={readOnly}
/>
<SwitchField
field="config.sendClientIdOnLogout"
label="sendClientIdOnLogout"
data-testid="sendClientIdOnLogout"
isReadOnly={readOnly}
/>
<FormGroup
label={t("nameIdPolicyFormat")}
labelIcon={

View file

@ -45,6 +45,15 @@ export const ExtendedNonDiscoverySettings = () => {
field="config.backchannelSupported"
label="backchannelLogout"
/>
<SwitchField
field="config.sendIdTokenOnLogout"
label="sendIdTokenOnLogout"
defaultValue={"true"}
/>
<SwitchField
field="config.sendClientIdOnLogout"
label="sendClientIdOnLogout"
/>
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
<SwitchField field="config.disableNonce" label="disableNonce" />
<TextField field="config.defaultScope" label="scopes" />

View file

@ -8,6 +8,7 @@ type FieldType = "boolean" | "string";
type SwitchFieldProps = FieldProps & {
fieldType?: FieldType;
defaultValue?: string | boolean;
};
export const SwitchField = ({
@ -15,6 +16,7 @@ export const SwitchField = ({
field,
fieldType = "string",
isReadOnly = false,
defaultValue,
}: SwitchFieldProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
@ -22,7 +24,9 @@ export const SwitchField = ({
<FormGroupField label={label}>
<Controller
name={field}
defaultValue={fieldType === "string" ? "false" : false}
defaultValue={
defaultValue ? defaultValue : fieldType === "string" ? "false" : false
}
control={control}
render={({ field }) => (
<Switch

View file

@ -142,7 +142,12 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
String sessionId = userSession.getId();
UriBuilder logoutUri = UriBuilder.fromUri(getConfig().getLogoutUrl())
.queryParam("state", sessionId);
logoutUri.queryParam("id_token_hint", idToken);
if (getConfig().isSendIdTokenOnLogout() && idToken != null) {
logoutUri.queryParam("id_token_hint", idToken);
}
if (getConfig().isSendClientIdOnLogout()) {
logoutUri.queryParam("client_id", getConfig().getClientId());
}
String url = logoutUri.build().toString();
try {
int status = SimpleHttp.doGet(url, session).asStatus();
@ -160,14 +165,19 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public Response keycloakInitiatedBrowserLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) {
if (getConfig().getLogoutUrl() == null || getConfig().getLogoutUrl().trim().equals("")) return null;
String idToken = userSession.getNote(FEDERATED_ID_TOKEN);
if (idToken != null && getConfig().isBackchannelSupported()) {
if (getConfig().isBackchannelSupported()) {
backchannelLogout(userSession, idToken);
return null;
} else {
String sessionId = userSession.getId();
UriBuilder logoutUri = UriBuilder.fromUri(getConfig().getLogoutUrl())
.queryParam("state", sessionId);
if (idToken != null) logoutUri.queryParam("id_token_hint", idToken);
if (getConfig().isSendIdTokenOnLogout() && idToken != null) {
logoutUri.queryParam("id_token_hint", idToken);
}
if (getConfig().isSendClientIdOnLogout()) {
logoutUri.queryParam("client_id", getConfig().getClientId());
}
String redirect = RealmsResource.brokerUrl(uriInfo)
.path(IdentityBrokerService.class, "getEndpoint")
.path(OIDCEndpoint.class, "logoutResponse")

View file

@ -61,6 +61,22 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
getConfig().put("logoutUrl", url);
}
public boolean isSendClientIdOnLogout() {
return Boolean.parseBoolean(getConfig().getOrDefault("sendClientIdOnLogout", Boolean.FALSE.toString()));
}
public void setSendClientOnLogout(boolean value) {
getConfig().put("sendClientIdOnLogout", Boolean.valueOf(value).toString());
}
public boolean isSendIdTokenOnLogout() {
return Boolean.parseBoolean(getConfig().getOrDefault("sendIdTokenOnLogout", Boolean.TRUE.toString()));
}
public void setSendIdTokenOnLogout(boolean value) {
getConfig().put("sendIdTokenOnLogout", Boolean.valueOf(value).toString());
}
public String getPublicKeySignatureVerifier() {
return getConfig().get("publicKeySignatureVerifier");
}

View file

@ -4,9 +4,12 @@ import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.VerificationException;
import org.keycloak.cookie.CookieType;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.OAuthClient;
@ -17,6 +20,10 @@ import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_PROV_NAME;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class KcOidcBrokerLogoutTest extends AbstractKcOidcBrokerLogoutTest {
@Rule
@ -139,4 +146,89 @@ public class KcOidcBrokerLogoutTest extends AbstractKcOidcBrokerLogoutTest {
waitForPage(driver, "sign in to provider", true);
}
@Test
public void testFrontChannelLogoutRequestsSendingOnlyClientId() {
RealmResource realm = adminClient.realm(bc.consumerRealmName());
IdentityProviderResource identityProviderResource = realm.identityProviders().get(bc.getIDPAlias());
IdentityProviderRepresentation representation = identityProviderResource.toRepresentation();
Map<String, String> config = representation.getConfig();
Map<String, String> originalConfig = new HashMap<>(config);
try {
config.put("backchannelSupported", Boolean.FALSE.toString());
config.put("sendIdTokenOnLogout", Boolean.FALSE.toString());
config.put("sendClientIdOnLogout", Boolean.TRUE.toString());
identityProviderResource.update(representation);
logInAsUserInIDPForFirstTime();
appPage.assertCurrent();
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
executeLogoutFromRealm(
getConsumerRoot(),
bc.consumerRealmName(),
"something-else",
null,
"account",
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/account"
);
logoutConfirmPage.isCurrent();
// confirm logout at consumer
logoutConfirmPage.confirmLogout();
// confirm logout at provider
logoutConfirmPage.confirmLogout();
oauth.clientId("account");
oauth.redirectUri(getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/account");
loginPage.open(REALM_PROV_NAME);
waitForPage(driver, "sign in to provider", true);
} finally {
representation.setConfig(originalConfig);
identityProviderResource.update(representation);
}
}
@Test
public void testFrontChannelLogoutRequestsSendingOnlyIdTokenHint() throws VerificationException {
RealmResource realm = adminClient.realm(bc.consumerRealmName());
IdentityProviderResource identityProviderResource = realm.identityProviders().get(bc.getIDPAlias());
IdentityProviderRepresentation representation = identityProviderResource.toRepresentation();
Map<String, String> config = representation.getConfig();
Map<String, String> originalConfig = new HashMap<>(config);
try {
config.put("backchannelSupported", Boolean.FALSE.toString());
config.put("sendIdTokenOnLogout", Boolean.TRUE.toString());
config.put("sendClientIdOnLogout", Boolean.FALSE.toString());
driver.navigate().to(getLoginUrl(getConsumerRoot(), bc.consumerRealmName(), "broker-app"));
logInWithBroker(bc);
updateAccountInformation();
// Exchange code from "broker-app" client of "consumer" realm for the tokens
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.realm(bc.consumerRealmName())
.clientId("broker-app")
.redirectUri(getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/app")
.doAccessTokenRequest(code, "broker-app-secret");
assertEquals(200, response.getStatusCode());
String idTokenString = response.getIdToken();
logoutFromRealm(
getConsumerRoot(),
bc.consumerRealmName(),
"something-else",
idTokenString,
null,
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/app"
);
// user should be logged out successfully from the IDP
oauth.clientId(bc.getIDPClientIdInProviderRealm());
oauth.redirectUri(BrokerTestTools.getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + bc.getIDPAlias() + "/endpoint/*");
loginPage.open(REALM_PROV_NAME);
waitForPage(driver, "sign in to provider", true);
} finally {
representation.setConfig(originalConfig);
identityProviderResource.update(representation);
}
}
}