Add KeyCape privacyIDEA token repair flow

This commit is contained in:
2026-05-29 03:07:17 +02:00
parent ab99380dec
commit c7b82df267
5 changed files with 345 additions and 6 deletions

View File

@@ -13,8 +13,9 @@
# ./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:
# The privacyIDEA admin token may expire depending on the live privacyIDEA
# policy/configuration. Refresh it if KeyCape reports "mfa check error".
# Store the current token in KeePassXC as:
# net-kingdom/KeyCape/pi-admin-token
#
# Requires: kubectl, curl, jq
@@ -108,7 +109,6 @@ echo " Done."
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."

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env bash
# refresh-pi-token-live.sh — refresh KeyCape's privacyIDEA admin token.
#
# This is an attended repair for "mfa check error" when the privacyIDEA admin
# JWT embedded in keycape-config has expired. It prompts for the pi-admin
# password, fetches a fresh token inside the privacyIDEA pod, updates the
# keycape-pi-token Secret, patches keycape-config without printing secrets, and
# restarts KeyCape.
#
# Usage:
# bash refresh-pi-token-live.sh [username]
#
# Optional environment:
# KUBECTL=/path/to/kubectl
# KEYCAPE_PI_REALM=coulomb|netkingdom
set -euo pipefail
USERNAME="${1:-platform-root}"
KUBECTL="${KUBECTL:-kubectl}"
SSO_NAMESPACE="${SSO_NAMESPACE:-sso}"
MFA_NAMESPACE="${MFA_NAMESPACE:-mfa}"
KEYCAPE_DEPLOYMENT="${KEYCAPE_DEPLOYMENT:-keycape}"
KEYCAPE_SECRET="${KEYCAPE_SECRET:-keycape-config}"
KEYCAPE_TOKEN_SECRET="${KEYCAPE_TOKEN_SECRET:-keycape-pi-token}"
read -r -s -p "privacyIDEA pi-admin password: " PI_ADMIN_PASSWORD
printf "\n" >&2
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
echo "[FAIL] Empty pi-admin password." >&2
exit 1
fi
PI_POD="$(
"$KUBECTL" get pod -n "$MFA_NAMESPACE" \
-l app.kubernetes.io/name=privacyidea \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
)"
if [[ -z "$PI_POD" ]]; then
echo "[FAIL] No running privacyIDEA pod in namespace $MFA_NAMESPACE." >&2
exit 1
fi
echo "Fetching fresh privacyIDEA admin token inside pod $PI_POD ..."
PI_TOKEN="$(
"$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \
env PI_ADMIN_PASSWORD="$PI_ADMIN_PASSWORD" \
python3 -c '
import json
import os
import sys
import urllib.parse
import urllib.request
payload = urllib.parse.urlencode({
"username": "pi-admin",
"password": os.environ["PI_ADMIN_PASSWORD"],
}).encode()
req = urllib.request.Request("http://localhost:8080/auth", data=payload)
try:
with urllib.request.urlopen(req, timeout=10) as response:
body = json.load(response)
print(body["result"]["value"]["token"])
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
' 2>/dev/null
)"
unset PI_ADMIN_PASSWORD
if [[ -z "$PI_TOKEN" ]]; then
echo "[FAIL] Could not fetch a privacyIDEA admin token." >&2
exit 1
fi
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
api_get() {
local path="$1"
"$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \
env PI_TOKEN="$PI_TOKEN" PI_PATH="$path" \
python3 -c '
import os
import sys
import urllib.request
path = os.environ["PI_PATH"]
token = os.environ["PI_TOKEN"]
req = urllib.request.Request(
"http://localhost:8080" + path,
headers={"Authorization": token},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
sys.stdout.write(response.read().decode())
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
'
}
echo "Inspecting non-secret MFA state for $USERNAME ..."
realm_summary="$(
for realm in coulomb netkingdom; do
users_json="$(api_get "/user/?realm=$realm&username=$USERNAME" || true)"
tokens_json="$(api_get "/token/?realm=$realm&user=$USERNAME" || true)"
REALM="$realm" USERS_JSON="$users_json" TOKENS_JSON="$tokens_json" python3 -c '
import json
import os
realm = os.environ["REALM"]
users = []
tokens = []
try:
users = json.loads(os.environ["USERS_JSON"]).get("result", {}).get("value", {}).get("users", [])
except Exception:
pass
try:
value = json.loads(os.environ["TOKENS_JSON"]).get("result", {}).get("value", {})
tokens = value.get("tokens", []) if isinstance(value, dict) else []
except Exception:
pass
active = sum(1 for token in tokens if token.get("active", True))
print(f"{realm} users={len(users)} tokens={len(tokens)} active={active}")
'
done
)"
printf '%s\n' "$realm_summary"
current_config="$(
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.config\.yaml}' | base64 -d
)"
current_realm="$(
CONFIG_YAML="$current_config" python3 -c '
import os
import re
match = re.search(r"(?m)^ realm:\s*[\"'\"']?([^\"'\"'\n]+)", os.environ["CONFIG_YAML"])
print(match.group(1).strip() if match else "")
'
)"
selected_realm="${KEYCAPE_PI_REALM:-}"
if [[ -z "$selected_realm" ]]; then
selected_realm="$(
REALM_SUMMARY="$realm_summary" CURRENT_REALM="$current_realm" python3 -c '
import os
lines = os.environ["REALM_SUMMARY"].splitlines()
counts = {}
for line in lines:
parts = dict(item.split("=", 1) for item in line.split()[1:])
counts[line.split()[0]] = {
"users": int(parts.get("users", "0")),
"tokens": int(parts.get("tokens", "0")),
"active": int(parts.get("active", "0")),
}
for realm in ("coulomb", "netkingdom"):
if counts.get(realm, {}).get("active", 0) > 0:
print(realm)
raise SystemExit
current = os.environ.get("CURRENT_REALM", "")
if current:
print(current)
else:
print("coulomb")
'
)"
fi
if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then
echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2
exit 1
fi
echo "Selected privacyIDEA realm for KeyCape: $selected_realm"
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem"
chmod 600 "$tmpdir/key.pem"
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" \
python3 -c '
import json
import os
import re
import sys
config = os.environ["CONFIG_YAML"]
token = json.dumps(os.environ["PI_TOKEN"])
realm = json.dumps(os.environ["PI_REALM"])
config, token_count = re.subn(r"(?m)^ adminToken:.*$", " adminToken: " + token, config)
config, realm_count = re.subn(r"(?m)^ realm:.*$", " realm: " + realm, config)
if token_count != 1 or realm_count != 1:
print("Could not patch exactly one adminToken and one realm field.", file=sys.stderr)
sys.exit(1)
sys.stdout.write(config)
' > "$tmpdir/config.yaml"
echo "Applying refreshed KeyCape config Secret ..."
"$KUBECTL" create secret generic "$KEYCAPE_TOKEN_SECRET" \
--namespace="$SSO_NAMESPACE" \
--from-literal=token="$PI_TOKEN" \
--dry-run=client -o yaml | "$KUBECTL" apply -f -
"$KUBECTL" create secret generic "$KEYCAPE_SECRET" \
--namespace="$SSO_NAMESPACE" \
--from-file=config.yaml="$tmpdir/config.yaml" \
--from-file=key.pem="$tmpdir/key.pem" \
--dry-run=client -o yaml | "$KUBECTL" apply -f -
unset PI_TOKEN
echo "Restarting KeyCape ..."
"$KUBECTL" rollout restart "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE"
"$KUBECTL" rollout status "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE" --timeout=90s
echo ""
echo "[OK] KeyCape privacyIDEA token refreshed. Retry the OIDC login with a fresh URL."

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# check-user-mfa-state.sh — non-secret privacyIDEA MFA state diagnostic.
#
# Reports realm presence and user/token counts for a user without printing
# passwords, OTP seeds, admin JWTs, token serials, or token metadata.
#
# Usage:
# bash check-user-mfa-state.sh [username]
set -euo pipefail
USERNAME="${1:-platform-root}"
PI_URL="${PI_URL:-https://pink.coulomb.social}"
SSO_NAMESPACE="${SSO_NAMESPACE:-sso}"
KUBECTL="${KUBECTL:-kubectl}"
TOKEN="$(
"$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true
)"
if [[ -z "$TOKEN" ]]; then
TOKEN="$(
"$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || true
)"
fi
if [[ -z "$TOKEN" || "$TOKEN" == "PENDING_create-pi-token.sh" ]]; then
echo "[FAIL] keycape-pi-token is missing or still a placeholder."
exit 1
fi
api_get() {
local path="$1"
curl -sk -H "Authorization: $TOKEN" "$PI_URL$path"
}
json_field() {
python3 -c "$1"
}
echo "privacyIDEA MFA state for user: $USERNAME"
echo "Endpoint: $PI_URL"
echo ""
REALM_RESP="$(api_get "/realm/")"
echo "Realms:"
printf '%s' "$REALM_RESP" | json_field '
import json
import sys
data = json.load(sys.stdin)
if not data.get("result", {}).get("status", False):
print(" [WARN] realm query returned status=false")
err = data.get("result", {}).get("error", {})
if err:
print(" [WARN] error=" + str(err.get("message", err)))
realms = data.get("result", {}).get("value", {})
if not realms:
print(" [WARN] no realms returned")
for name in sorted(realms):
print(" - " + name + " default=" + str(bool(realms[name].get("default"))))
'
for realm in coulomb netkingdom; do
echo ""
echo "Realm: $realm"
USER_RESP="$(api_get "/user/?realm=$realm&username=$USERNAME")"
TOKEN_RESP="$(api_get "/token/?realm=$realm&user=$USERNAME")"
printf '%s' "$USER_RESP" | json_field '
import json
import sys
data = json.load(sys.stdin)
users = data.get("result", {}).get("value", {}).get("users", [])
print(f" users={len(users)}")
'
printf '%s' "$TOKEN_RESP" | json_field '
import json
import sys
data = json.load(sys.stdin)
value = data.get("result", {}).get("value", {})
tokens = value.get("tokens", []) if isinstance(value, dict) else []
active = sum(1 for token in tokens if token.get("active", True))
print(f" tokens={len(tokens)} active={active}")
'
done

View File

@@ -37,9 +37,9 @@ PASS=0
FAIL=0
WARN=0
pass() { echo " [PASS] $1"; ((PASS++)); }
fail() { echo " [FAIL] $1"; ((FAIL++)); }
warn() { echo " [WARN] $1"; ((WARN++)); }
pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); }
warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); }
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }