diff --git a/src/main/java/nl/first8/keycloak/broker/saml/ErrorRedirectCallbackWrapper.java b/src/main/java/nl/first8/keycloak/broker/saml/ErrorRedirectCallbackWrapper.java new file mode 100644 index 0000000..6429e9a --- /dev/null +++ b/src/main/java/nl/first8/keycloak/broker/saml/ErrorRedirectCallbackWrapper.java @@ -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; + } + } +} diff --git a/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProvider.java b/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProvider.java index 7d40aed..be6ac09 100644 --- a/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProvider.java +++ b/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProvider.java @@ -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 diff --git a/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProviderConfig.java b/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProviderConfig.java index a6b4ab5..7724f62 100644 --- a/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProviderConfig.java +++ b/src/main/java/nl/first8/keycloak/broker/saml/SAMLIdentityProviderConfig.java @@ -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(); @@ -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(); diff --git a/src/main/java/nl/first8/keycloak/saml/processing/core/saml/v2/writers/BaseWriter.java b/src/main/java/nl/first8/keycloak/saml/processing/core/saml/v2/writers/BaseWriter.java index ecc9d45..a018b66 100644 --- a/src/main/java/nl/first8/keycloak/saml/processing/core/saml/v2/writers/BaseWriter.java +++ b/src/main/java/nl/first8/keycloak/saml/processing/core/saml/v2/writers/BaseWriter.java @@ -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; @@ -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;