Protecting web sites with NGINX subrequest authentication

2020-06-30

TL;DR

Protecting a web site with NGINX by using authentication server via a subrequest.

  • Use auth_request /auth in NGINX conf.
  • When user requests protected area, NGINX makes an internal request to /auth. If 201 is returned, protected contents are served. Anything else, NGINX responds with 401.
  • /auth is reverse proxied to Express app auth-server which handles authentication. Cookies are passed on as well, so the auth server can check for a JWT.
  • Auth server sets httpOnly cookie containing a JWT.
  • JWT updated with new expiry each time a user visits protected area.

Details

For this server block, we want to protect the entire site, except the authentication areas. We can use a NGINX conf file such as like this:

# optional:
# internal redirect to /login if there is a auth failure
# delete or comment this out if you don't want this behaviour and just show a generic 401 error
error_page 401 /login;

location / {
    auth_request /auth;

    # pass Set-Cookie headers from the subrequest response back to requestor
    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $auth_cookie;

    auth_request_set $auth_status $upstream_status;

    try_files $uri $uri/ /index.html;
}

location = /auth {
    # internaly only, /auth can not be accessed from outside
    internal;

    # internal proxy to auth-server running on port 3000, responses expected from proxy:
    #   2xx response = access allowed via auth_request
    #   401 or 403 response = access denied via auth_request
    #   anything else = error
    proxy_pass http://localhost:3000;

    # don't pass request body to proxied server, we only need the headers which are passed on by default
    proxy_pass_request_body off;

    # there is no content length since we stripped the request body
    proxy_set_header Content-Length "";

    # let proxy server know more details of request
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;
}

# these are handled by the proxy as part of the auth routines
location ~ ^/(login|logged-in|logout)$ {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;
}

# this CSS is used by the three requests above and is served by the proxy
location = /css/skeleton.css {
    proxy_pass http://localhost:3000;
}

# optional location block
# if you have other location blocks, be sure to add auth_request there too otherwise these requests won't get protected, for example
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
    expires 90d;
    log_not_found off;
    auth_request /auth;
}

Using directive auth_request /auth

We are protecting /. For each request to /* except for regex pattern ^/(auth|login|logged-in|logout)$ and /css/skeleton.css, NGINX will send a GET request to /auth and listen to the response. This is done with the auth_request directive.

A 201 response from /auth is a successful authentication and the /* contents will be served as normal. Any other reponse from /auth is a failed authentication and the client will be served a 401 (unauthorised) response. We'll customise this 401 response later by serving a login interface.

From NGINX's documentation:

"NGINX and NGINX Plus can authenticate each request to your website with an external server or service. To perform authentication, NGINX makes an HTTP subrequest to an external server where the subrequest is verified. If the subrequest returns a 2xx response code, the access is allowed, if it returns 401 or 403, the access is denied. Such type of authentication allows implementing various authentication schemes, such as multifactor authentication, or allows implementing LDAP or OAuth authentication."

We use add_header Set-Cookie $auth_cookie so that any Set-Cookie header returned from the upstream auth server is forwarded back to the client. The auth server usually uses Set-Cookie to renew the JWT each time, so that any timeout is respected and calculated from the time of last access.

The headers from client-to-server is passed on to /auth as well, including any cookies. This is important, as a JWT is used to determine if the client is authenticated.

Send subrequests to Node-Express app auth-server

We run a Node-Express auth-server on http://localhost:3000. Then proxy all requests to /auth to app. This app will ignore any request body content when made to /auth, so we can use:

location = /auth {
    internal;
    proxy_pass http://localhost:3000;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;
}

The last 3 directives here, add an extra 3 headers to the subrequest. The auth-server could use it to determine authentication status, but it doesn't at the moment.

proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Remote-Addr $remote_addr;
proxy_set_header X-Original-Host $host;

auth-server

We are running the open source auth-server (written by myself). In summary, it listens on port 3000 for the following requests:

  • GET /auth - requested by NGINX for subrequest authentication
  • POST /login - XHR made from /login interface
  • GET /login - login interface
  • GET /logged-in - tells user they are logged in, with link back to protected area and will have a logout link
  • GET /logout - forces immediate logout
  • POST /logout - XHR made from /logged-in interface

The following location block, will pass requests to those URIs to the auth-server at http://localhost:3000 with a reverse proxy.

location ~ ^/(login|logged-in|logout)$ {
    proxy_pass http://localhost:3000;
}

NGINX default error response

401 (unauthorised) errors are handled by rendering to the user the /login page. We add this to the server block

error_page 401 /login;

When a user is not authenticated and attempts to visit a protected area, it serves the /login interface. This is not an external redirect and the user's browser will still show original target URL.

Logging out

To log out, the client need to remove its cookie. Since it's a httpOnly cookie, the request to clear the cookies must come from a Set-Cookie response header with empty contents.

User authentication will also automatically time out from cookie expiry and JWT expiry time.

To do this, we proxy_pass a GET /logout request to the auth server, which then returns the desired Set-Cookie header which will subsequently remove the token.

Links