Identity Management - Centralized Identity with SSO, Keycloak, LDAP, and OIDC

Status: Active
Last Updated: 2026-08-14
Category: Security - Phase 4: Secret & Access Management
Prerequisites: two-factor-authentication, rbac-basics, tls-configuration
Time: 4-5 hours
Tags: keycloak, sso, oidc, oauth2, ldap, saml, identity, federation

Summary

Every service that manages its own user database is a separate attack surface, a separate password reset flow, and a separate offboarding risk. Centralized identity fixes this: one directory of users, one login experience, one place to disable an account everywhere. This lesson covers SSO concepts (OAuth2 vs OIDC vs SAML vs LDAP), a full Keycloak deployment behind TLS, integrating real services as OIDC clients, and connecting Vault to your new identity provider.


🎯 What You'll Learn


The Protocols, Untangled

People mix these up constantly. One paragraph each:

Protocol What it actually is Use for
LDAP A directory: hierarchical user/group database + simple bind auth. No web flows. Source of truth; legacy/infra tools (Jellyfin, Nextcloud, VPN)
OAuth2 An authorization framework: apps get delegated access via tokens ("allow X to read my photos"). Says nothing about who the user is. API authorization machinery
OIDC OAuth2 + identity layer: ID tokens (JWTs) that say who logged in. Web/mobile app single sign-on β€” the modern default
SAML XML-based federation, enterprise legacy Old enterprise IdPs; avoid for greenfield

Practical stack for self-hosters: Keycloak as IdP, backed by its own user store (or federated to LDAP later), exposing OIDC to every web service. Infra-only boxes keep local accounts or LDAP.

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   Grafana ──OIDC──▢│              │◀──OIDC── Vault UI
   Nextcloud ─────▢ β”‚   Keycloak   β”‚
   MinIO console ──▢│  (IdP)       │◀──LDAP── WireGuard / OS logins (optional)
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                   users & groups (one truth)

Deploying Keycloak

~/keycloak/docker-compose.yml:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD_FILE: /run/secrets/kc_db_pass
    secrets: [kc_db_pass]
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

  keycloak:
    image: quay.io/keycloak/keycloak:25.0
    command: start                      # production mode: requires HTTPS config below
    depends_on: [postgres]
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD_FILE: /run/secrets/kc_db_pass
      KC_HOSTNAME: sso.fogserv.cloud          # MUST match the URL users visit
      KC_PROXY_HEADERS: xforwarded            # trust reverse proxy headers
      KC_HTTP_ENABLED: "true"                 # TLS terminated at proxy
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD_FILE: /run/secrets/kc_admin_pass
    secrets: [kc_admin_pass]
    restart: unless-stopped

secrets:
  kc_db_pass:
    file: ./secrets/kc_db_pass
  kc_admin_pass:
    file: ./secrets/kc_admin_pass

volumes:
  pgdata:

Reverse proxy block (Traefik/Caddy/nginx pattern β€” see ../networking/README once built, or tls-configuration):

server {
    listen 443 ssl http2;
    server_name sso.fogserv.cloud;
    # certs via Let's Encrypt (see letsencrypt-automation)
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
}

What Happens: Keycloak stores everything in Postgres (back it up like any database). KC_HOSTNAME must exactly match the public hostname or redirects/OIDC issuer URLs break β€” the #1 Keycloak support question.

Bring it up:

mkdir -p secrets && openssl rand -base64 24 > secrets/kc_db_pass && \
openssl rand -base64 24 > secrets/kc_admin_pass
docker compose up -d
docker compose logs keycloak | grep -i started

Realm Setup

Never use the master realm for your users β€” it's for Keycloak administration only.

Administration Console β†’ Create Realm:
  Realm name: fogserv
  β†’ enabled, SSL required: external requests

Inside the fogserv realm:

  1. Groups (map roles here, like RBAC in rbac-basics): admins, developers, readers, service-accounts.
  2. Users β†’ join groups. Set Email verified, require Update password on first login.
  3. Authentication β†’ Required actions: make Configure OTP default for all users β€” MFA from day one (two-factor-authentication).
  4. Realm settings β†’ Tokens: access token lifespan 15 min; SSO session idle 8h; enable Revoke refresh token.

Create an OIDC Client (for Grafana as the example)

Clients β†’ Create client:
  Client type: OpenID Connect
  Client ID: grafana
  βœ“ Client authentication ON     ← confidential client (has a secret)
  Valid redirect URIs: https://grafana.fogserv.cloud/login/generic_oauth
  Web origins: https://grafana.fogserv.cloud

Credentials tab β†’ copy Client secret.

What Happens: redirect URIs are the allow-list of where Keycloak will send users with tokens. A wrong/open redirect is a credential-theft vector β€” always exact-match, never trailing wildcards in prod.

Grafana side (grafana.ini) β€” the consumer half:

[auth.generic_oauth]
name = FogServ SSO
enabled = true
client_id = grafana
client_secret = <from Credentials tab>
auth_url = https://sso.fogserv.cloud/realms/fogserv/protocol/openid-connect/auth
token_url = https://sso.fogserv.cloud/realms/fogserv/protocol/openid-connect/token
api_url  = https://sso.fogserv.cloud/realms/fogserv/protocol/openid-connect/userinfo
scopes = openid profile email groups
role_attribute_path = contains(groups[*], 'admins') && 'Admin' || 'Viewer'

Restart Grafana, hit logout, and login now routes through your Keycloak login page. Every subsequent OIDC integration follows this same four-URL pattern.


Connect Vault (Human Admin Access)

Vault was configured for OIDC in vault-authentication; create its client in Keycloak first:

Client ID: vault Β· Confidential Β·
Redirect URI: http://localhost:8250/oidc/callback   (vault CLI callback)

Then map Keycloak groups to Vault policies so group membership drives permissions:

vault write auth/oidc/groups/admins policies=security-team

Test the full loop: vault login -method=oidc, browser opens, authenticate with MFA, land back in your shell holding a scoped token.


Joiner / Mover / Leaver Operations

The payoff of centralization β€” per-event checklists instead of per-app archaeology:

Event Actions
Joiner Create user in realm β†’ assign group(s) β†’ force OTP setup on first login
Mover Change group membership β†’ access follows within one token lifetime
Leaver Disable (never delete first!) β†’ sessions die at next refresh β†’ after data handover/archive, delete. Revoke offline tokens: Users β†’ user β†’ Sessions β†’ revoke

Keep an auditable record of each event (ticket reference), because "who had access when" is a question you will be asked β€” by yourself during incident response if not by an auditor (compliance-automation).

Back up Keycloak: nightly pg_dump of the keycloak DB + export realm JSON (Realm settings β†’ Export). Test restore quarterly alongside your backup drills (disaster-recovery).


Troubleshooting & Common Issues

Symptom Cause Fix
invalid_redirect_uri at login Callback not in client's allow-list Exact-string match incl. scheme/port/path
Infinite redirect loop through proxy Keycloak unaware of external HTTPS Set KC_PROXY_HEADERS=xforwarded + correct KC_HOSTNAME; verify X-Forwarded-Proto reaches backend
Token validation fails with issuer mismatch App expects different issuer URL Issuer is https://<host>/realms/<realm> β€” must match byte-for-byte
Clock skew breaks JWT validation NTP drift on any node chrony on all hosts; >60s skew fails OIDC
Group claims missing from token Groups scope/mapper not added Client scopes β†’ groups β†’ add mapper, request groups scope
Locked out of admin console Lost admin creds in master realm Boot container once with temp KEYCLOAK_ADMIN* envs against restored DB, fix, remove envs
User deleted, app still works Cached access token unexpired Shorten lifespans; disable-don't-delete; revoke sessions

πŸ”— Related


Change Log

Next Steps / Ops Actions

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse