scripts: HA e2e login-flow test + token test (official APIs, leak-safe)
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).
This commit is contained in:
parent
4d5402946d
commit
978ca908e1
2 changed files with 384 additions and 0 deletions
165
scripts/ha_e2e_login_flow.sh
Executable file
165
scripts/ha_e2e_login_flow.sh
Executable file
|
|
@ -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":"<redirect_uri>?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.
|
||||||
219
scripts/ha_e2e_token.sh
Executable file
219
scripts/ha_e2e_token.sh
Executable file
|
|
@ -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".
|
||||||
Loading…
Add table
Add a link
Reference in a new issue