Support passive authentication requests to SAML IDPs

* Respond with "login_required" error when backing SAML IDP fails to silenty authenticate user
* Reword "accepts prompt=none" config option label and show option for all types of IDPs.
* Change is reflected in docs, and duplicate UI fields and documentation entries are removed.
* Add constants for broker-app client id and secret to KcSamlBrokerConfiguration to avoid duplicated literals.

Closes #41531

Signed-off-by: Mikkel Bernhof Jakobsen <bernhof@gmail.com>
This commit is contained in:
Mikkel Bernhof Jakobsen 2026-03-28 14:16:04 +01:00
parent 8543f62100
commit fb5b80b6dc
12 changed files with 222 additions and 39 deletions

View file

@ -52,6 +52,11 @@ Although each type of identity provider has its configuration options, all share
|Stored Tokens Readable
|When *ON*, users can retrieve the stored identity provider token. This action also applies to the _broker_ client-level role _read token_.
|Accepts passive authentication requests
|Specifies if the IDP accepts passive authentication requests, i.e. OIDC requests containing `prompt=none` query parameter, or SAML requests containing `IsPassive=true`. Normally, if a realm receives a passive authentication request, the realm checks if the user is authenticated and returns a `login_required` error if not. However, if {project_name} determines a default IDP for the authentication request (using either the `kc_idp_hint` query parameter or having a default IDP for the realm), it can forward the passive authentication request to the default IDP. The default IDP then checks if user is authenticated there. Because not all IDPs support this, {project_name} uses this switch to determine whether passive authentication requests can be forwarded to the IDP.
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.
|Trust Email
|When *ON*, {project_name} trusts email addresses from the identity provider. If the realm requires email validation, users that log in from this identity provider do not need to perform the email verification process.
If the target identity provider supports email verification and advertises this information when returning the user profile information, the email of the federated user will be (un)marked as verified.
@ -62,7 +67,6 @@ through the broker if the sync mode is set to `FORCE`.
|GUI Order
|The sort order of the available identity providers on the login page.
|Verify essential claim
|When *ON*, ID tokens issued by the identity provider must have a specific claim, otherwise, the user can not authenticate through this broker

View file

@ -48,11 +48,6 @@ In the case of JWT signed with private key or Client secret as jwt, it is requir
|Prompt
|The prompt parameter in the OIDC specification. Through this parameter, you can force re-authentication and other options. See the specification for more details.
|Accepts prompt=none forward from client
|Specifies if the IDP accepts forwarded authentication requests containing the `prompt=none` query parameter. If a realm receives an auth request with `prompt=none`, the realm checks if the user is currently authenticated and returns a `login_required` error if the user has not logged in. When {project_name} determines a default IDP for the auth request (using the `kc_idp_hint` query parameter or having a default IDP for the realm), you can forward the auth request with `prompt=none` to the default IDP. The default IDP checks the authentication of the user there. Because not all IDPs support requests with `prompt=none`, {project_name} uses this switch to indicate that the default IDP supports the parameter before redirecting the authentication request.
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).

View file

@ -58,11 +58,6 @@ In the case of JWT signed with private key or Client secret as jwt, it is requir
|Prompt
|The prompt parameter in the OIDC specification. Through this parameter, you can force re-authentication and other options. See the specification for more details.
|Accepts prompt=none forward from client
|Specifies if the IDP accepts forwarded authentication requests containing the `prompt=none` query parameter. If a realm receives an auth request with `prompt=none`, the realm checks if the user is currently authenticated and returns a `login_required` error if the user has not logged in. When {project_name} determines a default IDP for the auth request (using the `kc_idp_hint` query parameter or having a default IDP for the realm), you can forward the auth request with `prompt=none` to the default IDP. The default IDP checks the authentication of the user there. Because not all IDPs support requests with `prompt=none`, {project_name} uses this switch to indicate that the default IDP supports the parameter before redirecting the authentication request.
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).

View file

@ -2140,7 +2140,7 @@ eventTypes.UPDATE_TOTP_ERROR.description=Update totp error
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.
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 passive authentication request and the user is not authenticated, the error is not directly returned to the client; the passive authentication request 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
@ -2722,7 +2722,7 @@ deleteDialogTitle=Delete attribute group?
eventTypes.CLIENT_INITIATED_ACCOUNT_LINKING.description=Client initiated account linking
annotationsText=Annotations
ldapAttributeName=LDAP attribute name
acceptsPromptNone=Accepts prompt\=none forward from client
acceptsPromptNone=Accepts passive authentication requests
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.

View file

@ -162,14 +162,12 @@ export const AdvancedSettings = ({
fieldType="boolean"
/>
)}
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
{!isOIDC && !isSAML && !isOAuth2 && (
<>
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
</>
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
)}
{isOIDC && (
<SwitchField field="config.isAccessTokenJWT" label="isAccessTokenJWT" />

View file

@ -93,10 +93,6 @@ export const ExtendedNonDiscoverySettings = () => {
)}
/>
</FormGroupField>
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField
field="config.requiresShortStateParameter"
label="requiresShortStateParameter"

View file

@ -30,10 +30,6 @@ export const ExtendedOAuth2Settings = () => {
]}
controller={{ defaultValue: "" }}
/>
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField
field="config.requiresShortStateParameter"
label="requiresShortStateParameter"

View file

@ -42,7 +42,7 @@ public class IdentityProviderAuthenticator implements Authenticator {
private static final Logger LOG = Logger.getLogger(IdentityProviderAuthenticator.class);
protected static final String ACCEPTS_PROMPT_NONE = "acceptsPromptNoneForwardFromClient";
public static final String ACCEPTS_PROMPT_NONE = "acceptsPromptNoneForwardFromClient";
@Override
public void authenticate(AuthenticationFlowContext context) {

View file

@ -50,6 +50,7 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.UserAuthenticationIdentityProvider;
@ -69,7 +70,9 @@ import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType;
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
import org.keycloak.dom.saml.v2.protocol.RequestAbstractType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.dom.saml.v2.protocol.StatusCodeType;
import org.keycloak.dom.saml.v2.protocol.StatusResponseType;
import org.keycloak.dom.saml.v2.protocol.StatusType;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
@ -544,7 +547,9 @@ public class SAMLEndpoint {
}
session.getContext().setAuthenticationSession(authSession);
if (! isSuccessfulSamlResponse(responseType)) {
if (isNoPassiveSamlResponse(responseType)) {
return callback.error(config, OAuthErrorException.LOGIN_REQUIRED);
} else if (!isSuccessfulSamlResponse(responseType)) {
String statusMessage = responseType.getStatus() == null || responseType.getStatus().getStatusMessage() == null ? Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR : responseType.getStatus().getStatusMessage();
if (Constants.AUTHENTICATION_EXPIRED_MESSAGE.equals(statusMessage)) {
return callback.retryLogin(provider, authSession);
@ -756,15 +761,29 @@ public class SAMLEndpoint {
return authSession;
}
protected final boolean isSuccessfulSamlResponse(ResponseType responseType) {
return responseType != null
&& responseType.getStatus() != null
&& responseType.getStatus().getStatusCode() != null
&& responseType.getStatus().getStatusCode().getValue() != null
&& Objects.equals(responseType.getStatus().getStatusCode().getValue().toString(), JBossSAMLURIConstants.STATUS_SUCCESS.get());
private StatusCodeType getSamlResponseStatusCode(ResponseType responseType) {
return Optional.ofNullable(responseType)
.map(StatusResponseType::getStatus)
.map(StatusType::getStatusCode)
.orElse(null);
}
protected final boolean isSuccessfulSamlResponse(ResponseType responseType) {
var statusCode = Optional.ofNullable(getSamlResponseStatusCode(responseType))
.map(StatusCodeType::getValue)
.map(URI::toString)
.orElse(null);
return JBossSAMLURIConstants.STATUS_SUCCESS.get().equals(statusCode);
}
private boolean isNoPassiveSamlResponse(ResponseType responseType) {
var secondaryStatusCode = Optional.ofNullable(getSamlResponseStatusCode(responseType))
.map(StatusCodeType::getStatusCode)
.map(StatusCodeType::getValue)
.map(URI::toString)
.orElse(null);
return JBossSAMLURIConstants.STATUS_NO_PASSIVE.get().equals(secondaryStatusCode);
}
public Response handleSamlResponse(String samlResponse, String relayState, String clientId) {
SAMLDocumentHolder holder = extractResponseDocument(samlResponse);

View file

@ -173,11 +173,14 @@ public class SAMLIdentityProvider extends AbstractIdentityProvider<SAMLIdentityP
Boolean forceAuthn = getConfig().isForceAuthn();
if (protocol.requireReauthentication(null, request.getAuthenticationSession()))
forceAuthn = Boolean.TRUE;
String prompt = request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.PROMPT_PARAM);
boolean isPassive = OIDCLoginProtocol.PROMPT_VALUE_NONE.equals(prompt);
SAML2AuthnRequestBuilder authnRequestBuilder = new SAML2AuthnRequestBuilder()
.assertionConsumerUrl(assertionConsumerServiceUrl)
.destination(destinationUrl)
.issuer(issuerURL)
.forceAuthn(forceAuthn)
.isPassive(isPassive)
.protocolBinding(protocolBinding)
.nameIdPolicy(SAML2NameIDPolicyBuilder
.format(nameIDPolicyFormat)

View file

@ -55,6 +55,8 @@ public class KcSamlBrokerConfiguration implements BrokerConfiguration {
public static final KcSamlBrokerConfiguration INSTANCE = new KcSamlBrokerConfiguration();
public static final String ATTRIBUTE_TO_MAP_FRIENDLY_NAME = "user-attribute-friendly";
public static final String CONSUMER_CLIENT_ID = "broker-app";
public static final String CONSUMER_CLIENT_SECRET = "broker-app-secret";
private final boolean loginHint;
@ -217,9 +219,9 @@ public class KcSamlBrokerConfiguration implements BrokerConfiguration {
.attribute(SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, getConsumerRoot() + "/sales-post/saml")
.build(),
ClientBuilder.create()
.clientId("broker-app")
.clientId(CONSUMER_CLIENT_ID)
.name("broker-app")
.secret("broker-app-secret")
.secret(CONSUMER_CLIENT_SECRET)
.enabled(true)
.directAccessGrants()
.addRedirectUri(getConsumerRoot() + "/auth/*")

View file

@ -0,0 +1,175 @@
/*
* 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 java.net.URI;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.resources.RealmsResource;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.getProviderRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import static org.hamcrest.MatcherAssert.assertThat;
/**
* Tests forwarding of passive authentication requests (prompt=none) from OIDC clients to a backing SAML IDP.
*/
public final class KcSamlBrokerPassiveAuthenticationTest extends AbstractInitializedBaseBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return KcSamlBrokerConfiguration.INSTANCE;
}
@Before
public void configureDefaults() {
// Configure forwarding of passive authentication requests:
configureAcceptsPromptNoneForwardingFromClient(true);
// Disable profile update required action for the prompt=none propagation to work:
updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin);
// Ensure the broker user is logged out from both realms to start with a clean state
logoutFromBothRealms();
}
/**
* OIDC prompt=none login should succeed when the user is already authenticated at the backing SAML IDP.
*/
@Test
public void testOidcPromptNoneSuccessWhenAuthenticatedAtProvider() {
authenticateAtProvider();
initiateLoginWithPromptNone();
// Then the broker should start code flow without requiring user interaction
var response = oauth.parseLoginResponse();
assertThat("Authorization response should contain code", response.getCode(), Matchers.notNullValue());
// Code can be exchanged for tokens
var tokenResponse = oauth.doAccessTokenRequest(response.getCode());
assertThat("Token response should contain access token", tokenResponse.getAccessToken(), Matchers.notNullValue());
}
/**
* OIDC prompt=none login should fail when not authenticated at backing SAML IDP
*/
@Test
public void testOidcPromptNoneFailureWhenNotAuthenticatedAtProvider() {
initiateLoginWithPromptNone();
assertThatOAuthErrorIsReturned(OAuthErrorException.LOGIN_REQUIRED);
}
/**
* OIDC prompt=none login should fail when IDP is configured to NOT forward passive authentication requests,
* EVEN IF the user is already authenticated at the backing SAML IDP.
*/
@Test
public void testOidcPromptNoneFailureWhenProviderDoesNotAcceptPassiveAuthenticationRequests() {
configureAcceptsPromptNoneForwardingFromClient(false);
authenticateAtProvider();
initiateLoginWithPromptNone();
assertThatOAuthErrorIsReturned(OAuthErrorException.LOGIN_REQUIRED);
}
/**
* OIDC prompt=none login should fail when user interaction is required,
* EVEN IF the user is already authenticated at the backing SAML IDP.
*/
@Test
public void testOidcPromptNoneFailureWhenInteractionRequired() {
authenticateAtProvider();
updateExecutions(AbstractBrokerTest::enableUpdateProfileOnFirstLogin);
initiateLoginWithPromptNone();
assertThatOAuthErrorIsReturned(OAuthErrorException.INTERACTION_REQUIRED);
}
private void configureAcceptsPromptNoneForwardingFromClient(boolean accepts) {
var idp = identityProviderResource.toRepresentation();
idp.getConfig().put(IdentityProviderAuthenticator.ACCEPTS_PROMPT_NONE, Boolean.toString(accepts));
identityProviderResource.update(idp);
}
private void initiateLoginWithPromptNone() {
oauth
.client(KcSamlBrokerConfiguration.CONSUMER_CLIENT_ID, KcSamlBrokerConfiguration.CONSUMER_CLIENT_SECRET)
.realm(bc.consumerRealmName())
.redirectUri(getConsumerBaseUriBuilder().path("app").build().toString())
.loginForm()
.prompt(OIDCLoginProtocol.PROMPT_VALUE_NONE)
.param(AdapterConstants.KC_IDP_HINT, bc.getIDPAlias())
.open();
}
private void assertThatOAuthErrorIsReturned(String error) {
var response = oauth.parseLoginResponse();
assertThat("OAuth response error expected", response.getError(), Matchers.equalTo(error));
}
/**
* Authenticates the broker user directly in the SAML IDP to establish a valid authenticated session there.
*/
private void authenticateAtProvider() {
// Navigate to the provider realm's account console to establish a session
URI providerOidcProtocolUrl = RealmsResource.protocolUrl(getProviderBaseUriBuilder()).build(bc.providerRealmName(), OIDCLoginProtocol.LOGIN_PROTOCOL);
URI providerAccountUrl = RealmsResource.accountUrl(getProviderBaseUriBuilder()).build(bc.providerRealmName());
driver.navigate().to(providerAccountUrl.toString());
waitForPage(driver, "sign in to", true);
assertThat("Driver should be on the provider realm login page",
driver.getCurrentUrl(), Matchers.containsString(providerOidcProtocolUrl.toString()));
loginPage.login(bc.getUserLogin(), bc.getUserPassword());
// Wait for redirect to account page, indicating successful authentication
waitForPage(driver, "account", true);
assertThat("User should be authenticated in the provider realm",
driver.getCurrentUrl(), Matchers.containsString(providerAccountUrl.toString()));
}
private UriBuilder getProviderBaseUriBuilder() {
return getBaseUri(getProviderRoot());
}
private UriBuilder getConsumerBaseUriBuilder() {
return getBaseUri(getProviderRoot());
}
private UriBuilder getBaseUri(String root) {
return UriBuilder.fromUri(root).path("auth");
}
/**
* Logs out from both consumer and provider realms to ensure clean state.
*/
private void logoutFromBothRealms() {
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
logoutFromRealm(getProviderRoot(), bc.providerRealmName());
}
}