Runbook 13 — Authelia SSO/MFA + Caddy forward-auth + OIDC¶
Goal: an identity-aware SSO layer in front of the lab's apps. Authelia (sso,
10.20.20.41) authenticates users — SSO + MFA — against FreeIPA (LDAP), and either
(a) gates a site via Caddy forward_auth before traffic reaches it, or (b) is the OIDC
provider for apps that speak OIDC natively (Grafana, the control-plane Forgejo). It replaced
Authentik on 2026-07-06 — same FreeIPA-backed model, far less machinery.
Prereqs: runbook 10 (FreeIPA) + runbook 09 (origin Caddy). This is the "zero-trust / BeyondCorp" pattern: authenticate before the app, using one identity store.
Why Authelia over Authentik. Authentik was a Docker-host VM running server + worker + PostgreSQL + Redis. Authelia is a single Go binary in a podman-quadlet LXC, config- as-code (one
configuration.yml, secrets inline from vault like searxng's settings), SQLite + filesystem notifier — no DB/cache/worker to run or back up. Lower churn, smaller blast radius, the same LDAP→FreeIPA identity and Caddy integration. Theautheliarole owns it end to end.
Placement¶
sso— an unprivileged LXC in SERVERS (10.20.20.41),autheliarole, one podman quadlet (docker.io/authelia/authelia:4.38.x, digest-verify at deploy — the config schema is version-sensitive). Runs as a deterministic host uid:gid;/var/lib/autheliabind-mounts/config(config + SQLite db + notifications).- Its portal (
auth.${domain}) is published through the origin Caddy like any site; the app traffic itself is gated byforward_author brokered by OIDC. - LDAP source → FreeIPA (
ldaps://to${ipa_server}, the sharedsvc-ldapread bind, same DNs/filters the fleet uses;fleet-adminsis the admin group).tls_skip_verify: true— encrypted to a trusted internal SERVERS box (harden later by mounting the IPA CA).
0. One-time: the svc-ldap read bind (FreeIPA sysaccount)¶
Authelia (and Jellyfin, runbook 21) bind to FreeIPA as the shared svc-ldap read-only
system account — uid=svc-ldap,cn=sysaccounts,cn=etc,${basedn}. It's a directory
sysaccount (not an IPA user, so it's HBAC-immune and never a login identity), created
once on the FreeIPA server. Its password is vault_ldap_bind_password in the vault (the
same value every LDAP consumer uses):
# on the FreeIPA server, with the vault_ldap_bind_password value at hand:
ldapadd -x -D "cn=Directory Manager" -W <<EOF
dn: uid=svc-ldap,cn=sysaccounts,cn=etc,${basedn}
objectClass: account
objectClass: simplesecurityobject
uid: svc-ldap
userPassword: <vault_ldap_bind_password>
passwordExpirationTime: 20380119031407Z
nsIdleTimeout: 0
EOF
Verify the bind works: ldapsearch -x -D uid=svc-ldap,cn=sysaccounts,cn=etc,${basedn}
-W -b cn=users,cn=accounts,${basedn} uid=operator returns the user.
1. Deploy (the authelia role)¶
Config-as-code — nothing manual. Secrets are injected per-play from the vault (see the sso
play in playbooks/site.yml): authelia_session_secret, authelia_storage_encryption_key,
authelia_reset_jwt_secret, authelia_oidc_hmac_secret, authelia_oidc_issuer_key (RSA PEM), the
LDAP bind password, and each OIDC client secret.
cd ansible && ansible-playbook playbooks/site.yml --limit sso
The template (roles/authelia/templates/configuration.yml.j2) renders configuration.yml (0640);
the quadlet restarts on change. Verify: podman logs authelia clean, https://auth.${domain}
serves the portal, an IPA user can log in.
2. Forward-auth (gate a site in Caddy)¶
For sites without native OIDC, Caddy asks Authelia to authorize each request. Protected domains are
authelia_forward_auth_domains (role default: search, fmd) + instance overlays
(authelia_access_rules_extra — e.g. a casey-only app, or a gated sub-path). In the origin Caddy:
auth.${domain} {
reverse_proxy 10.20.20.41:9091 # the Authelia portal
}
search.${domain} {
forward_auth 10.20.20.41:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy <backend>
}
Per-domain access rules live in access_control (rendered from authelia_forward_auth_domains
+ authelia_access_rules_extra): a bare {domain} gates exactly the forwarded requests to any
authenticated user; add subject: ['user:casey'] to restrict, or resources: for path regexes
(path scoping is usually done in Caddy — only the gated paths are forward_auth'd). Default policy
is one_factor (LDAP password); set two_factor to force TOTP/WebAuthn.
Cross-apex gating. Authelia scopes a session to its cookie domain, so a gated site on a different apex than
${domain}(a subdomain of one apex can't share another apex's cookie) needs its own cookie domain and portal. Four pieces: (1)authelia_cookie_domains_extra—{domain: <apex>, authelia_url: https://auth.<apex>}; (2) anauth.<apex>portal vhost (origin_vhosts_extra→ Authelia:9091); (3) a public DNS recordauth.<apex>→ edge; (4)auth.<apex>in the edge SNI passthrough (edge_sni_names_extra). Miss any and Authelia 400s (it can't manage a session for an unconfigured apex). Example: a gated sub-path on a second apex —example.org/media-trust/*— gates viaauth.example.org.Long URLs and the header budget (
431 Request Header Fields Too Large). Caddy'sforward_authreplays the entire original browser header set to Authelia and addsX-Forwarded-Uricarrying the full path+query — so a long query string is charged to the budget twice (once as the URI, again in the browser'sRefereron in-app navigation). Authelia is fasthttp-based and itsserver.buffers.readdefaults to 4096 B; past that it returns 431, which Caddy relays verbatim (Via: 1.1 Caddyon the response — the error is Authelia's, not Caddy's, which happily accepts 6 KB+ on a non-gated vhost). Hit on 2026-07-26 byresearch.${domain}(rb44), whose UI puts whole prompts in the query string: a 1.7 KB query = a 1742 BX-Forwarded-Uri, 42% of the old budget before one browser header. The write side matters too — Authelia's 302 to the portal embeds the original URL doubly-encoded in?rd=, so itsLocationheader alone was ~2.4 KB (58% of the old 4096); left too small this fails as a broken redirect, which is harder to read than a clean 431. Both are now raised to 16384 (authelia_buffer_read/authelia_buffer_write). This is plane-wide, not aresearchquirk: it applies to every Tier-1 forward-auth'd vhost. To diagnose, bisect the hop — backend direct, then Authelia direct, then the public URL:curl -sS -o /dev/null -w '%{http_code}\n' "http://<backend>/<long-url>" # app itself curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Forwarded-Uri: <long-uri>" \ -H 'X-Forwarded-Method: GET' -H 'X-Forwarded-Proto: https' \ -H 'X-Forwarded-Host: <vhost>' http://10.20.20.41:9091/api/authz/forward-auth curl -sS -o /dev/null -w '%{http_code}\n' -H 'X-Junk: <6KB>' https://<gated-vhost>/ # vs a curl -sS -o /dev/null -w '%{http_code}\n' -H 'X-Junk: <6KB>' https://myos.io/ # non-gatedConfig-key changes are version-sensitive (
server.buffers.*replaced 4.37'sserver.read_buffer_size) — validate against the pinned image before converging:docker run --rm -v $PWD/cfg.yml:/cfg.yml:ro authelia/authelia:<pinned-tag> \ authelia validate-config --config /cfg.yml(it reportsconfiguration key not expected).
3. OIDC (native login for apps that speak it)¶
Apps that do OIDC skip forward-auth and log in against Authelia. Clients live under
identity_providers.oidc.clients in the config; the issuer is https://auth.${domain}. Current
clients:
- Grafana —
redirect_uris: [https://mon.${domain}/login/generic_oauth],fleet-admins→Admin. - Forgejo (control-plane,
git.${domain}) — IPA users log into the private git via Authelia. Forgejo is a DMZ box that can't reach the identity core, so its OIDC token-exchange hairpins through the origin Caddy (AddHost=auth.${domain}:<origin>in the quadlet; TLS still verified — the origin holds a valid cert). Auto-registration is bounded (Authelia only authenticates IPA users — no public signup); new users land as plain users;fleet-admins→site admin via the groups claim; the local admin stays break-glass. The publicforgejo-pubis deliberately NOT a client — keeping the internet-facing instance off the identity core (runbook 35 exposure model). Wired by thebackendsrole (forgejo_oidc_enabled: true). - Seafile (
files.${domain}) — IPA users log into Seafile (web + sync-client browser login) via Authelia. Redirectauthelia_oidc_seafile_redirect(https://files.${domain}/oauth/callback/); enabled by thebackendsrole (seafile_oidc_enabled: true, set inhost_vars/files.yml). Also a DMZ box, so like Forgejo its token-exchange hairpins through the origin Caddy (AddHostin the seafile quadlet). Seafile/oauthlib sends the secret via HTTP Basic, so the client istoken_endpoint_auth_method: client_secret_basic,require_pkce: false; the localseafile_adminstays break-glass (runbook 41).
Merging a pre-SSO local account with its SSO identity. ACCOUNT_LINKING = auto only links
when the auto-registered username/email would collide with an existing account — otherwise the
first OIDC login mints a fresh, empty user (this is how the operator ended up with casey beside
the manual account). Forgejo resolves an OIDC login by (1) user.login_name+login_source+login_type
(how auto-registered users are stored), then (2) an external_login_user row (what the UI
"link account" flow writes). To merge (done 2026-07-10 for the operator account): back up
gitea.db, forgejo admin user delete --id <auto-created> (verify it owns nothing first),
rename the old account to the SSO username via POST /api/v1/admin/users/{old}/rename
({"new_username": …}; a throwaway write:admin token, revoked after), then
INSERT INTO external_login_user (external_id, user_id, login_source_id, provider) VALUES
('<authelia subject UUID>', <uid>, <source id>, 'authelia'). The account keeps login_type=0,
so the local password (break-glass) keeps working alongside SSO — never convert it to
login_type=6, which would kill password login.
Add a client = a new entry in the template (mirror Grafana) + its client_secret in the vault +
wired in the sso play. require_pkce: true where the client supports it (forgejo:9's client does
not send PKCE, so it stays false).
4. MFA¶
Add a TOTP/WebAuthn second factor by setting a domain's policy to two_factor (or globally via
authelia_access_policy). WebAuthn/passkeys are the forward-looking choice. Enrolment happens at the
auth.${domain} portal.
Validation¶
- [ ]
https://auth.${domain}serves the Authelia portal; an IPA user logs in (LDAP). - [ ] A
forward_auth'd site (search.${domain}) redirects to the portal, requires login, then lands on the app; a user not in the allowed group/subject is denied. - [ ] SSO: after one protected app, a second doesn't re-prompt (shared session cookie on
${domain}). - [ ] Unauthenticated direct hits to a backend are blocked at Caddy (no bypass).
- [ ] OIDC: Grafana + the control-plane Forgejo log in via Authelia;
fleet-adminsget admin. - [ ] OIDC: Seafile (
files.${domain}) logs in via Authelia (web + sync-client browser flow); the localseafile_adminstill works as break-glass. - [ ] Authelia is not internet-reachable except its portal via Caddy; it sits in SERVERS.
Then → runbook 14 (end-to-end validation).