Protecting web sites with NGINX subrequest authentication
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 authenticationPOST /login
- XHR made from/login
interfaceGET /login
- login interfaceGET /logged-in
- tells user they are logged in, with link back to protected area and will have a logout linkGET /logout
- forces immediate logoutPOST /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.