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
- β Choose between LDAP, OAuth2, OIDC, and SAML with a clear mental model of each
- β Deploy Keycloak with docker-compose behind a TLS reverse proxy
- β Create realms, users, groups, and clients; enable MFA realm-wide
- β Integrate an application (Grafana-style) as an OIDC client end-to-end
- β Connect Vault's OIDC auth method to Keycloak for human admin access
- β Run joiner/mover/leaver processes without touching individual apps
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:
- Groups (map roles here, like RBAC in rbac-basics):
admins,developers,readers,service-accounts. - Users β join groups. Set
Email verified, requireUpdate passwordon first login. - Authentication β Required actions: make Configure OTP default for all users β MFA from day one (two-factor-authentication).
- 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
- Previous: rbac-basics β what happens after authentication succeeds
- Next: zero-trust-principles
- two-factor-authentication β OTP/WebAuthn policies configured here
- vault-authentication β consuming this IdP from Vault
- certificate-fundamentals β TLS underneath everything here
- ../containers/docker-compose-patterns β compose patterns used in deployment
Change Log
- 2026-08-14 β Initial lesson created as part of KB course build-out (security Phase 4).
Next Steps / Ops Actions
- Map client scopes β roles β groups for all Keycloak clients; verify groups claim arrives in tokens.
- Configure
groupsscope and add group mapper; test withcurlagainst protected endpoint. - Shorten access-token lifespans; set up session revocation hooks per
rbac-basics.md. - Document the master-realm recovery procedure (temp
KEYCLOAK_ADMIN*envs) insysadmin/secrets.md.