SeriesRebuilding Remote AccessPart 2 of 5

The identity spine: Keycloak in front of an existing directory

Before the mesh can ask 'who are you?', something has to answer authoritatively. This part stands up Keycloak as the SSO front door and federates the existing directory read-only — adding MFA without migrating a single user account.

Real work, anonymized — example.com, 10.0.0.x, and generic hostnames stand in for the real ones.

Part 1 set the goal: access should flow from the directory we already have, gated by MFA, instead of from a pile of VPN certificates. This part builds the piece that makes that possible — the identity spine — and the key constraint is that it has to add modern auth without touching the directory that’s already the source of truth for Linux logins, home directories, and group membership.

The mental model: broker, don’t migrate

The existing directory (FreeIPA) is load-bearing. It owns POSIX identities, SSH access, sudo rules — the lot. The wrong move would be to copy users into a new system and end up with two half-synced directories. The right move is a broker:

Why put Keycloak in front instead of replacing the directory

Keycloak speaks modern protocols (OIDC, SAML) that NetBird and other apps want, and enforces MFA. The directory speaks LDAP and owns POSIX truth. Federate one behind the other — Keycloak reads the directory read-only over LDAPS — and you get SSO + MFA on top of the accounts you already have, with zero user migration and exactly one source of truth. No passwords are copied; Keycloak verifies them against the directory on each login.

So Keycloak becomes the front door every app authenticates against; the directory stays the system of record behind it.

A read-only service account, and trusting the directory’s CA

Federation needs a dedicated bind account — a locked, read-only identity Keycloak uses to search the directory. Never a real admin, never read-write:

# On a directory-enrolled host, create a locked read-only system bind account
# (a dedicated service entry under cn=sysaccounts), and record its DN + password in the vault.
kinit admin
ldapmodify -x -D "cn=Directory Manager" -w '<pw>' <<'EOF'
dn: cn=svc-keycloak,cn=sysaccounts,cn=etc,dc=int,dc=example,dc=com
changetype: add
objectClass: account
objectClass: simpleSecurityObject
uid: svc-keycloak
userPassword: <generated-secret>
passwordExpirationTime: 20380119031407Z
EOF

Keycloak also has to trust the directory’s TLS, so export its CA and load it into Keycloak’s truststore:

# the directory's CA cert -> made available to the Keycloak container as a truststore entry
cp /etc/ipa/ca.crt /srv/keycloak/ipa-ca.crt

Before wiring anything into Keycloak, prove the bind works from the identity VM — if ldapsearch can’t authenticate, Keycloak certainly won’t:

ldapsearch -x -H ldaps://ipa.int.example.com \
  -D "cn=svc-keycloak,cn=sysaccounts,cn=etc,dc=int,dc=example,dc=com" -w '<pw>' \
  -b "cn=users,cn=accounts,dc=int,dc=example,dc=com" "(uid=testuser)" uid mail

Run from the identity VM, this returned the test user’s single entry with their uid and mail — confirming TLS trust, the bind DN, and the search base were all correct before Keycloak entered the picture.

Why test the bind from the command line first

Keycloak’s federation UI gives you a single “Test connection / Test authentication” button and a generic error if it fails. ldapsearch from the same host tells you which layer is broken — DNS, TLS trust, bind DN, or search base — before Keycloak is even in the picture. Isolate the variable before you add the next one.

Keycloak + Postgres, behind TLS

Keycloak runs as a container with a Postgres database, on the LAN-only identity VM. TLS is terminated upstream (at the gateway’s Caddy, Part 3), so Keycloak itself runs HTTP behind the proxy and is told its public hostname:

# identity VM — docker compose (trimmed)
services:
  postgres:
    image: postgres:16
    volumes: [ "kc-db:/var/lib/postgresql/data" ]
  keycloak:
    image: quay.io/keycloak/keycloak:<pinned>
    command: start --hostname=login.example.com --proxy-headers=xforwarded --http-enabled=true
    environment:
      KC_DB: postgres
      # + the IPA CA mounted into the container truststore
volumes: { kc-db: {} }

Then, in the Keycloak admin console: create a realm, add User Federation → LDAP pointed at ldaps://ipa.int.example.com, edit mode READ_ONLY, users DN cn=users,cn=accounts,…, and sync. Add a group mapper so directory groups (engineering, ops, staff, …) import as Keycloak groups — those group claims drive access policy later, so this isn’t optional.

After the sync, the realm listed our directory users alongside their groups, and a spot-check on a test user showed the expected group memberships had come across — which matters because those group claims are exactly what drives access policy later.

MFA: the actual upgrade over certificates

The whole point of the spine is that this is where MFA lives. In the realm’s authentication flow, bind an OTP/WebAuthn step and set “configure second factor” as a required action, so every user enrolls a passkey or TOTP on first login.

Passkeys first, cloud IdP later

I’m enforcing MFA natively in Keycloak now (WebAuthn/TOTP) because it’s entirely under my control and unblocks the build. A later phase hands MFA authority to the cloud IdP (Entra Conditional Access) by federating it into Keycloak too — but that depends on a licensing conversation, and the migration must not wait on it. Stage the dependency; don’t block on it.

The acceptance test for this whole part — and the moment it becomes real — is logging into the Keycloak account console as a genuine directory user, with their existing password plus a freshly enrolled second factor. And it passed: a real directory user signed into the Keycloak account console with their existing password plus a freshly enrolled passkey — SSO and MFA, on an account that was never migrated.

What’s next

We have a single sign-on front door that authenticates real users against the existing directory, with MFA — and no accounts were migrated to get it. Part 3 stands up the gateway: Caddy issuing TLS via DNS-01, self-hosted NetBird wired to this Keycloak as its identity provider, and a coturn relay for the peers that can’t connect directly.