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