Fix OIDC IDP broker basic auth encoding

Ensures that the client_id and client_secret are URL-encoded before being Base64-encoded for the Basic Auth header, following RFC 6749. This fixes authentication failures when the client_id contains special characters.

Closes #26374
Closes #43022

Signed-off-by: rpjicond <ronaldopaulino32@hotmail.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: rpjicond <ronaldopaulino32@hotmail.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: cgeorgilakis-grnet <cgeorgilakis@admin.grnet.gr>
This commit is contained in:
Ronaldo Paulino Jiconda 2025-10-20 23:48:24 +02:00 committed by GitHub
parent 37c4588c7d
commit 987ce19b45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 106 additions and 2 deletions

View File

@ -18,6 +18,15 @@ When the "Remember Me" option is disabled in the realm settings, all user sessio
Users will be required to log in again, and any associated refresh tokens will no longer be usable.
User sessions created without selecting "Remember Me" are not affected.
=== Correct encoding for OpenID Connect client credentials when acting as a broker
In a scenario where {project_name} acts as a broker and connects via OpenID Connect to another identity provider, it now sends the client credentials via basic authentication in the correct encoding as specified in RFC6749.
This prevents problems with client IDs or passwords that contain, for example, a colon or a percentage sign.
To revert to the old behavior, change the client authentication to *Client secret sent as HTTP Basic authentication without URL encoding (deprecated)* (`client_secret_basic_unencoded`).
// ------------------------ Deprecated features ------------------------ //
== Deprecated features

View File

@ -1441,7 +1441,7 @@ removeMappingConfirm_one=Are you sure you want to remove this role?
oidcSettings=OpenID Connect settings
oAuthSettings=OAuth2 settings
otpPolicyDigitsHelp=How many digits should the OTP have?
clientAuthentications.client_secret_post=Client secret sent as post
clientAuthentications.client_secret_post=Client secret sent in the request body
prompts.select_account=Select account
defaultACRValues=Default ACR Values
minimumACRValue=Minimum ACR Value
@ -2774,7 +2774,8 @@ authenticationFlow=Authentication flow
leaveGroup_other=Leave groups?
deleteClientPolicySuccess=Client policy deleted
mapperTypeCertificateLdapMapper=certificate-ldap-mapper
clientAuthentications.client_secret_basic=Client secret sent as basic auth
clientAuthentications.client_secret_basic=Client secret sent as HTTP Basic authentication
clientAuthentications.client_secret_basic_unencoded=Client secret sent as HTTP Basic authentication without URL encoding (deprecated)
started=Started
filteredByClaimHelp=If true, ID tokens issued by the identity provider must have a specific claim. Otherwise, the user can not authenticate through this broker.
mapperTypeCertificateLdapMapperHelp=Used to map single attribute which contains a certificate from LDAP user to attribute of UserModel in Keycloak database

View File

@ -10,6 +10,7 @@ import { TextField } from "../component/TextField";
const clientAuthentications = [
"client_secret_post",
"client_secret_basic",
"client_secret_basic_unencoded",
"client_secret_jwt",
"private_key_jwt",
];

View File

@ -622,6 +622,11 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
} else {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) {
if (getConfig().isBasicAuthentication()) {
String clientSecret = vaultStringSecret.get().orElse(getConfig().getClientSecret());
String header = org.keycloak.util.BasicAuthHelper.RFC6749.createHeader(getConfig().getClientId(), clientSecret);
return tokenRequest.header(HttpHeaders.AUTHORIZATION, header);
}
if (getConfig().isBasicAuthenticationUnencoded()) {
return tokenRequest.authBasic(getConfig().getClientId(), vaultStringSecret.get().orElse(getConfig().getClientSecret()));
}
return tokenRequest

View File

@ -125,6 +125,10 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
return getClientAuthMethod().equals(OIDCLoginProtocol.CLIENT_SECRET_BASIC);
}
public boolean isBasicAuthenticationUnencoded(){
return getClientAuthMethod().equals(OIDCLoginProtocol.CLIENT_SECRET_BASIC_UNENCODED);
}
public boolean isUiLocales() {
return Boolean.valueOf(getConfig().get("uiLocales"));
}

View File

@ -119,6 +119,12 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String PRIVATE_KEY_JWT = "private_key_jwt";
public static final String TLS_CLIENT_AUTH = "tls_client_auth";
/**
* This is just for legacy setups which expect an unencoded, non-RFC6749 compliant client secret send from Keycloak to an IdP.
*/
@Deprecated(since = "26.5")
public static final String CLIENT_SECRET_BASIC_UNENCODED = "client_secret_basic_unencoded";
// https://tools.ietf.org/html/rfc7636#section-4.3
public static final String CODE_CHALLENGE_PARAM = "code_challenge";
public static final String CODE_CHALLENGE_METHOD_PARAM = "code_challenge_method";

View File

@ -0,0 +1,78 @@
/*
* Copyright 2025 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 org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import java.util.Map;
import static org.keycloak.broker.oidc.OAuth2IdentityProviderConfig.TOKEN_ENDPOINT_URL;
import static org.keycloak.testsuite.broker.BrokerTestConstants.*;
import static org.keycloak.testsuite.broker.BrokerTestTools.*;
public class KcOidcBrokerColonAliasClientSecretBasicAuthTest extends AbstractBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerColonAliasClientSecretBasicAuthTest.KcOidcBrokerColonAliasConfigurationWithBasicAuthAuthentication();
}
private class KcOidcBrokerColonAliasConfigurationWithBasicAuthAuthentication extends KcOidcBrokerConfiguration {
public final static String CLIENT_ID_COLON = "https://kc-dev.general.gr/staging/realms/general";
@Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
config.put("clientAuthMethod", OIDCLoginProtocol.CLIENT_SECRET_BASIC);
return idp;
}
@Override
protected void applyDefaultConfiguration(final Map<String, String> config, IdentityProviderSyncMode syncMode) {
config.put(IdentityProviderModel.SYNC_MODE, syncMode.toString());
config.put("clientId", CLIENT_ID_COLON);
config.put("clientSecret", CLIENT_SECRET);
config.put("prompt", "login");
config.put("loginHint", "true");
config.put(OIDCIdentityProviderConfig.ISSUER, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME);
config.put("authorizationUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/auth");
config.put(TOKEN_ENDPOINT_URL, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token");
config.put("logoutUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/logout");
config.put("userInfoUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile");
config.put("backchannelSupported", "true");
config.put(OIDCIdentityProviderConfig.JWKS_URL,
getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/certs");
config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true");
config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true");
}
@Override
public String getIDPClientIdInProviderRealm() {
return CLIENT_ID_COLON;
}
}
}