Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package nl.first8.keycloak.broker.saml;

import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.UserAuthenticationIdentityProvider;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.sessions.AuthenticationSessionModel;

import java.net.URI;
import java.net.URISyntaxException;

/**
* Wraps an {@link UserAuthenticationIdentityProvider.AuthenticationCallback} to intercept error responses
* and redirect the user to a configurable client-side callback path instead of showing the default Keycloak error page.
*/
public class ErrorRedirectCallbackWrapper implements UserAuthenticationIdentityProvider.AuthenticationCallback {

private static final Logger logger = Logger.getLogger(ErrorRedirectCallbackWrapper.class);

private final KeycloakSession session;
private final SAMLIdentityProviderConfig config;
private final UserAuthenticationIdentityProvider.AuthenticationCallback delegate;

public ErrorRedirectCallbackWrapper(KeycloakSession session,
SAMLIdentityProviderConfig config,
UserAuthenticationIdentityProvider.AuthenticationCallback delegate) {
this.session = session;
this.config = config;
this.delegate = delegate;
}

@Override
public Response authenticated(BrokeredIdentityContext context) {
return delegate.authenticated(context);
}

@Override
public Response cancelled(IdentityProviderModel idpConfig) {
Response redirect = buildRedirectResponse(config.getCancelledCallbackPath());
if (redirect != null) {
return redirect;
}
return delegate.cancelled(idpConfig);
}

@Override
public Response error(IdentityProviderModel idpConfig, String message) {
logger.warnf("Error callback intercepted with message: %s", message);

String callbackPath = determineCallbackPath(message);
Response redirect = buildRedirectResponse(callbackPath);
if (redirect != null) {
return redirect;
}

logger.warn("Could not determine callback URL, falling back to default error handling");
return delegate.error(idpConfig, message);
}

@Override
public AuthenticationSessionModel getAndVerifyAuthenticationSession(String encodedCode) {
return delegate.getAndVerifyAuthenticationSession(encodedCode);
}

@Override
public Response retryLogin(UserAuthenticationIdentityProvider<?> identityProvider, AuthenticationSessionModel authSession) {
return delegate.retryLogin(identityProvider, authSession);
}

String determineCallbackPath(String message) {
if (message != null) {
if (message.contains("RequestDenied") || message.contains("denied") || message.contains("Denied")) {
return config.getErrorCallbackPath();
}
}
// Default: treat as cancelled (AuthnFailed, cancelled, or unknown)
return config.getCancelledCallbackPath();
}

private Response buildRedirectResponse(String callbackPath) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (authSession == null) {
logger.warn("No authentication session available for error redirect");
return null;
}

String callbackUrl = buildCallbackUrl(authSession.getRedirectUri(), callbackPath);
if (callbackUrl == null) {
callbackUrl = buildCallbackUrl(authSession.getClient().getBaseUrl(), callbackPath);
}

if (callbackUrl != null) {
logger.infof("Redirecting to: %s", callbackUrl);
return Response.status(Response.Status.FOUND).location(URI.create(callbackUrl)).build();
}

return null;
}

/**
* Builds a callback URL by extracting scheme, host, and port from the source URL
* and appending the configured callback path.
* Returns null if the source URL is invalid or blank.
*/
static String buildCallbackUrl(String sourceUrl, String callbackPath) {
if (sourceUrl == null || sourceUrl.isBlank()) {
return null;
}

try {
URI uri = new URI(sourceUrl);
String scheme = uri.getScheme();
String host = uri.getHost();

if (scheme == null || host == null) {
return null;
}

// Only allow https (or http for localhost development)
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
logger.warnf("Rejected redirect with unsupported scheme: %s", scheme);
return null;
}

// Sanitize callbackPath: must start with / and not contain query/fragment or path traversal
if (callbackPath == null || !callbackPath.startsWith("/")
|| callbackPath.contains("..") || callbackPath.contains("?")
|| callbackPath.contains("#") || callbackPath.contains("//")) {
logger.warnf("Rejected invalid callback path: %s", callbackPath);
return null;
}

StringBuilder sb = new StringBuilder();
sb.append(scheme).append("://").append(host);
if (uri.getPort() > 0 && uri.getPort() != 443 && uri.getPort() != 80) {
sb.append(":").append(uri.getPort());
}
sb.append(callbackPath);

return sb.toString();
} catch (URISyntaxException e) {
logger.warnf("Failed to parse source URL for error redirect: %s", e.getMessage());
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ public SAMLIdentityProvider(KeycloakSession session, SAMLIdentityProviderConfig

@Override
public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) {
return new SAMLEndpoint(session, this, getConfig(), callback, destinationValidator);
AuthenticationCallback effectiveCallback = callback;
if (getConfig().isCustomErrorRedirectEnabled()) {
effectiveCallback = new ErrorRedirectCallbackWrapper(session, getConfig(), callback);
}
return new SAMLEndpoint(session, this, getConfig(), effectiveCallback, destinationValidator);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ public class SAMLIdentityProviderConfig extends org.keycloak.broker.saml.SAMLIde
public static final String ATTRIBUTE_VALUE = "attributeValue";
public static final String DESCRIPTOR_CACHE_SECONDS = "descriptorCacheSeconds";


public static final String CUSTOM_ERROR_REDIRECT_ENABLED = "customErrorRedirectEnabled";
public static final String ERROR_CALLBACK_PATH = "errorCallbackPath";
public static final String CANCELLED_CALLBACK_PATH = "cancelledCallbackPath";

public SAMLIdentityProviderConfig() {
super();
Expand Down Expand Up @@ -277,6 +279,32 @@ public boolean isUseMetadataDescriptorUrl() {
return Boolean.parseBoolean(getConfig().get(USE_METADATA_DESCRIPTOR_URL));
}

public boolean isCustomErrorRedirectEnabled() {
return Boolean.parseBoolean(getConfig().get(CUSTOM_ERROR_REDIRECT_ENABLED));
}

public void setCustomErrorRedirectEnabled(boolean enabled) {
getConfig().put(CUSTOM_ERROR_REDIRECT_ENABLED, String.valueOf(enabled));
}

public String getErrorCallbackPath() {
String path = getConfig().get(ERROR_CALLBACK_PATH);
return path != null && !path.isEmpty() ? path : "/signin-callback/error";
}

public void setErrorCallbackPath(String path) {
getConfig().put(ERROR_CALLBACK_PATH, path);
}

public String getCancelledCallbackPath() {
String path = getConfig().get(CANCELLED_CALLBACK_PATH);
return path != null && !path.isEmpty() ? path : "/signin-callback/cancelled";
}

public void setCancelledCallbackPath(String path) {
getConfig().put(CANCELLED_CALLBACK_PATH, path);
}

@Override
public void validate(RealmModel realm) {
SslRequired sslRequired = realm.getSslRequired();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import javax.xml.stream.XMLStreamWriter;

import nl.first8.keycloak.saml.common.constants.JBossSAMLConstants;
import nl.first8.keycloak.dom.saml.v2.assertion.SamlEncryptedId;

import org.keycloak.dom.saml.v2.assertion.*;
import org.keycloak.dom.saml.v2.metadata.LocalizedNameType;
Expand All @@ -25,6 +26,7 @@
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.saml.processing.core.saml.v2.util.StaxWriterUtil;


import org.w3c.dom.Element;
import org.w3c.dom.Node;

Expand Down
Loading