diff --git a/skills/agent-provisioning/scripts/cloudron-oidc-login.sh b/skills/agent-provisioning/scripts/cloudron-oidc-login.sh index 8cdd3a0..fc0c34f 100755 --- a/skills/agent-provisioning/scripts/cloudron-oidc-login.sh +++ b/skills/agent-provisioning/scripts/cloudron-oidc-login.sh @@ -22,7 +22,7 @@ PW=$(SM get "$ITEM" --field password) SEED=$(SM get "$ITEM" --field totp_seed 2>/dev/null || true) VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n' | cut -c1-64) CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n') -AUTH="$BASE/openid/auth?client_id=$CLIENT&response_type=code&scope=openid%20email%20profile&redirect_uri=https%3A%2F%2Fmy.knownelement.com%2Fauthcallback.html&code_challenge=$CHALLENGE&code_challenge_method=S256" +AUTH="$BASE/openid/auth?client_id=$CLIENT&response_type=code&scope=openid%20email%20profile&redirect_uri=https%3A%2F%2Fmy.knownelement.com%2Fauthcallback.html&code_challenge=$CHALLENGE&code_challenge_method=S256&prompt=login" JAR="/tmp/oidc-jar-$U.txt"; rm -f "$JAR" INT=$(curl -sk -o /dev/null -w '%{url_effective}' -c "$JAR" -L --max-redirs 8 "$AUTH") @@ -56,10 +56,14 @@ case "$CALLBACK" in -d '' | grep -i '^location:' | head -1 | tr -d '\r' | sed 's/^[Ll]ocation: //') [ -n "$CLOC" ] || { echo "FAIL: consent confirm produced no redirect" >&2; exit 6; } case "$CLOC" in http*) : ;; *) CLOC="$BASE$CLOC" ;; esac + [ "$DBG" = 1 ] && echo "DBG cloc: ${CLOC:0:90}" >&2 CALLBACK=$(curl -sk -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' -L --max-redirs 8 "$CLOC") ;; esac -CODE=$(printf '%s' "$CALLBACK" | sed -n 's/[?&]code=\([^&]*\).*/\1/p') +CODE=$(printf '%s' "$CALLBACK" | sed -n 's/^.*[?&]code=\([^&]*\).*$/\1/p') +[ "$DBG" = 1 ] && { echo "DBG callback: ${CALLBACK:0:120}" >&2; echo "DBG code raw: $CODE" >&2; } +# codes may arrive percent-encoded in the Location header; decode before exchange +CODE=$(printf '%s' "$CODE" | perl -pe 's/%([0-9A-Fa-f]{2})/chr(hex($1))/ge') [ "$DBG" = 1 ] && echo "DBG code len: ${#CODE}" >&2 [ -n "$CODE" ] || { echo "FAIL: no authorization code at callback: $CALLBACK" >&2; exit 6; } @@ -71,8 +75,8 @@ TOKRESP=$(curl -sk -b "$JAR" -c "$JAR" -X POST "$BASE/openid/token" \ --data-urlencode "redirect_uri=$REDIRECT" \ --data-urlencode "code_verifier=$VERIFIER") ACCESS=$(printf '%s' "$TOKRESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') -[ -n "$ACCESS" ] || { echo "FAIL: token exchange: $(printf '%s' "$TOKRESP" | head -c 120)" >&2; exit 7; } +[ -n "$ACCESS" ] || { echo "FAIL: token exchange: $TOKRESP" >&2; exit 7; } printf '%s' "$ACCESS" > "/tmp/.oidc-bearer-$U" chmod 600 "/tmp/.oidc-bearer-$U" -WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p') +WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"username":[[:space:]]*"\([^"]*\)".*/\1/p') echo "OK: bearer for '$WHO' at /tmp/.oidc-bearer-$U (session jar $JAR)" diff --git a/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh b/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh index 1c8b87d..bd0fd3d 100755 --- a/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh +++ b/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh @@ -1,8 +1,9 @@ #!/bin/bash -# Enroll TOTP for an identity (2FA must NOT be enforced yet — run -# cloudron-oidc-login.sh first, or admin totp_disable for seed-lost users). +# Enroll TOTP for a Cloudron identity (Cloudron 10 API: profile-scoped routes). +# Prereq: cloudron-oidc-login.sh has run (bearer at /tmp/.oidc-bearer-). # usage: cloudron-totp-enroll.sh [vault-item] -# Captures the seed into the vault item and verifies end-to-end. +# Flow: POST /profile/totp_secret -> seed -> TOTP code -> POST /profile/totp_enable +# -> seed into vault -> end-to-end verify via fresh TOTP login. set -euo pipefail U="${1:?usage: cloudron-totp-enroll.sh [vault-item]}" ITEM="${2:-$U Cloudron}" @@ -10,23 +11,42 @@ BASE="https://my.knownelement.com" BEARER_FILE="/tmp/.oidc-bearer-$U" [ -f "$BEARER_FILE" ] || { echo "FAIL: run cloudron-oidc-login.sh $U first" >&2; exit 1; } ACCESS=$(cat "$BEARER_FILE") -MYUID=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"id":"\(uid-[^"]*\)".*/\1/p') -[ -n "$MYUID" ] || { echo "FAIL: bearer rejected" >&2; exit 2; } -SECRET=$(curl -sk "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p') -if [ -z "$SECRET" ]; then - SECRET=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d '{}' | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p') -fi -[ -n "$SECRET" ] || { echo "FAIL: no totp secret returned" >&2; exit 3; } + +SECRET=$(curl -sk -X POST "$BASE/api/v1/profile/totp_secret" \ + -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d '{}' \ + | sed -n 's/.*"secret":[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$SECRET" ] || { echo "FAIL: no totp secret returned (bearer stale? re-login)" >&2; exit 3; } + CODE=$(bash "$(dirname "$0")/totp.sh" "$SECRET") -ENAB=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d "{\"totpSecret\":\"$SECRET\",\"totpToken\":\"$CODE\"}") -case "$ENAB" in *Invalid*|*404*|*"No such route"*) echo "PARTIAL: enable said: $(printf '%s' "$ENAB" | head -c 100)";; esac +ENABLED=$(curl -sk -X POST "$BASE/api/v1/profile/totp_enable" \ + -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ + -d "{\"totpToken\":\"$CODE\"}" -w '|%{http_code}') +printf '%s' "$ENABLED" | grep -q '|200$' || { echo "FAIL: totp_enable said: $(printf '%s' "$ENABLED" | head -c 150)" >&2; exit 4; } + SM() { if [ "$(id -un)" = "TSGCOO" ]; then /data2/TSGCOO/.local/bin/sm "$@" /dev/null -# end-to-end verify: fresh login with the stored seed +# store seed; smcli setfield 400s ("Data missing") on some ciphers, so verify +# the write and fall back to a full item recreate with the seed included +STORED=0 +if SM setfield "$ITEM" totp_seed "$SECRET" >/dev/null 2>&1; then + STORED=1 +else + VU=$(SM get "$ITEM" --field username); VP=$(SM get "$ITEM" --field password); VR=$(SM get "$ITEM" --field uri 2>/dev/null || true) + [ -n "$VR" ] || VR="$BASE" + SM rm "$ITEM" >/dev/null 2>&1 + SM set "$ITEM" "username=$VU" "password=$VP" "uri=$VR" "totp_seed=$SECRET" >/dev/null 2>&1 && STORED=1 +fi +[ "$STORED" = 1 ] || { echo "FAIL: could not store seed in vault ($ITEM)" >&2; exit 5; } +[ "$(SM get "$ITEM" --field totp_seed)" = "$SECRET" ] || { echo "FAIL: seed readback mismatch ($ITEM)" >&2; exit 5; } + +# end-to-end verify: fresh login WITH the stored seed lands a working bearer bash "$(dirname "$0")/cloudron-oidc-login.sh" "$U" "$ITEM" >/dev/null 2>&1 if [ -s "$BEARER_FILE" ]; then - WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $(cat "$BEARER_FILE")" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p') - [ "$WHO" = "$U" ] && echo "OK: $U 2FA enrolled, seed in vault ($ITEM), TOTP login verified as $WHO" \ - && exit 0 + PROF=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $(cat "$BEARER_FILE")") + WHO=$(printf '%s' "$PROF" | sed -n 's/.*"username":[[:space:]]*"\([^"]*\)".*/\1/p') + TOTP_ON=$(printf '%s' "$PROF" | sed -n 's/.*"totpEnabled":[[:space:]]*\(true\|false\).*/\1/p') + if [ "$WHO" = "$U" ] && [ "$TOTP_ON" = "true" ]; then + echo "OK: $U 2FA enrolled (totpEnabled=true), seed in vault ($ITEM), TOTP login verified" + exit 0 + fi fi echo "PARTIAL: verify failed — check state for $U"