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 ──────────────────────────────────────"; }

View File

@@ -1398,6 +1398,22 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str]
f"{kubectl_bin} get ingress keycape -n sso -o jsonpath='{{.status.loadBalancer.ingress[0].ip}}{{\"\\n\"}}'\n"
"NETKINGDOM_KEYCAPE_PUBLIC_ROUTE\n"
)
refresh_pi_token_state = "done" if login_verified else "redo" if auth_configured else "blocked"
refresh_pi_token_reason = (
"Optional repair action. Run this if KeyCape shows 'mfa check error'; "
"it refreshes the expired privacyIDEA admin token without printing it."
)
if refresh_pi_token_state == "done":
refresh_pi_token_reason = "OIDC-backed OpenBao login is verified; no MFA-token repair is currently needed."
if refresh_pi_token_state == "blocked":
refresh_pi_token_reason = "Configure OpenBao OIDC auth before repairing the MFA check path."
refresh_pi_token_command = (
"bash <<'NETKINGDOM_KEYCAPE_PI_TOKEN_REFRESH'\n"
"set -euo pipefail\n"
f"cd {keycape_dir}\n"
f"KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\n"
"NETKINGDOM_KEYCAPE_PI_TOKEN_REFRESH\n"
)
login_command = (
"# Terminal 1: bridge the browser callback to the bao CLI running in the OpenBao pod.\n"
"kubectl -n openbao port-forward pod/openbao-0 8250:8250\n\n"
@@ -1435,6 +1451,14 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str]
public_route_command,
downstream_taint if yes(data, "openbao_initialized") else {},
),
action(
"Repair KeyCape privacyIDEA MFA token",
"Refresh KeyCape's privacyIDEA admin token when the browser flow reaches MFA and reports 'mfa check error'. The command prompts for the pi-admin password and does not print the token.",
refresh_pi_token_state,
refresh_pi_token_reason,
refresh_pi_token_command,
downstream_taint if yes(data, "openbao_initialized") else {},
),
action(
"Verify OIDC-backed OpenBao admin login",
"Use the bao CLI already present in the OpenBao pod, bridge its localhost callback to your workstation, complete the KeyCape MFA browser flow, and verify the returned token before checking the confirmation box.",