OK, his was not straightforward to diagnose nor fix, and I really had to receive some pointers from this SonarSource Community topic (credits to @ganncamp).
There are multiple factors that led here.
Factors that are SonarQube-specific:
The more recent SonarQube versions such as 9.9 and 2025.1 have no way to update the email of an external user. This is something advertised as a "feature" but I think it is rather a design failure. Although it would be easy to pick the email address from the LDAP query response and update the email address on logon, SonarQube choose deliberately not to do that. External users get their email field populated on first logon and then stick to it for the rest of their life. Well, except you dare to touch SonarQube's database directly.
SonarQube users must have unique email addresses. If on logon, an LDAP query returns a user not yet in SonarQube's own users table (looked up using username), but the email returned by the LDAP server is already present in the same users table, the login failes and the new user is not inserted to the users table.
(I don't have the faintest idea about the reasoning behind this. It's not hard to imagine use cases where multiple users have the same email address. Consider several technical users, which are all set up with [email protected] ...)
You can set up multiple LDAP servers in sonar.properties as external identity providers. The important detail is, that this sort of setup is not meant to work as a failover cluster even though it works similar to a failover cluster:
SonarQube Server's LDAP support is not designed to connect multiple servers in a failover mode.
(...)
Authentication will be tried on each server, in the order they are listed in the configurations until one succeeds.
What's it designed for then? They probably meant to provide access using heterogenous LDAP servers. Consider multiple firms or branches each with their own LDAP directory using the same SonarQube instance.
To address this use case in a multi-server LDAP setup, the SonarQube users table contains an external_login and an external_identity_provider field, which together must be unique in the whole table. In a single-server LDAP setup, external_identity_provider is always 'sonarqube'. In a multi-server LDAP setup, the field reflects the LDAP server the user was authenticated against the first time they logged in. For example: "LDAP_foobar". (See linked documentation above.) Now our two John Does can be told apart:
login | external_login | external_identity_provider |
---|---|---|
john_doe | john_doe | LDAP_foobar |
john_doe1234 | john_doe | LDAP_yeehaw |
Also, since the SonarQube users table had an original "login" field (which is unique of course), they had to work around that unique constraint by adding a random number sequence to the username. Since the login field is probably not used for external users anymore, this is just for backwards compatibility, I guess.
ldap.url=ldaps://foo.bar.local:636
ldap.foo.bindDn=CN=foo,OU=orgunit,DC=bar,DC=local
...then the certificate SAN should contain a bar.local DNS field, otherwise the query fails and produces a (debug-level) message in web.log:
2025.04.11 18:43:18 DEBUG web[b7a70ba3-0e9a-4685-a1ad-c2a30e919e64][o.s.a.l.LdapSearch] More result might be forthcoming if the referral is followed
javax.naming.PartialResultException: null
at java.naming/com.sun.jndi.ldap.AbstractLdapNamingEnumeration.hasMoreImpl(AbstractLdapNamingEnumeration.java:237)
at java.naming/com.sun.jndi.ldap.AbstractLdapNamingEnumeration.hasMore(AbstractLdapNamingEnumeration.java:189)
at org.sonar.auth.ldap.LdapSearch.hasMore(LdapSearch.java:156)
at org.sonar.auth.ldap.LdapSearch.findUnique(LdapSearch.java:146)
at org.sonar.auth.ldap.DefaultLdapUsersProvider.getUserDetails(DefaultLdapUsersProvider.java:78)
at org.sonar.auth.ldap.DefaultLdapUsersProvider.doGetUserDetails(DefaultLdapUsersProvider.java:58)
at org.sonar.server.authentication.LdapCredentialsAuthentication.doAuthenticate(LdapCredentialsAuthentication.java:92)
at org.sonar.server.authentication.LdapCredentialsAuthentication.authenticate(LdapCredentialsAuthentication.java:74)
at org.sonar.server.authentication.CredentialsAuthentication.lambda$authenticate$0(CredentialsAuthentication.java:71)
at java.base/java.util.Optional.or(Optional.java:313)
at org.sonar.server.authentication.CredentialsAuthentication.authenticate(CredentialsAuthentication.java:71)
at org.sonar.server.authentication.CredentialsAuthentication.authenticate(CredentialsAuthentication.java:57)
at org.sonar.server.authentication.ws.LoginAction.authenticate(LoginAction.java:116)
at org.sonar.server.authentication.ws.LoginAction.doFilter(LoginAction.java:95)
...
Caused by: javax.naming.CommunicationException: simple bind failed: bar.local:636
at java.naming/com.sun.jndi.ldap.LdapReferralContext.<init>(LdapReferralContext.java:96)
at java.naming/com.sun.jndi.ldap.LdapReferralException.getReferralContext(LdapReferralException.java:151)
at java.naming/com.sun.jndi.ldap.AbstractLdapNamingEnumeration.hasMoreReferrals(AbstractLdapNamingEnumeration.java:326)
at java.naming/com.sun.jndi.ldap.AbstractLdapNamingEnumeration.hasMoreImpl(AbstractLdapNamingEnumeration.java:227)
... 68 common frames omitted
Caused by: javax.net.ssl.SSLHandshakeException: No subject alternative DNS name matching bar.local found.
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:383)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:326)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:321)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:654)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:473)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:369)
at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:396)
at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:480)
at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:458)
at java.base/sun.security.ssl.TransportContext.dispatch(TransportContext.java:206)
at java.base/sun.security.ssl.SSLTransport.decode(SSLTransport.java:172)
at java.base/sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1510)
at java.base/sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1425)
at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:455)
at java.base/sun.security.ssl.SSLSocketImpl.ensureNegotiated(SSLSocketImpl.java:925)
at java.base/sun.security.ssl.SSLSocketImpl$AppOutputStream.write(SSLSocketImpl.java:1295)
at java.base/java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:81)
at java.base/java.io.BufferedOutputStream.flush(BufferedOutputStream.java:142)
at java.naming/com.sun.jndi.ldap.Connection.writeRequest(Connection.java:418)
at java.naming/com.sun.jndi.ldap.Connection.writeRequest(Connection.java:391)
at java.naming/com.sun.jndi.ldap.LdapClient.ldapBind(LdapClient.java:359)
at java.naming/com.sun.jndi.ldap.LdapClient.authenticate(LdapClient.java:214)
at java.naming/com.sun.jndi.ldap.LdapCtx.connect(LdapCtx.java:2896)
at java.naming/com.sun.jndi.ldap.LdapCtx.<init>(LdapCtx.java:348)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getLdapCtxFromUrl(LdapCtxFactory.java:229)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getUsingURL(LdapCtxFactory.java:189)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getLdapCtxInstance(LdapCtxFactory.java:152)
at java.naming/com.sun.jndi.url.ldap.ldapURLContextFactory.getObjectInstance(ldapURLContextFactory.java:52)
at java.naming/javax.naming.spi.NamingManager.getURLObject(NamingManager.java:625)
at java.naming/javax.naming.spi.NamingManager.processURL(NamingManager.java:402)
at java.naming/javax.naming.spi.NamingManager.processURLAddrs(NamingManager.java:382)
at java.naming/javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:354)
at java.naming/com.sun.jndi.ldap.LdapReferralContext.<init>(LdapReferralContext.java:119)
... 71 common frames omitted
Caused by: java.security.cert.CertificateException: No subject alternative DNS name matching bar.local found.
at java.base/sun.security.util.HostnameChecker.matchDNS(HostnameChecker.java:212)
at java.base/sun.security.util.HostnameChecker.match(HostnameChecker.java:103)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:471)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:418)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:238)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:132)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:638)
... 100 common frames omitted
The tricky part is: the same rigorous SAN-checking does not happen on server startup, when SonarQube checks connectivity to all configured LDAP servers. Even if the TLS certificate is imperfect, it will log:
2025.04.14 21:42:54 INFO web[][o.s.a.l.LdapContextFactory] Test LDAP connection on ldaps://foo.bar.local:636: OK
Factors and events related to our specific setup and situation:
We had a 5-server LDAP setup. Unfortunately, we meant to use it as a failover cluster, so these LDAP directories were really just replicates of each other.
At a point, several users in the LDAP directory had their email addresses changed.
Somewhat later, we had downtimes for the first few LDAP servers listed in sonar.properties (such as LDAP_foobar). It lasted a few days, then we fixed it.
Meanwhile, we messed up the TLS certificates of our LDAP servers except one down the list (LDAP_valid).
Not totally sure about how it all played down, but the results were as follows:
login | external_login | external_identity_provider | |
---|---|---|---|
john_doe | [email protected] | john_doe | LDAP_foobar |
john_doe1234 | [email protected] | john_doe | LDAP_yeehaw |
Since the first few LDAP servers listed in sonar.properties (such as LDAP_foobar and LDAP_yeehaw) had a TLS certificate problem, the login process always failed over to LDAP_valid.
The LDAP_valid authentication was succesful, but the email address in the LDAP response was already present in the users table, so SonarQube threw an "Email '[email protected]' is already used" error.
How we managed to fix the situation:
SonarQube service stop. Backup.
We changed the LDAP configuration back to a single LDAP-server setup.
We had to update all the users.external_identity_provider database fields to 'sonarqube' to reflect the switch to single LDAP-server setup:
UPDATE users SET external_identity_provider = 'sonarqube' WHERE external_identity_provider LIKE 'LDAP_%';
We removed all the john_doe1234 duplicate users entries. (One record at a time delete statements.)
We updated all the old users.email fields to their new values.
SonarQube service start.