RFC 9700, Best Current Practice for OAuth 2.0 Security, is fifteen sections
of things a deployment should do. Roughly half of them are the authorization
server’s obligations and roughly half are the client’s, and this document is
about the client’s half: what this debugger does about them, what it
deliberately does not, and — the part worth reading before changing anything —
why the whole of it is behind a checkbox that ships clear.
The mock STS in sts/ implements the server’s half, in its own
oauth2_bcp.js, behind its own switch (oauth2.rfc9700). The two are
independent and were built separately; running them together is what
tests/rfc9700_flows.js does, and that pairing is the only arrangement in
which either half is fully exercised.
Read alongside: client/src/rfc9700.js, which holds the model and is where the
reasoning for each individual rule lives; the checklist this work follows is
rcbj/mock-sts issue #2.
A debugger exists to be pointed at identity providers that are wrong. That is
most of what anybody uses one for. An authorization response with no state on
it is a finding worth seeing, and a client that refuses to send the request
cannot show it to you; an OP that still only speaks the Implicit Grant is a
thing somebody has to work with today, and a mode that removes it from the
dropdown removes their reason for opening the page.
So the mode is a checkbox in the Configuration Parameters pane, first row,
above the grant selector — because it governs every control below it. With the
box clear, this workflow behaves exactly as it did before any of this existed:
no check runs, no report is drawn, no request is refused, and
client/src/rfc9700.js is not consulted at all.
That property is invisible from inside a single run. Every test of the mode naturally turns it on, so nothing would ever notice the day a check started firing unconditionally — and the symptom would be this debugger refusing to talk to a provider somebody is trying to debug, which reads as the tool being broken rather than as a rule being enforced. It is therefore asserted explicitly, three ways:
tests/rfc9700_client.js requires an rfc9700.enabled() inside the
enclosing function of every call into a check, over both page modules;checked;tests/rfc9700_flows.js (RFC9700_FLOW=refused) loads the page with the
mode off and requires that nothing in the grant selector is disabled and
no report pane has been drawn.Four of the fifteen sections ask something of the client’s own posture rather than of its conversation with a provider:
| # | Rule | Where it lives |
|---|---|---|
| 11 | the client exposes no open redirector | client/server.js, /callback |
| 12 | a credential-bearing redirect is 303, never 307 | client/server.js |
| 14 | the pages refuse to be framed | client/server.js headers |
| 15 | browser messaging matches origins exactly | there is none in this workflow |
None of those is visible to an identity provider, so none of them can break a
flow against one. They are always in force. They appear in the catalogue as
rows with enforced: "always" so that the report says what is true rather
than only what the switch turned on, and each is asserted over the source that
holds it — those are exactly the properties a well-meaning edit removes
silently.
The headers are three, and the CSP is deliberately one clause:
Referrer-Policy: no-referrer
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
frame-ancestors and nothing else. Every page of this site carries inline
event handlers on nearly every control, so a default-src or a script-src
here would take the whole application out at once. RFC 9700 section 4.16 asks
for CSP Level 2 restricting frame-ancestors, and that is precisely what this
is; X-Frame-Options sits beside it for anything that does not implement the
CSP clause. The pages also carry <meta name="referrer" content="no-referrer">,
because the deployed static sites have no server of ours in front of them
and the header would not be sent there.
client/src/rfc9700.js holds REQUIREMENTS: one row per client-side
obligation, keyed to the fifteen-section checklist, carrying the level RFC 9700
states it at, how this client answers it, and a note saying why. Fifty-nine
rows at the time of writing.
enforced is one of four values and the distinction matters:
enforced — checked in mode, and a MUST-level failure refuses.detected — checked and reported; the client cannot enforce it, either
because the obligation is the server’s or because all a client can do is
observe what came back.always — in force whether or not the mode is on (the four above).no — deliberately not done, with the reason in the note. There is one
of these; see What is missing.The report, this document and the tests all read those rows rather than keeping lists of their own, so a check with no row is invisible to all three. Add a check, add a row.
Everything here is reversible, and that is a property to protect: a control
left disabled after the mode is switched off is indistinguishable from a broken
page, and it is the failure a test that only ever switches the mode on can
never see. tests/rfc9700_flows.js asserts the round trip.
title — the three Implicit variants and the two Hybrids that carry token
(section 1: no response type may return an access token from the
authorization endpoint), and Resource Owner Password Credentials (section 5,
which states it as a flat MUST NOT). OIDC Hybrid (code id_token)
survives, and that is the interesting half: it returns no access token from
the authorization endpoint, and its id_token is the code-injection defence
section 3 asks for.client_secret_basic is selected on all four panes that authenticate a
client — the header style rather than the body one.htu), so turning DPoP on without also selecting the front
end produces an unbound token and a red warning on the mode’s first use.
Both stay reversible, and turning either off yields a SHOULD row rather than
a refusal — RFC 9700 states sender-constraining at SHOULD.state, nonce and the PKCE verifier are regenerated per request rather
than per page load, which is what “transaction-specific” means: pressing the
button twice without reloading would otherwise send the same pair again.
Everything the response will be judged against is then frozen onto a
transaction record in sessionStorage — per-tab, dying with the browsing
session, which is what section 1 means by bound to the user-agent transaction.
localStorage, where this workflow keeps its configuration, is neither.
The record is frozen at the moment the request is built and nothing reads it back off the configuration afterwards. The fields on the page are editable while a request is in flight, and judging a response against a value changed after the question was asked refuses a server for answering correctly.
response_mode=form_post is added when two conditions hold: the metadata
advertises it, and this build has a backend to receive the POST. The second
is the one that is easy to forget — a deployed static site has no /callback
of ours at all, so a form_post response there would arrive nowhere and the
flow would stop with no error to point at.
/callback accepts a POST and answers 303, handing the parameters to the
page in the fragment. Three reasons for the fragment: a browser never sends
one to any server, so the same-origin hop leaks nothing further;
oauth2_oidc_2.js already parses fragments synchronously, which is how an
implicit response reaches it, so this needed no new machinery on the page; and
the mode removes it from the address bar as soon as the page has read it. The
alternative — holding the parameters in a map on the client server and handing
the page a claim ticket — would put state into a process that has none and buy
nothing the scrub does not already provide.
Two things had to change to make that work, and both were latent gaps rather than new requirements:
parseFragment() did not percent-decode. It never mattered, because
every value that had ever arrived in a fragment was an implicit response’s
base64url tokens, which contain nothing that needs escaping. iss and
state are not base64url. RFC 6749 section 4.2.2 requires the fragment to be
form-encoded, so decoding is what the specification asked for the whole time.#code field was filled from the query string only.
recreateUniqueGrantFlowElements() had a fragment fallback but only for the
OAuth2 code grant and the three hybrids, so oidc_authorization_code_flow
fell through both and the Token Request pane opened empty.Then, on the response itself: state must match and is single-use, so a
reload or a back button no longer matches; the RFC 9207 iss parameter must
match the stored issuer, and its absence is a refusal when the server
advertises authorization_response_iss_parameter_supported; the ID Token’s
iss must match too. A MUST-level failure hides the token request pane, since
exchanging a code from a response that failed one is exactly what sections 2
and 3 exist to prevent.
Then history.replaceState removes the whole response from the address bar and
from that history entry. Everything that reads a response parameter afterwards
answers from a snapshot taken just before the scrub, which is why
getParameterByName() and parseFragment() consult one.
No token from a response is used until the ID Token’s nonce has been
validated — and “used” includes rendering it, writing it to Token History,
and offering it to the UserInfo, introspection and refresh panes. Outside the
mode this workflow does not validate nonce at all; it decodes it for display
and for grouping the history. In the mode a mismatch discards the whole set.
A code is presented once: it is marked spent when the token request goes
out, not when it succeeds, because a code refused by the server is still a code
that has left this browser. A rotated refresh token spends the one it replaced.
Both lists live in sessionStorage beside the transaction, as hashes rather
than whole values — not a security boundary (the token is two keys away in
localStorage) but there is no reason for these lists to hold a live
credential.
The rest is reported rather than enforced, because the client can only observe
it: the audience on the access token, a grant wider than the request, whether a
token asked for with DPoP came back token_type=DPoP or quietly Bearer,
whether the server rotated the refresh token or sender-constrained it instead,
and whether the subject of a user token is confusable with the client_id.
private_key_jwt and mutual-TLS client authentication are not implemented
(requirement 6.3, enforced: "no"). RFC 9700 section 2.6 states asymmetric
client authentication at RECOMMENDED, and this workflow offers
client_secret_basic and client_secret_post only.
That is a credential mechanism rather than a check — a key-pair pane, an
assertion signed in the browser, and a third auth style threaded through
common/data.js and the api’s proxy — so it is a piece of work rather than a
rule, and half of one would be worse than a row that says so. The server side
is not what is missing: the mock STS accepts all six methods already, in its
own client_auth.js. What the mode does today is report it — when the
metadata advertises private_key_jwt or tls_client_auth and this client is
configured with a shared secret, a SHOULD row says so.
Two other absences are not gaps and should not be “fixed”:
detected rows so that a reader of the
report does not take it for a complete account of the section.tests/rfc9700_client.js asserts it rather than assuming it.Two files, and they ask different questions.
tests/rfc9700_client.js — node only, no browser, no services, never
skips. Forty-five checks: the catalogue’s shape, the mode-off contract, each
of the four check functions driven directly with inputs it chooses, and the
always-on posture asserted over the source. This is where the negatives live,
and they are most of the value — almost every rule in RFC 9700 only fires
against a misbehaving server, and a rule nothing exercises is a rule nobody
knows is broken.
tests/rfc9700_flows.js — Selenium, both sides compliant. Five jobs:
RFC9700_FLOW |
What it runs |
|---|---|
refused |
the six disabled grants and their reasons, a grant forced past the selector, a request with no metadata, the mode-off contract in the browser, and that turning the mode off gives every control back |
oidc_authorization_code_flow |
end to end, then the same code a second time |
authorization_grant |
as above |
oidc_hybrid_code_id_token |
as above — the one hybrid that survives |
client_credential |
that the mode invents no findings about an authorization request this grant never makes |
Each begins with the always-on posture over plain HTTP — the headers, both
callback methods answering 303, the form_post landing putting the response in
a fragment and not a query, and /callback refusing to take its destination
from three parameter names that usually work.
Against a deployed static site that posture has a second shape, and the flow
jobs are told which one they found. There is no Express there, so /callback
is the shim client/build.js writes and there is no POST landing at all — the
two requirements the 303 carries (11.1 and 12.1) are read off that shim
instead, and requirement 10.4 is asserted inverted: the request must NOT ask
for form_post, because backendAvailable is false and the answer would
arrive nowhere. checkAlwaysOnPosture() returns which landing it probed and
runFlow() branches on it. It did not until 2026-08-27, and the three flow
jobs failed every ./remote-run-tests.sh run on a property the client had
right.
Each also refuses to run against a permissive STS, by name: a job pointed at one would exercise the client’s checks against a server that never disagrees with them, which is the one arrangement that proves nothing.
The existing twelve oidc_flows.js jobs run the OAuth2/OIDC matrix with both
sides permissive. These run it again with both sides compliant, and the two
passes are not substitutes: permissive asks whether the debugger still works
against a server implementing none of this, compliant asks whether it meets one
that does.
It runs against a trust realm on the same mock STS the permissive pass
uses — .../realm/rfc9700 — rather than against a second instance.
It used to need a second one, and the reason is worth keeping because it is
the reason the arrangement looks the way it does. oauth2.rfc9700 derives
global.https in that service, so turning the mode on binds the main port as
TLS; a bound socket is settled once, when the process starts, so the flag was
restart-only and one process could not serve the permissive pass and the
compliant one. The second instance was sts-rfc9700, the same image as sts
with a different environment, on https://localhost:8091 locally and
https://sts-rfc9700:8081 on the bridge stack, restating all seven of the
mock’s listeners so they did not collide with the first instance’s under host
networking.
mock-sts closed that in 2026-08-25 by marking oauth2.rfc9700
realmRuntime: restart-only for the PROCESS, settable on a trust realm.
The argument is one sentence — a realm binds no socket. It answers on the
port the process already opened, in the scheme that port was opened in, so the
only thing a realm changes is which checks the requests arriving under its
prefix meet. oauth2_bcp.js’s enabled() already read the setting per request
through the realm layer, so nothing in that module was edited for it.
So one process now serves both:
| URL | what it is | |
|---|---|---|
| permissive | https://localhost:8081/oauth2/authorize |
the twelve oidc_flows.js jobs — a server implementing none of this |
| compliant | https://localhost:8081/realm/rfc9700/oauth2/authorize |
the five rfc9700_flows.js jobs — a server enforcing all of it |
Each has its own issuer, its own signing key, and its own authorization codes, tokens and sessions. What they share is the embedded directory, which is what a realm deliberately does not separate.
THE SCHEME IS THE PART THAT MOVED TO THE PROCESS, AND IT IS THE WHOLE COST OF
DROPPING THE SECOND CONTAINER. A realm cannot bind one, and this pass is only
honest over TLS — requirement 8.1 is that every configured endpoint is https,
and the client under test enforces it. So the mock is started with
STS_HTTPS=true on every stack (local-tests.yml,
docker-compose-run-tests.yml, keycloak-tests.yml) and every STS URL in
the suite is https: WSTRUST_STS_URL, WSFED_STS_METADATA_URL,
SAML_STS_METADATA_URL, SAML11_METADATA_URL, STS_TLS_URL, STS_URL,
SCIM_BASE_URL, SPIFFE_BUNDLE_URL, and the defaults baked into
client/src/env/local.js and client/src/env/docker-tests.js.
STS_HTTPS rather than STS_OAUTH2_RFC9700: the latter would put the DEFAULT
realm into the mode as well, and the permissive pass is the other half of the
matrix. That setting exists precisely to separate the scheme from the checks —
mock-sts’s own config.js says so on the global.https row.
The realm is held in memory by a service that persists nothing, so there is
nowhere to declare it. configureStsRfc9700Realm() in common/common.sh
creates it once the mock is answering:
POST /admin-api/realms/create
{"id":"rfc9700","name":"RFC 9700 mode","overrides":{"oauth2.rfc9700":true}}
The override travels with the create rather than in a second call, so the
realm is never briefly permissive — a test that started in that window would
pass while proving nothing. A re-run against a mock that is still up gets
“already defined”, which is success; the function then sets the override again
and finally asks GET /realm/rfc9700/oauth2/rfc9700 whether the mode is
actually on, which is the same document the jobs read to refuse a permissive
server.
Setting RFC9700_STS_URL turns on the five jobs in tests/run-report.js;
leaving it unset skips them. All three launchers now set it, and the third
is new:
| stack | file | URL |
|---|---|---|
./local-run-tests.sh |
local-tests.yml |
https://localhost:8081/realm/rfc9700 |
./docker-run-tests.sh, ./run-coverage.sh |
docker-compose-run-tests.yml |
https://sts:8081/realm/rfc9700 |
./remote-run-tests.sh |
keycloak-tests.yml |
https://localhost:8081/realm/rfc9700 |
The last row is a gap that closed as a side effect: a live-site run starts one
mock, so RFC9700_STS_URL was never set there and all five jobs were skipped on
every deployed-target run.
The create needs an access token, and forgetting one reads as an old mock.
The mock closed /admin-api on 2026-09-09, so mintAdminApiToken() has to run
before configureStsRfc9700Realm() — the two curls carry the bearer when
STS_ADMIN_API_TOKEN is set and go out bare when it is not. Bare, both answer
401, the function reports the realm as absent, and the five jobs are never
scheduled; the message it prints blames an sts/ submodule too old to have
realmRuntime, which is the other way to get here and the wrong answer in this
one. That happened on the containerized stack between the bump and 2026-09-10:
tests/run-tests-in-container.sh called the second without the first, and
mintAdminApiToken() could not have helped anyway — it looked for the tool at
tests/tools/, which is a checkout’s layout and not the tests image’s
(/usr/src/app/tools), and returning 0 for “no tool here” is what made it
silent. The trace it left was not a failing test but about 230 frontend lines
that stopped being covered.
That call replaced a worse probe. local-run-tests.sh used to decide
whether to schedule these jobs by looking for oauth2_bcp.js in the sts/
submodule — a PATH test, which silently took its else branch when mock-sts
reorganised its directories and printed a confident, wrong explanation while
quietly dropping five jobs. Asking the running service answers the same question
about the code that is actually running, and answers it about realms too, which
a file test could not have seen at all. When it fails, the launcher says so and
leaves the other 180 jobs to run — and the jobs themselves still refuse a
permissive server by name, so a realm that half-worked cannot pass quietly.
The mock’s certificate is self-signed and regenerated on every start. That
is deliberate on its side and is the whole difficulty on ours: nothing can hold
an anchor for it ahead of time — it cannot be committed, baked into an image, or
installed by hand, because it does not exist until the service is up. So it is
fetched from GET /tls/server-certificate once the service answers, and
installed for the three things that verify:
| consumer | mechanism | where |
|---|---|---|
| the test scripts (node) | NODE_EXTRA_CA_CERTS |
trustStsCertificate(), common/common.sh — inherited by every job run-report.js spawns |
| the browser | --ignore-certificate-errors-spki-list |
addStsTrustFlags(), tests/browser_flags.js, from STS_SPKI_PIN |
the api service |
NODE_EXTRA_CA_CERTS |
api/sts_truststore.sh, the image’s entrypoint, from STS_CERT_URL |
Every one of those adds an anchor. None of them turns verification off, and
that distinction is load-bearing here: NODE_TLS_REJECT_UNAUTHORIZED=0 and
Chrome’s blunt --ignore-certificate-errors would each have been one line and
would each have disarmed api_ssrf_guard.js, api_tls_probe.js and
url_safety_schemes.js, which exist to assert that a bad certificate is
refused. The SPKI pin is exact-key: the certificate the same mock generates on
its next start still meets an interstitial.
The fetch itself is made without verification (curl -k), which is the ordinary
bootstrap for a per-start certificate rather than a hole — it is the same act as
trusting the PEM the endpoint hands back, done one step earlier. mock-sts says
so at /tls.
Never start a mock STS by hand on 8081. It is the port the whole suite
reaches the mock on. On 2026-08-20 a hand-started instance left running from a
sibling checkout took out 71 of 184 tests and not one of them named the
cause: the compose sts container hit EADDRINUSE and exited, WS-Trust timed
out waiting for a response page, Kerberos reported ECONNREFUSED on 88, and the
jobs that probe their dependency first (LDAP, PKI mutual-TLS, the DPoP server
checks) reported PASS while quietly skipping. requireStsReachable() in
common/common.sh runs before the suite, probes the other scheme when the
expected one does not answer, and says so in one line — and the scheme it now
expects is https, so anything answering plain http on 8081 is somebody
else’s mock.
What it waits on. The sts/ submodule must point at a mock-sts commit
carrying realmRuntime on oauth2.rfc9700. Before that bump the realm cannot
be put into the mode at all: POST /admin-api/realms/set refuses it with the
restart reason, configureStsRfc9700Realm() says so by name, and the five jobs
are skipped rather than run against a permissive server.
An RFC 9700 authorization server compares redirect_uri by exact string
match against URIs it has been given, so the debugger’s callback has to be
registered. tests/rfc9700_flows.js does it through the mock’s management API
(POST /admin-api/config/set, oauth2.redirectUris) before it opens a
browser. That is not a workaround for the test — it is the registration step
the specification requires, and doing it in the open is what makes the pairing
honest.
The browser’s side of the certificate is covered above and is no longer this
job’s problem alone: browser_flags.js gives every browser job the mock’s SPKI
pin, because the whole suite meets that certificate now rather than these five
alone.
The arrangement described here is retired, and the defect it exposed is
not — which is why the section stays. The api and the client serve https
on every stack now (common/tls_listener.js), so
https://client:3000/callback satisfies requirement 1.3 on its first
clause (a redirect_uri is https) and the containerized stack no longer bends
anything: RFC9700_REDIRECT_URI is unset everywhere, and the
--host-resolver-rules=MAP localhost:3000 client:3000 that made a loopback
callback reachable from that origin is gone with it. The general lesson is
worth more than either: a requirement about SCHEMES cannot be satisfied by a
test that changes an ADDRESS, and the durable fix was to serve the scheme the
requirement asks for rather than to find an address it happened to permit.
Moving the containerized stack’s callback to a loopback origin (as
tests/run-tests-in-container.sh then did) exposed a real defect in the client,
and it
is worth recording because nothing about the symptom named it: all three flow
jobs signed in, came back, drew a clean Authorization Response report — and
then the token endpoint answered 400, with the token-response report never
drawing at all. The only other thing in the log was a browser console line
saying a resource had failed to load.
Step 2 read redirect_uri out of localStorage and healed it: any value not
beginning with appconfig.uiUrl was replaced by that origin’s own /callback.
On step 1 that is right — it is the field a user configures, the heal happens
before anything has been sent, and it is what re-defaults the field when the
site moves. On step 2 it is wrong by construction: the value there is not a
setting but the redirect_uri the authorization request has already sent,
and RFC 6749 section 4.1.3 requires the Token Request’s copy to be identical to
it. So the exchange carried http://client:3000/callback for a code issued to
http://localhost:3000/callback, and the mock refused it with invalid_grant
— correctly, and in mode unconditionally, since RFC 9700 makes that comparison
mandatory rather than one made only when the client volunteered the value
(checkTokenRequest() in sts/oauth-oidc/oauth2_bcp.js, requirement transaction-bound).
Step 2’s heal now replaces only a value that could not be sent at all — absent,
or with no scheme (isAbsoluteRedirectUri() in client/src/oauth2_oidc_2.js).
Step 1’s is unchanged. The bug was never specific to this mode or to the test
harness: any deployment whose callback is not on the origin serving the
pages had the same guaranteed invalid_grant, and RFC 8252’s loopback
exception — requirement 1.3 — makes that an ordinary arrangement rather than an
exotic one.
Both are un-gated because gating them would mean two divergent settings for one behaviour, and neither restricts anything:
/callback answers 303 where it used to answer 302. For the GET the two
are equivalent in practice; for the POST they are not, and the rule is a
property of the endpoint rather than of one of its methods.localStorage and the client server cannot read it, so a
condition there could only ever be a second, divergent setting.One consequence already surfaced: tests/oauth2_sts_endpoints.js and
tests/sts_dpop.js pinned the mock’s sign-in redirect at 302 exactly, and
the mock now answers 303 (its own section 12 work). Both now accept either and
additionally assert it is not 307 — which is the code RFC 9700 actually
forbids, and which neither test had ever checked.