Nginx – Different Nginx redirects based on upstream proxy response

nginxPROXYredirect

I have an upstream server handling the login of our website.
On a successful login I want to redirect the user to the secure part of the site.
On a failure to login I want to redirect the user to the login form.

The upstream server returns a 200 OK on a successful login and a 401 Unauthorized on a failed login.

This is the relevant part of my configuration:

{
    error_page 401 = @error401
    location @error401 {
        return 302 /login.html # this page holds the login form
    }

    location = /login { # this is the POST target of the login form
        proxy_pass http://localhost:8080;
        proxy_intercept_errors on;
        return 302 /secure/; # without this line, failures work. With it failed logins (401 upstream response) still get 302 redirected
    }
}

This setup works when succeeding to login. The client is redirect with a 302. This does not work when failing to login. The upstream server returns 401 and I expected that the error_page would then kick in. But I still get the 302. If I remove the return 302 /secure/ line the redirect to the login page works. So it seems I can have either one but not both.

Bonus question; I doubt the way I handle the error_page with that named location is The Way. Am I correct in doing it like this?

edit: Turns out having a return in the location block makes Nginx not use the proxy_pass at all. So it makes sense the error page is not hit. The problem on how to do this, however, remains.

Best Answer

The exact solution to the question is to use the Lua capabilities of Nginx.

On Ubuntu 16.04 you can install a version of Nginx supporting Lua with:

$ apt install nginx-extra

On other systems it might be different. You can also opt for installing OpenResty.

With Lua you have full access to the upstream response. Note that you appear to have access to the upstream status via the $upstream_status variable. And in a way you do but due to the way 'if' statements are evaluated in Nginx you can not use $upstream_status in the 'if' statement conditional.

With Lua your configuration will then look like:

    location = /login { # the POST target of your login form
           rewrite_by_lua_block {
                    ngx.req.read_body()
                    local res = ngx.location.capture("/login_proxy", {method = ngx.HTTP_POST})
                    if res.status == 200 then
                            ngx.header.Set_Cookie = res.header["Set-Cookie"] # pass along the cookie set by the backend
                            return ngx.redirect("/shows/")
                    else
                            return ngx.redirect("/login.html")
                    end
            }
    }

    location = /login_proxy {
            internal;
            proxy_pass http://localhost:8080/login;
    }

Pretty straight forward. The only two quirks are the reading of the request body in order to pass along the POST parameters and the setting of the cookie in the final response to the client.


What I actually ended up doing, after a lot of prodding from the community, is that I handled the upstream stream responses on the client side. This left the upstream server unchanged and my Nginx configuration simple:

location = /login {
       proxy_pass http://localhost:8080;
}

The client initialing the request handles the upstream response:

  <body>
    <form id='login-form' action="/login" method="post">
      <input type="text" name="username">
      <input type="text" name="password">
      <input type="submit">
    </form>
    <script type='text/javascript'>
      const form = document.getElementById('login-form');
      form.addEventListener('submit', (event) => {
        const data = new FormData(form);
        const postRepresentation = new URLSearchParams(); // My upstream auth server can't handle the "multipart/form-data" FormData generates.
        postRepresentation.set('username', data.get('username'));
        postRepresentation.set('password', data.get('password'));

        event.preventDefault();

        fetch('/login', {
          method: 'POST',
          body: postRepresentation,
        })
          .then((response) => {
            if (response.status === 200) {
              console.log('200');
            } else if (response.status === 401) {
              console.log('401');
            } else {
              console.log('we got an unexpected return');
              console.log(response);
            }
          });
      });
    </script>
  </body>

The solution above achieves my goal of having a clear separation of concerns. The authentication server is oblivious to the use cases the callers want to support.