In a previous post, I detailed a trick to get complicated webapps working with client certificates.
The problem this solves is that some combination of web sockets, service workers (and perhaps some demonic magic) don’t play nicely with client certificates. Under some circumstances, the client certificate is just not sent.
The basic idea behind the solution is to instead authenticate by a couple of cookies with an HMAC. When these cookies aren’t present, you’re required to specify a client certificate. When a valid client certificate is presented, HMAC cookies are generated and dropped. If the cookies are present, you’re allowed access, even if you don’t have a client certificate.
This has worked well for me, but I still occasionally ran into issues. Basically every time I started a new session with something requiring client certs, I’d get some sort of bizarre access error. I dug in a little, and it seemed like the request to fetch the service worker code was failing because the browser wasn’t sending client certificates.
This led me to double down on the HMAC cookies.
Coming clean
When I call this Single Sign On, please understand that I really only have the vaguest possible understanding of what that means. If there are standards or something that are implied by this term, I’m not following them.
What I mean is that I have a centralized lua script that I can include in arbitrary nginx server configs, and it handles auth in the same way for all of them.
The nitty gritty
Rather than using HMAC cookies as a fallback auth mechanism and having “ssl_verifiy_client” set to “optional,” I do the following:
- If HMAC cookies are not present, nginx redirects to a different subdomain (it’s important that it’s on the same domain). This server config requires the client certificate.
- If the certificate is valid, it generates and drops the appropriate cookies, and the client is redirected to the original URL. The cookies are configured to be sent for all subdomains of a given domain.
- Now that the client has HMAC cookies, it’s allowed access. If the cookies were present to begin with, the above is skipped.
The setup has a couple of pieces:
- An nginx server for an “SSO” domain. This is the piece responsible for dropping the HMAC cookies.
- A lua script which is included everywhere you want to auth using this mechanism.
This is the SSO server config:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
server { listen 80; server_name sso.mydomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; # managed by Certbot server_name sso.mydomain.com;; error_log /var/log/nginx/sso.mydomain.com;/error.log info; access_log /var/log/nginx/sso.mydomain.com;/access.log; #....bunch of stuff generated by certbot....# ssl_client_certificate /etc/ssl/ca/certs/ca.crt; ssl_crl /etc/ssl/ca/private/ca.crl; ssl_verify_client on; location / { access_by_lua_file "/etc/nginx/scripts/sso.lua"; } } |
And the SSO lua script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
--- -- SET THIS TO SOMETHING RANDOMLY GENERATED! -- -- Make this file only readable by the nginx process, and keep it away from web roots. --- local HMAC_SECRET = “hunter2” --- -- Set this to your domain. Note that you’ll only be able to use this -- for things that have this same TLD. --- local DOMAIN = “mydomain.com” local COOKIE_TTL = 864000 local crypto = require "crypto" function ComputeHmac(msg, expires) return crypto.hmac.digest("sha256", string.format("%s%d", msg, expires), HMAC_SECRET) end function formatCookie(key, value) return string.format( "%s=%s; Secure; Path=/; Expires=%s; domain=.%s", key, value, ngx.cookie_time(ngx.time() + COOKIE_TTL), DOMAIN ) end if ngx.var.server_name == string.format(“sso.%s”, DOMAIN) then verify_status = ngx.var.ssl_client_verify if verify_status == "SUCCESS" then client = crypto.digest("sha256", ngx.var.ssl_client_cert) expires = ngx.time() + COOKIE_TTL ngx.header["Set-Cookie"] = { formatCookie("AccessToken", ComputeHmac(client, expires)), formatCookie("ClientId", client), formatCookie("AccessExpires", expires) } return ngx.redirect(ngx.unescape_uri(ngx.var.arg_r)) else ngx.exit(ngx.HTTP_FORBIDDEN) end else client = ngx.var.cookie_ClientId client_hmac = ngx.var.cookie_AccessToken access_expires = ngx.var.cookie_AccessExpires if client ~= nil and client_hmac ~= nil and access_expires ~= nil then hmac = ComputeHmac(client, access_expires) if hmac ~= "" and hmac == client_hmac and tonumber(access_expires) > ngx.time() then return end end return ngx.redirect(string.format(“https://sso.%s/?r=%s”, DOMAIN, ngx.escape_uri("https://" .. ngx.var.http_host .. ngx.var.request_uri))) end |
An example of it being used:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream myservice { server 127.0.0.1:1234; } server { listen 80; server_name myservice.mydomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; # managed by Certbot server_name myservice.mydomain.com; #.....bunch of stuff managed by certbot.....# proxy_buffering off; proxy_redirect http:// https://; 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 $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; location / { proxy_pass http://myservice; access_by_lua_file /etc/nginx/scripts/sso.lua; } } |