Migrating SonarQube from AAD Plugin to Built-in SAML

A practical walkthrough of migrating SonarQube users from the community AAD plugin to the built-in SAML authentication, including fixing the HTTP/HTTPS callback URL.

Migrating SonarQube from AAD Plugin to Built-in SAML
Photo by Markus Spiske / Unsplash

We had a SonarQube instance running on Kubernetes. It used the community AAD plugin (sonar-auth-aad) for authentication with Microsoft Entra ID. The plugin worked, but it was a third-party plugin with no guaranteed future. SonarQube 10.6 has built-in SAML support, so I wanted to migrate to that.

What I thought would be a simple configuration change turned into a two-week journey through SAML encryption, callback URL mismatches, and database-level user migration. Here is what happened.

The Setup

Our SonarQube lives on a Tencent Cloud TKE cluster, behind quite a few layers:

User
  | HTTPS
  v
Cloudflare
  | HTTPS
  v
Public CLB (Tencent Cloud)     <- TLS terminates here
  | HTTP
  v
Private CLB (Tencent Cloud)
  | HTTP
  v
NGINX Ingress Controller
  | HTTP
  v
SonarQube (10.6 Community Build)

The key detail: TLS terminates at the public CLB. Everything downstream from that point is plain HTTP. This becomes important later.


Phase 1: SAML Configuration

Setting up SAML itself was simple. In the SonarQube admin UI, under Administration > Configuration > SAML, I filled in the values from the Entra ID Enterprise Application. The docs map everything clearly: Application ID, Provider ID, Login URL, the IdP certificate, and the user attribute claims.

I hit Test Configuration and got a NullPointerException. Not exactly encouraging.

The error was misleading. The real problem was in the logs:

XMLCipher::decryptElement unable to resolve a decryption key
Error executing decryption: encryption.nokey

Our Entra ID app had Token Encryption enabled. It was sending encrypted SAML assertions, and SonarQube had no key to decrypt them. The fix was to generate a PKCS8 private key and certificate, upload the certificate to Entra ID, and configure both in SonarQube under sonar.auth.saml.sp.privateKey.secured and sonar.auth.saml.sp.certificate.secured. After that, the test passed.

The test configuration showed all the expected attributes coming through. Things looked good. I was optimistic.


Phase 2: The Callback URL Nightmare

I clicked the SAML login button on the SonarQube login page. Entra ID authenticated me. Then I got this:

The response was received at http://sonarqube.example.com/oauth2/callback/saml
instead of https://sonarqube.example.com/oauth2/callback/saml

SonarQube's SAML library (OneLogin) constructs the callback URL based on the perceived request scheme. The request arrives at SonarQube as HTTP, so it constructs http://... callbacks. But the SAML response expects https://. The library rejects the mismatch.

This is where I spent most of the time. Here is everything I tried that did not work.

Attempt 1: Tencent Cloud CLB Custom Config

The public CLB has a custom configuration feature. I tried adding proxy_set_header X-Forwarded-Proto https; there. It did not work because Tencent Cloud CLB only supports a limited set of variables for proxy_set_header: $vport, $remote_port, $lbid, $stgw_request_id, $request_method, $uri. You cannot set a literal string value like https. The documentation confirmed this.

Attempt 2: Cloudflare Request Header Transform Rules

Cloudflare has rules to modify request headers. I tried setting X-Forwarded-Proto: https at the Cloudflare level. Cloudflare blocks this. They explicitly document that you cannot modify x-forwarded-proto in request header transform rules. It is a security measure to prevent header spoofing.

Attempt 3: NGINX Ingress Controller Snippets and ConfigMaps

I tried several approaches at the NGINX Ingress level:

server-snippet annotation: Adding proxy_set_header X-Forwarded-Proto https; in a server snippet. The NGINX controller overrides this with its own $pass_access_scheme value, which is http because the incoming traffic from the CLB is HTTP.

proxy-set-headers ConfigMap: Created a ConfigMap with X-Forwarded-Proto: https and referenced it via the nginx.ingress.kubernetes.io/proxy-set-headers annotation. Same problem. The controller's own proxy header logic wins.

configuration-snippet annotation: Same result. Whatever I set, the controller's calculated $pass_access_scheme overrides it.

Changing backend protocol: I tried setting nginx.ingress.kubernetes.io/backend-protocol: HTTPS. This caused the Ingress to send HTTPS to the backend. But SonarQube listens on HTTP (port 9000). The result was a 404 and the application failing to start.

Attempt 4: Spring Boot Forward Headers Strategy

I tried adding server.forward-headers-strategy=NATIVE to SonarQube's JVM options. This is a Spring Boot property that tells the application to trust the X-Forwarded-* headers. But the problem was upstream. The header was already wrong (http) by the time it reached this point. Trusting a wrong header does not help.

What Actually Worked: A Sidecar NGINX

The problem was that no component in the chain could set X-Forwarded-Proto: https as a literal value. The CLB could not do it. Cloudflare would not allow it. The NGINX Ingress controller only uses dynamic values from the connection.

The only place left was inside the pod itself. I added a sidecar NGINX container that sits in front of SonarQube and hardcodes the X-Forwarded-Proto header.

SonarQube Pod
├── SonarQube Container (port 9000)
└── NGINX Sidecar Container (port 8080)
         │
         │ Sets X-Forwarded-Proto: https
         │ Sets X-Forwarded-Port: 443
         ▼
    SonarQube (localhost:9000)

The sidecar config is simple:

server {
    listen 8080;
    client_max_body_size 100m;

    location / {
        proxy_pass http://localhost:9000;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Port 443;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $http_host;
    }
}

The Ingress now points to the sidecar (port 8080) instead of directly to SonarQube (port 9000). The sidecar receives the HTTP request from the Ingress, adds X-Forwarded-Proto: https, and proxies to SonarQube. SonarQube sees the correct header. The SAML callback URL is now https://.

This is admittedly a hack. A sidecar container just to set one header. But it was the only thing that worked across all the layers we could not control.

One side effect I hit later: the sidecar NGINX has a default client_max_body_size of 1MB. The SonarQube CE submit endpoint sends payloads larger than that. I got a 413 error. Adding client_max_body_size 100m; to the sidecar config fixed it. The original Ingress had proxy-body-size: 100m, but the sidecar was the bottleneck.


Phase 3: Migrating Users from AAD to SAML

The SAML login was working. But there was a problem. Our existing users were all created with external_identity_provider = 'aad' in the database. When they clicked SAML login, SonarQube did not match them to their existing account. Instead, it created a brand new user with saml provider and default permissions. No groups, no project access. The original AAD user with all the correct permissions was still there, untouched.

I needed to change every user's provider from aad to saml.

The API Approach That Failed

My first instinct was to use SonarQube's Web API. There is an endpoint: api/users/update_identity_provider. I tried it:

curl -X POST -u admin:<password> \
  "https://sonarqube.example.com/api/users/update_identity_provider?login=<user>&newExternalProvider=saml"

It returned a 500 error. The endpoint was deprecated in SonarQube 10.4. After deprecation, it just refuses to work.

I tried other API approaches. Deactivating and reactivating the user. Calling api/users/update with different parameters. None of them worked. The api/users/reactivate endpoint does not even exist in 10.6.

The API was not going to work. I had to go to the database.

The Database Migration Approach

The SonarQube database is PostgreSQL. The users table has columns external_identity_provider, external_login, and external_id. The migration needed to change external_identity_provider from aad to saml, and update the external_login and external_id to match what SAML returns.

Here is the catch. SAML returns the NameID claim with the domain part lowercased but the prefix preserved as-is. So if the original AAD login was [email protected], the SAML identity would be [email protected]. Not all lowercase. Just the domain.

I learned this by trial and error. The first few users I migrated manually, I lowercased everything. They could not log in. I checked the SAML test configuration output and noticed the pattern: the prefix was preserved, only the domain changed.

The migration script I wrote processes each user one by one:

  1. Fetch all users with external_identity_provider = 'aad'
  2. For each user, compute the lowercased domain version of external_login
  3. Delete any existing SAML user with that external identity (these are duplicates created by SonarQube when users tried SAML login before the migration)
  4. Update the user's external_identity_provider, external_login, and external_id

The script supports dry-run mode and single-user mode for testing. The first version used kubectl run to create a PostgreSQL pod for each query, which was painfully slow. About 30 minutes for 100 users. I rewrote it to use psql directly from the jumphost, which finishes all ~300 users in about five seconds.

The good news is that group memberships and permissions are preserved. The groups_users table references user_uuid, which we never change. We only update external_identity_provider, external_login, and external_id. So after migration, the user logs in via SAML and sees all their projects and permissions exactly as before.

The Final State

After the migration is complete, the steps are:

  1. Disable the AAD plugin in the SonarQube UI
  2. Delete the sonar-auth-aad-*.jar from the extensions directory
  3. Restart SonarQube

Users see only the SAML login button. They log in with their Entra ID credentials. Their permissions are the same as before.