From 22ffdb5ba58a76e4e066281639d62a943c9f5de8 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 15 Sep 2026 10:01:52 +0300 Subject: [PATCH] chore(tools): add read-only ADFS context collector for closed-contour debugging --- tools/mock-adfs/collect-context.sh | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tools/mock-adfs/collect-context.sh diff --git a/tools/mock-adfs/collect-context.sh b/tools/mock-adfs/collect-context.sh new file mode 100644 index 000000000..3683208c8 --- /dev/null +++ b/tools/mock-adfs/collect-context.sh @@ -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/.well-known/openid-configuration +# ./collect-context.sh --metadata-url "$ADFS_METADATA_URL" --token-file /tmp/id_token +# ADFS_ID_TOKEN= ./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}"