#!/usr/bin/env bash # auth401 — merchant HTTP 401 / case-sensitivity matrix (WebUI-shaped flows) # # Exercises the same APIs the merchant SPA uses for: # • self-provision signup (POST /instances) — mixed-case id # • login / createAccessToken (POST …/private/token) — Basic user case # • password change (POST …/private/auth) # • admin-create (POST /management/instances) when admin bearer available # • access-token + private/ with Bearer # # Backend fact (#11340): Basic-auth *username* is case-sensitive; URL path is # usually not. SPA must always lowercase the username (createAccessToken, signup # id, admin-create, pw change re-login). These tests assert the *raw API* # behaviour so a missing SPA lowercasing shows up as 401 where 200 is wanted # only after lowercasing. # # ./taler-monitoring.sh -d stage.monnaie.lefrancpaysan.ch auth401 # ./taler-monitoring.sh -d hacktivism.ch auth401 # # Continue through all groups / collect every ERROR (optional): # AUTH401_CONTINUE=1 ./taler-monitoring.sh -d stage… auth401 # CONTINUE_ON_ERROR=1 ./taler-monitoring.sh -d stage… auth401 # same # # Env: see secrets.env.example (AUTH401_*). # # NOTE: we intentionally do NOT use `set -e`. A single failed curl/grep under # set -e would abort with no ERROR summary — the opposite of what monitoring # needs. Optional CONTINUE_ON_ERROR still controls hard prereq early-exit. set -uo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" load_monitoring_secrets_env 2>/dev/null || true tmp=$(mktemp -d) AUTH401_FINISHED=0 _auth401_finish() { [ "${AUTH401_FINISHED}" = "1" ] && return 0 AUTH401_FINISHED=1 # summary() returns non-zero when FAIL_N>0 — never let that skip the print summary || true rm -rf "${tmp:-}" if [ "${FAIL_N:-0}" -gt 0 ]; then exit 1 fi exit 0 } trap '_auth401_finish' EXIT # --------------------------------------------------------------------------- # Parameters # --------------------------------------------------------------------------- : "${AUTH401_TIMEOUT:=${TIMEOUT:-20}}" : "${AUTH401_CREATE:=1}" : "${AUTH401_INSTANCE:=}" : "${AUTH401_PASSWORD:=${MERCHANT_INSTANCE_PASSWORD:-${AUTH401_PASS:-}}}" : "${AUTH401_ID_PREFIX:=mon401}" : "${AUTH401_SKIP_IF_MFA:=1}" : "${AUTH401_BEARER:=${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}}" : "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" : "${AUTH401_DURABLE_ID:=mon401}" # Prefer durable alone (skip throwaway) — default 0 so full matrix always runs : "${AUTH401_PREFER_DURABLE:=0}" # Run expanded groups (signup/login/pwchange/admin/webui). Default 1. : "${AUTH401_FULL:=1}" # Admin bearer for POST /management/instances (optional) : "${AUTH401_ADMIN_TOKEN:=${MERCHANT_ADMIN_TOKEN:-}}" : "${AUTH401_ADMIN_PASSWORD:=}" : "${AUTH401_ADMIN_USER:=admin}" # 1 = do not abort on hard prereq fail; run remaining groups (default 0) # Alias: CONTINUE_ON_ERROR=1 : "${AUTH401_CONTINUE:=${CONTINUE_ON_ERROR:-0}}" # Never rotate password on durable account (default 1 — keep mon401.password valid) : "${AUTH401_PWCHANGE_THROWAY_ONLY:=1}" TIMEOUT="$AUTH401_TIMEOUT" export TIMEOUT _auth401_abort_or_continue() { local why="${1:-prereq failed}" if [ "${AUTH401_CONTINUE}" = "1" ]; then warn "continue" "${why} (AUTH401_CONTINUE=1 / CONTINUE_ON_ERROR=1)" return 0 fi # hard stop — EXIT trap prints summary + all ERRORS collected so far exit 1 } BASE="${MERCHANT_PUBLIC%/}" if [ -z "$BASE" ]; then echo "MERCHANT_PUBLIC empty — set -d domain or --merchant" >&2 exit 2 fi # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- expect_http() { local label="$1" want="$2" got="$3" detail="${4:-}" case ",$want," in *",$got,"*) ok "$label" "HTTP $got${detail:+ · $detail}" ;; *) fail "$label" "HTTP $got (want $want)${detail:+ · $detail}" ;; esac } to_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } to_upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; } # First char upper, rest lower → synthetic MIX for Basic user (always ≠ low if len>0) synth_mix() { python3 -c "s='''$1'''; print((s[:1].upper()+s[1:].lower()) if s else s)" } # First 3 upper (partial mix) synth_partmix() { python3 -c "s='''$1'''; print(s[:3].upper()+s[3:] if len(s)>=3 else s.upper())" } # Normalize curl status: "200", "000", never "200000" / "000000" _http_norm() { local raw="$1" dig dig=$(printf '%s' "$raw" | tr -cd '0-9') if [ -z "$dig" ]; then printf '000' else # first 3 digits = real code (curl 200 then || echo 000 → 200000) printf '%s' "$dig" | head -c 3 fi } token_http() { local path_id="$1" user="$2" password="$3" local b64 raw b64=$(printf '%s' "${user}:${password}" | base64 -w0 2>/dev/null \ || printf '%s' "${user}:${password}" | base64) raw=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/last_token.json" -w '%{http_code}' \ -X POST "${BASE}/instances/${path_id}/private/token" \ -H "Authorization: Basic ${b64}" \ -H 'Content-Type: application/json' \ -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ 2>/dev/null || true) _http_norm "${raw:-}" } json_code_hint() { python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true import json, sys try: d = json.load(open(sys.argv[1])) except Exception: print("") raise SystemExit c = d.get("code", "") h = (d.get("hint") or d.get("detail") or "")[:70] print(f"code={c} {h}".strip()) PY } extract_token() { python3 - "$tmp/last_token.json" <<'PY' 2>/dev/null || true import json, sys try: d = json.load(open(sys.argv[1])) except Exception: print("") raise SystemExit(0) print(d.get("access_token") or d.get("token") or "") PY } instance_body() { local id="$1" pass="$2" name="${3:-auth401 probe}" python3 - "$id" "$pass" "$name" <<'PY' import json, sys print(json.dumps({ "address": {}, "auth": {"method": "token", "password": sys.argv[2]}, "default_pay_delay": {"d_us": 60000000}, "default_wire_transfer_delay": {"d_us": 30000000}, "default_refund_delay": {"d_us": 30000000}, "id": sys.argv[1], "jurisdiction": {}, "name": sys.argv[3], "use_stefan": True, })) PY } auth_change_http() { local path_id="$1" bearer="$2" new_pass="$3" local tok="$bearer" body case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac body=$(NEW_PASS="$new_pass" python3 -c 'import json,os; print(json.dumps({"method":"token","password":os.environ["NEW_PASS"]}))') curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/auth_change.json" -w '%{http_code}' \ -X POST "${BASE}/instances/${path_id}/private/auth" \ -H "Authorization: Bearer ${tok}" \ -H 'Content-Type: application/json' \ -d "$body" \ 2>/dev/null || echo 000 } # --------------------------------------------------------------------------- # Stage secrets (quiet) # --------------------------------------------------------------------------- _auth401_stage_secret_roots() { printf '%s\n' \ "${FRANCPAYSAN_SECRETS:-}" \ "${HOME}/francpaysan-secrets" \ "${HOME}/src/francpaysan-secrets" \ "${HOME}/taler/src/francpaysan-secrets" } _auth401_resolve_secrets_quiet() { local root inst_id pass for root in $(_auth401_stage_secret_roots); do [ -n "$root" ] && [ -d "$root" ] || continue # durable ids: prefer mon401fix when mon401 was rotated by old tests if [ -z "${DURABLE_PASS:-}" ]; then for inst_id in "${AUTH401_DURABLE_ID}" mon401fix mon401 "${MERCHANT_INSTANCE:-}"; do [ -n "$inst_id" ] || continue pass="" if [ -f "$root/stage/instances/${inst_id}.password" ]; then pass=$(tr -d '\n\r' <"$root/stage/instances/${inst_id}.password") fi # skip empty password files if [ -n "$pass" ]; then # verify file is for this id (mon401.password may be stale copy of mon401fix) DURABLE_PASS="$pass" DURABLE_ID=$(to_lower "$inst_id") AUTH401_SECRET_NOTE="durable ${inst_id} from ${root}/stage/instances/" # only set AUTH401_PASSWORD if caller did not pin an instance if [ -z "${AUTH401_PASSWORD}" ] && [ -z "${AUTH401_INSTANCE}" ]; then AUTH401_PASSWORD="$pass" fi break fi done fi if [ -z "${AUTH401_BEARER}" ] && [ -f "$root/stage/default-instance-token.txt" ]; then AUTH401_BEARER=$(tr -d '\n\r' <"$root/stage/default-instance-token.txt") AUTH401_BEARER_NOTE="default-instance-token.txt" fi if [ -z "${AUTH401_ADMIN_TOKEN}" ] && [ -f "$root/stage/admin-instance-token.txt" ]; then AUTH401_ADMIN_TOKEN=$(tr -d '\n\r' <"$root/stage/admin-instance-token.txt") AUTH401_ADMIN_NOTE="admin-instance-token.txt" fi [ -n "${DURABLE_PASS:-}" ] && break done # also keep AUTH401_INSTANCE for durable-only mode if [ "${AUTH401_PREFER_DURABLE}" = "1" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then AUTH401_INSTANCE="${AUTH401_INSTANCE:-$DURABLE_ID}" AUTH401_PASSWORD="${AUTH401_PASSWORD:-$DURABLE_PASS}" AUTH401_CREATE=0 AUTH401_MODE_NOTE="PREFER_DURABLE=1 → only durable ${AUTH401_INSTANCE}" fi } _auth401_resolve_secrets_quiet : "${AUTH401_CREATE_PASSWORD:=${AUTH401_PASSWORD:-Mon401-Test-Pass!}}" export AUTH401_INSTANCE AUTH401_PASSWORD AUTH401_BEARER AUTH401_CREATE AUTH401_ADMIN_TOKEN # --------------------------------------------------------------------------- set_area auth401 section "auth401 · merchant 401 / case matrix (WebUI APIs) · ${BASE}" if [ -n "${AUTH401_SECRET_NOTE:-}" ]; then info "stage secret" "$AUTH401_SECRET_NOTE"; fi if [ -n "${AUTH401_BEARER_NOTE:-}" ]; then info "stage secret" "AUTH401_BEARER from ${AUTH401_BEARER_NOTE}"; fi if [ -n "${AUTH401_ADMIN_NOTE:-}" ]; then info "stage secret" "AUTH401_ADMIN_TOKEN from ${AUTH401_ADMIN_NOTE}"; fi if [ -n "${AUTH401_MODE_NOTE:-}" ]; then info "auth401 mode" "$AUTH401_MODE_NOTE"; fi info "auth401 scope" "FULL=${AUTH401_FULL} CREATE=${AUTH401_CREATE} PREFER_DURABLE=${AUTH401_PREFER_DURABLE} CONTINUE=${AUTH401_CONTINUE}" if [ "${AUTH401_CONTINUE}" = "1" ]; then info "continue mode" "CONTINUE_ON_ERROR/AUTH401_CONTINUE=1 — hard prereqs soft; always full ERROR list via EXIT trap" fi # =========================================================================== set_group config # =========================================================================== cfg_code=$(http_body "$BASE/config" "$tmp/mer-config.json") expect_http "merchant /config" 200 "$cfg_code" "$BASE/config" HAVE_SP=0 MANDATORY_TAN=0 if [ "$cfg_code" = "200" ]; then eval "$(python3 - "$tmp/mer-config.json" <<'PY' import json, sys d = json.load(open(sys.argv[1])) sp = 1 if d.get("have_self_provisioning") else 0 tans = d.get("mandatory_tan_channels") or [] print(f"HAVE_SP={sp}") print(f"MANDATORY_TAN={1 if tans else 0}") print(f"TAN_CH={','.join(tans) if tans else ''}") print(f"API_VER={d.get('version','')}") PY )" info "self-provision" "have_self_provisioning=${HAVE_SP} mandatory_tan=${MANDATORY_TAN}${TAN_CH:+ ($TAN_CH)} api=${API_VER:-?}" else fail "merchant config required for auth401" "HTTP $cfg_code" _auth401_abort_or_continue "no /config" fi # =========================================================================== set_group webui # SPA surface: /webui/ loads + version stamp; optional toLowerCase in bundle # =========================================================================== if [ "${AUTH401_FULL}" = "1" ]; then wcode=$(http_code "$BASE/webui/") case "$wcode" in 200|301|302) ok "webui /" "HTTP $wcode" ;; *) warn "webui /" "HTTP $wcode" ;; esac vcode=$(http_body "$BASE/webui/version.txt" "$tmp/spa-ver.txt" 2>/dev/null || echo 000) if [ "$vcode" = "200" ] && [ -s "$tmp/spa-ver.txt" ]; then ok "webui version.txt" "$(tr -d '\n\r' <"$tmp/spa-ver.txt")" else warn "webui version.txt" "HTTP ${vcode:-000}" fi # Download a slice of index.js — look for lowercasing mitigation (#11340) jcode=$(curl -skS -m 60 -o "$tmp/spa-index.js" -w '%{http_code}' "$BASE/webui/index.js" 2>/dev/null || echo 000) jcode=$(printf '%s' "$jcode" | tr -cd '0-9' | head -c 3) if [ "$jcode" = "200" ] && [ -s "$tmp/spa-index.js" ]; then ok "webui index.js" "HTTP 200 · $(wc -c <"$tmp/spa-index.js" | tr -d ' ') bytes" if grep -q 'toLowerCase' "$tmp/spa-index.js" 2>/dev/null; then ok "webui bundle toLowerCase" "present (SPA may mitigate #11340)" else warn "webui bundle toLowerCase" "not found in index.js — login MIX may 401 without lowercasing" fi else warn "webui index.js" "HTTP ${jcode:-000}" fi fi # =========================================================================== set_group signup # WebUI self-provision: POST /instances with mixed-case id (as typed) # =========================================================================== CREATED=0 USE_ID="" USE_PASS="" ID_MIX="" ID_LOW="" CREATE_PASS="$AUTH401_CREATE_PASSWORD" stamp=$(date +%H%M%S) # Mixed-case id like the SPA would send before lowercasing (or if it forgot) ID_MIX="$(printf '%s' "${AUTH401_ID_PREFIX}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')X${stamp}" ID_LOW=$(to_lower "$ID_MIX") if [ "${AUTH401_FULL}" = "1" ] && [ "$AUTH401_CREATE" = "1" ] && [ "$HAVE_SP" = "1" ]; then if [ "$MANDATORY_TAN" = "1" ] && [ "$AUTH401_SKIP_IF_MFA" = "1" ]; then warn "signup skipped" "mandatory_tan_channels set — set AUTH401_INSTANCE+PASSWORD" else body=$(instance_body "$ID_MIX" "$CREATE_PASS" "auth401 signup MIX") ccode=$(_http_norm "$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/create.json" -w '%{http_code}' \ -X POST "${BASE}/instances" \ -H 'Content-Type: application/json' \ -d "$body" 2>/dev/null || true)") case "$ccode" in 200|204) ok "signup POST /instances id=MIX" "HTTP $ccode id=$ID_MIX (SPA self-provision)" CREATED=1 USE_ID="$ID_MIX" USE_PASS="$CREATE_PASS" # Immediate login as SPA after signup: must use *lowercase* Basic user code=$(token_http "$ID_LOW" "$ID_LOW" "$CREATE_PASS") expect_http "signup→login SPA-sim path=low user=low → 200" 200 "$code" "$(json_code_hint)" # Bug path: SPA forgot toLowerCase on login after signup with MIX id typed code=$(token_http "$ID_LOW" "$ID_MIX" "$CREATE_PASS") expect_http "signup→login RAW user=MIX (no lower) → 401" 401 "$code" "$(json_code_hint)" ;; 202) warn "signup needs MFA" "HTTP 202 — use AUTH401_INSTANCE" ;; *) fail "signup POST /instances" "HTTP $ccode $(head -c 120 "$tmp/create.json" 2>/dev/null | tr '\n' ' ')" ;; esac fi elif [ -n "${AUTH401_INSTANCE:-}" ] && [ -n "${AUTH401_PASSWORD:-}" ]; then USE_ID=$(to_lower "$AUTH401_INSTANCE") USE_PASS="$AUTH401_PASSWORD" ID_LOW=$(to_lower "$USE_ID") ID_MIX=$(synth_mix "$ID_LOW") info "signup" "using existing AUTH401_INSTANCE=$USE_ID (no POST /instances)" ok "using existing instance" "$USE_ID" else warn "signup skipped" "CREATE=${AUTH401_CREATE} HAVE_SP=${HAVE_SP} (need instance for matrix)" fi # Fallback durable if [ -z "$USE_ID" ] && [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then USE_ID="$DURABLE_ID" USE_PASS="$DURABLE_PASS" ID_LOW=$(to_lower "$USE_ID") ID_MIX=$(synth_mix "$ID_LOW") info "fallback durable" "$USE_ID" ok "using durable instance" "$USE_ID" fi if [ -z "$USE_ID" ]; then fail "no instance under test" "need signup OK or AUTH401_INSTANCE+PASSWORD or mon401.password" _auth401_abort_or_continue "no instance under test" # CONTINUE: synthesize dummy ids so later groups still run (expect many 404/401) ID_LOW_USE="auth401-missing-instance" ID_USER_MIX=$(synth_mix "$ID_LOW_USE") ID_PATH_MIX="$ID_USER_MIX" ID_UPPER=$(to_upper "$ID_LOW_USE") ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") PASS_OK="invalid" PASS_BAD='auth401-wrong-password-NOT-VALID' USE_ID="$ID_LOW_USE" USE_PASS="$PASS_OK" fi ID_LOW_USE=$(to_lower "$USE_ID") # Always synthetic case variants (even for mon401) so MIX ≠ low ID_USER_MIX=$(synth_mix "$ID_LOW_USE") ID_PATH_MIX="$ID_USER_MIX" ID_UPPER=$(to_upper "$ID_LOW_USE") ID_PARTMIX=$(synth_partmix "$ID_LOW_USE") PASS_OK="$USE_PASS" PASS_BAD='auth401-wrong-password-NOT-VALID' info "probe ids" "low=${ID_LOW_USE} MIX_user=${ID_USER_MIX} UPPER=${ID_UPPER} created=${CREATED} pass_len=${#PASS_OK}" # =========================================================================== set_group login # WebUI login = POST …/private/token with Basic (username, password) # =========================================================================== if [ "${AUTH401_FULL}" = "1" ]; then code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") expect_http "login SPA-sim user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") expect_http "login RAW user=MIX pwd=OK → 401 (#11340)" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") expect_http "login RAW user=UPPER pwd=OK → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") expect_http "login path=MIX user=low pwd=OK → 200 (path tolerant)" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") expect_http "login path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" fi # =========================================================================== set_group case # Full Basic case matrix (always hard expects — synthetic MIX) # =========================================================================== code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_OK") expect_http "path=MIX user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_PATH_MIX" "$ID_LOW_USE" "$PASS_OK") expect_http "path=MIX user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$PASS_OK") expect_http "path=low user=MIX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") expect_http "path=low user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_UPPER" "$ID_LOW_USE" "$PASS_OK") expect_http "path=UPPER user=low pwd=OK → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_UPPER" "$PASS_OK") expect_http "path=low user=UPPER pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_PARTMIX" "$PASS_OK") expect_http "path=low user=MiX pwd=OK → 401 (case)" 401 "$code" "$(json_code_hint)" # =========================================================================== set_group password # =========================================================================== code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_BAD") expect_http "pwd=BAD → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "") expect_http "pwd=EMPTY → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_PATH_MIX" "$ID_USER_MIX" "$PASS_BAD") expect_http "path=MIX user=MIX pwd=BAD → 401" 401 "$code" "$(json_code_hint)" # =========================================================================== set_group pwchange # WebUI password change: POST private/auth then re-login # Only on throwaway signup instances — never rotate durable mon401.password # =========================================================================== if [ "${AUTH401_FULL}" = "1" ]; then if [ "${AUTH401_PWCHANGE_THROWAY_ONLY}" = "1" ] && [ "${CREATED:-0}" != "1" ]; then info "pwchange skipped" "not a throwaway instance (CREATED=0) — refuse to rotate durable password" else code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") if [ "$code" != "200" ]; then fail "pwchange: login for token" "HTTP $code — skip pwchange" else TOK=$(extract_token) if [ -z "$TOK" ]; then fail "pwchange: parse token" "empty access_token" else ok "pwchange: got bearer" "len=${#TOK}" NEW_PASS="Auth401-New-${stamp}!" ac=$(_http_norm "$(auth_change_http "$ID_LOW_USE" "$TOK" "$NEW_PASS")") case "$ac" in 200|204) ok "pwchange POST private/auth" "HTTP $ac (SPA password form)" code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") expect_http "pwchange: old pwd → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$NEW_PASS") expect_http "pwchange: new pwd user=low → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "$ID_USER_MIX" "$NEW_PASS") expect_http "pwchange: new pwd user=MIX → 401" 401 "$code" "$(json_code_hint)" PASS_OK="$NEW_PASS" USE_PASS="$NEW_PASS" ;; 202) warn "pwchange needs MFA" "HTTP 202" ;; *) fail "pwchange POST private/auth" "HTTP $ac $(head -c 100 "$tmp/auth_change.json" 2>/dev/null | tr '\n' ' ')" ;; esac fi fi fi fi # =========================================================================== set_group admin # Admin UI: POST /management/instances (needs admin bearer) # =========================================================================== if [ "${AUTH401_FULL}" = "1" ]; then ADMIN_TOK="${AUTH401_ADMIN_TOKEN:-}" # Optional: login as admin user with password if [ -z "$ADMIN_TOK" ] && [ -n "${AUTH401_ADMIN_PASSWORD}" ]; then code=$(token_http "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_USER" "$AUTH401_ADMIN_PASSWORD") if [ "$code" = "200" ]; then ADMIN_TOK=$(extract_token) ok "admin login user=${AUTH401_ADMIN_USER}" "got token" else # try lower only au=$(to_lower "$AUTH401_ADMIN_USER") code=$(token_http "$au" "$au" "$AUTH401_ADMIN_PASSWORD") if [ "$code" = "200" ]; then ADMIN_TOK=$(extract_token) ok "admin login user=${au}" "got token" else warn "admin login" "HTTP $code — set AUTH401_ADMIN_TOKEN" fi fi fi if [ -z "$ADMIN_TOK" ]; then info "admin-create skipped" "no AUTH401_ADMIN_TOKEN / AUTH401_ADMIN_PASSWORD (stage default is not admin)" else case "$ADMIN_TOK" in secret-token:*) ;; *) ADMIN_TOK="secret-token:${ADMIN_TOK}" ;; esac AID_MIX="Adm401X${stamp}" AID_LOW=$(to_lower "$AID_MIX") APASS="Adm401-Pass-${stamp}!" body=$(instance_body "$AID_MIX" "$APASS" "auth401 admin-create MIX") acode=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/admin_create.json" -w '%{http_code}' \ -X POST "${BASE}/management/instances" \ -H "Authorization: Bearer ${ADMIN_TOK}" \ -H 'Content-Type: application/json' \ -d "$body" 2>/dev/null || echo 000) case "$acode" in 200|204) ok "admin-create POST /management/instances id=MIX" "HTTP $acode id=$AID_MIX" code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") expect_http "admin-create→login user=low → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$AID_LOW" "$AID_MIX" "$APASS") expect_http "admin-create→login user=MIX → 401" 401 "$code" "$(json_code_hint)" # admin-set auth on new instance code=$(token_http "$AID_LOW" "$AID_LOW" "$APASS") if [ "$code" = "200" ]; then AT=$(extract_token) NEW_A="Adm401-New-${stamp}!" ch=$(auth_change_http "$AID_LOW" "$AT" "$NEW_A") case "$ch" in 200|204) ok "admin-created instance pwchange" "HTTP $ch" code=$(token_http "$AID_LOW" "$AID_LOW" "$NEW_A") expect_http "admin-created re-login new pwd → 200" 200 "$code" "$(json_code_hint)" ;; *) warn "admin-created pwchange" "HTTP $ch" ;; esac fi ;; 401|403) warn "admin-create unauthorized" "HTTP $acode — token not admin (expected on stage default)" ;; 202) warn "admin-create MFA" "HTTP 202" ;; *) fail "admin-create" "HTTP $acode $(head -c 120 "$tmp/admin_create.json" 2>/dev/null | tr '\n' ' ')" ;; esac fi fi # =========================================================================== set_group missing # =========================================================================== code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/last_token.json" -w '%{http_code}' \ -X POST "${BASE}/instances/${ID_LOW_USE}/private/token" \ -H 'Content-Type: application/json' \ -d '{"scope":"write","duration":{"d_us":3600000000},"refreshable":true}' \ 2>/dev/null || echo 000) expect_http "private/token NO_AUTH → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "$ID_LOW_USE" "auth401-not-a-real-user" "$PASS_OK") expect_http "user=OTHER pwd=OK → 401" 401 "$code" "$(json_code_hint)" code=$(token_http "auth401-does-not-exist-${stamp}" "$ID_LOW_USE" "$PASS_OK") # Must not look like auth failure (401). Prefer 404; 502 from proxy is soft. case "$code" in 404) ok "path=missing → 404 (not 401)" "HTTP 404 · $(json_code_hint)" ;; 401) fail "path=missing must not be 401" "HTTP 401 · $(json_code_hint)" ;; 502|503) warn "path=missing" "HTTP $code (want 404; proxy flake, not auth)" ;; *) fail "path=missing → 404 (not 401)" "HTTP $code (want 404) · $(json_code_hint)" ;; esac # =========================================================================== set_group bearer # =========================================================================== code=$(token_http "$ID_LOW_USE" "$ID_LOW_USE" "$PASS_OK") if [ "$code" = "200" ]; then TOKEN=$(extract_token) if [ -n "$TOKEN" ]; then ok "got access_token" "len=${#TOKEN}" code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/priv.json" -w '%{http_code}' \ "${BASE}/instances/${ID_LOW_USE}/private/" \ -H "Authorization: Bearer ${TOKEN}" 2>/dev/null || echo 000) expect_http "GET private/ Bearer OK → 200" 200 "$code" code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/priv.json" -w '%{http_code}' \ "${BASE}/instances/${ID_LOW_USE}/private/" \ -H "Authorization: Bearer secret-token:AUTH401INVALID000" 2>/dev/null || echo 000) expect_http "GET private/ Bearer BAD → 401" 401 "$code" code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/priv.json" -w '%{http_code}' \ "${BASE}/instances/${ID_LOW_USE}/private/" 2>/dev/null || echo 000) expect_http "GET private/ NO_AUTH → 401" 401 "$code" else fail "parse access_token" "HTTP $code but no token field" fi else fail "token for bearer tests" "HTTP $code" fi code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/mgmt.json" -w '%{http_code}' \ "${BASE}/management/instances" 2>/dev/null || echo 000) expect_http "GET /management/instances NO_AUTH → 401" 401 "$code" if [ -n "${AUTH401_BEARER:-}" ]; then if [ -z "${AUTH401_BEARER_INSTANCE:-}" ]; then case "${AUTH401_BEARER_NOTE:-}" in *default-instance-token*) AUTH401_BEARER_INSTANCE=default ;; *) AUTH401_BEARER_INSTANCE="${DURABLE_ID:-default}" ;; esac fi inst=$(to_lower "$AUTH401_BEARER_INSTANCE") tok="$AUTH401_BEARER" case "$tok" in secret-token:*) ;; *) tok="secret-token:${tok}" ;; esac code=$(curl -skS --max-redirs 0 -m "${TIMEOUT}" \ -o "$tmp/priv2.json" -w '%{http_code}' \ "${BASE}/instances/${inst}/private/" \ -H "Authorization: Bearer ${tok}" 2>/dev/null || echo 000) case "$code" in 200) ok "GET private/ secrets Bearer" "HTTP 200 instance=$inst" ;; 401) warn "secrets Bearer rejected" "HTTP 401 on $inst" ;; *) warn "secrets Bearer" "HTTP $code on $inst" ;; esac fi # =========================================================================== set_group durable # Extra: durable mon401 still works after throwaway tests (if present) # =========================================================================== if [ -n "${DURABLE_ID:-}" ] && [ -n "${DURABLE_PASS:-}" ]; then dlow=$(to_lower "$DURABLE_ID") dmix=$(synth_mix "$dlow") code=$(token_http "$dlow" "$dlow" "$DURABLE_PASS") expect_http "durable ${dlow} login user=low → 200" 200 "$code" "$(json_code_hint)" code=$(token_http "$dlow" "$dmix" "$DURABLE_PASS") expect_http "durable ${dlow} login user=MIX → 401" 401 "$code" "$(json_code_hint)" else info "durable" "no mon401.password — skipped" fi # =========================================================================== set_group report # =========================================================================== info "summary matrix" "401: Basic user case≠low | bad/empty pwd | no auth | bad bearer | wrong user · 200: user=low + path any case · 404: missing instance · SPA must toLowerCase username on login/signup/admin/pw-relogin" if [ "$CREATED" = "1" ]; then info "throwaway left" "id=${ID_MIX:-?} low=${ID_LOW_USE} (password rotated if pwchange ran)" fi # fall through → EXIT trap runs _auth401_finish (summary + full ERROR list) :