Files
paskia/docs/proxy/haproxy.md
LeoVasanko 84985501f5 MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:14:42 +00:00

6.9 KiB

HAProxy Forward-Auth

/auth/api/forward · Proxy guides

HAProxy does not have a built-in forward-auth primitive, but the community haproxy-auth-request Lua script provides an auth-intercept action that works very similarly to Nginx's auth_request. It makes an internal HTTP request to Paskia and copies the response headers to the backend request.

Requirements

  • HAProxy 2.2 or newer (2.0+ may work but 2.2+ supports all features shown here).
  • Compiled with USE_LUA=1.
  • The haproxy-auth-request Lua script loaded.
  • The haproxy-lua-http dependency in the Lua path.

Overview

global
    lua-load /usr/share/haproxy/auth-request.lua

defaults
    mode http
    timeout connect 5s
    timeout client 30s
    timeout server 30s

# Backend that runs the Paskia auth check.
backend paskia_auth
    server paskia 127.0.0.1:4401

# Backend that runs the Paskia UI / WebSocket / API.
backend paskia_ui
    server paskia 127.0.0.1:4401

# Your protected application.
backend app_backend
    server app 127.0.0.1:3000

frontend app
    bind *:80

    # 1. Route /auth/ straight to Paskia, bypassing the auth check.
    acl is_auth path_beg /auth/
    use_backend paskia_ui if is_auth

    # 2. Add the headers Paskia logs / expects. These are then copied to the
    #    auth subrequest by auth-intercept.
    http-request set-header X-Forwarded-Method %[method]
    http-request set-header X-Forwarded-Uri %[url]

    # 3. Run the auth check. The parameters are:
    #    backend       path                                   method
    #    req-headers   success-headers                        failure-headers
    #
    #    - req-headers:   headers copied from client to Paskia.
    #    - success-headers: headers copied from Paskia response to backend request.
    #    - failure-headers: headers copied from Paskia response to client response.
    http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *

    # 4. If the subrequest was not successful, deny the request.
    http-request deny if ! { var(txn.auth_response_successful) -m bool }

    default_backend app_backend

What the configuration does

  1. Route /auth/ to Paskia — users must be able to reach the login/profile UI without already being authenticated. HAProxy will proxy WebSocket upgrade headers automatically for this backend when the client requests them.
  2. Set X-Forwarded-Method and X-Forwarded-Uri — HAProxy adds these headers to the incoming request so the Lua script can copy them to the auth subrequest. %[method] returns the HTTP method and %[url] returns the path and query string.
  3. lua.auth-intercept — sends a GET request to /auth/api/forward?perm=myapp:login on the paskia_auth backend. It copies the listed request headers (including the dynamic ones we just set) to the auth subrequest.
  4. On success (2xx) — copies every response header matching Remote-* from Paskia to the backend request. This overrides any client-supplied Remote-* headers, so the backend can trust them.
  5. On failure (4xx) — copies all response headers (*) to the client response and uses Paskia's response body, so the browser gets the login HTML or the JSON auth URL.
  6. Deny if auth failed — the final http-request deny rule is a safety net. In practice, auth-intercept with * as the failure-headers already terminates the transaction with Paskia's response.

Backend definition for the auth subrequest

The paskia_auth backend can be the same physical server as paskia_ui, but using a separate backend is convenient because the Lua script will use the first available server in the backend. The auth subrequest is a plain HTTP request, so no special WebSocket options are needed here.

backend paskia_auth
    server paskia 127.0.0.1:4401

Per-route permissions

You can run different auth checks for different paths by using HAProxy ACLs. Place the more specific rules before the generic one:

frontend app
    bind *:80

    acl is_auth path_beg /auth/
    use_backend paskia_ui if is_auth

    acl is_reports path_beg /reports
    http-request set-header X-Forwarded-Method %[method] if is_reports
    http-request set-header X-Forwarded-Uri %[url] if is_reports
    http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:reports&max_age=5min GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * if is_reports

    http-request set-header X-Forwarded-Method %[method]
    http-request set-header X-Forwarded-Uri %[url]
    http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *

    http-request deny if ! { var(txn.auth_response_successful) -m bool }
    default_backend app_backend

See perm argument and max_age argument for query parameter syntax.

Public access

For routes where anonymous visitors are allowed but logged-in users should still be identified, add public=1 to the auth subrequest path:

http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1 GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
# or with a permission the backend will check itself:
http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1&perm=myapp:reports GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *

The Remote-* success-headers glob already copies the Remote-Public header that marks each request as anonymous, forbidden or authenticated. With public=1 the backend always runs and must check Remote-Public before treating the request as authorized; only reauth (max_age) still returns the 401 auth flow, so the http-request deny safety net simply never triggers on public routes. See public access and trusted headers.

Notes

  • The Lua script strips the request body from the auth subrequest, so Paskia's /auth/api/forward will only see the headers.
  • HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. Remote-User becomes req.auth_response_header.remote_user). The Remote-* glob pattern in the success-headers argument handles this automatically.
  • The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
  • If you use a dedicated authentication host (the domain's auth-host setting), route auth.example.com to the Paskia backend instead of exposing /auth/ on every site.