diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java index b62118a0954..08f911b2810 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/idm/query/internal/LDAPQuery.java @@ -17,6 +17,11 @@ package org.keycloak.storage.ldap.idm.query.internal; +import org.keycloak.storage.StorageUnavailableException; + +import javax.naming.CommunicationException; +import javax.naming.NameNotFoundException; +import javax.naming.AuthenticationException; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.ModelDuplicateException; @@ -181,6 +186,17 @@ public class LDAPQuery implements AutoCloseable { result.add(ldapObject); } } catch (Exception e) { + // Check if this is an LDAP connectivity issue + Throwable current = e; + while (current != null) { + if (current instanceof NameNotFoundException || current instanceof CommunicationException || + current instanceof AuthenticationException) { + throw new StorageUnavailableException("LDAP server is unavailable for provider [" + + ldapFedProvider.getModel().getName() + "]", e); + } + current = current.getCause(); + } + throw new ModelException("Failed to fetch results from the LDAP [" + ldapFedProvider.getModel().getName() + "] provider", e); } diff --git a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java index a794231cb04..df1e97a378f 100755 --- a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -1146,9 +1146,18 @@ public class UserStorageManager extends AbstractStorageManager loader) { - return mapEnabledStorageProvidersWithTimeout(realm, UserLookupProvider.class, loader) - .findFirst() - .orElse(null); + return mapEnabledStorageProvidersWithTimeout(realm, UserLookupProvider.class, provider -> { + try { + return loader.apply(provider); + } catch (StorageUnavailableException e) { + logger.warnf(e, "User storage provider %s is unavailable. " + + "Continuing with other providers for graceful degradation.", + provider.getClass().getSimpleName()); + return null; + } + }) + .findFirst() + .orElse(null); } private boolean isSyncSettingsUpdated(UserStorageProviderModel previous, UserStorageProviderModel actual) { diff --git a/server-spi/src/main/java/org/keycloak/storage/StorageUnavailableException.java b/server-spi/src/main/java/org/keycloak/storage/StorageUnavailableException.java new file mode 100644 index 00000000000..f8bbc5f6f43 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/storage/StorageUnavailableException.java @@ -0,0 +1,47 @@ +/* + * 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.storage; + +/** + * Exception thrown by user storage providers to indicate that the external storage + * system is temporarily unavailable due to connectivity issues, server downtime, + * or other infrastructure problems. + * + *

This exception allows storage providers to signal graceful degradation scenarios + * where the UserStorageManager should skip the unavailable provider and continue + * with other available providers or local storage.

+ * + */ +public class StorageUnavailableException extends RuntimeException { + + public StorageUnavailableException() { + super(); + } + + public StorageUnavailableException(String message) { + super(message); + } + + public StorageUnavailableException(String message, Throwable cause) { + super(message, cause); + } + + public StorageUnavailableException(Throwable cause) { + super(cause != null ? cause.getMessage() : null, cause); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/UserStorageGracefulDegradationLdapTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/UserStorageGracefulDegradationLdapTest.java index b1377084247..d28bdb22b35 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/UserStorageGracefulDegradationLdapTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/UserStorageGracefulDegradationLdapTest.java @@ -21,18 +21,24 @@ import org.junit.Rule; import org.junit.Test; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.testsuite.arquillian.annotation.ModelTest; +import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.util.LDAPRule; import org.keycloak.testsuite.util.LDAPTestConfiguration; import org.keycloak.testsuite.util.LDAPTestUtils; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.storage.ldap.LDAPStorageProvider; import org.keycloak.storage.ldap.idm.model.LDAPObject; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; /** @@ -104,4 +110,74 @@ public class UserStorageGracefulDegradationLdapTest extends AbstractLDAPTest { session.users().removeUser(realm, localUser); } } + + @Test + public void testLoginWithEmbeddedLDAPFailure() { + // Get original URL first + String originalUrl = ldapRule.getConfig().get(LDAPConstants.CONNECTION_URL); + AtomicReference userIdRef = new AtomicReference<>(); + + try { + // First create a dedicated LDAP user + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealm("test"); + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + + // Create LDAP user for testing + LDAPObject testLdapUser = LDAPTestUtils.addLDAPUser(ldapProvider, realm, "testldapuser", "Test", "LdapUser", "testldap@example.com", null, "12345"); + LDAPTestUtils.updateLDAPPassword(ldapProvider, testLdapUser, "TestPassword123!"); + }); + + // Break LDAP connection and disable sync + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealm("test"); + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + ldapModel.getConfig().putSingle("connectionUrl", "ldap://invalid-server:999"); + ldapModel.getConfig().putSingle("syncRegistrations", "false"); + ldapModel.getConfig().putSingle("importEnabled", "false"); + realm.updateComponent(ldapModel); + }); + + // Create local user with @ in username + UserRepresentation localUser = UserBuilder.create() + .username("user@domain.com") + .password("password") + .enabled(true) + .build(); + String userId = ApiUtil.getCreatedId(testRealm().users().create(localUser)); + userIdRef.set(userId); + + // Test that LDAP users fail to login when LDAP is down + loginPage.open(); + loginPage.login("testldapuser", "TestPassword123!"); + + // Should stay on login page with error since LDAP user can't be authenticated + Assert.assertTrue("Should stay on login page when LDAP user login fails", + loginPage.isCurrent()); + + // Now try to login with the local user - this should work despite LDAP being down + loginPage.login("user@domain.com", "password"); + + // Should succeed despite LDAP failure + appPage.assertCurrent(); + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + } finally { + // Cleanup + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealm("test"); + ComponentModel ldapModel = LDAPTestUtils.getLdapProviderModel(realm); + ldapModel.getConfig().putSingle("connectionUrl", originalUrl); + ldapModel.getConfig().putSingle("syncRegistrations", "true"); + ldapModel.getConfig().putSingle("importEnabled", "true"); + realm.updateComponent(ldapModel); + + LDAPStorageProvider ldapProvider = LDAPTestUtils.getLdapProvider(session, ldapModel); + LDAPTestUtils.removeLDAPUserByUsername(ldapProvider, realm, ldapProvider.getLdapIdentityStore().getConfig(), "testldapuser"); + }); + + testRealm().users().get(userIdRef.get()).remove(); + } + } }