#!/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 ]