Add option 'Requires short state parameter' to OIDC IDP

closes #40237

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2025-07-10 09:44:02 +02:00 committed by Marek Posolda
parent 164274ac51
commit 274afa88fa
8 changed files with 118 additions and 3 deletions

View File

@ -53,6 +53,9 @@ In the case of JWT signed with private key or Client secret as jwt, it is requir
If the user is unauthenticated in the IDP, the client still receives a `login_required` error. If the user is authentic in the IDP, the client can still receive an `interaction_required` error if {project_name} must display authentication pages that require user interaction. This authentication includes required actions (for example, password change), consent screens, and screens set to display by the `first broker login` flow or `post broker login` flow.
|Requires short state parameter
|This switch needs to be enabled if identity provider does not support long value of the `state` parameter sent in the initial OAuth2 authorization request (EG. more than 100 characters). In this case, {project_name} will try to make shorter `state` parameter and may omit some client data to be sent in the initial request. This may result in the limited functionality in some very corner case scenarios (EG. in case that IDP redirects to {project_name} with the error in the OAuth2 authorization response, {project_name} might need to display error page instead of being able to redirect to the client in case that login session is expired).
|===
After the user authenticates to the identity provider and is redirected back to {project_name}, the broker will fetch the user profile information from the endpoint defined in the `User Info URL` setting. For that,

View File

@ -63,6 +63,9 @@ In the case of JWT signed with private key or Client secret as jwt, it is requir
If the user is unauthenticated in the IDP, the client still receives a `login_required` error. If the user is authentic in the IDP, the client can still receive an `interaction_required` error if {project_name} must display authentication pages that require user interaction. This authentication includes required actions (for example, password change), consent screens, and screens set to display by the `first broker login` flow or `post broker login` flow.
|Requires short state parameter
|This switch needs to be enabled if identity provider does not support long value of the `state` parameter sent in the initial OIDC authentication request (EG. more than 100 characters). In this case, {project_name} will try to make shorter `state` parameter and may omit some client data to be sent in the initial request. This may result in the limited functionality in some very corner case scenarios (EG. in case that IDP redirects to {project_name} with the error in the OIDC authentication response, {project_name} might need to display error page instead of being able to redirect to the client in case that login session is expired).
|Validate Signatures
|Specifies if {project_name} verifies signatures on the external ID Token signed by this IDP. If *ON*, {project_name} must know the public key of the external OIDC IDP. For performance purposes, {project_name} caches the public key of the external OIDC identity provider.
@ -82,4 +85,4 @@ If the user is unauthenticated in the IDP, the client still receives a `login_re
You can import all this configuration data by providing a URL or file that points to OpenID Provider Metadata. If you connect to a {project_name} external IDP, you can import the IDP settings from `<root>{kc_realms_path}/{realm-name}/.well-known/openid-configuration`. This link is a JSON document describing metadata about the IDP.
If you want to use https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] ID Tokens or UserInfo responses in the provider, the IDP needs to know the public key to use with {project_name}. The provider uses the <<realm_keys, realm keys>> defined for the different encryption algorithms to decrypt the tokens. {project_name} provides a standard xref:con-server-oidc-uri-endpoints_{context}[JWKS endpoint] which the IDP can use for downloading the keys automatically.
If you want to use https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] ID Tokens or UserInfo responses in the provider, the IDP needs to know the public key to use with {project_name}. The provider uses the <<realm_keys, realm keys>> defined for the different encryption algorithms to decrypt the tokens. {project_name} provides a standard xref:con-server-oidc-uri-endpoints_{context}[JWKS endpoint] which the IDP can use for downloading the keys automatically.

View File

@ -2048,6 +2048,7 @@ titleEvents=Events
signServiceProviderMetadata=Sign service provider metadata
updateClientPoliciesError=Could not update client policies\: {{error}}
acceptsPromptNoneHelp=This is used only together with the Identity Provider Authenticator or when kc_idp_hint points to this identity provider. If that client sends a request with prompt\=none and the user is not authenticated, the error is not directly returned to the client; the request with prompt\=none is forwarded to this identity provider.
requiresShortStateParameterHelp=This switch needs to be enabled if identity provider does not support long value of the 'state' parameter sent in the initial OIDC/OAuth2 authentication request (EG. more than 100 characters). In this case, Keycloak will try to make shorter 'state' parameter and may omit some client data to be sent in the initial request. This may result in the limited functionality in some very corner case scenarios (EG. in case that IDP redirects to Keycloak with the error in the OIDC authentication response, Keycloak might need to display error page instead of being able to redirect to the client in case that login session is expired).
roleDetails=Role details
eventTypes.USER_INFO_REQUEST.name=User info request
clientScopeType.none=None
@ -2635,6 +2636,7 @@ eventTypes.CLIENT_INITIATED_ACCOUNT_LINKING.description=Client initiated account
annotationsText=Annotations
ldapAttributeName=LDAP attribute name
acceptsPromptNone=Accepts prompt\=none forward from client
requiresShortStateParameter=Requires short state parameter
loginThemeHelp=Select theme for login, OTP, grant, registration and forgot password pages.
AESKeySizeHelp=Size in bytes for the generated AES key. Size 16 is for AES-128, Size 24 for AES-192, and Size 32 for AES-256. WARN\: Bigger keys than 128 are not allowed on some JDK implementations.
client-accesstype.tooltip=Access Type of the client, for which the condition will be applied. Confidential client has enabled client authentication when public client has disabled client authentication. Bearer-only is a deprecated client type.

View File

@ -97,6 +97,10 @@ export const ExtendedNonDiscoverySettings = () => {
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField
field="config.requiresShortStateParameter"
label="requiresShortStateParameter"
/>
<FormGroup
label={t("allowedClockSkew")}
labelIcon={

View File

@ -34,6 +34,10 @@ export const ExtendedOAuth2Settings = () => {
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField
field="config.requiresShortStateParameter"
label="requiresShortStateParameter"
/>
<NumberControl
name="config.allowedClockSkew"
label={t("allowedClockSkew")}

View File

@ -895,5 +895,8 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
}
@Override
public boolean supportsLongStateParameter() {
return !getConfig().isRequiresShortStateParameter();
}
}

View File

@ -39,6 +39,8 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public static final String JWT_X509_HEADERS_ENABLED = "jwtX509HeadersEnabled";
public static final String REQUIRES_SHORT_STATE_PARAMETER = "requiresShortStateParameter";
public OAuth2IdentityProviderConfig(IdentityProviderModel model) {
super(model);
}
@ -135,6 +137,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
return getConfig().get("prompt");
}
public boolean isRequiresShortStateParameter() {
return Boolean.parseBoolean(getConfig().get(REQUIRES_SHORT_STATE_PARAMETER));
}
public void setRequiresShortStateParameter(boolean requiresShortStateParameter) {
getConfig().put(REQUIRES_SHORT_STATE_PARAMETER, String.valueOf(requiresShortStateParameter));
}
public String getForwardParameters() {
return getConfig().get("forwardParameters");
}

View File

@ -24,12 +24,15 @@ import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.BrowserTabUtil;
@ -41,6 +44,8 @@ import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assume.assumeTrue;
import static org.keycloak.testsuite.AssertEvents.DEFAULT_REDIRECT_URI;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_CONS_NAME;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
/**
@ -169,6 +174,87 @@ public class KcOidcMultipleTabsBrokerTest extends AbstractInitializedBaseBroker
}
}
// Same like testAuthenticationExpiredWithMoreBrowserTabs_loginExpiredInBothConsumerAndProvider, but OIDC IDP supports only short "state" parameter. Hence client_data cannot be encoded into it
// So this is similar behaviour like KcSamlMultipleTabsBrokerTest.testAuthenticationExpiredWithMoreBrowserTabs_loginExpiredInBothConsumerAndProvider
@Test
public void testAuthenticationExpiredWithMoreBrowserTabs_loginExpiredInBothConsumerAndProvider_requiresShortStateParameter() {
assumeTrue("Since the JS engine in real browser does check the expiration regularly in all tabs, this test only works with HtmlUnit", driver instanceof HtmlUnitDriver);
// Update IDP and set invalid credentials there
IdentityProviderResource idpResource = adminClient.realm(REALM_CONS_NAME).identityProviders().get(IDP_OIDC_ALIAS);
IdentityProviderRepresentation idpRep = idpResource.toRepresentation();
idpRep.getConfig().put(OAuth2IdentityProviderConfig.REQUIRES_SHORT_STATE_PARAMETER, "true");
idpResource.update(idpRep);
try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
// Open login page in tab1 and click "login with IDP"
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
loginPage.clickSocial(bc.getIDPAlias());
// Open login page in tab 2
tabUtil.newTab(oauth.loginForm().build());
assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2));
Assert.assertTrue(loginPage.isCurrent("consumer"));
getLogger().infof("URL in tab2: %s", driver.getCurrentUrl());
setTimeOffset(7200000);
// Finish login in tab2
loginPage.clickSocial(bc.getIDPAlias());
Assert.assertEquals(loginPage.getError(), "Your login attempt timed out. Login will start from the beginning.");
logInWithBroker(bc);
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
Assert.assertTrue("We must be on consumer realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), bc.getUserEmail(), "Firstname", "Lastname");
appPage.assertCurrent();
events.clear();
// Login in provider realm will redirect back to consumer with "authentication_expired" error.
// The consumer has also expired authentication session, but it cannot redirect to client due the "clientData" missing from IdentityBrokerState.
// That is the difference from testAuthenticationExpiredWithMoreBrowserTabs_loginExpiredInBothConsumerAndProvider, which is able to redirect back to client
tabUtil.closeTab(1);
assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1));
loginPage.login(bc.getUserLogin(), bc.getUserPassword());
// Event for "already logged-in" in the provider realm
events.expectLogin().error(Errors.ALREADY_LOGGED_IN)
.realm(getProviderRealmId())
.client("brokerapp")
.user((String) null)
.session((String) null)
.removeDetail(Details.CONSENT)
.removeDetail(Details.CODE_ID)
.detail(Details.REDIRECT_URI, Matchers.equalTo(OAuthClient.AUTH_SERVER_ROOT + "/realms/" + bc.consumerRealmName() + "/broker/" + bc.getIDPAlias() + "/endpoint"))
.detail(Details.REDIRECTED_TO_CLIENT, "true")
.detail(Details.RESPONSE_TYPE, OIDCResponseType.CODE)
.detail(Details.RESPONSE_MODE, OIDCResponseMode.QUERY.value())
.assertEvent();
// Event for "already logged-in" in the consumer realm
events.expect(EventType.IDENTITY_PROVIDER_LOGIN).error(Errors.ALREADY_LOGGED_IN)
.realm(getConsumerRealmId())
.client("broker-app")
.user((String) null)
.session((String) null)
.removeDetail(Details.REDIRECT_URI)
.detail(Details.REDIRECTED_TO_CLIENT, "false")
.assertEvent();
// Being on "You are already logged-in" now. No way to redirect to client due "clientData" are null in "state" of OIDC IDP as OIDC IDP requires short state parameter
loginPage.assertCurrent("consumer");
Assert.assertEquals("You are already logged in.", loginPage.getInstruction());
} finally {
// Revert config
idpRep.getConfig().put(OAuth2IdentityProviderConfig.REQUIRES_SHORT_STATE_PARAMETER, "false");
idpResource.update(idpRep);
}
}
@Test
public void testAuthenticationExpiredWithMoreBrowserTabs_loginExpiredInProvider() throws Exception {
assumeTrue("Since the JS engine in real browser does check the expiration regularly in all tabs, this test only works with HtmlUnit", driver instanceof HtmlUnitDriver);
@ -222,7 +308,7 @@ public class KcOidcMultipleTabsBrokerTest extends AbstractInitializedBaseBroker
.detail(Details.RESPONSE_MODE, OIDCResponseMode.QUERY.value())
.assertEvent();
// SAML IDP on "consumer" will retry IDP login on the "provider"
// OIDC IDP on "consumer" will retry IDP login on the "provider"
events.expect(EventType.IDENTITY_PROVIDER_LOGIN)
.realm(getConsumerRealmId())
.client("broker-app")