Reference-client conformance harness #
Boots a real atproto-pds and drives the real @atproto/oauth-client-node
through PAR → authorize → token → resource request → refresh against it, and
then through signOut(), checking that the grant ended with it.
tools/conformance/ci.sh # build, install, run — exits non-zero on failure
That is the whole command, and it is run from anywhere — it resolves its own
paths. It builds pds, installs the pinned client, and runs the checks.
--runs=N changes how many sign-ins the soak performs (default 20). To run
against a binary built somewhere else — another commit, say — use
node run.mjs --pds-bin=/path/to/pds from this directory, after pnpm install.
If you have a CARGO_TARGET_DIR #
run.mjs looks for the binary in <repo>/target/debug/pds and
<repo>/target/release/pds, and nowhere else. So a CARGO_TARGET_DIR in your
environment, or a build.target-dir in a .cargo/config.toml, puts the build
somewhere the search will not look, and a bare node run.mjs fails with:
Error: no pds binary found. Run:
cargo build -p atproto-pds --bin pds --features clap
or pass --pds-bin=<path>.
after a build that succeeded. That message is telling the truth about the fix and lying about the cause — nothing is missing, it is in the other directory.
ci.sh handles this: it asks cargo metadata for the real target_directory
and exports CONFORMANCE_PDS_BIN. Driving run.mjs yourself, pass the path:
node run.mjs --pds-bin="$CARGO_TARGET_DIR/debug/pds"
# or, equivalently
CONFORMANCE_PDS_BIN="$CARGO_TARGET_DIR/debug/pds" node run.mjs
Why this exists #
Two OAuth bugs shipped from this workspace and survived every test in it:
d7da6c7— the DPoP nonce challenge on/oauth/parand/oauth/tokenwas answered with RFC 9449 §9's resource-server form (401+WWW-Authenticate) instead of §8's authorization-server form (400+{"error":"use_dpop_nonce"}).@atproto/oauth-clientbuilds its authorization-server fetch withisAuthServer: trueand then recognises only the §8 shape, so it never retried and sign-in failed at its first request.aaa5fa9—SignaturePolicy::AnySwas a no-op on secp256k1, becausek256enforces low-S inside its own verifier.@atproto/jwksignsES256Kand does not normalises, so roughly half of a conforming client's DPoP proofs were refused as "invalid signature".
Neither is subtle. Both were invisible from Rust, because the only client that
ever spoke to this server was the Rust one: our DPoP client accepts either
status and consults WWW-Authenticate first, and our signer always emitted
low-S for a verifier that demanded it. Client and server agreed with each
other and every test passed. The defects were visible only from TypeScript.
So the harness contributes exactly one thing: a client we did not write.
What it checks #
- Nonce challenge shape (RFC 9449 §8). A proof carrying no nonce is sent
to
/oauth/parand to/oauth/token; each must answer400with{"error":"use_dpop_nonce"}and aDPoP-Nonceheader, and the retry carrying that nonce must get past the nonce check. Both endpoints, because fixing/oauth/paralone moves the failure toclient.callback(). - A high-S ES256K proof is a valid proof. Two proofs are minted from one
key over one request:
(r, s)and(r, n−s). Both are valid ECDSA signatures over the same message under the same key and RFC 7515 blesses neither, so both must be accepted. The low-S request is the control — if it fails, the harness is wrong about how to build a proof and the high-S result cannot be read. - Twenty cold sign-ins with the reference client. Each run constructs a
fresh
NodeOAuthClient— an empty nonce cache — and completes PAR, authorize, the code exchange, an authenticatedgetSession, and a forced refresh, asserting the refresh returned a different access token.
Cold is not a detail. fetchDpop stores the nonce off a response it did not
understand as a challenge, so a warmed client succeeds on its second attempt
and a broken challenge reads as flakiness rather than as a bug. And twenty runs
is not superstition: the PDS advertises ES256K, compareAlgos prefers it
unconditionally, so every run mints a secp256k1 DPoP key and signs several
proofs with it. One run has an even chance of never producing the high-S form.
Twenty do not.
The resource-server half of the nonce protocol (§9: 401 +
WWW-Authenticate) is covered by check 3 rather than by a probe of its own.
The two nonce spaces have separate HMAC domain separators, so the nonce the
client cached during PAR is refused at /xrpc/, and the challenge has to be
understood a second time — in the other shape. A server that answered both
spaces the same way fails one check or the other whichever way it chose.
Proof that it detects the bugs it exists to prevent #
A harness that cannot catch its two founding bugs is decoration. Both were
re-introduced by building pds from the commit before each fix and pointing
the harness at it with --pds-bin:
| build | nonce shape | high-S | 20 cold sign-ins |
|---|---|---|---|
d7da6c7^ — neither fix |
FAIL (/oauth/par answered 401) |
FAIL (high-S refused, low-S accepted) | FAIL 20/20 |
aaa5fa9^ (= d7da6c7) — nonce fixed, high-S not |
ok | FAIL | FAIL 19/20 |
HEAD |
ok | ok | ok 20/20 |
The middle row is the one to look at. Nineteen of twenty sign-ins failed and one passed — that single lucky run is what a one-shot harness would have reported as a green build.
To reproduce. The three commands run in three different directories, which is
the thing this recipe is easiest to get wrong — git clone . clones whatever
repository you are standing in, so starting it from tools/conformance/ clones
the right repo but leaves every relative path after it pointing one level too
deep:
# 1. at the repository root — `.` is the repo being cloned
cd "$(git rev-parse --show-toplevel)"
git clone . /tmp/ablation
git -C /tmp/ablation checkout aaa5fa9^
# 2. in the clone — and with CARGO_TARGET_DIR explicitly cleared, see below
(cd /tmp/ablation && env -u CARGO_TARGET_DIR \
cargo build -p atproto-pds --bin pds --features clap)
# 3. in tools/conformance/, where run.mjs and node_modules live
cd tools/conformance
node run.mjs --runs=20 --pds-bin=/tmp/ablation/target/debug/pds
Give the clone its own target/, which is what env -u CARGO_TARGET_DIR above
is for. Sharing one does not work: two checkouts of the same workspace hash to
the same -C metadata, so the clone's libatproto_identity.rlib overwrites
yours and your next cargo build links the old one without recompiling it. The
harness catches that — an atproto-pds carrying the nonce fix and an
atproto-identity without the high-S one reports exactly the middle row above —
but you will spend an hour deciding whether the bug is back.
Note that step 3 passes --pds-bin pointing into the clone's target/. That
is the whole point of the flag here, and it is the same flag the
CARGO_TARGET_DIR note at the top of this file is about.
How it is wired #
run.mjs ── lib/plc.mjs a did:plc directory for one server, in memory
├─ lib/pds.mjs spawns the real `pds` binary on a loopback port
├─ lib/gateway.mjs the reverse proxy the PDS expects to sit behind
├─ lib/proofs.mjs DPoP proofs built by hand, for checks 1 and 2
└─ lib/flow.mjs the reference client, for check 3
Nothing links against the crates. The harness talks to the pds binary over
HTTP, so it can be pointed at a build of any commit.
Three pieces of scaffolding stand in for things a local server does not have, and each is worth understanding before trusting a result:
The gateway. issuer_url() in crates/atproto-pds/src/oauth/metadata.rs
derives the OAuth issuer, the resource of the protected-resource document and
the htu a pushed or token request is checked against from the service DID,
always over https. The reference client refuses an https: URL whose host is
loopback (httpsUriSchema in @atproto/oauth-types). A PDS is therefore only
reachable by a conformant client from behind a TLS terminator, so the harness
gives the server the identity pds.conformance.test and maps that origin onto
the loopback socket exactly as a terminating proxy would: the URL's scheme and
authority are replaced, X-Forwarded-Proto and X-Forwarded-Host are added
(the server runs with PDS_TRUSTED_PROXY_HOPS=1, which is what makes a
resource request's htu come out as the public origin), and nothing else is
touched. Status, headers and body are handed back verbatim. That rule is not
negotiable: the 401-vs-400 bug was diagnosed with a shim that rewrote a status
code, and a gateway that edits a response is a gateway that can hide the next
one.
The PLC directory. createAccount mints a DID by POSTing a signed genesis
operation to the configured directory, and the client resolves that DID back to
a document to check that the token it was handed came from the server the
account actually lives on. lib/plc.mjs answers both, rendering the operation
the PDS submitted as the document it describes. It does not verify signatures
or order a log — it is a fixture, not an implementation.
The consent POST. client.authorize() returns a URL a human would open.
The harness posts the credentials to /oauth/authorize — the JSON endpoint the
consent form itself submits to — and carries code, state and iss back to
client.callback(). That is the only step of the flow the harness performs
rather than the client.
That shortcut has a cost, and it has already been paid once. Posting the JSON
directly means the consent page itself is never exercised — not the form,
not the inline script that lifts its submit into that JSON, not the redirect
the script assembles afterwards. Every request the harness makes to
/oauth/authorize is one the page would have made correctly. So a bug living
in the page is invisible here in the same way the two founding bugs were
invisible to the Rust suite, and for the same reason: the only client that ever
drives the consent page is a browser, and no browser runs in this harness.
One did get out that way. The script called preventDefault, then .json() on
every response, and attached nothing to the rejection — so any answer that was
not a JSON body (an empty-bodied 408 from the request deadline, a 503 from
load shedding, an HTML error page from a proxy in front of the origin) left the
page doing nothing at all, for ever. It was reported to us as a PDS that hangs.
The invariants are pinned in oauth::consent's unit tests now, which is a
weaker thing than running them: those tests read the rendered document, they do
not execute it. A harness that drove a real browser through the page would be
the honest version, and does not exist yet.
The loopback client identity (http://localhost?redirect_uri=…&scope=atproto)
is a spec-defined development client, so no client-metadata document has to be
served. The redirect URI is never listened on; the harness intercepts the code
before a browser would be redirected anywhere.
How CI runs it #
.tangled/workflows/ci.yml has run this harness since c668dd0. The step is
oauth conformance, near the end of the file:
- name: "oauth conformance"
command: |
ulimit -n "$(ulimit -Hn)" 2>/dev/null || true
tools/conformance/ci.sh
It is last but for audit on purpose: it is the only step that boots a server
and the only one that installs an npm tree. dependencies.nixpkgs carries
nodejs and pnpm for it — pnpm specifically, so the install honours
pnpm-lock.yaml; ci.sh falls back to npm, which ignores that lockfile and
takes its versions from the exact pins in package.json instead. coreutils
is there in part for this script's dirname.
What it costs, and what it touches #
The checks themselves take a few seconds once the binary is built — three checks, twenty sign-ins, several hundred requests — and reach nothing but loopback. The PLC directory, the client metadata and the handle resolution are all local fixtures.
ci.sh as a whole does reach the network, and it is worth being precise
about where. pnpm install --frozen-lockfile fetches the pinned client from
registry.npmjs.org. That install is the harness's only npm dependency, and in CI
it is the only fetch this step makes: the step's cargo build finds the crates
the Rust steps before it already downloaded. (From a cold Cargo cache, as on a
fresh machine, the build fetches the locked crate graph from crates.io first,
like any build.) So when this step fails in a way that smells like DNS or TLS
rather than OAuth, the install is the first thing to rule out. Once the tree is
on disk, nothing further leaves the machine.
It is not the workflow's only outbound dependency. rustup downloads the
pinned toolchain in the toolchain step, submodules clones dasl-testing
from GitHub, the first step that builds fetches the locked crate graph from
crates.io (nothing is vendored or built --offline), and audit fetches the
RustSec advisory database. A DNS- or TLS-shaped failure in one of those steps is
that step's, not this harness's.
So "the network is never reached" is true of the checks and false of the
script. A run whose pnpm install was served entirely from a warm pnpm store
touches nothing at all, which is why the distinction is easy to lose locally and
sharp in CI.