id-proto-debugger

RFC 9700 compliance mode — the client side

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.

Why it is off by default, and why that is not timidity

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:

What is not behind the checkbox, and why

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.

The catalogue is the interface

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:

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.

What the mode actually does

To the page

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.

To the request

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.

To the response

/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:

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.

To the tokens

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.

What is missing

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”:

The tests

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.

Running the second pass

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 has to be created, and that is the capability probe

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 certificate, and why it is a truststore rather than a switch

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.

One thing the pairing needs that the permissive pass does not

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 step-2 heal that sent a redirect_uri the code was not issued for

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.

Two changes that are not gated and might surprise

Both are un-gated because gating them would mean two divergent settings for one behaviour, and neither restricts anything:

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.