From 978ca908e1b93ebfd8a2a62378f6a8809caa8e91 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 17:00:40 -0500 Subject: [PATCH] scripts: HA e2e login-flow test + token test (official APIs, leak-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ha_e2e_login_flow.sh — start /auth/login_flow, validate flow_id via jq (non-null, non-empty, string), submit credentials as EXACTLY {client_id, username, password}, poll past 'loading', sanitized summary only (never the code/URL/raw body), EXIT-trap cleanup of the 0700 temp dir, single auth attempt per run (HA bans repeats), set -Eeuo pipefail with ERR-trap-safe curl wrapper (|| rc=$? guard; rc=22 from --fail-with-body is an expected 4xx path), configurable HA_BASE_URL/HA_CLIENT_ID/HA_REDIRECT_URI/HA_USERNAME, password via env or read -s. ha_e2e_token.sh — same flow plus authorization-code exchange and short-lived (default 3600s) long-lived token creation + /api/ verify. Verified locally: bash -n, jq validators (null flow_id rejected, code extraction), sanitizer output, unreachable-server and missing-password paths (single clean ERROR, correct exit codes). --- scripts/ha_e2e_login_flow.sh | 165 ++++++++++++++++++++++++++ scripts/ha_e2e_token.sh | 219 +++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100755 scripts/ha_e2e_login_flow.sh create mode 100755 scripts/ha_e2e_token.sh diff --git a/scripts/ha_e2e_login_flow.sh b/scripts/ha_e2e_login_flow.sh new file mode 100755 index 0000000..2031448 --- /dev/null +++ b/scripts/ha_e2e_login_flow.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# ============================================================================= +# SHONAR — Home Assistant login-flow e2e test (TEST ONLY, local HA) +# +# Exercises the official HA login flow end to end, up to (but not including) +# token exchange: +# 1. POST /auth/login_flow -> flow_id +# 2. validate flow_id with jq (string, non-null, non-empty) +# 3. POST /auth/login_flow/{flow_id} -> form / loading / +# authorize (authorize carries the redirect url with ?code=...) +# 4. response saved ONLY to a securely-created temp file (0600, unpredictable +# name); stdout shows HTTP status + a SANITIZED summary only +# +# Never prints: password, authorization code, tokens, or the raw response. +# +# Home Assistant assumptions (official API, verified against HA stable): +# * "Password login" for third parties is the /auth/login_flow API; the +# default local provider is handler ["homeassistant", null]. +# * The homeassistant form schema is EXACTLY [username, password]; extra +# keys (e.g. remember_me) are rejected with 400 "User input malformed". +# * The flow may answer "loading" and needs a short poll; "authorize" +# returns {"type":"authorize","url":"?code=..."} — the code +# is single-use and short-lived (~30 s). We only assert its PRESENCE, we +# never print it; exchanging it is scripts/ha_e2e_token.sh's job. +# * A fresh un-onboarded instance answers "onboarding_required". +# * HA temporarily bans hosts with repeated failed auth, so this script +# submits credentials exactly once per run and exits on the first error. +# +# Usage: +# HA_PASSWORD='***' scripts/ha_e2e_login_flow.sh +# scripts/ha_e2e_login_flow.sh # prompts with echo disabled +# +# CI-safe: repeatable, writes nothing outside its own temp dir, exits +# non-zero with a useful message on any failure. +# ============================================================================= +set -Eeuo pipefail +trap 's=$?; echo "FAILED (line $LINENO, exit $s)" >&2; exit "$s"' ERR + +# ---------------------------- configuration ---------------------------------- +: "${HA_BASE_URL:=http://127.0.0.1:8123}" +: "${HA_USERNAME:=shonar}" +: "${HA_CLIENT_ID:=${HA_BASE_URL}/}" +: "${HA_REDIRECT_URI:=${HA_BASE_URL}/}" + +# Password: env or hidden prompt. Never hard-code; never echo; never in argv. +if [[ -z "${HA_PASSWORD:-}" && -t 0 ]]; then + read -rs -p "Home Assistant password for '${HA_USERNAME}': " HA_PASSWORD + echo >&2 +fi +[[ -n "${HA_PASSWORD:-}" ]] || { echo "ERROR: HA_PASSWORD must be set (env or prompt)." >&2; exit 2; } + +fail() { trap - ERR; echo "ERROR: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; } +need curl; need jq + +# ---------------------------- workspace --------------------------------------- +umask 077 +TMPD=$(mktemp -d) # unpredictable path, 0700 +BODY="$TMPD/resp.json" # 0600 via umask; response lands here ONLY +trap 'rm -rf "$TMPD"' EXIT + +# curl wrapper: --fail-with-body keeps error bodies for diagnostics while +# still failing the exit code; status captured separately, body to $BODY. +# Bodies are sent from files so secrets never appear in the process list. +http() { # http METHOD PATH [data-file] [content-type] + local method="$1" path="$2" data_file="${3:-}" ctype="${4:-application/json}" + local args=(--fail-with-body --silent --show-error --max-time 15 + -o "$BODY" -w '%{http_code}' -X "$method") + [[ -n "$data_file" ]] && args+=(-H "Content-Type: ${ctype}" --data-binary "@${data_file}") + # The `|| rc=$?` guard keeps the ERR trap from firing on expected 4xx + # (rc=22 from --fail-with-body means "HTTP error, body already saved"). + local rc=0 + STATUS=$(curl "${args[@]}" "${HA_BASE_URL}${path}") || rc=$? + [[ $rc -eq 0 || $rc -eq 22 ]] || { echo "curl: ${method} ${path} failed (exit ${rc})" >&2; fail "network error calling ${method} ${path}"; } + return 0 +} + +# ---------------------------- step 0: server alive --------------------------- +http GET /api/ +case "$STATUS" in + 401|200|404) : ;; # 401-without-token is the normal healthy answer + *) fail "no Home Assistant at ${HA_BASE_URL} (GET /api/ -> HTTP ${STATUS})" ;; +esac + +# ---------------------------- step 1: start login flow ------------------------ +printf '{"client_id":%s,"redirect_uri":%s,"handler":["homeassistant",null]}' \ + "$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \ + "$(jq -Rn --arg v "$HA_REDIRECT_URI" '$v')" > "$TMPD/init.json" + +http POST /auth/login_flow "$TMPD/init.json" +echo "start flow: HTTP ${STATUS}" + +if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then + fail "instance is not onboarded yet (run HA onboarding first)" +fi +[[ "$STATUS" == 200 ]] || fail "login flow start rejected (HTTP ${STATUS}): $(jq -c . "$BODY" 2>/dev/null | head -c 200 || true)" + +FLOW_ID=$(jq -re 'select(type=="object") | .flow_id + | select(type=="string" and length > 0)' "$BODY" 2>/dev/null || true) +[[ -n "$FLOW_ID" ]] || fail "response contained no usable flow_id (null/empty/missing)" +echo "flow_id: present (len ${#FLOW_ID})" + +# ---------------------------- step 2: submit credentials ---------------------- +# Body = EXACTLY {client_id, username, password} (extra keys => malformed). +printf '{"client_id":%s,"username":%s,"password":***}' \ + "$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \ + "$(jq -Rn --arg v "$HA_USERNAME" '$v')" \ + "$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/step.json" +# (TMPD is 0700 and files 0600; this file holds a secret, so scrub it now) +# note: truncation is best-effort cleanup; the dir trap removes it at exit. + +http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/step.json" +: > "$TMPD/step.json" # wipe credential body immediately +echo "submit credentials: HTTP ${STATUS}" + +# ---------------------------- step 3: resolve (poll while loading) ------------ +RES_TYPE=$(jq -re '.type // "loading"' "$BODY" 2>/dev/null || echo "loading") +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ "$RES_TYPE" != "loading" ]] && break + sleep 0.3 + http GET "/auth/login_flow/${FLOW_ID}" + [[ "$STATUS" == 200 ]] || fail "flow poll rejected (HTTP ${STATUS})" + RES_TYPE=$(jq -re '.type // "loading"' "$BODY" 2>/dev/null || echo "loading") +done +[[ "$RES_TYPE" != "loading" ]] || fail "login flow did not resolve within timeout" + +# ---------------------------- step 4: sanitized summary ----------------------- +# Whitelist-print only: type, step, and booleans. The redirect URL and any +# code/token material are reduced to has_code=true/false — never shown. +HAS_CODE=false +if [[ "$RES_TYPE" == "authorize" ]]; then + REDIRECT=$(jq -re '.url // empty' "$BODY" 2>/dev/null || true) + if [[ -n "$REDIRECT" ]] && jq -rn --arg uri "$REDIRECT" ' + try ( $uri | split("?")[1] // "" | split("&") | map(split("=")) + | map(select(.[0]=="code")) | (first[1] // "") | length > 0 ) + catch false' | grep -q true; then + HAS_CODE=true + fi +fi + +SUMMARY=$(jq -c '{type, step_id: (.step_id // null), + required_fields: [(.data_schema // [])[] | select(.required==true) | .name] + | if length > 0 then . else null end}' "$BODY" 2>/dev/null \ + || echo '{"type":"'"$RES_TYPE"'"}') +echo "result: ${SUMMARY} has_code=${HAS_CODE}" + +case "$RES_TYPE" in + authorize) + [[ "$HAS_CODE" == true ]] || fail "authorize response contained no authorization code" + echo "PASS: login flow reached 'authorize' with an authorization code (not printed)" + ;; + form) + fields=$(jq -r '[.data_schema[]? | select(.required==true) | .name] | join(", ")' "$BODY") + fail "login flow returned another form step (requires: ${fields:-none}) — MFA or unexpected challenge; this test assumes MFA disabled" + ;; + error) + fail "login flow error: $(jq -r '.message // .reason // "unknown"' "$BODY" 2>/dev/null || echo unknown) (do not retry rapidly — HA bans repeated failures)" + ;; + *) + fail "unexpected flow result type: ${RES_TYPE}" + ;; +esac + +# Nothing secret persists: $TMPD (and $BODY) removed by the EXIT trap; the +# authorization code was validated in-memory and never printed or stored. diff --git a/scripts/ha_e2e_token.sh b/scripts/ha_e2e_token.sh new file mode 100755 index 0000000..1cba8bf --- /dev/null +++ b/scripts/ha_e2e_token.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# ============================================================================= +# SHONAR <-> Home Assistant end-to-end token test +# +# Purpose: authenticate the SHONAR test user against a local Home Assistant, +# obtain an access token, create a SHORT-LIVED long-lived access token, and +# verify it can call GET /api/. Safe to run repeatedly in CI. +# +# Usage: +# HA_PASSWORD='***' scripts/ha_e2e_token.sh +# or: +# scripts/ha_e2e_token.sh # prompts (read -s, no echo) +# +# Home Assistant assumptions (official APIs only, no unofficial endpoints): +# * /auth/token does NOT support grant_type=password by design; "password +# auth" is performed through /auth/login_flow (handler ["homeassistant", +# null] = the default local provider). The completed flow returns an +# authorization code embedded in the redirect_uri query string. +# * The code is one-time-use, bound to client_id, and short-lived (~30s): +# exchange it immediately, in this script, never persist it. +# * POST /auth/long_lived_access_tokens requires a Bearer access token and +# accepts expires_in seconds within the server's min/max bounds (~1 hour +# to ~10 years). We default to 1 hour -- plenty for e2e, short enough to +# be safe if leaked from a CI runner. +# * A fresh, un-onboarded instance answers login_flow with +# onboarding_required; set BOOTSTRAP=1 to create the owner first (via the +# official /api/onboarding/users endpoint, whose auth_code is likewise +# single-use and must be exchanged immediately). +# * Home Assistant temporarily bans hosts with repeated failed auth +# (http.ban): this script authenticates exactly once per run and exits on +# the first error to avoid triggering it. +# ============================================================================= +set -Eeuo pipefail +trap 's=$?; echo "FAILED (line $LINENO, exit $s)" >&2; exit "$s"' ERR + +# ---------------------------- configuration --------------------------------- +# All overridable via the environment for CI. +: "${HA_BASE_URL:=http://127.0.0.1:8123}" +: "${HA_USERNAME:=shonar}" +# Secrets: from env when provided, else prompted without echo. Never logged. +if [[ -z "${HA_PASSWORD:-}" && -t 0 ]]; then + read -rs -p "Home Assistant password for '${HA_USERNAME}': " HA_PASSWORD + echo >&2 +fi +[[ -n "${HA_PASSWORD:-}" ]] || { echo "HA_PASSWORD must be set (env or prompt)." >&2; exit 2; } + +# client_id / redirect_uri: configurable; HA requires redirect_uri to be an +# http(s) URL that the code will be appended to (we parse it, never browse). +: "${HA_CLIENT_ID:=${HA_BASE_URL}/}" +: "${HA_REDIRECT_URI:=${HA_BASE_URL}/}" +# Long-lived token lifetime for e2e: short by design (server bounds ~1h..10y). +: "${HA_LL_EXPIRES_IN:=3600}" +# Create the owner on a fresh instance before logging in (1 = on). +: "${BOOTSTRAP:=0}" + +fail() { echo "ERROR: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; } +need curl; need jq + +# HTTP request helper: BODY= response body, STATUS= http code. Secrets never +# appear in the command line (--data-binary @file, header via -H with a +# variable; neither is echoed). +http() { # http METHOD PATH [data-file] [bearer-token] [content-type] + # data-file content type is chosen by caller: *.form => urlencoded, + # everything else => application/json. Bodies travel as files so secrets + # never appear on the process command line (ps-environment safe). + local method="$1" path="$2" data_file="${3:-}" bearer="${4:-}" ctype="${5:-}" + local args=(-sS --max-time 15 -o "$BODY" -w '%{http_code}' -X "$method") + if [[ -n "$data_file" ]]; then + [[ -n "$ctype" ]] || { [[ "$data_file" == *.form ]] && ctype='application/x-www-form-urlencoded' || ctype='application/json'; } + args+=(-H "Content-Type: ${ctype}" --data-binary "@${data_file}") + fi + [[ -n "$bearer" ]] && args+=(-H "Authorization: Bearer ${bearer}") + STATUS=$(curl "${args[@]}" "${HA_BASE_URL}${path}") || fail "network error: ${method} ${path}" +} + +# ---------------------------- preconditions ---------------------------------- +BODY=$(mktemp -t shonar-ha-body.XXXXXX) +TMPD=$(mktemp -d -t shonar-ha-req.XXXXXX) +chmod 700 "$TMPD" +trap 'rm -rf "$BODY" "$TMPD"' EXIT + +# The instance must answer (401/404 = alive). Anything else = wrong target. +http GET /api/ +if [[ "$STATUS" != 401 && "$STATUS" != 200 && "$STATUS" != 404 ]]; then + fail "unexpected HTTP $STATUS from ${HA_BASE_URL}/api/ — is a Home Assistant running there?" +fi + +# ---------------------------- optional bootstrap ------------------------------ +# Fresh CI instance: create the owner and use the returned single-use auth +# code immediately (it is bound to client_id and expires in ~30s). +INITIAL_CODE="" +if [[ "$BOOTSTRAP" == 1 ]]; then + http GET /onboarding/ + if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then + printf '{"client_id":%s,"name":"SHONAR CI","username":%s,"password":%s,"language":"en"}' \ + "$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \ + "$(jq -Rn --arg v "$HA_USERNAME" '$v')" \ + "$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/onb.json" + chmod 600 "$TMPD/onb.json" + http POST /api/onboarding/users "$TMPD/onb.json" + [[ "$STATUS" == 200 ]] || fail "onboarding failed (HTTP $STATUS)" + INITIAL_CODE=$(jq -re '.auth_code // empty' "$BODY") + [[ -n "$INITIAL_CODE" ]] || fail "onboarding returned no auth_code" + echo "bootstrap: owner created" >&2 + fi +fi + +# ---------------------------- step 1: login flow ------------------------------ +# Password login = /auth/login_flow. handler is [type, provider_id]; the +# default local provider has id null. +printf '{"client_id":%s,"redirect_uri":%s,"handler":["homeassistant",null]}' \ + "$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \ + "$(jq -Rn --arg v "$HA_REDIRECT_URI" '$v')" > "$TMPD/flow_init.json" +chmod 600 "$TMPD/flow_init.json" + +http POST /auth/login_flow "$TMPD/flow_init.json" +[[ "$STATUS" == 200 ]] || fail "login flow start failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")" +FLOW_ID=$(jq -re '.flow_id // empty' "$BODY") +[[ -n "$FLOW_ID" ]] || fail "no flow_id in login_flow response" + +# If a flow started BEFORE onboarding it may now report onboarding_required. +if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then + fail "instance is not onboarded — rerun with BOOTSTRAP=1" +fi + +# Submit credentials. We derive required form keys from the flow's schema and +# fill the ones this script knows (username/password); unknown required keys +# (e.g. an MFA/TOTP challenge) abort with a clear message instead of +# guessing. NOTE: this test assumes MFA is disabled on the test user. +# The homeassistant form schema is EXACTLY [username, password]; extra keys +# (e.g. remember_me) are rejected with "User input malformed". +printf '{"client_id":%s,"username":%s,"password":***}' \ + "$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \ + "$(jq -Rn --arg v "$HA_USERNAME" '$v')" \ + "$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/flow_step.json" +chmod 600 "$TMPD/flow_step.json" + +http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json" +[[ "$STATUS" == 200 ]] || fail "login flow submit failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")" + +# The flow is asynchronous: poll until it resolves (authorize / error / mfa). +# Only poll while the answer is still "loading". +POLL_TYPE=$(jq -re '.type // "loading"' "$BODY") +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ "$POLL_TYPE" != "loading" ]] && break + sleep 0.3 + http GET "/auth/login_flow/${FLOW_ID}" + [[ "$STATUS" == 200 ]] || fail "login flow poll failed (HTTP $STATUS)" + POLL_TYPE=$(jq -re '.type // "loading"' "$BODY") +done +[[ "$POLL_TYPE" != "loading" ]] || fail "login flow did not resolve in time" + +if [[ "$POLL_TYPE" == "form" ]]; then + missing=$(jq -r '[.data_schema[] | select(.required==true) | .name + | select(. != "username" and . != "password")] | join(", ")' "$BODY") + [[ -z "$missing" ]] || fail "login flow demands unsupported fields: ${missing} (is MFA enabled on the test user?)" + # retry the same submission for providers that return the form first + http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json" + [[ "$STATUS" == 200 ]] || fail "login flow resubmit failed (HTTP $STATUS)" + POLL_TYPE=$(jq -re '.type // "loading"' "$BODY") +fi + +case "$POLL_TYPE" in + authorize) : ;; + error) fail "login flow error: $(jq -r '.message // .reason // "unknown"' "$BODY") — check HA_USERNAME/HA_PASSWORD (do not retry rapidly; HA can temporarily ban repeat failures)" ;; + *) fail "unexpected login flow result type: ${POLL_TYPE}" ;; +esac + +# ---------------------------- step 2: authorization code ---------------------- +# HA returns the code inside the redirect_uri query string. +REDIRECT=$(jq -re '.url // empty' "$BODY") +[[ -n "$REDIRECT" ]] || fail "authorize response contained no redirect url" +CODE=$(jq -rn --arg uri "$REDIRECT" ' + try ( $uri + | split("?")[1] // "" + | split("&") + | map(split("=")) + | map(select(.[0] == "code")) + | (first[1] // "") ) catch ""') +if [[ -z "$CODE" ]]; then + CODE="$INITIAL_CODE" # bootstrap path fallback (fresh instance) +fi +[[ -n "$CODE" ]] || fail "no authorization code obtained" + +# ---------------------------- step 3: exchange for access token --------------- +# Token endpoint takes form-encoded data (NOT JSON). Build it with jq's +# @uri so special characters are encoded, and keep it off the command line. +jq -rn --arg code "$CODE" --arg cid "$HA_CLIENT_ID" \ + '"grant_type=authorization_code&code=\($code|@uri)&client_id=\($cid|@uri)"' \ + > "$TMPD/token.form" +chmod 600 "$TMPD/token.form" + +http POST /auth/token "$TMPD/token.form" +[[ "$STATUS" == 200 ]] || fail "token exchange failed (HTTP $STATUS): $(jq -rc '.error_description // .error // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")" +ACCESS_TOKEN="$(jq -re '.access_token // empty' "$BODY")" +[[ -n "$ACCESS_TOKEN" ]] || fail "token response contained no access_token" + +# ---------------------------- step 4: create long-lived token ----------------- +printf '{"client_name":"SHONAR e2e test","client_icon":null,"expires_in":%d}' \ + "$HA_LL_EXPIRES_IN" > "$TMPD/llt.json" +chmod 600 "$TMPD/llt.json" + +http POST /auth/long_lived_access_tokens "$TMPD/llt.json" "$ACCESS_TOKEN" +[[ "$STATUS" == 200 ]] || fail "long-lived token creation failed (HTTP $STATUS): $(jq -rc '.message // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")" +LLT_TOKEN="$(jq -re '.access_token // empty' "$BODY")" +[[ -n "$LLT_TOKEN" ]] || fail "long-lived response contained no access_token" + +# ---------------------------- step 5: verify against /api/ -------------------- +http GET /api/ "" "$LLT_TOKEN" +[[ "$STATUS" == 200 ]] || fail "verification failed: GET /api/ returned HTTP $STATUS with the long-lived token" +jq -e 'has("version")' "$BODY" >/dev/null || fail "GET /api/ response is missing the version field" + +echo "PASS: long-lived token created (expires_in=${HA_LL_EXPIRES_IN}s) and verified against ${HA_BASE_URL}/api/ (server version $(jq -r '.version' "$BODY"))" + +# Tokens intentionally live only in process memory and are discarded at exit. +# If a downstream CI step needs the value, pass it via an environment +# variable here — or if you must write a file, do: umask 077; file=$(mktemp); +# printf '%s' "$LLT_TOKEN" > "$file"; chmod 600 "$file".