Jenkins Azure AD login redirects to root instead of original URL

A walkthrough of debugging a Jenkins OAuth redirect issue where users landed on the root URL after Azure AD login instead of the original page they requested.

Jenkins Azure AD login redirects to root instead of original URL
Photo by Stephen Andrews / Unsplash

We run Jenkins behind nginx, with Azure AD for authentication. A user reported that when they tried to access a deep URL, like /job/<team>/<project>/<pipeline>/, they got bounced to Azure AD to log in. So far, normal. But after logging in, they landed on the Jenkins root URL instead of the page they originally asked for. Every time.

This kind of thing is annoying. You click a link someone sent you, you authenticate, and then you have to navigate back to where you wanted to go. Multiply that by a team of engineers and it adds up fast.

The setup

Jenkins is running in a Docker container behind an nginx reverse proxy, both on the same VM. The HTTPS certificate is terminated by a cloud load balancer, which forwards HTTP to nginx.

The DNS for <jenkins-domain> is a CNAME pointing to the load balancer's public DNS name. The load balancer's public IP is something like <lb-public-ip>.

Jenkins is configured with its URL set to https://<jenkins-domain>/. The Azure AD app registration has the Reply URL set to https://<jenkins-domain>/securityRealm/finishLogin. Both look correct.

So what was going wrong?


First clue: the NullPointerException

The Jenkins logs had this error, repeated every time someone tried to log in:

Error while serving https://<lb-public-ip>/securityRealm/commenceLogin
java.lang.NullPointerException: Cannot invoke "Object.toString()" because the
return value of "jakarta.servlet.http.HttpSession.getAttribute(String)" is null
    at PluginClassLoader for azure-ad//com.microsoft.jenkins.azuread.oauth.StateCache.generateValue(StateCache.java:18)
    at PluginClassLoader for azure-ad//com.microsoft.jenkins.azuread.AzureSecurityRealm.doCommenceLogin(AzureSecurityRealm.java:388)

Two things jumped out at me.

First, the URL in the error message was https://<lb-public-ip>/securityRealm/commenceLogin. Not <jenkins-domain>. The IP address. That meant Jenkins was seeing the request as coming from the load balancer's IP, not the domain name.

Second, the StateCache.generateValue method was failing because a session attribute was null. The StateCache is what stores the original URL the user was trying to access. If it can't find the session attribute, the original URL is lost.

Why the load balancer IP was showing up

The request flow is:

Browser (DNS) → CNAME → Load balancer → nginx → Jenkins

The load balancer terminates HTTPS and forwards HTTP to nginx. But when it forwards the request, it sends the Host header as its own IP address, not the original domain. My nginx config had:

proxy_set_header Host $host;

The $host variable in nginx evaluates to whatever Host header it received from upstream. In this case, the load balancer was sending Host: <lb-public-ip>. So nginx forwarded that to Jenkins. Jenkins then used the IP address to construct its redirect URLs.

When Jenkins redirected the browser to /securityRealm/commenceLogin, the browser got a URL with the IP address. The browser went there, but the JSESSIONID cookie was set for the domain <jenkins-domain>. The browser didn't send the cookie to the IP address. Jenkins saw a brand new session with no saved attributes. The StateCache crashed.

Fix 1: hardcode the Host header in nginx

The fix was simple. Instead of forwarding whatever Host header the load balancer sent, I hardcoded it to the domain:

proxy_set_header Host <jenkins-domain>;
proxy_set_header X-Forwarded-Host <jenkins-domain>;

I also replaced proxy_redirect off with proper redirect rewriting, just as a safety net:

proxy_redirect http://<jenkins-container>:8080/ https://<jenkins-domain>/;
proxy_redirect http://<jenkins-container>:8080 https://<jenkins-domain>/;

After restarting nginx, the NullPointerException was gone. The doCommenceLogin method was completing without errors. But the redirect after login was still going to the root URL.

Second clue: the updateAvatar error

The new logs showed a different error:

WARNING ... AzureSecurityRealm updateAvatar
Failed to get profile photo for <user-oid>
com.microsoft.graph.http.GraphServiceException: 401 : Unauthorized
GET https://graph.microsoft.com/v1.0/users/<user-oid>/photos/48x48

The doFinishLogin method was now being called successfully. It authenticated the user. Then it called updateAvatar, which tried to fetch the user's profile photo from Microsoft Graph. That call failed with a 401.

I looked at the Azure AD app permissions. The app had User.Read (Delegated) for Microsoft Graph. But looking at the plugin source code, the getAzureClient() method uses client credentials (application context) to call the Graph API. The getAccessToken() method requests the .default scope, which only resolves application permissions. User.Read is a delegated permission, not an application permission. So the token didn't have the right scope.

But here's the thing. Looking at the updateAvatar method in the plugin source, it catches the ApiException and logs it. It doesn't re-throw it. So the doFinishLogin method should continue to the redirect code. Yet the redirect was still going to the root URL.

I was about to try disabling the avatar fetching with a system property when I noticed something in the plugin changelog.

The real fix: plugin version 668

The Azure AD plugin we had installed was version 667. Version 668 had a single fix listed:

"Restore support for redirecting to non root path" (PR #800)

That was it. The redirect to the root URL was a known bug in version 667, fixed in 668. The updateAvatar error was a red herring. It was logged as a warning, but the real reason the redirect was failing was the plugin bug.

Upgrading to version 668 fixed the issue immediately. After the upgrade, users who logged in through Azure AD were redirected to the original URL they requested, not the root.


Closing

The nginx Host header fix was still necessary. The load balancer sending the IP as the Host header was causing the StateCache NullPointerException, which was a separate issue. If I had only upgraded the plugin, the login would still have failed with the first error.

The updateAvatar error was a distraction. It was logged as a warning but didn't actually block the redirect. I spent time looking at Graph API permissions when the real fix was a one-line plugin upgrade.

After all changes, here's what the nginx config looks like:

server {
    listen 80;
    server_name <jenkins-domain>;

    location / {
        proxy_pass http://<jenkins-container>:8080;

        proxy_http_version 1.1;

        proxy_set_header Host <jenkins-domain>;
        proxy_set_header X-Forwarded-Host <jenkins-domain>;
        proxy_set_header X-Forwarded-Server $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-Proto https;
        proxy_set_header X-Forwarded-Port 443;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_redirect http://<jenkins-container>:8080/ https://<jenkins-domain>/;
        proxy_redirect http://<jenkins-container>:8080 https://<jenkins-domain>/;
    }
}

And the Azure AD plugin is now on version 668.