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,145 @@
# T05c — KeyCape (OIDC Orchestration Layer)
KeyCape is the stateless OIDC server that ties the stack together. It orchestrates
the full authentication flow:
1. User visits a registered application
2. Application redirects to KeyCape (`kc.coulomb.social`) for login
3. KeyCape redirects the browser to Authelia (`auth.coulomb.social`) for password auth
4. Authelia validates the password against LLDAP and returns an authorization code
5. KeyCape exchanges the code for user identity, then calls privacyIDEA for MFA
6. On success, KeyCape issues a signed OIDC token to the application
KeyCape is stateless — all state lives in Authelia (sessions), LLDAP (users), and
privacyIDEA (MFA tokens). No PVC is required.
## Prerequisites
- T04 complete (privacyIDEA is Running and bootstrapped — admin account + enckey done)
- T05a complete (LLDAP is Running)
- T05b complete (Authelia is Running)
- KeyCape container image built and available (see "Building the image" below)
- `bootstrap/gen-secrets.sh` run
- `kubectl` configured with cluster access
## Building the image
KeyCape has no published image. Build it from the source repository and make it
available to K3s before applying `deployment.yaml`.
### Option A — Local import into K3s (dev/single-node)
```bash
cd ~/key-cape
docker build -t keycape:v0.1 .
# Import directly into the K3s containerd runtime (no registry needed)
docker save keycape:v0.1 | sudo k3s ctr images import -
# After import, set imagePullPolicy: Never in deployment.yaml
# (the image is now in the K3s local store, not a registry)
```
### Option B — Private registry (production)
```bash
cd ~/key-cape
docker build -t <registry>/keycape:v0.1 .
docker push <registry>/keycape:v0.1
# Update the image field in deployment.yaml:
# image: <registry>/keycape:v0.1
# imagePullPolicy: IfNotPresent (default) is correct for registry images.
```
After building, update `deployment.yaml` line:
```yaml
image: keycape:v0.1 # replace with your actual tag
```
## Apply order
```bash
# 1. Create Secrets (config.yaml + key.pem)
# Run this AFTER T04 bootstrap if you want the privacyIDEA token included.
# If T04 is not yet done, run it now and re-run after create-pi-token.sh.
cd sso-mfa/k8s/keycape
chmod +x create-secrets.sh create-pi-token.sh
./create-secrets.sh
# 2. Apply manifests
kubectl apply -f deployment.yaml
kubectl apply -f middleware.yaml
kubectl apply -f ingress.yaml
# 3. Wait for pod to be ready
kubectl rollout status deployment/keycape -n sso --timeout=60s
```
## Post-deploy: inject privacyIDEA admin token
If T04 was not complete when you ran `create-secrets.sh`, the privacyIDEA admin
token is a placeholder. After T04 bootstrap is done:
```bash
# 1. Fetch the token from privacyIDEA and store it
chmod +x create-pi-token.sh
./create-pi-token.sh
# 2. Re-run create-secrets.sh to update keycape-config with the real token
./create-secrets.sh
# 3. Restart KeyCape to pick up the new Secret
kubectl rollout restart deployment/keycape -n sso
```
## OIDC client registration
Downstream applications are registered in the `clients:` block in
`keycape/create-secrets.sh`. After editing:
```bash
./create-secrets.sh # regenerates keycape-config Secret
kubectl rollout restart deployment/keycape -n sso
```
Example entry (public client, PKCE, for a SPA):
```yaml
clients:
- clientId: "my-app"
displayName: "My Application"
redirectUris:
- "https://my-app.coulomb.social/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
```
## Secrets managed
| Secret name | Keys | Purpose |
|-------------|------|---------|
| `keycape-config` | `config.yaml` | Full KeyCape configuration (LLDAP URL + creds, Authelia URL + client secret, privacyIDEA URL + admin token, OIDC clients) |
| | `key.pem` | RSA-2048 private key for signing OIDC tokens issued to downstream applications |
| `keycape-pi-token` | `pi_admin_token` | privacyIDEA admin JWT — created by `create-pi-token.sh`, referenced in `config.yaml` |
Store `key.pem` in KeePassXC as a binary attachment. If it is lost, all active
sessions become invalid (tokens cannot be verified) and all applications must
re-authenticate.
## Verify
```bash
# Pod status
kubectl get pod -n sso -l app.kubernetes.io/name=keycape
# Health check
kubectl run -n sso --rm -it kc-test --image=busybox --restart=Never \
-- wget -qO- http://keycape.sso.svc.cluster.local:8080/healthz
# OIDC discovery (public endpoint)
curl -s https://kc.coulomb.social/.well-known/openid-configuration | jq .
# Check issuer matches CP-NK-004
curl -s https://kc.coulomb.social/.well-known/openid-configuration \
| jq -r .issuer # should be: https://kc.coulomb.social
```

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# create-pi-token.sh — fetch a privacyIDEA admin JWT and store it for KeyCape
#
# Usage:
# ./create-pi-token.sh [secrets-dir]
#
# Run this script AFTER T04 bootstrap (privacyIDEA admin account created).
# It authenticates to the privacyIDEA API, fetches a long-lived admin JWT,
# and writes it to secrets/keycape/pi_admin_token.
#
# After running this script, re-run create-secrets.sh to update the
# keycape-config K8s Secret with the real token, then restart KeyCape:
# ./create-secrets.sh
# kubectl rollout restart deployment/keycape -n sso
#
# The privacyIDEA admin token does NOT expire by default (it is a permanent
# service account token). Store it in KeePassXC as:
# net-kingdom/KeyCape/pi-admin-token
#
# Requires: kubectl, curl, jq
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
TOKEN_FILE="$SECRETS_DIR/keycape/pi_admin_token"
if [[ ! -f "$PI_ENV" ]]; then
echo "ERROR: $PI_ENV not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
PI_ADMIN_PASSWORD=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD)
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
echo "ERROR: PI_ADMIN_PASSWORD is empty in $PI_ENV" >&2
exit 1
fi
# Determine privacyIDEA base URL — use cluster-internal URL if kubectl is available
# and we can reach the service, otherwise fall back to the public hostname.
PI_BASE_URL=""
if kubectl get service privacyidea -n mfa &>/dev/null 2>&1; then
# Prefer running a one-shot pod inside the cluster to avoid needing
# public TLS to be up during bootstrap.
PI_BASE_URL="http://privacyidea.mfa.svc.cluster.local:8080"
USE_CLUSTER=true
else
PI_BASE_URL="https://pink.coulomb.social"
USE_CLUSTER=false
fi
echo "Fetching privacyIDEA admin token from: $PI_BASE_URL"
if [[ "$USE_CLUSTER" == "true" ]]; then
# Run curl inside the cluster (avoids needing public TLS to be live)
TOKEN=$(kubectl run -n mfa --rm -i --restart=Never pi-token-fetch \
--image=curlimages/curl:8 --quiet \
-- curl -sf \
-X POST "$PI_BASE_URL/auth" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
2>/dev/null \
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
2>/dev/null || echo "")
else
TOKEN=$(curl -sf \
-X POST "$PI_BASE_URL/auth" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
2>/dev/null || echo "")
fi
if [[ -z "$TOKEN" ]]; then
echo "ERROR: failed to fetch token from privacyIDEA." >&2
echo " Verify that privacyIDEA is Running and the pi-admin account exists." >&2
echo " Check: kubectl logs -n mfa \$(kubectl get pod -n mfa -l app.kubernetes.io/name=privacyidea -o name | head -1)" >&2
exit 1
fi
mkdir -p "$(dirname "$TOKEN_FILE")"
echo -n "$TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo ""
echo "Token written to: $TOKEN_FILE"
echo "Token preview : ${TOKEN:0:32}"
echo ""
echo "IMPORTANT: Store this token in KeePassXC → net-kingdom/KeyCape/pi-admin-token"
echo " as a password entry. It cannot be recovered without re-authenticating."
echo ""
echo "Next steps:"
echo " 1. Re-run create-secrets.sh to update keycape-config with the real token."
echo " 2. Restart KeyCape: kubectl rollout restart deployment/keycape -n sso"

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# create-secrets.sh — create the keycape-config K8s Secret
#
# Usage:
# ./create-secrets.sh [secrets-dir]
#
# Creates ONE Secret in the sso namespace:
# keycape-config — config.yaml (full KeyCape config) + key.pem (RSA signing key)
#
# The privacyIDEA admin token is a separate Secret (keycape-pi-token) created
# by create-pi-token.sh AFTER privacyIDEA is bootstrapped (T04 complete).
# The PI admin token is read from that Secret at startup via config.yaml.
#
# Re-run this script to:
# - Rotate the Authelia client secret (update secrets/authelia/secrets.env first)
# - Add or modify OIDC client registrations (edit CLIENTS block below)
# - Rotate the RSA signing key (delete and regenerate secrets/keycape/key.pem)
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
KEYCAPE_ENV="$SECRETS_DIR/keycape/secrets.env"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
for f in "$KEYCAPE_ENV" "$LLDAP_ENV" "$AUTHELIA_ENV"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
done
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
LLDAP_BIND_PW=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
AUTHELIA_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
if [[ -z "$LLDAP_BIND_PW" || -z "$AUTHELIA_CLIENT_SECRET" ]]; then
echo "ERROR: could not read LLDAP_LDAP_USER_PASS or AUTHELIA_KEYCAPE_CLIENT_SECRET" >&2
exit 1
fi
# The privacyIDEA admin token is read from a separate Secret at runtime.
# Placeholder here — create-pi-token.sh populates the real value.
PI_ADMIN_TOKEN="PENDING_create-pi-token.sh"
if [[ -f "$SECRETS_DIR/keycape/pi_admin_token" ]]; then
PI_ADMIN_TOKEN=$(cat "$SECRETS_DIR/keycape/pi_admin_token")
echo "INFO: Using privacyIDEA admin token from $SECRETS_DIR/keycape/pi_admin_token"
fi
# ── RSA signing key ───────────────────────────────────────────────────────────
if [[ ! -f "$KEY_FILE" ]]; then
echo "Generating RSA-2048 signing key for KeyCape JWT tokens..."
mkdir -p "$(dirname "$KEY_FILE")"
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
chmod 600 "$KEY_FILE"
echo " Generated: $KEY_FILE"
echo " IMPORTANT: Store this key in KeePassXC → net-kingdom/KeyCape/jwt-signing-key"
echo " as a binary attachment. It cannot be recovered if lost."
else
echo "INFO: Using existing key: $KEY_FILE"
fi
KEY_CONTENT=$(cat "$KEY_FILE")
# ── Build config.yaml ─────────────────────────────────────────────────────────
# Edit the OIDC clients block below to register downstream applications.
# Re-run this script after any change.
CONFIG_YAML=$(cat <<EOF
issuer: "https://kc.coulomb.social"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "/etc/keycape/key.pem"
environment: "production"
lldap:
url: "ldap://lldap.sso.svc.cluster.local:3890"
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
bindPW: "${LLDAP_BIND_PW}"
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "${AUTHELIA_CLIENT_SECRET}"
redirectURI: "https://kc.coulomb.social/authorize/callback"
privacyidea:
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
adminToken: "${PI_ADMIN_TOKEN}"
realm: "netkingdom"
# ── OIDC client registrations ─────────────────────────────────────────────────
# Add one entry per downstream application.
# clientType: "public" for SPAs/native apps (PKCE, no client secret)
# "confidential" for server-side apps (client secret required)
clients: []
# Example:
# clients:
# - clientId: "my-app"
# displayName: "My Application"
# redirectUris:
# - "https://my-app.coulomb.social/callback"
# allowedScopes: ["openid", "profile", "email", "groups"]
# grantTypes: ["authorization_code"]
# clientType: "public"
EOF
)
echo "Creating K8s Secret: keycape-config (namespace: sso)"
kubectl create secret generic keycape-config \
--namespace=sso \
--from-literal=config.yaml="$CONFIG_YAML" \
--from-literal=key.pem="$KEY_CONTENT" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "Done. Secret keycape-config created in namespace: sso"
echo ""
if [[ "$PI_ADMIN_TOKEN" == "PENDING_create-pi-token.sh" ]]; then
echo "WARN: privacyIDEA admin token is a placeholder."
echo " After T04 bootstrap is complete, run:"
echo " ./create-pi-token.sh"
echo " Then re-run this script to update keycape-config."
echo ""
fi
echo "Next: apply deployment.yaml, middleware.yaml, ingress.yaml"

View File

@@ -0,0 +1,136 @@
# Deployment + Service — KeyCape (namespace: sso)
#
# KeyCape is the OIDC orchestration layer. It is stateless: all persistent
# state lives in Authelia (session), LLDAP (users), and privacyIDEA (MFA tokens).
# No PVC is required.
#
# Configuration is stored entirely in the keycape-config Secret, which holds
# a complete config.yaml and the RSA private key used to sign OIDC tokens
# issued to downstream applications.
#
# Prerequisites (apply in order):
# 1. keycape-config Secret — run keycape/create-secrets.sh
# 2. keycape-pi-token Secret — run keycape/create-pi-token.sh (after T04 bootstrap)
# 3. This file
# 4. middleware.yaml + ingress.yaml
#
# Container image:
# KeyCape has no published image. Build from ~/key-cape/ and push to a registry,
# or import directly into K3s (see README.md "Building the image").
# Image tag below is a placeholder — update before applying.
apiVersion: apps/v1
kind: Deployment
metadata:
name: keycape
namespace: sso
labels:
app.kubernetes.io/name: keycape
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: keycape
strategy:
type: RollingUpdate # stateless — safe to roll
template:
metadata:
labels:
app.kubernetes.io/name: keycape
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534 # nobody — matches distroless static image
fsGroup: 65534
containers:
- name: keycape
# EDIT before applying — see README.md "Building the image".
# Option A (registry): docker build -t <registry>/keycape:v0.1 ~/key-cape/ && docker push ...
# Option B (K3s local): docker build -t keycape:v0.1 ~/key-cape/ &&
# docker save keycape:v0.1 | sudo k3s ctr images import -
# After Option B, set imagePullPolicy: Never.
image: keycape:v0.1
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: KEYCAPE_CONFIG
value: /etc/keycape/config.yaml
volumeMounts:
# keycape-config Secret provides config.yaml and key.pem
- name: config-secret
mountPath: /etc/keycape
readOnly: true
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 3
periodSeconds: 3
failureThreshold: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: "25m"
memory: "32Mi"
limits:
cpu: "200m"
memory: "128Mi"
volumes:
- name: config-secret
secret:
secretName: keycape-config
# Secret must contain two keys: config.yaml and key.pem
items:
- key: config.yaml
path: config.yaml
- key: key.pem
path: key.pem
mode: 0400 # key.pem is sensitive; restrict to owner read only
---
# Service — ClusterIP; Traefik reaches KeyCape via port 8080.
apiVersion: v1
kind: Service
metadata:
name: keycape
namespace: sso
labels:
app.kubernetes.io/name: keycape
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: keycape
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP

View File

@@ -0,0 +1,42 @@
# Ingress — KeyCape OIDC server (namespace: sso)
#
# kc.coulomb.social — OIDC discovery, /authorize, /token, /jwks, /userinfo
#
# This hostname is public — applications redirect users here for login.
# The auth.coulomb.social hostname (Authelia login UI) is where users
# actually enter their passwords; browsers are redirected there by KeyCape.
#
# Config points (see CONFIG.md):
# CP-NK-004 kc.coulomb.social
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycape
namespace: sso
labels:
app.kubernetes.io/name: keycape
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: >-
sso-keycape-rate-limit@kubernetescrd,
sso-keycape-hsts@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: kc.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycape
port:
number: 8080
tls:
- secretName: kc-tls
hosts:
- kc.coulomb.social

View File

@@ -0,0 +1,36 @@
# Traefik Middlewares for KeyCape (namespace: sso)
#
# Middleware names referenced in ingress.yaml:
# sso-keycape-rate-limit@kubernetescrd
# sso-keycape-hsts@kubernetescrd
# ── Rate limit — all OIDC endpoints ──────────────────────────────────────────
# OIDC discovery + JS app calls are bursty; keep limit generous.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: keycape-rate-limit
namespace: sso
labels:
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
rateLimit:
average: 100
period: 1m
burst: 20
---
# ── HSTS ─────────────────────────────────────────────────────────────────────
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: keycape-hsts
namespace: sso
labels:
app.kubernetes.io/part-of: net-kingdom-sso-mfa
net-kingdom/component: sso
spec:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true