Add a BTCPay Server SSO harness to the docker fixture (#1669)

* Add a BTCPay Server SSO harness to the docker fixture

BTCPay bundles RTL and runs it in single-sign-on mode, reached over an entry
path the standalone login never exercises: no password, a rotating cookie file,
an unregistered /rtl/api/authenticate/cookie URL that falls through to the
catch-all in server/utils/app.ts, and a reverse proxy in front. Regressions on
that path have previously gone unnoticed until they reached BTCPay users.

Adds an "sso" compose profile, so a plain `docker compose up -d` is unchanged:

  - rtl-sso, a second RTL running with RTL_SSO=1, RTL_COOKIE_PATH and
    LOGOUT_REDIRECT_LINK -- the environment block lifted verbatim from BTCPay's
    own compose fragment, so this exercises the env-driven SSO path BTCPay
    actually uses. A second container is required because RTL selects one
    authentication mode at startup, so SSO and password login cannot coexist in
    one instance.
  - rtl-sso-config-init, staging rtl/RTL-Config.sso.json into a volume -- the
    same copy-into-a-volume dance the standalone RTL already needs, because RTL
    rewrites its config on startup.
  - rtl-sso-proxy, nginx standing in for BTCPay's traefik, routing only /rtl
    and /rtl/* exactly as BTCPay's router rule does. There is no prefix
    stripping anywhere: RTL is built with <base href="/rtl/"> and mounts every
    route under baseHref '/rtl', so the prefix is passed through unmodified.
    Everything outside /rtl 404s, so a request escaping the prefix surfaces as
    a failure rather than being quietly served.

scripts/verify-sso.sh asserts the whole flow in 11 checks -- prefix routing,
CSRF token minting on the catch-all, the sha256 access-key handshake, an
authenticated node call, cookie rotation on login, and rejection of a wrong key
-- and exits non-zero so it can gate a change. bin/sso-url prints the link
BTCPay renders on its Services page. RTL_IMAGE overrides both RTL containers at
once, so a branch build gets tested through both entry paths.

BTCPay itself (postgres, nbxplorer, btcpayserver) is deliberately not included;
the README documents what that leaves untested and how to run against BTCPay's
own regtest stack when the question is BTCPay's behaviour rather than RTL's.

Also bumps the fixture's default RTL image from v0.15.8 to v0.15.10.

* Document the SSO harness in the rtl-docker-fixture skill

* Point CLAUDE.md at the BTCPay SSO harness

* Note that no CI runs on an open PR
This commit is contained in:
Suheb 2026-08-04 18:17:19 -07:00 committed by GitHub
parent a005b687a7
commit d4e2554ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 439 additions and 2 deletions

View file

@ -33,8 +33,23 @@ bin/ln-cli bob fwdinghistory
docker compose logs -f rtl docker compose logs -f rtl
``` ```
Testing the **BTCPay Server integration** (RTL in single-sign-on mode behind a proxy) —
behind a compose profile, so a plain `up` does not start it:
```bash
docker compose --profile sso up -d
./scripts/verify-sso.sh # 11 assertions over the whole entry path; non-zero on failure
open "$(bin/sso-url)" # the link BTCPay renders on its Services page
```
Key facts when working with the fixture: Key facts when working with the fixture:
- **Run `scripts/verify-sso.sh` after touching authentication, CSRF or static serving.**
BTCPay reaches RTL over a path the standalone login never exercises — a rotating cookie
file, an unregistered `/rtl/api/authenticate/cookie` URL that falls through to the
catch-all in `server/utils/app.ts`, and a reverse proxy. Note `GET /rtl/` is served by
`express.static` and mints **no** `XSRF-TOKEN`; only the catch-all does, so a client
entering there 403s on its first POST. That is long-standing, not a regression.
- **`scripts/seed.sh` is deterministic but not idempotent.** Every amount is fixed, so a - **`scripts/seed.sh` is deterministic but not idempotent.** Every amount is fixed, so a
fresh run always produces identical state (screenshots differ only by your change) — so fresh run always produces identical state (screenshots differ only by your change) — so
**do not introduce randomness**. It refuses to run twice against an already-seeded **do not introduce randomness**. It refuses to run twice against an already-seeded

View file

@ -50,7 +50,10 @@ frequently do, since the controllers were written in parallel.
`ERESOLVE` conflict from `@fortawesome/angular-fontawesome`. `ERESOLVE` conflict from `@fortawesome/angular-fontawesome`.
- **`npm run server` only works on Windows** — it sets `NODE_ENV` with `set X=Y&&` syntax. On - **`npm run server` only works on Windows** — it sets `NODE_ENV` with `set X=Y&&` syntax. On
macOS/Linux use `npm run serverUbuntu`. macOS/Linux use `npm run serverUbuntu`.
- **`npm run lint` and `npm run test` must both be green before a PR.** - **`npm run lint` and `npm run test` must both be green before a PR.** Nothing will check
this for you: no build or test CI runs on an open PR. `checks.yml` fires on
`pull_request: closed` (i.e. on merge) and on tags/releases, and `rtlreviewbot` only on a
requested review or a comment — so running both locally is the only gate before merge.
- If lint reports hundreds of template "Parsing error" failures, look for a stale - If lint reports hundreds of template "Parsing error" failures, look for a stale
**`coverage/`** directory (git-ignored Karma output). The template linter walks its HTML **`coverage/`** directory (git-ignored Karma output). The template linter walks its HTML
report. Delete it and re-run. report. Delete it and re-run.
@ -91,6 +94,17 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations
`docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only; `docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only;
every credential in it is throwaway. every credential in it is throwaway.
The fixture also carries a **BTCPay Server SSO harness** behind a compose profile
(`docker compose --profile sso up -d`). BTCPay bundles RTL and reaches it over a path the
standalone login never exercises: a rotating cookie file, an unregistered
`/rtl/api/authenticate/cookie` URL that is not a route at all and falls through to the
catch-all in `server/utils/app.ts`, and a reverse proxy serving it under `/rtl`. Run
`docker/scripts/verify-sso.sh` (11 assertions, exits non-zero) after touching
authentication, CSRF or static serving — none of that path is covered by logging into the
fixture's own RTL. One trap it encodes: `GET /rtl/` is served by `express.static`, which
sits above the catch-all and mints no `XSRF-TOKEN`, so a client entering there gets a 403
on its first POST. That is long-standing behaviour, not a regression.
Backend regression tests live in `test/backend/` (plain `node:test`, run against the Backend regression tests live in `test/backend/` (plain `node:test`, run against the
compiled `backend/`). `npm run test` compiles the backend, then runs them compiled `backend/`). `npm run test` compiles the backend, then runs them
(`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale (`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale

View file

@ -74,6 +74,9 @@ docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl
./scripts/seed.sh # fund, connect, open channels, make payments ./scripts/seed.sh # fund, connect, open channels, make payments
``` ```
To also bring up the BTCPay single-sign-on harness, add `--profile sso` — see
[BTCPay SSO harness](#btcpay-sso-harness).
Then open <http://localhost:3000> — password `rtldev`. All five nodes (alice, bob, Then open <http://localhost:3000> — password `rtldev`. All five nodes (alice, bob,
carol, cln, eclair) appear in the node switcher. carol, cln, eclair) appear in the node switcher.
@ -118,6 +121,7 @@ bin/ln-cli bob listchannels
bin/ln-cli bob fwdinghistory # forwarding history bin/ln-cli bob fwdinghistory # forwarding history
bin/e-cli getinfo # eclair-cli bin/e-cli getinfo # eclair-cli
bin/e-cli channels bin/e-cli channels
bin/sso-url # BTCPay-style SSO link (needs --profile sso)
docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning
``` ```
@ -128,6 +132,94 @@ docker compose logs -f rtl
docker compose logs alice docker compose logs alice
``` ```
## BTCPay SSO harness
BTCPay Server bundles RTL and runs it in single-sign-on mode, reached through a very
different entry path than the standalone login: no password, a rotating cookie, and a
reverse proxy in front. That path has broken before without the standalone flow
noticing, so the fixture can reproduce it.
It is behind a compose profile, so a plain `docker compose up -d` does not start it:
```bash
docker compose --profile sso up -d
./scripts/verify-sso.sh # 11 assertions over the whole entry path
open "$(bin/sso-url)" # or click through it yourself
```
`bin/sso-url` prints the link BTCPay renders on its Services page. Following it lands
you in RTL already authenticated, against the `alice` node.
### How the flow works
```mermaid
sequenceDiagram
participant B as Browser
participant P as rtl-sso-proxy<br/>(stands in for traefik)
participant R as rtl-sso<br/>(RTL_SSO=1)
participant C as .cookie<br/>(shared volume)
R->>C: writes 64 random bytes at startup
Note over B: bin/sso-url reads the cookie —<br/>BTCPay reads the same file
B->>P: GET /rtl/api/authenticate/cookie?access-key=<cookie>
P->>R: same URI, prefix passed through
R-->>B: not a registered route → catch-all:<br/>mints XSRF-TOKEN, serves index.html
B->>P: POST /rtl/api/authenticate<br/>{ PASSWORD, sha256(access-key) }
P->>R:
R->>C: matches → rotates the cookie
R-->>B: JWT
```
The three services are `rtl-sso-config-init` (stages `rtl/RTL-Config.sso.json`, same
copy-into-a-volume dance as the standalone RTL), `rtl-sso` (RTL with `RTL_SSO=1`,
`RTL_COOKIE_PATH` and `LOGOUT_REDIRECT_LINK` — the env block is lifted verbatim from
BTCPay's own compose fragment), and `rtl-sso-proxy` (nginx standing in for BTCPay's
traefik). `RTL_IMAGE` overrides both RTL containers at once, so a branch build gets
tested through both entry paths.
It is a second RTL container rather than a flag on the first because RTL picks one
authentication mode at startup — SSO and the password login cannot coexist in one
instance. Both are up at the same time on different ports.
### Things this makes visible
**No prefix stripping anywhere.** RTL is built with `<base href="/rtl/">` and mounts
every route under `baseHref '/rtl'`, so BTCPay's traefik — and the nginx here — pass
`/rtl/…` through unmodified. The proxy deliberately 404s everything outside `/rtl`, so
a request escaping the prefix shows up as a failure instead of being quietly served.
**The entry URL is not a real route.** `/rtl/api/authenticate/cookie` matches nothing in
`server/routes/shared/authenticate.ts`; it falls through to the catch-all in
`server/utils/app.ts`, which is what mints the `XSRF-TOKEN` cookie and serves the SPA.
The access-key is the raw cookie file content — the frontend sha256s it before posting
and the backend compares against `sha256(cookieValue)`.
**`GET /rtl/` mints no CSRF token.** That path is served by `express.static`, which
sits *above* the catch-all, so a client entering there has no `XSRF-TOKEN` and its first
POST gets a 403. Only the catch-all mints one. This is long-standing behaviour, not a
regression — but it is why `verify-sso.sh` always seeds its cookie jar from the entry
URL, and worth remembering before concluding that CSRF is broken.
**The cookie is effectively single-use.** Authenticating rotates it, so a stale
`bin/sso-url` link fails. BTCPay re-reads the file on every page render, which is why
this is invisible in normal use.
### What it does not cover
BTCPay itself is not here — no postgres, nbxplorer or btcpayserver container. So this
does not exercise BTCPay *generating* the link, its Services page, or its own upgrades.
For that, run BTCPay's own regtest stack and point it at a local image:
```bash
# in a btcpayserver-docker checkout, after building an RTL image locally
docker build -t shahanafarooqui/rtl:dev /path/to/RTL
# then edit the rtl image tag in the generated docker-compose, or set it in
# docker-compose-generator/docker-fragments/bitcoin-lnd.yml before generating
```
That tests the real composition rather than this reconstruction of it; the harness here
is the fast everyday check.
## Notes and gotchas ## Notes and gotchas
**RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites **RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites
@ -180,3 +272,7 @@ one LND uses, eclair never sees new blocks and channels never confirm.
## Not included ## Not included
The Boltz swap service. The Boltz swap service.
BTCPay Server itself (postgres + nbxplorer + btcpayserver). The `sso` profile
reproduces the entry path BTCPay uses to reach RTL without running BTCPay — see
[BTCPay SSO harness](#btcpay-sso-harness) for what that covers and what it does not.

39
docker/bin/sso-url Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# Print the BTCPay-style single-sign-on entry URL for the SSO harness.
#
# docker compose --profile sso up -d
# bin/sso-url # print it
# open "$(bin/sso-url)" # or follow it straight into RTL
#
# This is the link BTCPay renders on its Services page. BTCPay builds it from
# BTCPAY_BTCEXTERNALRTL="server=/rtl/api/authenticate/cookie;cookiefile=..."
# by reading the cookie file RTL wrote and appending it as ?access-key=. The
# value is the raw file content: RTL's frontend sha256s it before posting
# (src/app/app.component.ts) and the backend compares against
# sha256(cookieValue), so no hashing happens here.
#
# Authenticating rotates the cookie (common.refreshCookie), so re-run this for
# each login -- exactly as BTCPay re-reads the file on every page render.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck disable=SC1091
[ -f .env ] && source .env
port="${RTL_SSO_PORT:-3001}"
if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
exit 1
fi
cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
if [ -z "$cookie" ]; then
echo "The cookie file /RTL/cookie/.cookie is empty. Is RTL_SSO=1 set on rtl-sso?" >&2
exit 1
fi
echo "http://localhost:${port}/rtl/api/authenticate/cookie?access-key=${cookie}"

View file

@ -17,6 +17,9 @@ volumes:
eclair_data: eclair_data:
rtl_db: rtl_db:
rtl_config: rtl_config:
rtl_sso_db:
rtl_sso_config:
rtl_sso_cookie:
x-lnd: &lnd x-lnd: &lnd
image: polarlightning/lnd:0.20.0-beta image: polarlightning/lnd:0.20.0-beta
@ -285,7 +288,7 @@ services:
container_name: ${COMPOSE_PROJECT_NAME}_rtl container_name: ${COMPOSE_PROJECT_NAME}_rtl
# Defaults to the published image; override with RTL_IMAGE (e.g. a locally built # Defaults to the published image; override with RTL_IMAGE (e.g. a locally built
# branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up. # branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up.
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8} image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
rtl-config-init: rtl-config-init:
@ -311,3 +314,87 @@ services:
- carol_data:/lnd/carol:ro - carol_data:/lnd/carol:ro
- cln_data:/cln:ro - cln_data:/cln:ro
- rtl_db:/RTL/database - rtl_db:/RTL/database
# ---------------------------------------------------------------------------
# BTCPay Server SSO harness -- profile "sso", so a plain 'up' does not start it:
#
# docker compose --profile sso up -d
# bin/sso-url
#
# BTCPay bundles RTL as a service and runs it in single-sign-on mode. RTL
# writes a random cookie to RTL_COOKIE_PATH; BTCPay reads that file and renders
# a link to /rtl/api/authenticate/cookie?access-key=<cookie> on its Services
# page. That path is not a registered route -- it falls through to RTL's
# catch-all, which mints the XSRF-TOKEN cookie and serves index.html, and the
# SPA then reads access-key from the query string and posts it as a password
# login. Reproducing that entry path is the whole point of this harness: it is
# where RTL's auth, CSRF and static-serving behaviour meet an external caller,
# and it has broken before without the standalone flow noticing.
#
# BTCPay itself (postgres + nbxplorer + btcpayserver) is deliberately not here.
# See README.md, "BTCPay SSO harness", for what that leaves untested and how to
# run against a real BTCPay when it matters.
# ---------------------------------------------------------------------------
# Same copy-into-a-volume dance as rtl-config-init above, and for the same
# reason: RTL rewrites its config on startup.
rtl-sso-config-init:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_config_init
profiles: ["sso"]
image: busybox:1.36
command: >
sh -c "cp /template/RTL-Config.sso.json /config/RTL-Config.json &&
chmod 644 /config/RTL-Config.json &&
echo 'sso config staged'"
volumes:
- ./rtl/RTL-Config.sso.json:/template/RTL-Config.sso.json:ro
- rtl_sso_config:/config
# A second RTL rather than a flag on the first: RTL picks one authentication
# mode at startup, so SSO and the password login cannot coexist in one
# instance. Only alice is wired up, matching BTCPay's one-node-per-RTL layout.
#
# The environment block is copied from BTCPay's own compose fragment
# (docker-compose-generator/docker-fragments/bitcoin-lnd.yml in
# btcpayserver-docker), so this exercises the env-driven SSO path BTCPay
# actually uses rather than the config-file equivalent -- which is why
# RTL-Config.sso.json leaves its SSO block zeroed and carries no multiPass.
#
# Only 'expose'd, never published: all access goes through the proxy, as it
# does under BTCPay.
rtl-sso:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso
profiles: ["sso"]
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
restart: unless-stopped
depends_on:
rtl-sso-config-init:
condition: service_completed_successfully
alice:
condition: service_started
environment:
RTL_CONFIG_PATH: /RTL/config
RTL_SSO: 1
RTL_COOKIE_PATH: /RTL/cookie/.cookie
LOGOUT_REDIRECT_LINK: /server/services
expose:
- "3000"
volumes:
- rtl_sso_config:/RTL/config
- rtl_sso_cookie:/RTL/cookie
- alice_data:/lnd/alice:ro
- rtl_sso_db:/RTL/database
# Stands in for BTCPay's traefik. Routes only /rtl and /rtl/*, mirroring the
# label BTCPay puts on its RTL container; see nginx/rtl-sso.conf.
rtl-sso-proxy:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_proxy
profiles: ["sso"]
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- rtl-sso
ports:
- "${RTL_SSO_PORT:-3001}:80"
volumes:
- ./nginx/rtl-sso.conf:/etc/nginx/conf.d/default.conf:ro

46
docker/nginx/rtl-sso.conf Normal file
View file

@ -0,0 +1,46 @@
# Reverse proxy in front of RTL running in BTCPay Server's single-sign-on mode.
#
# Stands in for the traefik instance BTCPay puts in front of its bundled RTL.
# The location regex mirrors the router rule BTCPay labels that container with:
#
# Host(`${BTCPAY_HOST}`) && (Path(`/rtl`) || PathPrefix(`/rtl/`))
#
# so only /rtl and /rtl/* are proxied and everything else 404s here. That
# strictness is deliberate: RTL is built with <base href="/rtl/"> (angular.json)
# and mounts every route under baseHref '/rtl' (server/utils/common.ts), so a
# request that escapes the prefix is a bug the harness should surface rather
# than quietly serve.
#
# The prefix is passed through unmodified -- there is no strip-prefix step to
# get wrong, because RTL expects to see it. proxy_pass without a URI part
# preserves the original request URI.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
location ~ ^/rtl(/|$) {
proxy_pass http://rtl-sso:3000;
# RTL runs with Express 'trust proxy' enabled, so these are what it sees
# as the client address in its logs and rate limiting.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# RTL's websocket lives at /rtl/api/ws and stays open for the life of the
# session. Without the upgrade headers it fails the handshake and the UI
# silently stops receiving live updates; without the long read timeout
# nginx drops it after 60s.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}

View file

@ -0,0 +1,30 @@
{
"port": "3000",
"defaultNodeIndex": 1,
"dbDirectoryPath": "/RTL/database",
"SSO": {
"rtlSSO": 0,
"rtlCookiePath": "",
"logoutRedirectLink": ""
},
"nodes": [
{
"index": 1,
"lnNode": "alice",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/alice/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://alice:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
}
]
}

110
docker/scripts/verify-sso.sh Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Check the BTCPay SSO harness end to end.
#
# Walks the exact path a browser takes when an operator clicks the RTL link on
# BTCPay Server's Services page, and asserts each step. Run it after any change
# to authentication, CSRF or static serving -- this is the entry path BTCPay
# uses, and none of it is covered by logging into the standalone fixture RTL.
#
# docker compose --profile sso up -d
# ./scripts/verify-sso.sh
#
# Usage: ./scripts/verify-sso.sh (from the docker/ directory)
#
# Exits non-zero if any check fails, so it can gate a PR.
# Deliberately no -e: a failing assertion must record itself and let the rest of
# the checks run, rather than aborting on the first one. That means anything
# that would normally rely on -e needs its own guard.
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
BASE="http://localhost:${RTL_SSO_PORT:-3001}"
JAR="$(mktemp)"
JAR2="$(mktemp)"
trap 'rm -f "$JAR" "$JAR2"' EXIT
pass=0
fail=0
# Both return 0 explicitly: the checks below are written as `test && ok || bad`,
# which would also run `bad` if `ok` itself ever returned non-zero.
ok() { echo " PASS: $1"; pass=$((pass + 1)); return 0; }
bad() { echo " FAIL: $1"; fail=$((fail + 1)); return 0; }
if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
exit 1
fi
cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
echo "cookie: ${cookie:0:16}... (${#cookie} chars)"
echo
echo "1. the proxy routes only /rtl, mirroring BTCPay's traefik rule"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/")
[ "$code" = "404" ] && ok "GET / -> 404" || bad "GET / -> $code (want 404)"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/authenticate")
[ "$code" = "404" ] && ok "GET /api/authenticate -> 404" || bad "GET /api/authenticate -> $code (want 404)"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/rtl/")
[ "$code" = "200" ] && ok "GET /rtl/ -> 200" || bad "GET /rtl/ -> $code (want 200)"
echo
echo "2. the entry URL falls through to the catch-all, which mints the CSRF token"
body=$(curl -s -c "$JAR" "$BASE/rtl/api/authenticate/cookie?access-key=$cookie")
echo "$body" | grep -q '<base href="/rtl/">' \
&& ok "entry URL serves the SPA shell with base href /rtl/" \
|| bad "entry URL did not serve index.html"
xsrf=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR")
[ -n "$xsrf" ] && ok "XSRF-TOKEN cookie minted" || bad "no XSRF-TOKEN cookie"
grep -q '_csrf' "$JAR" && ok "_csrf cookie set" || bad "no _csrf cookie"
echo
echo "3. the SPA posts sha256(access-key) as a password login"
hash=$(printf '%s' "$cookie" | shasum -a 256 | cut -d' ' -f1)
resp=$(curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/rtl/api/authenticate" \
-H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $xsrf" \
-d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$hash\"}")
token=$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null)
[ -n "$token" ] && ok "authenticated, JWT issued" || bad "auth failed: $resp"
echo
echo "4. the JWT reaches the node behind it"
info=$(curl -s -b "$JAR" "$BASE/rtl/api/lnd/getinfo" -H "Authorization: Bearer $token")
node_alias=$(echo "$info" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("alias",""))' 2>/dev/null)
[ "$node_alias" = "alice" ] && ok "GET /rtl/api/lnd/getinfo -> alias '$node_alias'" || bad "getinfo returned: ${info:0:200}"
echo
echo "5. the cookie rotates on login, so each BTCPay page render hands out a fresh one"
after=$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')
[ "$after" != "$cookie" ] && ok "cookie rotated after authentication" || bad "cookie did NOT rotate"
echo
echo "6. a wrong access-key is refused"
# The token has to come from the catch-all: GET /rtl/ is served by express.static,
# which mints no XSRF-TOKEN, and the POST would then fail CSRF (403) before it
# ever reached the access-key comparison this step is checking.
curl -s -c "$JAR2" "$BASE/rtl/api/authenticate/cookie?access-key=x" > /dev/null
x2=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR2")
badhash=$(printf '%s' "not-the-cookie-value-but-long-enough-to-pass-the-length-check" | shasum -a 256 | cut -d' ' -f1)
code=$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR2" -X POST "$BASE/rtl/api/authenticate" \
-H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $x2" \
-d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$badhash\"}")
[ "$code" = "406" ] && ok "wrong access-key -> 406" || bad "wrong access-key -> $code (want 406)"
echo
echo "7. the standalone fixture RTL is unaffected"
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${RTL_PORT:-3000}/rtl/")
[ "$code" = "200" ] && ok "standalone RTL still serving on ${RTL_PORT:-3000}" || bad "standalone RTL -> $code"
echo
echo "=== $pass passed, $fail failed ==="
[ "$fail" -eq 0 ]