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 with authority. This part stands up Keycloak as the SSO front door and federates the existing directory read-only, adding MFA without migrating a single account.

Real client 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 the client already runs, with a second factor required, instead of from a pile of VPN certificates. This part builds the piece that makes that possible, the identity spine. The hard 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, and sudo rules. The wrong move would be to copy users into a new system and end up with two directories that are forever half in sync. The right move is to put a broker in front of it.

Why broker instead of replace

Keycloak speaks the modern protocols that NetBird and other apps want (OIDC, SAML) and it enforces MFA. The directory speaks LDAP and owns the POSIX truth. Put one behind the other, with Keycloak reading the directory read-only over LDAPS, and you get SSO plus MFA on top of the accounts that already exist. Nobody gets migrated, and there’s still exactly one source of truth. No passwords are copied either. Keycloak checks them against the directory on every login.

So Keycloak becomes the front door every app authenticates against, and 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. Not a real admin, and 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, I proved the bind worked straight from the identity VM. If ldapsearch can’t authenticate, Keycloak won’t either.

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

That came back with the test user’s single entry and their uid and mail, which confirmed the TLS trust, the bind DN, and the search base were all correct before Keycloak ever entered the picture.

Why test the bind from the command line first

Keycloak’s federation screen gives you one “Test connection / Test authentication” button and a generic error when it fails. Running ldapsearch from the same host tells you which layer actually broke, whether that’s DNS, TLS trust, the bind DN, or the search base, before Keycloak is even involved. Isolate one variable before adding the next.

Keycloak and Postgres, behind TLS

Keycloak runs as a container with a Postgres database on the LAN-only identity VM. TLS terminates upstream at the gateway’s Caddy (Part 3), so Keycloak itself runs plain HTTP behind the proxy and just needs to know 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
      # plus the IPA CA mounted into the container truststore
volumes: { kc-db: {} }

Then it’s a few steps in the admin console. Create a realm. Add User Federation, LDAP, pointed at ldaps://ipa.int.example.com, with edit mode set to READ_ONLY and the users DN cn=users,cn=accounts,…, and sync. Then add a group mapper so the directory groups (engineering, ops, staff, and so on) import as Keycloak groups. That last step isn’t optional, because those group claims are what drives access policy later.

After the sync, the realm listed the directory’s users along with their groups, and a quick check on a test user showed the right group memberships had come across.

MFA, the part that actually beats a certificate

This is where the second factor lives, which is the whole reason for the spine. In the realm’s authentication flow, bind an OTP or WebAuthn step and set “configure second factor” as a required action, so every user enrolls a passkey or TOTP on first login.

Passkeys now, cloud IdP later

I’m enforcing MFA in Keycloak itself for now (WebAuthn or TOTP) because it’s fully under my control and lets the build keep moving. A later phase hands MFA authority to the client’s cloud IdP (Entra Conditional Access) by federating that into Keycloak too. That step depends on a licensing conversation, and the migration can’t wait on it. Stage the dependency rather than blocking on it.

The acceptance test for this part, and the moment it stopped being theoretical, was a real directory user signing into the Keycloak account console with their existing password and a freshly enrolled passkey. That’s SSO and MFA on an account nobody had to migrate.

What’s next

We have a single sign-on front door that authenticates real users against the existing directory, with a second factor, and not one account was migrated to get there. Part 3 stands up the gateway: Caddy issuing TLS over 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.