#!/usr/bin/env bash # Single sign-on for a facility's own people — the whole path, against a stand-in broker. # # The product never talks SAML itself; it talks to the Jackson broker, and scripts/mock-jackson.cjs # speaks enough of Jackson for these checks to go end to end: an admin connects an identity # provider and registers a domain, the Log in box offers single sign-on for that domain, the start # route sends the browser to the broker with a state cookie, the broker sends it back, the callback # matches the identity to an existing account and mints the ordinary session. Then the guards: a # forged state, an address the facility never added, an inactive account, "require SSO" refusing # the password for everyone but the break-glass admin, password resets going quiet, a wearer on the # web, and the whole feature vanishing when the broker is not configured. # # The dev server must have JACKSON_URL=http://127.0.0.1:3199 and JACKSON_API_KEY=e2e-jackson-key # (the runner's .env does); the mock is started here. set -u B=${BASE:-http://127.0.0.1:3111} T=${TMP:-/tmp} MOCK=http://127.0.0.1:3199 PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; } code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } where() { curl -s -o /dev/null -w '%{redirect_url}' "$@"; } is() { local name=$1 want=$2 got=$3; if [ "$got" = "$want" ]; then ok "$name"; else fail "$name" "wanted $want, got $got"; fi; } 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; } J="$T/tc-sso-admin.txt"; SJ="$T/tc-sso-second.txt"; WJ="$T/tc-sso-wearer.txt"; BJ="$T/tc-sso-browser.txt" rm -f "$J" "$SJ" "$WJ" "$BJ" XFF="10.66.$((RANDOM%250)).$((RANDOM%250))" post() { local jar=$1 path=$2 body=$3; curl -s -b "$jar" -c "$jar" -X POST "$B$path" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "$body"; } patch() { local jar=$1 path=$2 body=$3; curl -s -b "$jar" -c "$jar" -X PATCH "$B$path" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "$body"; } mut() { post "$1" /api/mutate "{\"op\":\"$2\",\"payload\":$3}"; } identity() { curl -s -X POST "$MOCK/__mock/identity" -H 'content-type: application/json' -d "{\"email\":\"$1\",\"name\":\"$2\"}" >/dev/null; } # Walk the redirect dance the way a browser would: start → broker → callback, cookies in one jar. signin() { local jar=$1 email=$2 as=${3:-user}; rm -f "$jar"; curl -s -L -b "$jar" -c "$jar" -o /dev/null -w '%{url_effective}' -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$email&as=$as"; } node scripts/mock-jackson.cjs 3199 >/dev/null 2>&1 & MOCKPID=$! trap 'kill $MOCKPID 2>/dev/null' EXIT for i in $(seq 1 30); do curl -s -o /dev/null "$MOCK/__mock/connections" && break; sleep 0.2; done TS=$(date +%s) DOM="sso$TS.example" AE="admin@$DOM"; IE="issuer@$DOM"; WE="wearer@$DOM"; OE="outsider@$DOM" FN="SSO Hospital $TS" echo "== a facility, its people, and a wearer" check "the admin signs up" "$(post "$J" /api/auth/signup "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"$FN\",\"email\":\"$AE\",\"password\":\"password123\"}")" '"ok":true' check "an issuer is added" "$(mut "$J" users.add "{\"first\":\"Ivy\",\"last\":\"Issuer\",\"email\":\"$IE\",\"password\":\"password123\",\"role\":\"ISSUER\"}")" '"ok":true' SID=$(mut "$J" staff.save '{"num":"W1","first":"Wynn","last":"Wearer","group":"Nursing","dept":"Ward 1"}' | python3 -c 'import sys,json; print(json.load(sys.stdin).get("result",{}).get("id",""))') check "a staff member is added" "$SID" '^[a-z0-9]\{20,\}$' CODE=$(mut "$J" staff.selfCode "{\"id\":\"$SID\"}" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("result",{}).get("code",""))') check " and an activation code" "$CODE" '^[A-Z0-9-]\{6,\}$' check "the wearer activates an account" "$(post "$WJ" /api/staff/activate "{\"code\":\"$CODE\",\"email\":\"$WE\",\"password\":\"password123\",\"agreed\":true}")" '"ok":true' echo "== before anything is connected" check "the lookup says no" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"$AE\"}")" '"sso":false' check " and start sends you back" "$(where -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$AE")" 'sso_unavailable' check "the settings say not connected" "$(curl -s -b "$J" "$B/api/sso")" '"enabled":false' echo "== an admin connects the identity provider" check "an issuer may not" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}" >/dev/null; post "$SJ" /api/sso "{\"metadataXml\":\"\",\"domains\":\"$DOM\"}")" 'Admins only' check "no metadata is refused" "$(post "$J" /api/sso "{\"domains\":\"$DOM\"}")" 'metadata' check "an http metadata URL is refused" "$(post "$J" /api/sso "{\"metadataUrl\":\"http://idp.example/meta\",\"domains\":\"$DOM\"}")" 'https' check "a public mail domain is refused" "$(post "$J" /api/sso "{\"metadataXml\":\"\",\"domains\":\"gmail.com\"}")" 'public mail service' check "bad metadata is refused by the broker" "$(post "$J" /api/sso "{\"metadataXml\":\"not xml\",\"domains\":\"$DOM\"}")" 'rejected' check " and SSO stays off" "$(curl -s -b "$J" "$B/api/sso")" '"enabled":false' check "good metadata connects" "$(post "$J" /api/sso "{\"metadataXml\":\"\",\"domains\":\"$DOM, Extra.$DOM\"}")" '"enabled":true' ST=$(curl -s -b "$J" "$B/api/sso") check " the broker holds it" "$ST" '"connected":true' check " the domains are kept, lower-cased" "$ST" "extra.$DOM" check " and the audit trail says so" "$(curl -s -b "$J" "$B/api/activity" | head -c 4000)" 'settings.sso.connect' check "a second facility cannot claim the domain" "$(post "$SJ" /api/auth/signup "{\"first\":\"Bo\",\"last\":\"Other\",\"facility\":\"Other $TS\",\"email\":\"bo@other$TS.example\",\"password\":\"password123\"}" >/dev/null; post "$SJ" /api/sso "{\"metadataXml\":\"\",\"domains\":\"$DOM\"}")" 'already registered' echo "== the Log in box learns about it" check "the lookup now says yes" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@$DOM\"}")" '"sso":true' check " naming the facility" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@$DOM\"}")" "$FN" check " but not for other domains" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"someone@else$TS.example\"}")" '"sso":false' check " and not for a bare word" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"nonsense\"}")" '"sso":false' echo "== signing in through the identity provider" identity "$AE" "Ada Admin" END=$(signin "$BJ" "$AE") check "the admin lands in the app" "$END" '/app' check " with a coordinator session" "$(cat "$BJ")" 'tc_session' is " that opens the app" 200 "$(code -b "$BJ" "$B/app")" check " and the trail records an sso sign-in" "$(curl -s -b "$BJ" "$B/api/activity" | head -c 4000)" 'sso' identity "$IE" "Ivy Issuer" check "the issuer too" "$(signin "$BJ" "$IE")" '/app' identity "$OE" "Ollie Outsider" check "an address the facility never added is refused" "$(signin "$BJ" "$OE")" 'sso_no_account' no " with no session" "$(cat "$BJ")" 'tc_session' LP=$(curl -s "$B/auth?error=sso_no_account") check " and the box explains" "$LP" 'has no account at this facility' check "a cross-site start is bounced" "$(where -H "sec-fetch-site: cross-site" "$B/api/auth/sso/start?email=$AE")" '/auth$' echo "== a forged return" identity "$AE" "Ada Admin" rm -f "$BJ" LOC=$(where -c "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/start?email=$AE") check "start goes to the broker" "$LOC" '3199/api/oauth/authorize' check " with a state cookie" "$(cat "$BJ")" 'tc_sso' check "the broker's code with the wrong state is refused" "$(where -b "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?code=abc&state=wrong")" 'sso_state' check "a callback with no cookie is refused" "$(where -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?code=abc&state=abc")" 'sso_state' check "an error from the provider is refused" "$(where -b "$BJ" -H "x-forwarded-for: $XFF" "$B/api/auth/sso/callback?error=access_denied")" 'sso_failed' echo "== requiring it" check "requiring with no break-glass admin is refused" "$(patch "$J" /api/sso '{"required":true}')" 'break-glass' AID=$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT id FROM \"User\" WHERE email=$1",[process.argv[1]])).then(r=>{console.log(r.rows[0]?.id||"");return c.end()})' "$AE") check "the admin marks themselves break-glass" "$(mut "$J" users.update "{\"id\":\"$AID\",\"ssoBreakGlass\":true}")" '"ok":true' check "an issuer cannot be break-glass" "$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT id FROM \"User\" WHERE email=$1",[process.argv[1]])).then(r=>{console.log(r.rows[0]?.id||"");return c.end()})' "$IE" | xargs -I{} sh -c "curl -s -b '$J' -c '$J' -X POST '$B/api/mutate' -H 'content-type: application/json' -d '{\"op\":\"users.update\",\"payload\":{\"id\":\"{}\",\"ssoBreakGlass\":true}}'")" 'Only an admin' check "now it can be required" "$(patch "$J" /api/sso '{"required":true}')" '"ssoRequired":true' check "the issuer's password is refused" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}")" '"ssoRequired":true' is " with a 403" 403 "$(code -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: $XFF" -d "{\"email\":\"$IE\",\"password\":\"password123\"}")" check "the break-glass admin's password still works" "$(post "$SJ" /api/auth/login "{\"email\":\"$AE\",\"password\":\"password123\"}")" '"ok":true' check "a reset for the issuer goes quiet" "$(post "$SJ" /api/auth/forgot "{\"email\":\"$IE\"}")" '"ok":true' is " and writes no reset row" 0 "$(node -e 'require("dotenv/config");const{Client}=require("pg");const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(()=>c.query("SELECT count(*)::int AS n FROM \"PasswordReset\" r JOIN \"User\" u ON u.id=r.\"userId\" WHERE u.email=$1",[process.argv[1]])).then(r=>{console.log(String(r.rows[0].n));return c.end()})' "$IE")" identity "$IE" "Ivy Issuer" check " while SSO still lets the issuer in" "$(signin "$BJ" "$IE")" '/app' echo "== a wearer on the web" identity "$WE" "Wynn Wearer" check "with staff SSO off the wearer is refused" "$(signin "$BJ" "$WE")" 'sso_no_account' check "the admin lets staff use it" "$(patch "$J" /api/sso '{"staff":true}')" '"ssoStaff":true' check "now the wearer lands in the staff app" "$(signin "$BJ" "$WE")" '/my' check " with a staff session" "$(cat "$BJ")" 'tc_staff' no " and not a coordinator one" "$(cat "$BJ")" 'tc_session' check "the staff-app door does the same" "$(signin "$BJ" "$WE" staff)" '/my' check "the wearer's password still works (staff app)" "$(post "$WJ" /api/staff/login "{\"email\":\"$WE\",\"password\":\"password123\"}")" '"ok":true' echo "== disconnecting" check "a wearer may not" "$(curl -s -b "$WJ" -X DELETE "$B/api/sso")" 'Admins only\|Not signed in' check "the admin disconnects" "$(curl -s -b "$J" -c "$J" -X DELETE "$B/api/sso" -H "x-forwarded-for: $XFF")" '"enabled":false' check " the broker no longer holds it" "$(curl -s "$MOCK/__mock/connections")" '^\[\]$' check " the lookup says no again" "$(post "$BJ" /api/auth/sso/lookup "{\"email\":\"$AE\"}")" '"sso":false' check " and the issuer's password works again" "$(post "$SJ" /api/auth/login "{\"email\":\"$IE\",\"password\":\"password123\"}")" '"ok":true' echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]