Using the NGINX Auth Request Module

In this post I will describe one interesting customer request we had previously dealt with. This is an older project, but I think the problem is still relevant.

The customer has an existing web application that is hosted in a dedicated datacenter along with the entire HW infrastructure, which includes [Citrix NetScaler] (https://www.citrix.com/products/netscaler-adc/) - a load balancer and reverse proxy appliance with few extra features. One such feature is an authentication gateway, i.e. NetScaler only allows access to backend applications to authenticated users. The customer’s web application is, however, only one of many applications that together form a complex system. All the applications are hosted in the same data center and share the same domain users, i.e., the domain user can access every application with one username/password pair. Within each application, each domain user is mapped to an application user. The situation is schematically illustrated in the following figure.

NetScaler authentication scheme

The simplified user authentication process consists of the following steps:

  1. HTTP GET https://protected-resource.example.redbyte.eu
  2. NetScaler detects that the user is not authenticated and redirects (HTTP 302) to login page
  3. POST HTTP request to login page
  4. User Authentication against Active Directory
  5. Redirect (HTTP 302) to the original destination (https://protected-resource.example.redbyte.eu)
  6. HTTP GET https://protected-resource.example.redbyte.eu
  7. Proxy to a backend server. The backend server reads domain username from HTTP header and identifies the corresponding application user.

The problem with such setup is its testability. The customer has several staging environments and introducing NetScaler into these environments would be overkill (not counting the domain management for all the environments). The customer’s request was to somehow “bypass” NetScaler and all the complexity of user configuration and management without changing the code or configuration of the application. It had to look and behave as if NetScaler was there.

Meet the ngx_http_auth_request_module

The first solution that came to our minds was to use the excellent [HAProxy] (http://www.haproxy.org/) load balancer (because we have several backends) and place a custom authentication proxy before it. Let’s call it FakeNetScaler (basically a reverse proxy server). This would mean that each HTTP request would be processed by two reverse proxies. Surely, there must be a more straightforward and simpler solution.

Another solution is to use [NGINX] (https://nginx.org/) HTTP Server along with the [ngx_http_auth_request_module] (http://nginx.org/en/docs/http/ngx_http_auth_request_module.html). The documentation for this module says, it implements client authorization based on the result of a subrequest. What exactly does this mean? The principle is quite simple - when you make an HTTP request to a protected URL, NGINX performs an internal subrequest to a defined authorization URL. If the result of the subrequest is HTTP 2xx, NGINX proxies the original HTTP request to the backend server. If the result of the subrequest is HTTP 401 or 403, access to the backend server is denied. By configuring NGINX, you can redirect those 401s or 403s to a login page where the user is authenticated and then redirected to the original destination. The entire authorization subrequest process is then repeated, but because the user is now authenticated the subrequest returns HTTP 200 and the original HTTP request is proxied to the backend server.

Naturally, NGINX only provides a mechanism to achieve this - the authorization server must be custom build for specific use case. In our case, FakeNetscaler is the authorization server - I will get to that later. Now let’s see how the ngx_http_auth_request_module works:

Authentications scheme using NGINX and ngx_http_auth_request_module

  1. HTTP GET https://protected-resource.example.redbyte.eu
  2. NGINX sends an authorization subrequest to FakeNetScaler
  3. The user is not yet authenticated, so FakeNetScaler returns the HTTP 401 code
  4. NGINX redirects browser (HTTP 302) to login page
  5. The user enters the login credentials and submits the login form
  6. Login credentials are valid, FakeNetScaler returns a cookie containing “the user with username XXX is authenticated” and redirects browser (HTTP 302) to the original destination
  7. HTTP GET the original destination
  8. NGINX sends an authorization subrequest to FakeNetScaler
  9. FakeNetscaler reads the cookie content and realizes that the user is authenticated, therefore returns HTTP 200 as the result of the subrequest
  10. NGINX proxies the request to a backend server, together with HTTP header with domain username. Backend server reads the domain username HTTP header and identifies the corresponding application user.

At first glance, this seems to be even more complex than the original NetScaler authentication process, but the truth is that I just described it using white box approach, where in case of NetScaler it was described as a black box (especially the points 3., 4. and 5.).

It should be clear now, how the ngx_http_auth_request_module works. Let’s look at the NGINX configuration file for protected-resource.example.redbyte.eu domain:

 1server {
 2  listen   443 ssl;
 3  server_name protected-resource.example.redbyte.eu;
 4
 5  # ssl and server configuration left out
 6
 7  location / {
 8    auth_request /auth;
 9    error_page 401 = @error401;
10
11    auth_request_set $user $upstream_http_x_forwarded_user;
12    proxy_set_header X-Forwarded-User $user;
13    proxy_pass http://protected-resource:8080;
14  }
15
16  location /auth {
17    internal;
18    proxy_set_header Host $host;
19    proxy_pass_request_body off;
20    proxy_set_header Content-Length "";
21    proxy_pass http://fakenetscaler:8888;
22  }
23
24  location @error401 {
25    add_header Set-Cookie "NSREDIRECT=$scheme://$http_host$request_uri;Domain=.example.redbyte.eu;Path=/";
26    return 302 https://fakenetscaler.example.redbyte.eu;
27  }
28
29}

The most important lines are:

  • 8 - here we say that for all URLs beginning with / NGINX will execute the authorization subrequest to the /auth URL
  • 9 - that HTTP code 401 will be redirected to the login page
  • 11 - here we set $user variable to the value sent by authorization server via the X-Forwarded-User HTTP header
  • 12 - X-Forwarded-User HTTP header is set by NGINX to the value of $user variable
  • 16 - here we define the authorization subrequest. The subrequest is proxied to http://fakenetscaler:8888, which is a host on the internal network
  • 25 - here we set a cookie with original destination URL
  • 26 - an HTTP 302 redirect to the login page served by the authorization server. In this case, we need to use a full domain name because the browser is not able to resolve internal hostnames.

NGINX configuration file for authorization server domain fakenetscaler.example.redbyte.eu:

 1server {
 2  listen   443 ssl;
 3  server_name fakenetscaler.example.redbyte.eu;
 4
 5  # ssl and server configuration left out
 6
 7  location / {
 8        proxy_set_header Host $http_host;
 9        proxy_pass http://fakenetscaler:8888;
10  }
11}

As you can see, it is a reverse proxy to a backend server at http://fakenetscaler:8888 running the autorization HTTP server.

FakeNetScaler

So far, we have only played with NGINX server configuration. Let’s look at the FakeNetscaler authorization server. As I mentioned earlier, NGINX only provides an authorization framework, the authorization server needs to be custom build and tailored to customer’s requirements:

  • must be able to respond to HTTP GET /auth request and decide whether or not the user is authenticated on a cookie. If it is, it will respond with HTTP 200 if it does not, HTTP 401

  • to HTTP GET / displays the login page

  • to HTTP POST / submit the login form. If a user has entered the correct login and password, the cookie establishes that the user is authenticated and redirects it to the original destination based on the information stored in the Cookie. If the user did not enter the correct login information, the login page with the error description will be displayed again

  • must be able to respond to the HTTP GET /auth request and based on the cookie value, decide whether user is logged in or not. In case the user is logged in the HTTP response code is 200, 401 otherwise.

  • HTTP GET to / URL displays the login page

  • HTTP POST to / URL submits the login form. If the user has entered a valid username and password, a login cookie is created and the browser is redirected to original destination. If the user did not enter valid username or password the login page with error message is displayed.

This should be a really simple service and we are going to implement it using the [Go] (https://golang.org/) programming language. Go has a rich standard library including a very capable HTTP server. There is no need for a third party server runtime (e.g. as in most Java deployments).

Please, judge yourself, this is a complete source code of FakeNetScaler server:

  1package main
  2
  3import (
  4	"flag"
  5	"fmt"
  6	"github.com/BurntSushi/toml"
  7	"github.com/codegangsta/negroni"
  8	"github.com/gorilla/securecookie"
  9	"github.com/julienschmidt/httprouter"
 10	"gopkg.in/unrolled/render.v1"
 11	"log"
 12	"net/http"
 13	"time"
 14)
 15
 16var (
 17	nsCookieName         = "NSLOGIN"
 18	nsCookieHashKey      = []byte("SECURE_COOKIE_HASH_KEY")
 19	nsRedirectCookieName = "NSREDIRECT"
 20	cfg                  config
 21)
 22
 23type config struct {
 24	// e.g. https://protected-resource.example.redbyte.eu
 25	DefaultRedirectUrl string
 26	// shared password
 27	Password string
 28	// shared domain prefix between protected resource and auth server
 29	// e.g. .example.redbyte.eu (note the leading dot)
 30	Domain string
 31}
 32
 33func main() {
 34
 35	// configuration
 36	port := flag.Int("port", 8888, "listen port")
 37	flag.Parse()
 38	var err error
 39	if cfg, err = loadConfig("config.toml"); err != nil {
 40		log.Fatal(err)
 41	}
 42
 43	// template renderer
 44	rndr := render.New(render.Options{
 45		Directory:     "templates",
 46		IsDevelopment: false,
 47	})
 48
 49	// router
 50	router := httprouter.New()
 51	router.GET("/", indexHandler(rndr))
 52	router.POST("/", loginHandler(rndr))
 53	router.GET("/auth", authHandler)
 54
 55	// middleware and static content file server
 56	n := negroni.New(negroni.NewRecovery(), negroni.NewLogger(),
 57		&negroni.Static{
 58			Dir:    http.Dir("public"),
 59			Prefix: ""})
 60	n.UseHandler(router)
 61
 62	n.Run(fmt.Sprintf(":%d", *port))
 63}
 64
 65func authHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 66	var s = securecookie.New(nsCookieHashKey, nil)
 67	// get the cookie from the request
 68	if cookie, err := r.Cookie(nsCookieName); err == nil {
 69		value := make(map[string]string)
 70		// try to decode it
 71		if err = s.Decode(nsCookieName, cookie.Value, &value); err == nil {
 72			// if if succeeds set X-Forwarded-User header and return HTTP 200 status code
 73			w.Header().Add("X-Forwarded-User", value["user"])
 74			w.WriteHeader(http.StatusOK)
 75			return
 76		}
 77	}
 78
 79	// otherwise return HTTP 401 status code
 80	http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 81}
 82
 83func indexHandler(render *render.Render) httprouter.Handle {
 84	return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
 85		// just render the login page
 86		render.HTML(w, http.StatusOK, "index", nil)
 87	}
 88}
 89
 90func loginHandler(render *render.Render) httprouter.Handle {
 91	return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
 92		login := r.PostFormValue("login")
 93		passwd := r.PostFormValue("passwd")
 94
 95		var errorMessage = false
 96
 97		// nothing fancy here, it is just a demo so every user has the same password
 98		// and if it doesn't match render the login page and present user with error message
 99		if login == "" || passwd != cfg.Password {
100			errorMessage = true
101			render.HTML(w, http.StatusOK, "index", errorMessage)
102		} else {
103			var s = securecookie.New(nsCookieHashKey, nil)
104			value := map[string]string{
105				"user": login,
106			}
107
108			// encode username to secure cookie
109			if encoded, err := s.Encode(nsCookieName, value); err == nil {
110				cookie := &http.Cookie{
111					Name:    nsCookieName,
112					Value:   encoded,
113					Domain:  cfg.Domain,
114					Expires: time.Now().AddDate(1, 0, 0),
115					Path:    "/",
116				}
117				http.SetCookie(w, cookie)
118			}
119
120			// after successful login redirect to original destination (if it exists)
121			var redirectUrl = cfg.DefaultRedirectUrl
122			if cookie, err := r.Cookie(nsRedirectCookieName); err == nil {
123				redirectUrl = cookie.Value
124			}
125			// ... and delete the original destination holder cookie
126			http.SetCookie(w, &http.Cookie{
127				Name:    nsRedirectCookieName,
128				Value:   "deleted",
129				Domain:  cfg.Domain,
130				Expires: time.Now().Add(time.Hour * -24),
131				Path:    "/",
132			})
133
134			http.Redirect(w, r, redirectUrl, http.StatusFound)
135		}
136
137	}
138}
139
140// loads the config file from filename
141// Example config file content:
142/*
143defaultRedirectUrl = "https://protected-resource.example.redbyte.eu"
144password = "shared_password"
145domain = ".example.redbyte.eu"
146*/
147func loadConfig(filename string) (config, error) {
148	var cfg config
149	if _, err := toml.DecodeFile(filename, &cfg); err != nil {
150		return config{}, err
151	}
152	return cfg, nil
153}

After compiling the Go code, a statically linked binary with no other runtime dependencies is created. When you run it you will get an HTTP server listening on port 8888.

Summary

In this blog, we have shown how to use NGINX and its ngx_http_auth_request_module, which provides a basic framework for creating custom client authorization using simple principles. Using the Go programming language, we have implemented our own authorization server, which we used together with NGINX.