Add OIDC single sign-on to any website with HAProxy and Keycloak

Given a simple web application that is unaware of authentication, it's possible to wrap a single sign-on implementation around it to require authentication using only Keycloak and HAProxy.

Why Keycloak?

Most full-featured identity providers are cloud-based from the large vendors: Okta, OneLogin, AWS, others. The only self-hosted alternative I'm aware of is Ory Hydra which I admittedly haven't tried.

While applications and databases can be migrated to different platforms fairly easily, it's very difficult to migrate your user store to some other product. The vendors may not provide tooling since don't want you to leave, and they probably won't support moving user passwords. This makes the migration high-risk and disruptive for users. For these reasons I advocate self-hosting your IdP, even if the rest of your stack is cloud-native.

Keycloak is also surprisingly good. It does everything including authentication (single sign-on, OIDC) and authorization (OAuth) with a state-of-the-art feature set that I can host myself with no cloud dependencies.

Why HAProxy?

I've been levelling up my HAProxy skills and am always impressed at how good is. I explore a number of new-to-me features in this article.

I suspected that I could take a standalone website and put HAProxy in front of it to require OIDC authentiction.

There are other ways to wrap an application with OIDC like Dex. Using HAProxy was attractive since I'm already running it in a general CDN/WAF way so using it also for OIDC doesn't add a new dependecy to my stack.

It's all running locally. Keycloak is started with the local dev settings:

erik@carbon ~/tmp/keycloak/keycloak-20.0.1 $ ./bin/kc.sh start-dev
2022-12-22 12:56:22,312 INFO  [org.keycloak.quarkus.runtime.hostname.DefaultHostnameProvider] (main) Hostname settings: Base URL: <unset>, Hostname: <request>, Strict HTTPS: false, Path: <request>, Strict BackChannel: false, Admin URL: <unset>, Admin: <request>, Port: -1, Proxied: false
2022-12-22 12:56:23,257 WARN  [io.quarkus.agroal.runtime.DataSources] (main) Datasource <default> enables XA but transaction recovery is not enabled. Please enable transaction recovery by setting quarkus.transaction-manager.enable-recovery=true, otherwise data may be lost if the application is terminated abruptly
2022-12-22 12:56:23,883 WARN  [org.infinispan.CONFIG] (keycloak-cache-init) ISPN000569: Unable to persist Infinispan internal caches as no global state enabled
2022-12-22 12:56:23,897 WARN  [org.infinispan.PERSISTENCE] (keycloak-cache-init) ISPN000554: jboss-marshalling is deprecated and planned for removal
...

... with a realm "myrealm" and user "myuser" created as per the Get Started guide for bare metal.

The app I'm protecting is the magic8ball example of an HAProxy service. It's not even an application but an HAProxy service which is a small application that runs in the HAProxy process as a Lua script. This code is here.

Run the unprotected service

magic8ball.lua:

local function magic8ball(applet)
    local responses = {"Reply hazy", "Yes - definitely", "Don't count on it", "Outlook good", "Very doubtful"}
    local myrandom = math.random(1, #responses)
    local response = string.[[
        <html>
            <body>
              <div>responses[myrandom]</div>
            </body>
        </html>
    ]]

    applet:set_status(200)
    applet:add_header("content-length", string.8)
    applet:add_header("content-type", "text/html")
    applet:start_response()
    applet:send(response)
end

core.register_service("magic8ball", "http", magic8ball)

This is largely pasted in from the guide linked above.

haproxy.cfg

global
    # service: the target application
    lua-load magic8ball.lua

defaults
    mode http
    timeout connect         10s
    timeout client          1m
    timeout server          1m

frontend fe_main

    bind :8891

    # regular resource
    default_backend resource

backend resource

    # pass legit traffic to application
    http-request use-service lua.magic8ball

This is a boilerplate configuration. Notice the load-lua and use-service directives which wire in our magic 8 ball service.

Now start HAProxy:

erik@carbon ~/tmp/magic8ball $ haproxy -d -f haproxy.cfg
Note: setting global.maxconn to 262126.
Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result FAILED
Total: 3 (2 usable), will use epoll.

Available filters :
	[BWLIM] bwlim-in
	[BWLIM] bwlim-out
	[CACHE] cache
	[COMP] compression
	[FCGI] fcgi-app
	[SPOE] spoe
	[TRACE] trace
Using epoll() as the polling mechanism.
...

The -d flag enables debugging and running in the foreground.

Now the service loads:

We have a trivial service to protect.

Neat that HAProxy provides the capability to run a service. It has other useful hooks like actions, which we'll see in a minute. This makes all kinds of edge computing possible.

Reminder how OIDC works

Tokens (JWTs) are the core of OAuth/OIDC. With some browser redirects and some server-side requests, HAProxy gets a hold of the tokens specific to 'myuser':

  • The ID token is for our magic8ball application (the OAuth client) to use. It tells us the user is authenticated and has some profile information like the user's full name, etc. Importantly, it doesn't tell us what the user is authorized to do in the applicaiton.
  • The access token is for authorization. We ignore it here because we're not providing authorization and because our client isn't the consumer ("audience") of this token.
  • The refresh token lets us get replacement ID and access tokens, allowing the user to remain logged in indefinitely but still securely.

Our main task is to redirect the user to the Keycloak signin page, which will redirect us back to our applicaiton with an authorization code. HAProxy will make a server-side request to get the tokens and store them for the user (keyed to a session ID). Then subsequent requests to the application will check that there's a valid, current ID token for the session so the user is authenticated.

Creating our client

Create a client "magic8ball" in the "myrealm" Keycloak realm:

This client corresponds to our magic8ball application. Most settings can be left at defaults. Some important ones:

The client ID can be any string so "magic8ball" is clear enough.

Add the /code URI to Valid Redirect URIs:

This is an allowlist for where the user can be redircted to after sign-in with the authorization code.

The standard flow is all we require for this implementation.

The client secret has been generated for us. Note its location since we'll need it to fetch the tokens.

Manual token fetch with cURL

This isn't strictly necessary but It's good to check with cURL that we can fetch and inspect the tokens. Then if we get some error later while integrating it into our application we'll have eliminated some variables.

Prepare the basic auth credential. It's base64(clientid:clientsecret). When preparing it like I do here, be careful to press Ctrl+D twice, don't press Enter (so no newline is encoded).

Capture the resulting value (starts with 'bWF' in this example).

Prepare this URL. This is where your application will redirect the browser to in Keycloak for sign-in.

http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?scope=openid&response_type=code&client_id=magic8ball&redirect_uri=http://localhost:8891/code
    Note some values
  • /realms/myrealm ... our realm
  • scope=openid ... generally always required to get an ID token
  • response_type=code ... says we want an authentication code
  • client_id=magic8ball ... matches the client ID we set
  • redirect_uri ... the value we allowed in the client configuration

Open the URL in a browser, log in 'myuser', and get redirected to our application. If you didn't leave HAProxy running it's okay. We just want the code value in the redirected URL.

Capture the value of the code query parameter for the next step.

Prepare the token fetch request:

erik@carbon ~ $ curl -X POST -H 'Authorization: Basic bWFnaWM4YmFsbDprU01tYndHOGoxRlVCeG5UZ0FKd1JkdEUwcjBjcnR5Sw==' 'http://localhost:8080/realms/myrealm/protocol/openid-connect/token' --data 'grant_type=authorization_code&client_id=magic8ball&redirect_uri=http://localhost:8891/code&code=379e73c8-5dd2-43cb-9216-2172db4c4b9a.04215bfa-e11c-4d53-bfe6-f3d8ecf94914.c14108a4-e20e-4204-b15f-22798a0ba8d8'
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJoaHpkV29CWGpDSlVEcjJrOV9INkxGV2p1MEVIVVphUXZya3FFcGdSNWxFIn0.eyJleHAiOjE2NzE5MTY2NDQsImlhdCI6MTY3MTkxNjM0NCwiYXV0aF90aW1lIjoxNjcxOTE2MzA5LCJqdGkiOiI2Y2QzYjczMS1kMjJhLTRhYzEtYTA3MC02ZDAyNjQ4ZTk5NTMiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iLCJhdWQiOlsiYXBpLmNhdHMubmV0IiwiYWNjb3VudCJdLCJzdWIiOiJlZGIxYzM5NS00ZDFhLTQzMjMtOWViMS05YzI1N2FmM2NhMGUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJtYWdpYzhiYWxsIiwic2Vzc2lvbl9zdGF0ZSI6IjA0MjE1YmZhLWUxMWMtNGQ1My1iZmU2LWYzZDhlY2Y5NDkxNCIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1teXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsInJlYWxtZm9vcm9sZSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFwaS5jYXRzLm5ldCI6eyJyb2xlcyI6WyJhZG1pbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjA0MjE1YmZhLWUxMWMtNGQ1My1iZmU2LWYzZDhlY2Y5NDkxNCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6Ik15IFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoiTXkiLCJmYW1pbHlfbmFtZSI6IlVzZXIifQ.UD20q7cRzdvvgsX4svkegwOMVwFU6tUZCABSscO2vS1wvy50V7PqjwWmjFn2g1mbMFpas5Vfhlqctkt6frmGzoKuXVs2vOM2oQkRcQH-P210H-J2KOz_QYJZe18XVe3CpujAvXzOf2rwKvcCbpPJppcRkD1BFZymXO1xUN0KdCUppO-X-BAeFPSNePGGFx2z3doWdcA26hkzJgsdT6TIINomsDppj2aXv2kLkvRC4BEjS9rd93VkJMflt6pY3tzKFol6GxD4VZBlF6YQP9EB2oFPZUHjd3mnfEAp36knMQ59HOtcqT27TW-RY-mO14UZmvgxcUXC1llh8hWK--YutA","expires_in":300,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkODhhY2ZmZC1lMjhmLTQwYmMtODlhZi1lNGY5YWRhZDJmNTkifQ.eyJleHAiOjE2NzE5MTgxNDQsImlhdCI6MTY3MTkxNjM0NCwianRpIjoiNmUyMTM3YmMtMWVkOS00NDQ0LTg1MjEtMmMzNjRmYWY0YmIzIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9teXJlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9teXJlYWxtIiwic3ViIjoiZWRiMWMzOTUtNGQxYS00MzIzLTllYjEtOWMyNTdhZjNjYTBlIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Im1hZ2ljOGJhbGwiLCJzZXNzaW9uX3N0YXRlIjoiMDQyMTViZmEtZTExYy00ZDUzLWJmZTYtZjNkOGVjZjk0OTE0Iiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjA0MjE1YmZhLWUxMWMtNGQ1My1iZmU2LWYzZDhlY2Y5NDkxNCJ9.TvrHVKesMpjR01BPRiJL1fQ6Fgu_VmkoorQjiqpilYM","token_type":"Bearer","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJoaHpkV29CWGpDSlVEcjJrOV9INkxGV2p1MEVIVVphUXZya3FFcGdSNWxFIn0.eyJleHAiOjE2NzE5MTY2NDQsImlhdCI6MTY3MTkxNjM0NCwiYXV0aF90aW1lIjoxNjcxOTE2MzA5LCJqdGkiOiIwMTQ0OTA5OC05MGI1LTRlOTEtYTU1MC0wNGU2ZTJhY2FjNWEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iLCJhdWQiOiJtYWdpYzhiYWxsIiwic3ViIjoiZWRiMWMzOTUtNGQxYS00MzIzLTllYjEtOWMyNTdhZjNjYTBlIiwidHlwIjoiSUQiLCJhenAiOiJtYWdpYzhiYWxsIiwic2Vzc2lvbl9zdGF0ZSI6IjA0MjE1YmZhLWUxMWMtNGQ1My1iZmU2LWYzZDhlY2Y5NDkxNCIsImF0X2hhc2giOiJqX3Rzc0l2RUpIZ19RVEhSdkQwLUJRIiwiYWNyIjoiMSIsInNpZCI6IjA0MjE1YmZhLWUxMWMtNGQ1My1iZmU2LWYzZDhlY2Y5NDkxNCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6Ik15IFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoiTXkiLCJmYW1pbHlfbmFtZSI6IlVzZXIifQ.FqGK3FfiVAL4SctD5mdT56Ciq42rjnfaq_MAEL6ssL_JtQuKCHma5ObnAKct5TGzCZ6uHGCPgEUcUTYX1gTLqcDQkJPtVnq3losJl2oqossXQ0Zh_eASEDFy7mPXi7C6clqk9kb8xDVc_k3xJ5rJOAsNd7siEL7GJT0sTNhZG3yznLTZjfFwQTaaJMI4hjPLOwof_ti1VbsxlZHJCzAqQ0XpDunUVs-V3FCHhqEdTiOD1j_QbTEtMCevjJxh1g7The8ehJOJgtQjKcGrLcH8MMJEN_U4iIG26pmV7Kgvd5gNvEAb51YNGSRjgcRIl9ZxF3zpqs_rVxgaHBtfUZbuWA","not-before-policy":0,"session_state":"04215bfa-e11c-4d53-bfe6-f3d8ecf94914","scope":"openid profile email"}erik@carbon ~ $ 

We have tokens! You can inspect them online with the debugger at jwt.io or some console tool like 'cargo install jwt-cli'. Here's the ID token:

erik@carbon ~ $ jwt decode eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJoaHpkV29CWGpDSlVEcjJrOV9INkxGV2p1MEVIVVphUXZya3FFcGdSNWxFIn0.eyJleHAiOjE2NzE3NTk4MTcsImlhdCI6MTY3MTc1OTUxNywiYXV0aF90aW1lIjoxNjcxNzU2ODA1LCJqdGkiOiIwMTQyYTQ0NC0xZjU2LTQ3NDAtYTg4ZS0zZGY0ODc3YmJmY2IiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iLCJhdWQiOiJtYWdpYzhiYWxsIiwic3ViIjoiZWRiMWMzOTUtNGQxYS00MzIzLTllYjEtOWMyNTdhZjNjYTBlIiwidHlwIjoiSUQiLCJhenAiOiJtYWdpYzhiYWxsIiwic2Vzc2lvbl9zdGF0ZSI6ImM3NDQ3ZGVhLTE4MDItNDIyMC1iYTk3LTdmNjMzZjhkMjU1OSIsImF0X2hhc2giOiI4b3RyblJiR2dLbGlPYXFxSnZBeEhBIiwiYWNyIjoiMCIsInNpZCI6ImM3NDQ3ZGVhLTE4MDItNDIyMC1iYTk3LTdmNjMzZjhkMjU1OSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6Ik15IFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoiTXkiLCJmYW1pbHlfbmFtZSI6IlVzZXIifQ.XOIyWMxn77BWSIhIkrts21uZZ-9BdEIUvv8wYoKlfEvEnsp50HYhwyBhsEj6KxEOuhm7rmRu5M5aGArWYV91ARNinUTvbc-ahyVcsQ1FLhB4yU0bWa_3i9-eP9CAx2mnBdbU8u4EdMQFrwKFEF4e7HvRNLtWcKHy1PP3IAZG3YySaG7IqmfuneXr9ITt5yolMVxZPQDfkzRsZh5qdF0ATGjgy__65LWjRhcvSYB15tQVPdrIBj_hiJhBNHXA-pF4W77RQqbRFKXZaJ7g5SD2lGroN6Ir-Wkydmw8SoeRNRJETfHip63GHovNPBP51HshYR7ZWWrJl94WXI9e0kAD9A

Token header
------------
{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "hhzdWoBXjCJUDr2k9_H6LFWju0EHUZaQvrkqEpgR5lE"
}

Token claims
------------
{
  "acr": "0",
  "at_hash": "8otrnRbGgKliOaqqJvAxHA",
  "aud": "magic8ball",
  "auth_time": 1671756805,
  "azp": "magic8ball",
  "email_verified": false,
  "exp": 1671759817,
  "family_name": "User",
  "given_name": "My",
  "iat": 1671759517,
  "iss": "http://localhost:8080/realms/myrealm",
  "jti": "0142a444-1f56-4740-a88e-3df4877bbfcb",
  "name": "My User",
  "preferred_username": "myuser",
  "session_state": "c7447dea-1802-4220-ba97-7f633f8d2559",
  "sid": "c7447dea-1802-4220-ba97-7f633f8d2559",
  "sub": "edb1c395-4d1a-4323-9eb1-9c257af3ca0e",
  "typ": "ID"
}

Redirect to get tokens if there's no session cookie

We will eventually set a cookie "magicsess" that HAProxy can use to look up the tokens from a map. Currently there's no such cookie so the user should be redirected to get the tokens (and later set the session). Here's the haproxy.cfg and a stub magictokens.lua:

global
    # action: exchange an authcode for tokens
    lua-load magictokens.lua

    # service: the target application
    lua-load magic8ball.lua

defaults
    mode http
    timeout connect         10s
    timeout client          1m
    timeout server          1m

frontend fe_main

    bind :8891

    # /code will fetch tokens from an authcode
    use_backend authcode if { path == /code }

    # regular resource
    default_backend resource

backend resource

    # get tokens if no cookie
    acl has_cookie req.cook(magicsess) -m found

    # get tokens
    http-request redirect location 'http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?scope=openid&response_type=code&client_id=magic8ball&redirect_uri=http://localhost:8891/code' unless has_cookie

    # pass legit traffic to application
    http-request use-service lua.magic8ball

backend authcode

    # use this lua action to fetch access/id/refresh tokens with the auth code
    http-request set-var(req.authcode) url_param(code)
    http-request lua.magictokens

    # Send the user somewhere after the tokens are fetched
    http-request redirect location https://cnn.com
    
local function magictokens(txn)

    -------------------------------
    -- Get the auth code variable
    -------------------------------
    authcode = txn.get_var(txn,"req.authcode")
    core.Debug(string.format("auth code is %s",authcode))
end

core.register_action("magictokens",{'http-req'}, magictokens, 0)

Our "resource" backend will redirect to the Keycloak /auth page for signin when the cookie is missing.

After the signin the user is redirected back to the /code URI which goes to the new "authcode" backend.

The "http-request lua.magictokens" executes the Lua "action" in the magictokens.lua file. This action is a stub that just prints the auth code to the debug output. Note the authcode has been parsed and set as a request variable in the 'http-request set-var(req.authcode) url_param(code)" which makes it available to the Lua action.

After the token fetch we'd like to redirect the user back to the /magic URI to get their 8-ball result. For now we redirect to a dummy location (cnn.com) since, without setting a cookie yet, a redirect loop would occur.

Implement the token fetch

magictokens.lua becomes:

local function magictokens(txn)

    -------------------------------
    -- Get the auth code variable
    -------------------------------
    authcode = txn.get_var(txn,"req.authcode")
    --core.Debug(string."auth code is authcode")

    -------------------------------
    -- Prepare token request
    -------------------------------
    -- http://localhost:8080/realms/myrealm/protocol/openid-connect/token

    client_id_and_secret = "bWFnaWM4YmFsbDprU01tYndHOGoxRlVCeG5UZ0FKd1JkdEUwcjBjcnR5Sw=="
    realm = "myrealm"
    token_endpoint = string.format("/realms/%s/protocol/openid-connect/token",realm)
    client_id="magic8ball"
    grant_type="authorization_code"
    redirect_uri="http://localhost:8891/code"
    data=string.format("grant_type=%s&client_id=%s&redirect_uri=%s&code=%s",grant_type,client_id,redirect_uri,authcode)

    auth_header = string.format("Authorization: Basic %s",client_id_and_secret)
    content_length_header = string.format("Content-Length: %d", string.len(data))
    content_type_header = "Content-Type: application/x-www-form-urlencoded"

    token_request = string.format("POST %s HTTP/1.1\r\n%s\r\n%s\r\n%s\r\n\r\n%s",token_endpoint,auth_header,content_type_header,content_length_header,data)

    --core.Debug(string."request is token_request")
    
    -------------------------------
    -- Make request over TCP
    -------------------------------
    contentlen = 0

    idp = core.tcp()
    idp:settimeout(5)
    -- connect to issuer
    if idp:connect('127.0.0.1','8080') then
      if idp:send(token_request) then
      
        -- Skip response headers
        while true do
          local line, err = idp:receive('*l')

          if err then
            core.Alert(string.format("error reading header: %s",err))
            break
          else
            if line then
              --core.Debug(string."data: line")
              if line == '' then break end
              -- core.Debug(string."substr: string.sub(line,1,3)")
              if string.sub(string.lower(line),1,15) == 'content-length:' then
                --core.Debug(string."found content-length: string.sub(line,16)")
                contentlen = tonumber(string.sub(line,16))
              end
            else
              --core.Debug("no more data")
              break
            end
          end
        end

        -- Get response body, if any
        --core.Debug("read body")
        local content, err = idp:receive(contentlen)

        if content then
          -- save the entire token response out to a variable for
          -- further processing in haproxy.cfg
          --core.Debug(string."tokens are content")
          txn.set_var(txn,'req.tokenresponse',content)
        else
          core.Alert(string.format("error receiving tokens: %s",err))
        end
      else
          core.Alert('Could not send to IdP (send)')
      end
      idp:close()
    else
        core.Alert('Could not connect to IdP (connect)')
    end

end

core.register_action("magictokens",{'http-req'}, magictokens, 0)

There's a lot here but it's basically making an HTTP request over a TCP connection, equivalent to the token fetch we did earlier with cURL.

I did HTTP-over-TCP since the Lua blog doc linked above does that, and it's just as awkward as it looks. I noticed later that there's an HTTPClient class in the quite-good documentation. If I did it over I'd use this obviously.

The "txn.set_var(txn,'req.tokenresponse',content)" is saving the JSON blob as a request variable that can be used in the haproxy.cfg after the action completes. I parse the tokens out of the JSON in haproxy.cfg although it should be possible to do this in Lua as well with the Converters class.

Store the tokens in a map and set the session cookie

Maps! These are a powerful and dynamic data structure in the HAProxy process. A good introduction is here.

We want three maps for ID tokens, access tokens and refresh tokens. The key for each map will be our session ID (a random UUID).

Maps require a corresponding file on disk, even if the file is empty and never written to. This seems awkward at first but actually allows persisting the data when HAProxy restarts or reloads, since you can trivially write a cronjob that dumps the runtime data structure onto the disk. Create the empty map files:

erik@carbon ~ $ touch accesstoken.map
erik@carbon ~ $ touch idtoken.map
erik@carbon ~ $ touch refreshtoken.map
local function magicsetcookie(applet)
    applet:set_status(302)

    -- set cookie
    cookie = applet.get_var(applet,'req.sessioncookie')
    --core.Debug(cookie)
    applet:add_header("Set-Cookie", string.format("magicsess=%s; HttpOnly",cookie))

    applet:add_header("Location", "/magic")

    applet:start_response()
end

core.register_service("magicsetcookie", "http", magicsetcookie)

Replace our redirect to cnn.com with another custom Lua service that redirects to /magic and sets the cookie.

(I wanted to do this with an "http-request redirect" directive in haproxy.cfg but it can't set the cookie in the response. This is the workaround.)

global
    # action: exchange an authcode for tokens
    lua-load magictokens.lua

    # service: redirect back to the application while setting a cookie in a way that redirect can't do
    lua-load magicsetcookie.lua

    # service: the target application
    lua-load magic8ball.lua

defaults
    mode http
    timeout connect         10s
    timeout client          1m
    timeout server          1m

frontend fe_main

    bind :8891

    # /code will fetch tokens from an authcode
    use_backend authcode if { path == /code }

    # regular resource
    default_backend resource

backend resource

    # get tokens if no cookie
    acl has_cookie req.cook(magicsess) -m found

    # get tokens
    http-request redirect location 'http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?scope=openid&response_type=code&client_id=magic8ball&redirect_uri=http://localhost:8891/code' unless has_cookie

    # pass legit traffic to application
    http-request use-service lua.magic8ball

backend authcode

    # use this lua action to fetch access/id/refresh tokens with the auth code
    http-request set-var(req.authcode) url_param(code)
    http-request lua.magictokens

    # Generate a random (type 4) UUID as a session key.  This is the key in the maps    
    http-request set-var(req.sessioncookie) uuid

    # This noise is required for our maps to be visible
    # https://github.com/haproxy/haproxy/issues/1156 ... not fixed
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/accesstoken.map)]
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/refreshtoken.map)]
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/idtoken.map)]
    http-request del-header Foo

    # store the fetched tokens in our maps with session ID as key
    http-request set-map(/home/erik/tmp/magic8ball/accesstoken.map) %[var(req.sessioncookie)] %[var(req.accesstoken)]
    http-request set-map(/home/erik/tmp/magic8ball/refreshtoken.map) %[var(req.sessioncookie)] %[var(req.refreshtoken)]
    http-request set-map(/home/erik/tmp/magic8ball/idtoken.map) %[var(req.sessioncookie)] %[var(req.idtoken)]

    # Send the user back to /magic, setting the new session cookie
    http-request use-service lua.magicsetcookie

The comments above describe what the directives do. The "noise" section is an unfortunate workaround for an unresolved bug.

Now I can navigate to my application at "http://localhost:8891/magic", log in if necessary, and have a session created once my tokens are fetched to HAProxy. I can see my "magicsess" cookie created:

We're not done but we have the start of authentication.

How to troubleshoot an HAProxy configuration

Obviously this is a lot. What tools are available to inspect and troubleshoot all this configuration?

Running HAProxy in debug mode

As shown above, running HAProxy with -d gives us debug output and runs in the foreground.

Log customization

HAProxy logging is one line per request. This is what you expect in an HTTP log. When you need println-style debugging it's still possible but takes some extra thought:

global
    ...
    log stdout local2

frontend fe_main

    log global
    # log-format "%[var(sess.now)] %[var(sess.expiration)] ... remaining %[var(sess.now),neg,add(sess.expiration)]"
    # log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r"
    log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r %[var(sess.now),neg,add(sess.expiration)] %[var(sess.username)]"
    ...

By setting "log stdout" I can see the HTTP log interleaved with the foreground debug output. Note that HAProxy by design won't touch a filesystem once it has started serving traffic so there's no direct way to say "log to /var/log/haproxy.out". In production you're meant to have syslog running which can write to log files.

The commented log format line above is the default HTTP log format. The uncommented line after it shows that I've added two fields at the end which are session variables that I've set elsewhere in the haproxy.cfg.

    In this case the fields are
  • "%[var(sess.now),neg,add(sess.expiration)]" - how many seconds are left before the ID token expires
  • "%[var(sess.username)]" - the username from the ID token

So it's possible to put any information you want in the HTTP log entry.

erik@carbon ~ $ haproxy -d -f haproxy.cfg
Note: setting global.maxconn to 262121.
Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result FAILED
Total: 3 (2 usable), will use epoll.

Available filters :
        [BWLIM] bwlim-in
        [BWLIM] bwlim-out
        [CACHE] cache
        [COMP] compression
        [FCGI] fcgi-app
        [SPOE] spoe
        [TRACE] trace
Using epoll() as the polling mechanism.
00000000:fe_main.accept(0005)=001f from [127.0.0.1:33352] ALPN=
00000000:fe_main.clireq[001f:ffffffff]: GET /magic HTTP/1.1
00000000:fe_main.clihdr[001f:ffffffff]: host: localhost:8891
00000000:fe_main.clihdr[001f:ffffffff]: user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0
00000000:fe_main.clihdr[001f:ffffffff]: accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
00000000:fe_main.clihdr[001f:ffffffff]: accept-language: en-US,en;q=0.5
00000000:fe_main.clihdr[001f:ffffffff]: accept-encoding: gzip, deflate, br
00000000:fe_main.clihdr[001f:ffffffff]: dnt: 1
00000000:fe_main.clihdr[001f:ffffffff]: cookie: magicsess=061d34de-e8ab-4a15-832d-c7eeb30aa8ae
00000000:fe_main.clihdr[001f:ffffffff]: upgrade-insecure-requests: 1
00000000:fe_main.clihdr[001f:ffffffff]: sec-fetch-dest: document
00000000:fe_main.clihdr[001f:ffffffff]: sec-fetch-mode: navigate
00000000:fe_main.clihdr[001f:ffffffff]: sec-fetch-site: cross-site
00000000:resource.clicls[001f:ffff]
00000000:resource.closed[001f:ffff]
<150>Dec 25 09:26:33 haproxy[924472]: 127.0.0.1:33352 [25/Dec/2022:09:26:33.400] fe_main resource/ 0/-1/-1/-1/0 302 239 - - LR-- 1/1/0/0/0 0/0 "GET /magic HTTP/1.1" - -
00000001:fe_main.accept(0005)=001f from [127.0.0.1:33352] ALPN=
00000001:fe_main.clireq[001f:ffffffff]: GET /code?session_state=135ff1b2-0471-497d-a5e5-40ad3a3546b9&code=0f3f1d5f-a2a1-44c4-90a2-0a8fc6dbab60.135ff1b2-0471-497d-a5e5-40ad3a3546b9.c14108a4-e20e-4204-b15f-22798a0ba8d8 HTTP/1.1
00000001:fe_main.clihdr[001f:ffffffff]: host: localhost:8891
00000001:fe_main.clihdr[001f:ffffffff]: user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0
00000001:fe_main.clihdr[001f:ffffffff]: accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
00000001:fe_main.clihdr[001f:ffffffff]: accept-language: en-US,en;q=0.5
00000001:fe_main.clihdr[001f:ffffffff]: accept-encoding: gzip, deflate, br
00000001:fe_main.clihdr[001f:ffffffff]: dnt: 1
00000001:fe_main.clihdr[001f:ffffffff]: cookie: magicsess=061d34de-e8ab-4a15-832d-c7eeb30aa8ae
00000001:fe_main.clihdr[001f:ffffffff]: upgrade-insecure-requests: 1
00000001:fe_main.clihdr[001f:ffffffff]: sec-fetch-dest: document
00000001:fe_main.clihdr[001f:ffffffff]: sec-fetch-mode: navigate
00000001:fe_main.clihdr[001f:ffffffff]: sec-fetch-site: cross-site
00000001:authcode.srvcls[001f:ffff]
00000001:authcode.srvrep[001f:ffffffff]: HTTP/1.1 302 Moved Temporarily
00000001:authcode.srvhdr[001f:ffffffff]: set-cookie: magicsess=9c18a9fb-b855-4a57-aefe-2b6be7dff962; HttpOnly
00000001:authcode.srvhdr[001f:ffffffff]: location: /magic
00000001:authcode.srvhdr[001f:ffffffff]: transfer-encoding: chunked
00000001:authcode.clicls[001f:ffff]
00000001:authcode.closed[001f:ffff]
<150>Dec 25 09:26:33 haproxy[924472]: 127.0.0.1:33352 [25/Dec/2022:09:26:33.434] fe_main authcode/ 19/0/0/0/19 302 153 - - LR-- 1/1/0/0/0 0/0 "GET /code?session_state=135ff1b2-0471-497d-a5e5-40ad3a3546b9&code=0f3f1d5f-a2a1-44c4-90a2-0a8fc6dbab60.135ff1b2-0471-497d-a5e5-40ad3a3546b9.c14108a4-e20e-4204-b15f-22798a0ba8d8 HTTP/1.1" - -
00000002:LUA-SOCKET.clicls[ffff:001e]
00000002:LUA-SOCKET.srvcls[ffff:ffff]
00000002:LUA-SOCKET.closed[ffff:ffff]
00000003:fe_main.accept(0005)=001f from [127.0.0.1:33352] ALPN=
00000003:fe_main.clireq[001f:ffffffff]: GET /magic HTTP/1.1
00000003:fe_main.clihdr[001f:ffffffff]: host: localhost:8891
00000003:fe_main.clihdr[001f:ffffffff]: user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0
00000003:fe_main.clihdr[001f:ffffffff]: accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
00000003:fe_main.clihdr[001f:ffffffff]: accept-language: en-US,en;q=0.5
00000003:fe_main.clihdr[001f:ffffffff]: accept-encoding: gzip, deflate, br
00000003:fe_main.clihdr[001f:ffffffff]: dnt: 1
00000003:fe_main.clihdr[001f:ffffffff]: cookie: magicsess=9c18a9fb-b855-4a57-aefe-2b6be7dff962
00000003:fe_main.clihdr[001f:ffffffff]: upgrade-insecure-requests: 1
00000003:fe_main.clihdr[001f:ffffffff]: sec-fetch-dest: document
00000003:fe_main.clihdr[001f:ffffffff]: sec-fetch-mode: navigate
00000003:fe_main.clihdr[001f:ffffffff]: sec-fetch-site: cross-site
00000003:resource.srvcls[001f:ffff]
00000003:resource.srvrep[001f:ffffffff]: HTTP/1.1 200 OK
00000003:resource.srvhdr[001f:ffffffff]: content-type: text/html
00000003:resource.srvhdr[001f:ffffffff]: content-length: 99
00000003:resource.clicls[001f:ffff]
00000003:resource.closed[001f:ffff]
<150>Dec 25 09:26:33 haproxy[924472]: 127.0.0.1:33352 [25/Dec/2022:09:26:33.457] fe_main resource/ 0/0/0/0/0 200 170 - - LR-- 1/1/0/0/0 0/0 "GET /magic HTTP/1.1" 300 myuser

Note "300 myuser" at the end. This request is for authenticated user "myuser" and the ID token expires in 300 seconds.

Map inspection with the stats socket

We can see the contents of our maps by enabling the stats socket and sending commands to it with the socat program. This is documented above in the maps article.

global
    ...
    # good for inspecting our maps
    stats socket /home/erik/tmp/magic8ball/haproxy.stat mode 600 level admin
    stats timeout 2m
erik@carbon ~ $ socat ~/tmp/magic8ball/haproxy.stat readline
prompt

> show map
# id (file) description
2 (/home/erik/tmp/magic8ball/idtoken.map) pattern loaded from file '/home/erik/tmp/magic8ball/idtoken.map' used by map at file 'haproxy.cfg' line 45, by map at file 'haproxy.cfg' line 50, by map at file 'haproxy.cfg' line 60, by map at file 'haproxy.cfg' line 61, by map at file 'haproxy.cfg' line 78. curr_ver=0 next_ver=0 entry_cnt=1
5 (/home/erik/tmp/magic8ball/accesstoken.map) pattern loaded from file '/home/erik/tmp/magic8ball/accesstoken.map' used by map at file 'haproxy.cfg' line 76. curr_ver=0 next_ver=0 entry_cnt=1
6 (/home/erik/tmp/magic8ball/refreshtoken.map) pattern loaded from file '/home/erik/tmp/magic8ball/refreshtoken.map' used by map at file 'haproxy.cfg' line 77. curr_ver=0 next_ver=0 entry_cnt=1

> show map #2
0x5602fadc7270 9c18a9fb-b855-4a57-aefe-2b6be7dff962 eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJoaHpkV29CWGpDSlVEcjJrOV9INkxGV2p1MEVIVVphUXZya3FFcGdSNWxFIn0.eyJleHAiOjE2NzE5ODU4OTMsImlhdCI6MTY3MTk4NTU5MywiYXV0aF90aW1lIjoxNjcxOTgzMTc2LCJqdGkiOiI1Zjg5MmY4Yy0zNDZhLTRjN2UtYjkyOC1lZDE3NGMzMWM4NTUiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iLCJhdWQiOiJtYWdpYzhiYWxsIiwic3ViIjoiZWRiMWMzOTUtNGQxYS00MzIzLTllYjEtOWMyNTdhZjNjYTBlIiwidHlwIjoiSUQiLCJhenAiOiJtYWdpYzhiYWxsIiwic2Vzc2lvbl9zdGF0ZSI6IjEzNWZmMWIyLTA0NzEtNDk3ZC1hNWU1LTQwYWQzYTM1NDZiOSIsImF0X2hhc2giOiJRWXBIaEJaMVNjcER3a1JyWEVIT2tBIiwiYWNyIjoiMCIsInNpZCI6IjEzNWZmMWIyLTA0NzEtNDk3ZC1hNWU1LTQwYWQzYTM1NDZiOSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6Ik15IFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJteXVzZXIiLCJnaXZlbl9uYW1lIjoiTXkiLCJmYW1pbHlfbmFtZSI6IlVzZXIifQ.EcMYJNBiyRSxneOHapDhbwFm1pmW_7I6c96Gg9gjRnbsni3-OJTmsO3fgxTpvxn_S1GQhzy8pixyvXnoNLCHy2YSKJEqq9QQUmNZPoLWc6sIZiHceo5c7qAjGMcuWKJIjmE-ttfyTlV_ikda3TINo_wMgi8GDVph47q-_Vy_SmQe-E9KtfXnw2lkZSfhiCXOnU6JsFqDesnhUWPYnq2IbXNkXAfUwRMPdUdbfafN3nvQupDTI1MJSPeUWOM2WVBr6NwRgD2gTWJrSaR7B8MeikB2X-ULM2tDquo4dGcxAz7Tor4uP6O32l3awQQqu9sOg0D7ONPPcL9eGZ4UstvaDg

>

Debug statements in Lua

The "core.Debug()" and "core.Alert()" statements in our Lua scripts give us println-style debugging when we're running HAProxy with the -d flag (but not in a production configuration).

Validate the received tokens

Our authentication seems to work but isn't secure yet. A user could set any random UUID as their session cookie and gain access to the application. We must now validate the tokens.

When we fetch the tokens we'll verify the signature, issuer and audience before storing them in our map.

On every request we'll verify that the ID token exists and isn't expired.

Verifying the token signature is done with asymmetric cryptography. We configure the public key beforehand then use it to verify the signature part of the JWT.

For this HAProxy requires having the PEM-encoded keys (JWKS). Keycloak provides a per-realm JSON blob with the keys at http://localhost:8080/realms/myrealm/protocol/openid-connect/certs. There are two not-PEM-encoded keys given so we manually PEM-encode them and configure them at key0.pem and key1.pem and expect the token to be signed by one of them.

Converting the keys from JWKS to PEM was awkward and ultimately I just used this online converter.

Here's the token-fetch-time validation of the signature, issuer and audience. We validate the signature of the access token too though technically we're not the consumer (audience) of the access token so it's not our problem to validate that.

backend authcode
...
    # validate token signature - access token
    http-request set-var(req.accesstoken) var(req.tokenresponse),json_query('$.access_token')
    http-request set-var(req.jwt_alg) var(req.accesstoken),jwt_header_query('$.alg')
    acl jwt_sig_rs256 var(req.jwt_alg) -m str -i "RS256"
    http-request deny if !jwt_sig_rs256
    acl key0_valid var(req.accesstoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key0.pem") 1
    acl key1_valid var(req.accesstoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key1.pem") 1
    http-request deny if !key0_valid !key1_valid

    # don't validate token signature - refresh token - note it's HS256 and doesn't validate with PEM files?
    http-request set-var(req.refreshtoken) var(req.tokenresponse),json_query('$.refresh_token')

    # validate token signature - id token
    http-request set-var(req.idtoken) var(req.tokenresponse),json_query('$.id_token')
    http-request set-var(req.jwt_alg) var(req.idtoken),jwt_header_query('$.alg')
    acl jwt_sig_rs256 var(req.jwt_alg) -m str -i "RS256"
    http-request deny if !jwt_sig_rs256
    acl key0_valid var(req.idtoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key0.pem") 1
    acl key1_valid var(req.idtoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key1.pem") 1
    http-request deny if !key0_valid !key1_valid

    # validate id token issuer
    acl id_token_issuer_valid var(req.idtoken),jwt_payload_query('$.iss') -m str "http://localhost:8080/realms/myrealm"
    http-request deny if !id_token_issuer_valid

    # validate id token audience - should be our client ID
    acl id_token_audience_valid var(req.idtoken),jwt_payload_query('$.aud') -m str "magic8ball"
    http-request deny if !id_token_audience_valid
...

Here's the validation done every request to our protected resource. We expect the ID token to exist in our map and to not be expired. If those things don't all validate we redirect to fetch new tokens (as before), possibly prompting the user to log in again.

backend resource

    # get tokens if no cookie
    acl has_cookie req.cook(magicsess) -m found

    # get tokens if cookie doesn't map to an id token
    acl cookie_has_id_token req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map) -m found

    # get tokens if id token is expired
    #   (eventually http-request lua.magicrefresh if invalid exp)
    http-request set-var(sess.now) date
    http-request set-var(sess.expiration) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.exp','int')
    acl id_token_not_expired var(sess.now),neg,add(sess.expiration) gt 0

    # get tokens
    http-request redirect location 'http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?scope=openid&response_type=code&client_id=magic8ball&redirect_uri=http://localhost:8891/code' unless has_cookie cookie_has_id_token id_token_not_expired

We have now secured our application.

Use profile information in the application

We're now free to pass information from the ID token to the application if we want, though this isn't required. Here we set variables sess.name and sess.username which our magic8ball service can render.

backend resource
...
    # TODO: retool /code to also use refresh token to get new tokens

    # want to pass headers X-Authenticated-User and X-Name for use in the application
    # but a Lua service doesn't get them.  Using variables instead
    http-request set-var(sess.username) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.preferred_username')
    http-request set-var(sess.name) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.name')
    # pass legit traffic to application
    http-request use-service lua.magic8ball

local function magic8ball(applet)
    -- If client is POSTing request, receive body
    -- local request = applet:receive()

    local responses = {"Reply hazy", "Yes - definitely", "Don't count on it", "Outlook good", "Very doubtful"}
    local myrandom = math.random(1, #responses)
    local response = string.format([[
        <html>
            <body>
              <div>%s</div>
              <div>For you, %s</div>
            </body>
        </html>
    ]], responses[myrandom], applet.get_var(applet,"sess.name"))

    applet:set_status(200)
    applet:add_header("content-length", string.len(response))
    applet:add_header("content-type", "text/html")
    applet:start_response()
    applet:send(response)
end

core.register_service("magic8ball", "http", magic8ball)

The complete implementation

Here's the complete implementation.

haproxy.cfg:

global

    # action: exchange an authcode for tokens
    lua-load magictokens.lua

    # service: redirect back to the application while setting a cookie in a way that redirect can't do
    lua-load magicsetcookie.lua

    # service: the target application
    lua-load magic8ball.lua

    # good for inspecting our maps
    stats socket /home/erik/tmp/magic8ball/haproxy.stat mode 600 level admin
    stats timeout 2m

    log stdout local2

defaults
    mode http
    timeout connect         10s
    timeout client          1m
    timeout server          1m

frontend fe_main

    log global
    # log-format "%[var(sess.now)] %[var(sess.expiration)] ... remaining %[var(sess.now),neg,add(sess.expiration)]"
    # log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r"
    log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r %[var(sess.now),neg,add(sess.expiration)] %[var(sess.username)]"

    bind :8891

    # /code will fetch tokens from an authcode
    use_backend authcode if { path == /code }

    # regular resource
    default_backend resource

backend resource

    # get tokens if no cookie
    acl has_cookie req.cook(magicsess) -m found

    # get tokens if cookie doesn't map to an id token
    acl cookie_has_id_token req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map) -m found

    # get tokens if id token is expired
    #   (eventually http-request lua.magicrefresh if invalid exp)
    http-request set-var(sess.now) date
    http-request set-var(sess.expiration) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.exp','int')
    acl id_token_not_expired var(sess.now),neg,add(sess.expiration) gt 0

    # get tokens
    http-request redirect location 'http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?scope=openid&response_type=code&client_id=magic8ball&redirect_uri=http://localhost:8891/code' unless has_cookie cookie_has_id_token id_token_not_expired

    # TODO: retool /code to also use refresh token to get new tokens

    # want to pass headers X-Authenticated-User and X-Name for use in the application
    # but a Lua service doesn't get them.  Using variables instead
    http-request set-var(sess.username) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.preferred_username')
    http-request set-var(sess.name) req.cook(magicsess),map(/home/erik/tmp/magic8ball/idtoken.map),jwt_payload_query('$.name')
    # pass legit traffic to application
    http-request use-service lua.magic8ball

backend authcode

    # use this lua action to fetch access/id/refresh tokens with the auth code
    http-request set-var(req.authcode) url_param(code)
    http-request lua.magictokens

    # Generate a random (type 4) UUID as a session key.  This is the key in the maps    
    http-request set-var(req.sessioncookie) uuid

    # This noise is required for our maps to be visible
    # https://github.com/haproxy/haproxy/issues/1156 ... not fixed
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/accesstoken.map)]
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/refreshtoken.map)]
    http-request set-header Foo %[path,map(/home/erik/tmp/magic8ball/idtoken.map)]
    http-request del-header Foo

    # validate token signature - access token
    http-request set-var(req.accesstoken) var(req.tokenresponse),json_query('$.access_token')
    http-request set-var(req.jwt_alg) var(req.accesstoken),jwt_header_query('$.alg')
    acl jwt_sig_rs256 var(req.jwt_alg) -m str -i "RS256"
    http-request deny if !jwt_sig_rs256
    acl key0_valid var(req.accesstoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key0.pem") 1
    acl key1_valid var(req.accesstoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key1.pem") 1
    http-request deny if !key0_valid !key1_valid

    # don't validate token signature - refresh token - note it's HS256 and doesn't validate with PEM files?
    http-request set-var(req.refreshtoken) var(req.tokenresponse),json_query('$.refresh_token')

    # validate token signature - id token
    http-request set-var(req.idtoken) var(req.tokenresponse),json_query('$.id_token')
    http-request set-var(req.jwt_alg) var(req.idtoken),jwt_header_query('$.alg')
    acl jwt_sig_rs256 var(req.jwt_alg) -m str -i "RS256"
    http-request deny if !jwt_sig_rs256
    acl key0_valid var(req.idtoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key0.pem") 1
    acl key1_valid var(req.idtoken),jwt_verify(req.jwt_alg,"/home/erik/tmp/magic8ball/key1.pem") 1
    http-request deny if !key0_valid !key1_valid

    # validate id token issuer
    acl id_token_issuer_valid var(req.idtoken),jwt_payload_query('$.iss') -m str "http://localhost:8080/realms/myrealm"
    http-request deny if !id_token_issuer_valid

    # validate id token audience - should be our client ID
    acl id_token_audience_valid var(req.idtoken),jwt_payload_query('$.aud') -m str "magic8ball"
    http-request deny if !id_token_audience_valid

    # store the fetched tokens in our maps with session ID as key
    http-request set-map(/home/erik/tmp/magic8ball/accesstoken.map) %[var(req.sessioncookie)] %[var(req.accesstoken)]
    http-request set-map(/home/erik/tmp/magic8ball/refreshtoken.map) %[var(req.sessioncookie)] %[var(req.refreshtoken)]
    http-request set-map(/home/erik/tmp/magic8ball/idtoken.map) %[var(req.sessioncookie)] %[var(req.idtoken)]

    # Send the user back to /magic, setting the new session cookie
    http-request use-service lua.magicsetcookie

magic8ball.lua

local function magic8ball(applet)
    -- If client is POSTing request, receive body
    -- local request = applet:receive()

    local responses = {"Reply hazy", "Yes - definitely", "Don't count on it", "Outlook good", "Very doubtful"}
    local myrandom = math.random(1, #responses)
    local response = string.format([[
        <html>
            <body>
              <div>%s</div>
              <div>For you, %s</div>
            </body>
        </html>
    ]], responses[myrandom], applet.get_var(applet,"sess.name"))

    applet:set_status(200)
    applet:add_header("content-length", string.len(response))
    applet:add_header("content-type", "text/html")
    applet:start_response()
    applet:send(response)
end

core.register_service("magic8ball", "http", magic8ball)

magicsetcookie.lua

local function magicsetcookie(applet)
    applet:set_status(302)

    -- set cookie
    cookie = applet.get_var(applet,'req.sessioncookie')
    --core.Debug(cookie)
    applet:add_header("Set-Cookie", string.format("magicsess=%s; HttpOnly",cookie))

    applet:add_header("Location", "/magic")

    applet:start_response()
end

core.register_service("magicsetcookie", "http", magicsetcookie)

magictokens.lua

local function magictokens(txn)

    -------------------------------
    -- Get the auth code variable
    -------------------------------
    authcode = txn.get_var(txn,"req.authcode")
    --core.Debug(string."auth code is authcode")

    -------------------------------
    -- Prepare token request
    -------------------------------
    -- http://localhost:8080/realms/myrealm/protocol/openid-connect/token

    client_id_and_secret = "bWFnaWM4YmFsbDprU01tYndHOGoxRlVCeG5UZ0FKd1JkdEUwcjBjcnR5Sw=="
    realm = "myrealm"
    token_endpoint = string.format("/realms/%s/protocol/openid-connect/token",realm)
    client_id="magic8ball"
    grant_type="authorization_code"
    redirect_uri="http://localhost:8891/code"
    data=string.format("grant_type=%s&client_id=%s&redirect_uri=%s&code=%s",grant_type,client_id,redirect_uri,authcode)

    auth_header = string.format("Authorization: Basic %s",client_id_and_secret)
    content_length_header = string.format("Content-Length: %d", string.len(data))
    content_type_header = "Content-Type: application/x-www-form-urlencoded"

    token_request = string.format("POST %s HTTP/1.1\r\n%s\r\n%s\r\n%s\r\n\r\n%s",token_endpoint,auth_header,content_type_header,content_length_header,data)

    --core.Debug(string."request is token_request")
    
    -------------------------------
    -- Make request over TCP
    -------------------------------
    contentlen = 0

    idp = core.tcp()
    idp:settimeout(5)
    -- connect to issuer
    if idp:connect('127.0.0.1','8080') then
      if idp:send(token_request) then
      
        -- Skip response headers
        while true do
          local line, err = idp:receive('*l')

          if err then
            core.Alert(string.format("error reading header: %s",err))
            break
          else
            if line then
              --core.Debug(string."data: line")
              if line == '' then break end
              -- core.Debug(string."substr: string.sub(line,1,3)")
              if string.sub(string.lower(line),1,15) == 'content-length:' then
                --core.Debug(string."found content-length: string.sub(line,16)")
                contentlen = tonumber(string.sub(line,16))
              end
            else
              --core.Debug("no more data")
              break
            end
          end
        end

        -- Get response body, if any
        --core.Debug("read body")
        local content, err = idp:receive(contentlen)

        if content then
          -- save the entire token response out to a variable for
          -- further processing in haproxy.cfg
          --core.Debug(string."tokens are content")
          txn.set_var(txn,'req.tokenresponse',content)
        else
          core.Alert(string.format("error receiving tokens: %s",err))
        end
      else
          core.Alert('Could not send to IdP (send)')
      end
      idp:close()
    else
        core.Alert('Could not connect to IdP (connect)')
    end

end

core.register_action("magictokens",{'http-req'}, magictokens, 0)

How could this implementation be improved?

While secure and useful, there are more steps to make this production-ready and maintainable.

TLS

... in production, obviously, for both Keycloak and HAProxy.

Use Lua HTTP Client class

As noted above, there's an easier way to do HTTP from the Lua action.

Use the refresh token

When the ID token expires the user is redirected back to Keycloak and may have to log in again. We can instead use the refresh token on the server side to fetch new tokens. I'd modify the magictokens action to support this case too. This would provide the user a less-disruptive experience.

Add authorization

We have implemented authentication in a way that any realm user can access the application. What if we want to require that users have a certain role or are in a certain group to interact with certain resources. For this Keycloak provides the access token which has a list of permissions that. Enabling authorization is another whole thing but is totally possible. In that case we'd expose those permissions from the access token to the application to base authorization decisions.

Socket task: periodically dump tokens to map files

If HAProxy is reloaded or restarted, the in-memory contents of the maps are lost. As hinted above, a cronjob can periodically dump the map contents to the map files using the socket (socat).

Socket task: periodically remove expired tokens

Over time many session UUID's are created and put into the maps, growing unbounded. A cronjob, again using the socket (socat), can sweep the map and delete entries with expired tokens so the data remains lean.

Periodically download JWKS keys

We manually downloaded the JWKS keys and converted them to PEM format. Of course those keys expire eventually. A cronjob can download them periodically, convert to PEM, and reload the HAProxy process when they've changed.

Conclusion

I've shown how an auth-unaware web application can be made to require authentication using Keycloak and HAProxy alone.

I'd be lying if I said I whipped that out in a couple hours. It was a lot of hours, enough that it was worth documenting in a blog post.

Still, I'd absolutely reach for this solution in a case where some first- or third-party application needs to be made to authenticate with an organization's standard OIDC solution (in this case Keycloak).

I want to also show off how powerful those two technologies are.

I hope you found this interesting and maybe useful.