#!/usr/bin/env bash # Password reset: the happy path, and the ways it must refuse. # Runs without SMTP configured — the token is read from the database, which is exactly what an # attacker cannot do, so the checks here are about the token's lifecycle rather than the email. set -u B=${BASE:-http://127.0.0.1:3111} # Refuses early, with the fix, when the server under test is in production mode with # Turnstile refusing every auth route — otherwise the first signup fails and every check # after it reports a security-check error instead of what it was testing. . "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B" T=${TMP:-/tmp}; J="$T/tc-rst-cj.txt"; rm -f "$J" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; } check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 200)"; fi; } no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 200)"; else ok "$name"; fi; } post() { curl -s -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; } TS=$(date +%s) EMAIL="rst$TS@example.com" echo "== setup" check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \ -H "x-forwarded-for: 10.13.$((RANDOM%250)).$((RANDOM%250))" \ -d "{\"first\":\"Rita\",\"last\":\"Reset\",\"facility\":\"Reset Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true' check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true' echo "== asking for a link tells you nothing about the address" # The route would give the game away by shape long before it gave it away in words — a 404, or an # {"error":...} for an address it has never heard of, says "no account here" as plainly as any # sentence would. So the two replies are compared byte for byte, status code included, rather than # read for particular turns of phrase. forgot() { curl -s -w '\n%{http_code}' -X POST "$B/api/auth/forgot" -H 'content-type: application/json' -H "origin: $B" -d "$1"; } KNOWN=$(forgot "{\"email\":\"$EMAIL\"}") UNKNOWN=$(forgot '{"email":"nobody-at-all@example.com"}') check "a known address gets ok" "$KNOWN" '"ok":true' if [ "$KNOWN" = "$UNKNOWN" ]; then ok "an unknown address gets a byte-identical reply" else fail "an unknown address gets a byte-identical reply" "$KNOWN vs $UNKNOWN"; fi check "a malformed address is accepted silently too" "$(post /api/auth/forgot '{"email":"not-an-email"}')" '"ok":true' echo "== a request actually records a row" # reset-token.cjs prints " ", with usedAt as "-" when unspent. # A row on its own proves nothing: /api/auth/forgot supersedes the outstanding rows and creates the # new one inside one transaction, and in the wrong order that would stamp the fresh row used — every # emailed link born dead, with a row in the table to show for it. So the row has to be a spendable one. ROW=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null) check "asking for a link records a reset" "$ROW" '.' check "and the reset it records is unspent" "$ROW" ' -$' check "and has not already expired" "$(echo "$ROW" | awk -v now="$(date -u +%Y-%m-%dT%H:%M:%S)" '{ print ($3 > now) ? "future" : "past" }')" '^future$' echo "== the happy path" TOKEN=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null) check "a token can be minted for the test" "$TOKEN" '.' # What the table holds for a token whose raw value we know. The design rests on a pg_dump of # PasswordReset being useless to whoever reads it: the row carries the SHA-256, and the raw token # exists only in the email. Both halves are worth saying — the stored value is that hash, and the # raw token appears nowhere in the row. The reader projects four columns, so a plaintext column # added later slips past the second half until reset-token.cjs prints the whole row. MINTED=$(node scripts/reset-token.cjs "$EMAIL" 2>/dev/null) check "the stored value is the token's SHA-256" "$MINTED" "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)" # A substring test rather than a grep: a raw token can begin with "-", which grep reads as an option. case "$MINTED" in *"$TOKEN"*) fail "the raw token is never stored" "$MINTED" ;; *) ok "the raw token is never stored" ;; esac check "the new password is accepted" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"brandnewpass456\"}")" '"ok":true' check "the new password signs in" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true' check "the old password no longer works" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"password123\"}")" 'doesn' echo "== a token is single use" check "the same token a second time is refused" "$(post /api/auth/reset "{\"token\":\"$TOKEN\",\"password\":\"anotherpass789\"}")" 'expired or has already been used' check "and the password did not change again" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}")" '"ok":true' echo "== an expired token is refused" OLD=$(node scripts/reset-mint.cjs "$EMAIL" -1000 2>/dev/null) check "an already-expired token is refused" "$(post /api/auth/reset "{\"token\":\"$OLD\",\"password\":\"expiredpass123\"}")" 'expired or has already been used' echo "== asking again kills the previous link" T1=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null) post /api/auth/forgot "{\"email\":\"$EMAIL\"}" > /dev/null check "the superseded token is dead" "$(post /api/auth/reset "{\"token\":\"$T1\",\"password\":\"supersededpass1\"}")" 'expired or has already been used' echo "== refusals" check "a made-up token is refused" "$(post /api/auth/reset '{"token":"totally-made-up","password":"newpassword123"}')" 'expired or has already been used' check "a short password is refused" "$(post /api/auth/reset '{"token":"whatever","password":"short"}')" 'at least 8' check "an empty token is refused" "$(post /api/auth/reset '{"token":"","password":"newpassword123"}')" 'incomplete' echo "== a reset does not walk past the second factor" # The failure this covers: setting a password used to sign the account straight in, second factor or # not — a reset link in a stolen mailbox was a complete bypass of the very thing 2FA is for. The # response now has to be the ticket shape /api/auth/login uses, with no session cookie attached. # The resets above changed the password, and a session cookie carries a version derived from the # hash — so the jar from signup is already dead. Sign in again to have a session to enrol with. curl -s -c "$J" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" \ -d "{\"email\":\"$EMAIL\",\"password\":\"brandnewpass456\"}" > /dev/null SETUP=$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"setup"}') SECRET=$(echo "$SETUP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('secret',''))" 2>/dev/null) if [ -n "$SECRET" ]; then CODE=$(npx tsx -e "import { base32Decode, totp } from './lib/totp'; console.log(totp(base32Decode('$SECRET')));" 2>/dev/null | tail -1) check "two-factor turned on for the test" "$(curl -s -b "$J" -c "$J" -X POST "$B/api/2fa" -H 'content-type: application/json' -H "origin: $B" -d "{\"action\":\"enable\",\"code\":\"$CODE\"}")" '"ok":true' TFT=$(node scripts/reset-mint.cjs "$EMAIL" 2>/dev/null) C="$T/tc-rst-2fa.txt"; rm -f "$C" OUT=$(curl -s -c "$C" -X POST "$B/api/auth/reset" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TFT\",\"password\":\"twofactorpass99\"}") check "a reset on a 2FA account asks for the code" "$OUT" '"need2fa":true' no "and does not report a plain sign-in" "$OUT" '"ok":true' no "and issues no session cookie" "$(cat "$C" 2>/dev/null || true)" 'tc_session' check "the new password is still set" "$(post /api/auth/login "{\"email\":\"$EMAIL\",\"password\":\"twofactorpass99\"}")" '"need2fa":true' rm -f "$C" else fail "two-factor setup for the reset test" "no secret returned from /api/2fa" fi echo "== the pages" # Anchored on the heading markup: the h1 ends in a full stop and the tab title app/reset/layout.tsx # sets does not, and that title goes into the head whatever the page component does — including when # it renders nothing but chrome. check "/reset renders server-side" "$(curl -s "$B/reset?token=abc")" '>Reset your password\.' # Someone who copied half a link out of an email should land on the reset page, not on a 404 or a # bounce to /auth. The sentence that tells them so ("That link is incomplete") sits inside the # useSearchParams boundary and is client-rendered, out of curl's reach; the page arriving under its # own name, with its own status, is the server's half of that promise. check "/reset says what it is even with no token" "$(curl -s "$B/reset")" '>Reset your password\.' check "and a half-copied link is not bounced away" "$(curl -s -o /dev/null -w '%{http_code}' "$B/reset")" '^200$' check "the phone sign-in offers a reset" "$(curl -s "$B/m/login")" 'Forgot password' echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]