feat(sso-mfa): T05 SSO stack pivot — Keycloak → Authelia + LLDAP + KeyCape (NK-WP-0001-T05)

Replaces the Keycloak+privacyIDEA SSO tier with the lightweight stack built
during KEY-WP-0001: Authelia (password frontend), LLDAP (directory), and
KeyCape (OIDC orchestration). privacyIDEA is retained as the MFA engine.

Stack:
  kc.coulomb.social   — KeyCape OIDC server (stateless, custom Go)
  auth.coulomb.social — Authelia login portal (password auth → Authelia OIDC → KeyCape)
  lldap.coulomb.social — LLDAP admin UI (IP-restricted)
  pink.coulomb.social — privacyIDEA MFA engine (unchanged)

Changes:
- Remove sso-mfa/k8s/keycloak/ (7 files)
- Add sso-mfa/k8s/lldap/ (pvc, deployment, middleware, ingress, create-secrets, README)
- Add sso-mfa/k8s/authelia/ (pvc, configmap, deployment, ingress, create-secrets, README)
- Add sso-mfa/k8s/keycape/ (deployment, middleware, ingress, create-secrets, create-pi-token, README)
- Update network-policies/netpol-sso.yaml for new component topology
- Update verify-t05.sh: checks LLDAP + Authelia + KeyCape (23 checks)
- Update CONFIG.md: fix CP-NK-004 (KeyCape), add CP-NK-005 (Authelia), CP-NK-006 (LLDAP)
- Update bootstrap/gen-secrets.sh: add LLDAP/Authelia/KeyCape sections, remove Keycloak
- Update k8s/README.md: network policy table reflects new traffic paths
- Add sso-mfa/WORKPLAN.md: resumable task checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 08:31:51 +00:00
parent d0ed7d9cd6
commit 0754dc32e6
31 changed files with 2098 additions and 1077 deletions

View File

@@ -0,0 +1,85 @@
# T05b — Authelia (Authentication Frontend)
Authelia is the password-authentication frontend for the net-kingdom SSO stack.
It acts as an upstream OIDC provider for KeyCape: users are redirected here to
enter their password; Authelia validates credentials against LLDAP and returns
an authorization code to KeyCape, which then performs the MFA step via privacyIDEA.
**Important:** Authelia's access control policy is set to `one_factor` (password only).
MFA is handled exclusively by KeyCape + privacyIDEA. Do not change this to `two_factor`.
## Prerequisites
- T05a complete (LLDAP is Running and healthy, application groups created)
- `bootstrap/gen-secrets.sh` run and `secrets/authelia/secrets.env` populated in KeePassXC
- `kubectl` configured with cluster access
## Apply order
```bash
# 1. Create K8s Secret
cd sso-mfa/k8s/authelia
chmod +x create-secrets.sh
./create-secrets.sh
# 2. Apply manifests (order matters)
kubectl apply -f pvc.yaml
kubectl apply -f configmap.yaml
kubectl apply -f deployment.yaml
kubectl apply -f ingress.yaml
# 3. Wait for pod to be ready
# The startup probe allows 90 s for the initial LLDAP connection.
kubectl rollout status deployment/authelia -n sso --timeout=120s
```
## Configuration
All non-sensitive configuration is in `configmap.yaml` (mounted as `configuration.yml`).
Sensitive values are injected via `*_FILE` environment variables pointing to
Secret-mounted files (see `deployment.yaml` env section).
Key config points:
- `authentication_backend.ldap.url` — points to LLDAP cluster-internal service
- `identity_providers.oidc.clients[0].redirect_uris` — must match CP-NK-004 (`kc.coulomb.social`)
- `session.domain` — set to parent domain `coulomb.social` so cookies are valid across
both `auth.coulomb.social` and `kc.coulomb.social`
## Secrets managed
| Secret name | Keys | Purpose |
|-------------|------|---------|
| `authelia-secrets` | `jwt_secret` | Session JWT signing |
| | `session_secret` | Session cookie encryption |
| | `storage_encryption_key` | SQLite database encryption |
| | `ldap_password` | LDAP bind password (= `LLDAP_LDAP_USER_PASS`) |
| | `oidc_hmac_secret` | OIDC HMAC signing |
| | `oidc_issuer_private_key` | RSA-2048 private key for OIDC token signing |
| | `keycape_client_secret_hash` | Bcrypt hash of `AUTHELIA_KEYCAPE_CLIENT_SECRET` |
`create-secrets.sh` reads plaintext values from `secrets/authelia/secrets.env` and
`secrets/lldap/secrets.env`. It generates the bcrypt hash on the fly (requires
`python3+bcrypt` or `apache2-utils`). The RSA OIDC private key is generated
automatically if `AUTHELIA_OIDC_PRIVATE_KEY_FILE` is not set.
## Storage
`authelia-data` PVC (1 Gi, ReadWriteOnce) holds:
- `db.sqlite3` — SQLite database (user sessions, regulation data)
- `notification.txt` — notification log (filesystem notifier)
Back this PVC up alongside the LLDAP PVC.
## Verify
```bash
# Pod status
kubectl get pod -n sso -l app.kubernetes.io/name=authelia
# Health check
kubectl run -n sso --rm -it auth-test --image=busybox --restart=Never \
-- wget -qO- http://authelia.sso.svc.cluster.local:9091/api/health
# OIDC discovery (should return issuer + endpoints)
curl -s https://auth.coulomb.social/.well-known/openid-configuration | jq .
```

View File

@@ -0,0 +1,120 @@
# ConfigMap — Authelia configuration (namespace: sso)
#
# Contains the full Authelia configuration.yml EXCEPT sensitive values,
# which are injected at runtime via environment variables from authelia-secrets:
#
# AUTHELIA_JWT_SECRET_FILE
# AUTHELIA_SESSION_SECRET_FILE
# AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
# AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
# AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
#
# The *_FILE convention tells Authelia to read the secret from a file path
# (mounted from the authelia-secrets K8s Secret — see deployment.yaml).
#
# Access control policy is deliberately set to one_factor (password only).
# MFA is handled out-of-band by KeyCape via the privacyIDEA adapter AFTER
# Authelia confirms the user's password. Authelia must NOT prompt for a
# second factor; doing so would double-challenge the user.
apiVersion: v1
kind: ConfigMap
metadata:
name: authelia-config
namespace: sso
labels:
app.kubernetes.io/name: authelia
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
data:
configuration.yml: |
---
theme: dark
server:
host: "0.0.0.0"
port: 9091
log:
level: info
# jwt_secret: injected via AUTHELIA_JWT_SECRET_FILE
authentication_backend:
ldap:
# LLDAP preset configures the correct attributes for lldap/lldap image.
implementation: lldap
url: ldap://lldap.sso.svc.cluster.local:3890
base_dn: dc=netkingdom,dc=local
username_attribute: uid
additional_users_dn: ou=people
users_filter: "(&(uid={input})(objectClass=inetOrgPerson))"
additional_groups_dn: ou=groups
groups_filter: "(member={dn})"
group_name_attribute: cn
mail_attribute: mail
display_name_attribute: displayName
user: uid=admin,ou=people,dc=netkingdom,dc=local
# password: injected via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
session:
name: authelia_session
# secret: injected via AUTHELIA_SESSION_SECRET_FILE
expiration: 1h
inactivity: 15m
# domain must cover both auth.coulomb.social and kc.coulomb.social
# so the session cookie is valid across the SSO flow redirect.
domain: coulomb.social # CP-NK — parent domain; update if hostname domain changes
regulation:
max_retries: 5
find_time: 2m
ban_time: 10m
storage:
# encryption_key: injected via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: /var/authelia/data/db.sqlite3
notifier:
disable_startup_check: true
filesystem:
filename: /var/authelia/data/notification.txt
# ── Access control ────────────────────────────────────────────────────────
# one_factor = password only. MFA is handled by KeyCape + privacyIDEA.
# Do NOT change to two_factor here.
access_control:
default_policy: one_factor
# ── OIDC identity provider ────────────────────────────────────────────────
# Authelia acts as an upstream OIDC provider for KeyCape.
# KeyCape is the only registered client.
identity_providers:
oidc:
# hmac_secret: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# issuer_private_key: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
clients:
- id: keycape
description: "KeyCape IAM Orchestration Layer"
# secret (bcrypt hash): injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
public: false
authorization_policy: one_factor
consent_mode: implicit
redirect_uris:
# CP-NK-004 — update if kc.coulomb.social hostname changes
- "https://kc.coulomb.social/authorize/callback"
scopes:
- openid
- profile
- email
- groups
grant_types:
- authorization_code
response_types:
- code
response_modes:
- query
userinfo_signing_algorithm: none

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# create-secrets.sh — create the authelia-secrets K8s Secret
#
# Usage:
# ./create-secrets.sh [secrets-dir]
#
# <secrets-dir> is the output directory from sso-mfa/bootstrap/gen-secrets.sh
# (default: ../../bootstrap/secrets).
#
# Creates ONE Secret in the sso namespace:
# authelia-secrets — all Authelia sensitive values as named files:
# jwt_secret ← AUTHELIA_JWT_SECRET_FILE
# session_secret ← AUTHELIA_SESSION_SECRET_FILE
# storage_encryption_key ← AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
# ldap_password ← AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
# oidc_hmac_secret ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
# oidc_issuer_private_key ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
# keycape_client_secret_hash ← AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
#
# The keycape_client_secret_hash is the bcrypt hash of AUTHELIA_KEYCAPE_CLIENT_SECRET
# from secrets/authelia/secrets.env. The plaintext is stored in KeyCape's secret.
#
# Requires: kubectl, openssl, python3 (for bcrypt hash generation)
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
for f in "$AUTHELIA_ENV" "$LLDAP_ENV"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: $f not found" >&2
echo "Run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
done
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
AUTHELIA_JWT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_JWT_SECRET)
AUTHELIA_SESSION_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_SESSION_SECRET)
AUTHELIA_STORAGE_ENCRYPTION_KEY=$(read_env "$AUTHELIA_ENV" AUTHELIA_STORAGE_ENCRYPTION_KEY)
AUTHELIA_OIDC_HMAC_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_HMAC_SECRET)
AUTHELIA_OIDC_PRIVATE_KEY=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_PRIVATE_KEY_FILE)
AUTHELIA_KEYCAPE_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
LLDAP_LDAP_USER_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
# Validate required values
REQUIRED=(AUTHELIA_JWT_SECRET AUTHELIA_SESSION_SECRET AUTHELIA_STORAGE_ENCRYPTION_KEY
AUTHELIA_OIDC_HMAC_SECRET AUTHELIA_KEYCAPE_CLIENT_SECRET LLDAP_LDAP_USER_PASS)
for var in "${REQUIRED[@]}"; do
if [[ -z "${!var}" ]]; then
echo "ERROR: $var is empty — re-run gen-secrets.sh" >&2
exit 1
fi
done
# The OIDC issuer private key is stored as a file path in secrets.env.
# Read the actual PEM content from the path referenced there.
AUTHELIA_OIDC_PRIVATE_KEY_PATH=$(read_env "$AUTHELIA_ENV" AUTHELIA_OIDC_PRIVATE_KEY_FILE)
if [[ -z "$AUTHELIA_OIDC_PRIVATE_KEY_PATH" ]]; then
# Fall back: generate a new RSA key on the fly (dev only)
echo "WARN: AUTHELIA_OIDC_PRIVATE_KEY_FILE not set — generating a new RSA-2048 key."
echo " Store the generated key in KeePassXC as net-kingdom/Authelia/oidc-private-key."
TMP_KEY=$(mktemp)
openssl genrsa -out "$TMP_KEY" 2048 2>/dev/null
AUTHELIA_OIDC_PRIVATE_KEY_CONTENT=$(cat "$TMP_KEY")
shred -u "$TMP_KEY"
elif [[ -f "$AUTHELIA_OIDC_PRIVATE_KEY_PATH" ]]; then
AUTHELIA_OIDC_PRIVATE_KEY_CONTENT=$(cat "$AUTHELIA_OIDC_PRIVATE_KEY_PATH")
else
echo "ERROR: OIDC private key file not found: $AUTHELIA_OIDC_PRIVATE_KEY_PATH" >&2
exit 1
fi
# Hash the Authelia-KeyCape client secret with bcrypt (cost 12).
# Authelia requires the bcrypt hash for OIDC client secrets.
echo "Hashing KeyCape client secret with bcrypt (this may take a moment)..."
if command -v python3 &>/dev/null && python3 -c "import bcrypt" 2>/dev/null; then
KEYCAPE_CLIENT_SECRET_HASH=$(python3 -c "
import bcrypt, sys
pw = sys.argv[1].encode()
print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode())
" "$AUTHELIA_KEYCAPE_CLIENT_SECRET")
elif command -v htpasswd &>/dev/null; then
KEYCAPE_CLIENT_SECRET_HASH=$(htpasswd -nbBC 12 "" "$AUTHELIA_KEYCAPE_CLIENT_SECRET" | cut -d: -f2)
else
echo "ERROR: bcrypt hash generation requires python3+bcrypt or apache2-utils (htpasswd)" >&2
echo " Install: pip3 install bcrypt OR apt install apache2-utils" >&2
exit 1
fi
echo "Creating K8s Secret: authelia-secrets (namespace: sso)"
kubectl create secret generic authelia-secrets \
--namespace=sso \
--from-literal=jwt_secret="$AUTHELIA_JWT_SECRET" \
--from-literal=session_secret="$AUTHELIA_SESSION_SECRET" \
--from-literal=storage_encryption_key="$AUTHELIA_STORAGE_ENCRYPTION_KEY" \
--from-literal=ldap_password="$LLDAP_LDAP_USER_PASS" \
--from-literal=oidc_hmac_secret="$AUTHELIA_OIDC_HMAC_SECRET" \
--from-literal=oidc_issuer_private_key="$AUTHELIA_OIDC_PRIVATE_KEY_CONTENT" \
--from-literal=keycape_client_secret_hash="$KEYCAPE_CLIENT_SECRET_HASH" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "Done. Secret authelia-secrets created in namespace: sso"
echo ""
echo "Next:"
echo " Apply deployment.yaml, then ingress.yaml."
echo " The plaintext Authelia-KeyCape client secret is in secrets/authelia/secrets.env"
echo " as AUTHELIA_KEYCAPE_CLIENT_SECRET. This value goes into keycape/create-secrets.sh"
echo " as the authelia.clientSecret in the KeyCape config."

View File

@@ -0,0 +1,147 @@
# Deployment + Service — Authelia (namespace: sso)
#
# Authelia is the authentication frontend: it handles username/password entry
# and redirects back to KeyCape with an authorization code. KeyCape then
# invokes the privacyIDEA adapter to perform the MFA step.
#
# Prerequisites (apply in order):
# 1. pvc.yaml — authelia-data PVC
# 2. configmap.yaml — authelia-config ConfigMap
# 3. create-secrets.sh — authelia-secrets (JWT, session, storage, LDAP, OIDC keys)
# 4. This file
# 5. ingress.yaml
#
# Sensitive values are passed as *_FILE env vars pointing to Secret-mounted files.
# See configmap.yaml for the full list of injected secrets.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authelia
namespace: sso
labels:
app.kubernetes.io/name: authelia
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: authelia
strategy:
type: Recreate # single replica; SQLite cannot be accessed concurrently
template:
metadata:
labels:
app.kubernetes.io/name: authelia
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
securityContext:
runAsNonRoot: true
runAsUser: 8000 # authelia default user
fsGroup: 8000
containers:
- name: authelia
# Pin to a specific 4.x release. Check https://hub.docker.com/r/authelia/authelia
image: authelia/authelia:4.38
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 9091
protocol: TCP
# ── Secret file paths — Authelia reads *_FILE env vars ──────────
env:
- name: AUTHELIA_JWT_SECRET_FILE
value: /run/secrets/authelia/jwt_secret
- name: AUTHELIA_SESSION_SECRET_FILE
value: /run/secrets/authelia/session_secret
- name: AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
value: /run/secrets/authelia/storage_encryption_key
- name: AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
value: /run/secrets/authelia/ldap_password
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
value: /run/secrets/authelia/oidc_hmac_secret
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
value: /run/secrets/authelia/oidc_issuer_private_key
- name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS_0_SECRET_FILE
value: /run/secrets/authelia/keycape_client_secret_hash
volumeMounts:
# Config from ConfigMap
- name: config
mountPath: /config/configuration.yml
subPath: configuration.yml
readOnly: true
# Secrets as files
- name: secrets
mountPath: /run/secrets/authelia
readOnly: true
# Writable data (SQLite DB + notification log)
- name: data
mountPath: /var/authelia/data
startupProbe:
httpGet:
path: /api/health
port: 9091
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 18 # 18 × 5s = 90s for initial LDAP connection
livenessProbe:
httpGet:
path: /api/health
port: 9091
initialDelaySeconds: 0
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 9091
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
volumes:
- name: config
configMap:
name: authelia-config
- name: secrets
secret:
secretName: authelia-secrets
- name: data
persistentVolumeClaim:
claimName: authelia-data
---
# Service — ClusterIP; Traefik and KeyCape reach Authelia via port 9091.
apiVersion: v1
kind: Service
metadata:
name: authelia
namespace: sso
labels:
app.kubernetes.io/name: authelia
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: authelia
ports:
- name: http
port: 9091
targetPort: 9091
protocol: TCP

View File

@@ -0,0 +1,39 @@
# Ingress — Authelia login portal (namespace: sso)
#
# auth.coulomb.social — Authelia login page; browsers are redirected here
# by KeyCape during the OIDC authorization flow.
#
# This hostname MUST be publicly reachable: users' browsers redirect here
# to enter their password. (MFA happens at the KeyCape layer, not here.)
#
# Config points (see CONFIG.md):
# CP-NK-005 auth.coulomb.social
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authelia
namespace: sso
labels:
app.kubernetes.io/name: authelia
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
rules:
- host: auth.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: authelia
port:
number: 9091
tls:
- secretName: auth-tls
hosts:
- auth.coulomb.social

View File

@@ -0,0 +1,20 @@
# PersistentVolumeClaim for Authelia (namespace: sso)
#
# authelia-data — /var/authelia/data/
# Holds: SQLite database (user sessions, TOTP registrations if used,
# webauthn data) and notification log.
# All authentication state is here; back this PVC up alongside LLDAP's.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authelia-data
namespace: sso
labels:
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi