chore(tools): add read-only ADFS context collector for closed-contour debugging

This commit is contained in:
2026-09-15 10:01:52 +03:00
parent b4148cfe5f
commit 22ffdb5ba5

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# collect-context.sh — read-only сбор контекста о реальном ADFS ВНУТРИ закрытого контура.
#
# Ничего не меняет в контуре: только GET discovery/jwks и локальное декодирование
# токена БЕЗ проверки подписи. Результат — каталог, который можно вынести наружу
# и положить в репозиторий для настройки tools/mock-adfs.
#
# Использование:
# ./collect-context.sh --metadata-url https://<adfs>/adfs/.well-known/openid-configuration
# ./collect-context.sh --metadata-url "$ADFS_METADATA_URL" --token-file /tmp/id_token
# ADFS_ID_TOKEN=<jwt> ./collect-context.sh --metadata-url "$ADFS_METADATA_URL"
#
# Опции:
# --metadata-url URL URL OIDC discovery (или env ADFS_METADATA_URL)
# --token-file FILE файл с реальным id_token (можно взять из DevTools/логов backend)
# --ca FILE дополнительный CA (если контур с внутренним PKI)
# --out DIR каталог вывода (по умолчанию ./adfs-context)
# -h, --help
#
# Что НЕ собирать и не выносить: ADFS_CLIENT_SECRET, любые пароли, полные логи с токенами.
set -euo pipefail
OUT="${OUT:-adfs-context}"
METADATA_URL="${ADFS_METADATA_URL:-}"
TOKEN_FILE="${TOKEN_FILE:-}"
EXTRA_CA="${EXTRA_CA:-}"
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--metadata-url) METADATA_URL="${2:-}"; shift 2 ;;
--token-file) TOKEN_FILE="${2:-}"; shift 2 ;;
--ca) EXTRA_CA="${2:-}"; shift 2 ;;
--out) OUT="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
if [ -z "${METADATA_URL}" ]; then
printf 'ADFS_METADATA_URL or --metadata-url is required\n' >&2
exit 2
fi
command -v python3 >/dev/null 2>&1 || { printf 'python3 not found\n' >&2; exit 1; }
command -v curl >/dev/null 2>&1 || { printf 'curl not found\n' >&2; exit 1; }
mkdir -p "${OUT}"
CURL=(curl -fsS --max-time 30)
if [ -n "${EXTRA_CA}" ]; then
CURL+=(--cacert "${EXTRA_CA}")
fi
printf '[*] Discovery: %s\n' "${METADATA_URL}"
"${CURL[@]}" "${METADATA_URL}" -o "${OUT}/discovery.json"
JWKS_URI="$(python3 - "${OUT}/discovery.json" <<'PY'
import json, sys
meta = json.load(open(sys.argv[1], encoding="utf-8"))
for key in ("jwks_uri", "userinfo_endpoint", "issuer"):
print(f"{key}={meta.get(key, '')}")
PY
)"
printf '%s\n' "${JWKS_URI}"
JWKS_URL="$(printf '%s\n' "${JWKS_URI}" | sed -n 's/^jwks_uri=//p')"
if [ -n "${JWKS_URL}" ]; then
printf '[*] JWKS: %s\n' "${JWKS_URL}"
"${CURL[@]}" "${JWKS_URL}" -o "${OUT}/jwks.json"
else
printf '[!] jwks_uri отсутствует в discovery — возможно, это не OIDC (SAML/WS-Fed?)\n' >&2
fi
TOKEN="${ADFS_ID_TOKEN:-}"
if [ -n "${TOKEN_FILE}" ]; then
TOKEN="$(tr -d '\r\n' < "${TOKEN_FILE}")"
fi
if [ -n "${TOKEN}" ]; then
TMP_TOKEN="$(mktemp)"
trap 'rm -f "${TMP_TOKEN}"' EXIT
printf '%s' "${TOKEN}" > "${TMP_TOKEN}"
python3 - "${TMP_TOKEN}" "${OUT}/token" <<'PY'
import base64, json, sys
raw = open(sys.argv[1], encoding="utf-8").read().strip()
parts = raw.split(".")
if len(parts) < 2:
sys.exit("Provided value is not a JWT (need header.payload[.signature])")
def b64url(segment: str) -> bytes:
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
header = json.loads(b64url(parts[0]))
payload = json.loads(b64url(parts[1]))
prefix = sys.argv[2]
open(prefix + ".header.json", "w", encoding="utf-8").write(json.dumps(header, indent=2, ensure_ascii=False))
# Оставляем только значимые для интеграции claim'ы — PII вроде sub/email можно вычистить вручную.
interesting = ("upn", "email", "name", "preferred_username", "groups", "group", "role", "roles", "scp", "aud", "iss", "tid")
summary = {k: payload[k] for k in interesting if k in payload}
open(prefix + ".payload.json", "w", encoding="utf-8").write(json.dumps(payload, indent=2, ensure_ascii=False))
open(prefix + ".claims-summary.json", "w", encoding="utf-8").write(json.dumps(summary, indent=2, ensure_ascii=False))
print("[*] Decoded id_token. Header alg/kid:", header.get("alg"), header.get("kid"))
print("[*] Claim keys:", ", ".join(sorted(payload.keys())))
groups = payload.get("groups") or payload.get("group") or []
print("[*] groups claim present:", bool(groups), "| sample type:", type(groups[0]).__name__ if groups else "n/a")
PY
else
printf '[*] id_token не передан (--token-file / ADFS_ID_TOKEN). groups-claim не проверен.\n'
fi
cat > "${OUT}/MANIFEST.txt" <<'EOF'
Собрано read-only (GET discovery/jwks + локальное decode токена без проверки подписи).
Проверьте перед выносом:
- discovery.json, jwks.json — публичные, безопасны.
- token.payload.json — может содержать PII (sub, email). Вычистите при необходимости.
- token.claims-summary.json — только upn/email/name/groups/aud/iss.
НЕ выносить: ADFS_CLIENT_SECRET, пароли, сырые подписанные токены, полные логи.
Что важно для mock: iss/aud, alg/kid, формат groups (names | SIDs | DNs), endpoint'ы.
EOF
printf '\n[*] Готово: %s\n' "$(cd "${OUT}" && pwd)"
printf '[*] Для repo: %s/discovery.json, %s/jwks.json, %s/token.claims-summary.json\n' "${OUT}" "${OUT}" "${OUT}"