id-proto-debugger

The mock STS (sts/, a submodule)

Read this before changing anything about the sts/ service, its build, or a test that drives it.

This lives in docs/ rather than in sts/CLAUDE.md because sts/ is somebody else’s checkout — it is the rcbj/mock-sts submodule, and that repository has a CLAUDE.md of its own. A file written into sts/ from here would be an edit to the submodule, which is exactly the mistake the first paragraph below warns about.

/sts/ — A mock Security Token Service used by the test suite. Its code is no longer in this repository: since 2026-08-04 it is rcbj/mock-sts, linked here as a submodule on branch main, and what this repository holds is the link. Everything below still describes what the service does — the submodule is a copy of what was here, plus /oauth2/userinfo and OIDC Discovery — but change it in that repository, not here: an edit under sts/ is an edit to somebody else’s checkout, and git status shows it as a modified submodule rather than as a modified file. Three consequences to know before touching the build. The compose files build it with context: ./sts (not this repo’s root with dockerfile: sts/Dockerfile), because the submodule’s Dockerfile writes its COPY paths relative to its own root; it carries its own contexts/, so the STS build no longer reads client/src/contexts, though tests/Dockerfile still does for the wallet’s copy; and an uninitialised submodule is an empty directory, which compose reports as failed to read dockerfile and the tests image as COPY sts/common/vendored/bbs2023.js: not found — neither of which names a submodule, so every launcher calls requireMockStsCheckout() (common/common.sh) before building and the CI workflows check out with submodules: recursive. recursive, not true, and --init --recursive, not --init: since the 2026-08-18 bump the mock STS has a submodule of its own — node-ldapjs, which its package.json takes as "ldapjs": "file:node-ldapjs" for sts/ldap/ldap_server.js — and one level of init stops short of it. That one fails differently from every other case here and worse: an uninitialised submodule is an empty directory, so the COPY succeeds, the image builds, npm installs a package with no main, and the container dies at startup with Cannot find module 'ldapjs' — a message naming a package rather than a submodule, arriving long after the build that would have been the place to say so. requireMockStsCheckout() therefore tests for sts/node-ldapjs/package.json as well as for sts/Dockerfile and sts/server.js, both before it decides the checkout is already populated and after it has run the update. The build uses the recorded commit; MOCK_STS_TRACK_REMOTE=1 takes the tip of main instead, at the cost of a run that is not repeatable. It is split across eleven modules (it was one 4,489-line server.js until 2026-08-03; eight protocol families in one file meant no way to see what was in it short of reading it), and since 2026-08-23 those modules live in FOLDERS rather than in the package root — common/, common/vendored/, kerberos/, oauth-oidc/, oid4vc/, saml/, scim/, authn/, ldap/, tls/, ws-trust/, ws-federation/, mgmt-api/, admin-ui/ and spiffe/. That move reaches into this repository in four places and every one of them is a path written down here: tests/Dockerfile’s COPY sts/… lines (whose destinations must MIRROR the mock’s folders, because a module that used to require ./app now requires ../common/app), tests/module_paths.js’s mockStsModule() (which searches for a module by NAME rather than holding a folder table, so the next reshuffle costs nothing), common/krb5/sync-to-mock-sts.sh (which now writes the vendored codec into whichever folder holds krb5_kdc.js), and local-run-tests.sh’s RFC 9700 probe (which looked for sts/oauth2_bcp.js and would otherwise have SKIPPED five jobs with a confident, wrong reason). It also added common/config_file.js, which makes a relative CONFIG_FILE absolute once so that fourteen modules in fourteen directories still resolve it the same way. server.js is now the shell — it requires the modules and listens: helpers.js (the log, the keys, and the helpers more than one protocol needs), app.js (the express app and every middleware), saml2.js, wstrust.js, oauth2.js, vc_configs.js, vc_offers.js, vc_did.js, vc_issuer.js, vc_verifier.js, sts_metadata.js.

Three things about that split are load-bearing. Requiring a module registers its endpoints — each does app.get(...) at its top level against the shared app from app.js, rather than exporting a register() function, which is what let 4,400 lines of handlers move without being re-indented; so the require order in server.js is the route order, and the middleware has to live in app.js because express applies it only to routes added after it. vc_configs.js and vc_offers.js exist to break cycles, not to group code: the credential configurations are read by both the issuer and the authorization server, and the Credential Offer’s pre-authorized codes are minted by the offer pages and redeemed at the token endpoint, so that state cannot live in either OID4VCI or OAuth2 without those two requiring each other. A require cycle in node does not fail loudly — it hands back a half-initialised module whose exports are undefined, and the symptom arrives later as something that is not a function. Five helpers (userFor, parseBody, oauthError, vciError, signJwt) are in helpers.js for the same reason and not because they are especially general.

It speaks WS-Trust; answers the WS-Federation 1.2 Web (Passive) Requestor Profile (sts/ws-federation/wsfed.js, added 2026-08 — GET|POST /wsfed dispatching on wa, the sign-in screen, /wsfed/rp as a mock relying party, and signed federation metadata at the AD FS path /FederationMetadata/2007-06/FederationMetadata.xml), which since then is the second IdP the WS-Federation suite runs against beside the Keycloak 8.0.1 side-car — see docs/wsfed.md for why both are needed and where they differ; hosts a bare-minimum OID4VCI Credential Issuer (/.well-known/openid-credential-issuer, /.well-known/jwt-vc-issuer, /oid4vci/nonce, /oid4vci/credential) that mints SD-JWT VCs per RFC 9901; hosts the issuer side of an OID4VCI Credential Offer for use cases H.1, H.2 and H.3 (/issuer, /issuer/offer[?mode=cross-device|deferred] which renders a real QR code and a Transaction Code, /oid4vci/credential-offer/:id, /oid4vci/deferred_credential, sending the End-User to OID4VCI_WALLET_URL); implements the pre-authorized code grant with tx_code enforcement, authorization_detailscredential_identifierscredential_identifier (with the section 8.2 mutual exclusion enforced) including its optional claims member — a wallet may ask for a subset of the claims the metadata advertises, at the authorization endpoint or, for the pre-authorized flow that has no authorization request, in the Token Request (section 6.1.1), and the credential endpoint issues only those; an unadvertised path, a claim described twice (Appendix A.3) and a malformed claims path pointer are each refused with invalid_authorization_details, batch issuance (one c_nonce per request, not per proof), credential response encryption (RSA-OAEP-256 JWE), and a Notification Endpoint that validates and records what it is told (plus a non-spec GET /oid4vci/notification/:id so a test can check the notification landed); hosts a mock OID4VP Verifier (/oid4vp/verifier and /oid4vp/start build an Authorization Request with response_type=vp_token, a DCQL query, a fresh nonce and response_mode=direct_post, by value or as a signed Request Object at /oid4vp/request/:id, with a QR screen for cross-device; POST /oid4vp/response is the Response URI and verifies the presentation properly — issuer signature, every Disclosure’s digest against _sd, the KB-JWT’s typ/alg/nonce/aud/iat/sd_hash and its signature against the credential’s cnf.jwk, the validity window, and whether the claims it asked for arrived — recording a per-check verdict readable at the non-spec GET /oid4vp/result/:state); acts as a mock OAuth 2.0 authorization server — an RFC 8414 metadata document plus every endpoint it advertises (/oauth2/authorize, /oauth2/token, /oauth2/introspect, /oauth2/revoke, /oauth2/register, /oauth2/jwks); publishes GET /admin/sts-metadata, a web page listing every endpoint it registers with the HTTP methods each accepts, and every specification it implements with an honest coverage note (see below); and publishes its own DID (/.well-known/did.json, a did:web document with the RS256 key and the BBS key as a Multikey, plus /.well-known/did-configuration.json, the DIF Domain Linkage Credential that proves the DID and the origin are one entity — see docs/dids.md). OID4VCI_WALLET_URL must be the base URL the BROWSER uses for the debugger (OID4VP_WALLET_URL falls back to it): the issuer’s Credential Offer pages and the verifier’s request pages hand the End-User back to the wallet by appending /vc-issuance-1.html or /vc-presentation-1.html to it. Its default, http://localhost:3000, is never right any more and every stack here now sets it explicitly: the debugger’s client serves https as of 2026-08-31, so that default names an origin nothing is listening on even on the host-networking stack where the browser and the wallet do share a host. local-tests.yml and docker-compose.yml set https://localhost:3000, docker-compose-run-tests.yml sets https://client:3000 (the browser runs in the tests container there), and remote-run-tests.sh derives it from the target. The submodule’s default is unchanged, because that is somebody else’s checkout. Get it wrong and the hand-off lands on an unreachable host: the URL still contains the wallet page (only the origin is wrong), so a urlContains wait passes and the failure surfaces as a bare timeout waiting for an element on a page that never loaded. Both mock-based SD-JWT tests now check the origin after the hand-off and name the variable. It selects its configuration (log level) with CONFIG_FILE like the other services (sts/env/*.js), and at the default debug level it logs every endpoint call (path, request/response headers and bodies, status, elapsed time) and every assertion/JWT/SD-JWT VC before and after signing or encryption. The authorization endpoint hands an unauthenticated request to the authentication service (/authn/login, Keycloak’s field ids) and the username typed there becomes the identity in every token; no password is ever checked. See the section below — the screen stopped being part of /oauth2/authorize on 2026-08-19. All tokens are RS256 JWTs signed with the STS key, so they verify against the advertised JWKS. That key is regenerated on every start, and two consequences follow: its kid is derived from the key material (sts-mock-<thumbprint>) rather than being a constant, so two instances — a stale container beside a fresh one — cannot both claim the same kid over different keys and make “the signature does not verify” look like a corrupt document instead of the wrong issuer; and every document that carries or describes the key (RFC 8414 metadata, OID4VCI credential issuer metadata, jwt-vc-issuer, the JWKS, the DID document and the DID Configuration) is served Cache-Control: no-store, because a cached copy outlives the key it describes. The debugger’s full OAuth2 / OIDC workflow can therefore run against it with no identity provider.

One instance, two authorization servers, and an HTTPS port

Its main port is HTTPS in every stack in this repositorySTS_HTTPS=true on the sts service in local-tests.yml, docker-compose-run-tests.yml and keycloak-tests.yml — and there is exactly one instance of it, where there used to be two.

The two are the same fact. The suite runs the OAuth2/OIDC matrix twice, once against a permissive server and once against one enforcing RFC 9700, and that second server used to be a container of its own: oauth2.rfc9700 derives global.https over there, so turning the mode on binds the main port as TLS, and one process could not bind it two ways. mock-sts closed that by marking that flag realmRuntime — restart-only for the process, settable on a trust realm, because a realm binds no socket. So one process answers permissively at /oauth2/authorize and enforces the BCP at /realm/rfc9700/oauth2/authorize, each with its own issuer, signing key, codes and tokens, sharing the embedded directory that a realm deliberately does not separate.

What a realm cannot bring with it is a scheme, and the compliant pass is only honest over TLS, so the scheme moved to the process and every STS URL in this suite is https. The certificate is self-signed and regenerated on every start, so nothing holds an anchor for it ahead of time: it is fetched from GET /tls/server-certificate once the service answers and installed as an anchor for node (NODE_EXTRA_CA_CERTS), for Chrome (an exact SPKI pin) and for the api container (its own entrypoint). All three add an anchor; none turns verification off, because three jobs here exist to assert that a bad certificate is refused.

docs/rfc9700.md argues the whole of it, including who creates the realm and what happens when the sts/ submodule is too old to have it.

The token endpoint’s deliberate divergence: a redeemed authorization code is replayed, not refused

Added 2026-08-18, in oauth2.js, and it changes an answer this service used to give, so read it before writing a test against the authorization_code grant.

RFC 6749 section 4.1.2 says an authorization code is single use and section 10.5 says a second presentation SHOULD invalidate what the first one issued. This mock implemented that in the shortest possible way — authzCodes.delete(code) on the line after the lookup — and the result was that every second Token Request carrying that code got invalid_grant / Unknown or already-used authorization code, a sentence that is equally true of a stolen code, a reloaded oauth2_oidc_2.html, a double-submitted form, and a client retrying after the first attempt was refused for a bad code_verifier. It named none of them, and the last case is the perverse one: the check burnt the code it refused, so the corrected request — the one that had just been fixed — was answered by talking about reuse instead of issuing tokens.

Three things are different now, and only the second is a divergence from the RFC:

What this is not: it is not a way to redeem somebody else’s code (anything that differs is refused), it is not a longer-lived code (the window is the code’s own), and it does not touch the pre-authorized code grant, whose single use is a property of the Credential Offer being tested and is asserted by tests/sd_jwt_vc_issuance.js. tests/oauth2_sts_endpoints.js pins all of the above — the identical replay, the two mismatches, the unknown code, and the fact that a refused PKCE check leaves the code alive.

The Kerberos KDC’s one deliberate divergence knob

KRB5_S2KPARAMSomit (default) or send.

s2kparams in PA-ETYPE-INFO2 is optional (RFC 4120 section 5.2.7.5), and when it is absent the client applies the etype’s own default: 4096 iterations for the AES profiles, per RFC 3962 section 4. This mock used to send it always, and tests/krb5_as_exchange.js asserted that it did — until a capture from a real Windows Server 2025 domain controller on 2026-08-16 showed Active Directory omitting the field entirely (tests/captures/windows-server-2025.json).

That is the direction of difference that hides a bug rather than causing one. A client which required s2kparams — dereferenced it, or refused an entry without one — passed the whole suite against this mock and would then have failed against every real domain in the world, reporting a wrong password. The mock was teaching the client a habit no real KDC supports. So the default is now AD’s behaviour, KRB5_S2KPARAMS=send restores the old one, and the test covers both. Key derivation is unaffected either way: longTermKey() has always passed a null s2kparams and therefore always used the profile default, so what the switch changes is only what the KDC advertises.

One place the mock still differs from Windows on purpose. A service ticket’s PAC here carries PAC_ATTRIBUTES_INFO (17) and PAC_REQUESTOR (18); the captured Windows service ticket carries neither, and its seven buffers are 1, 6, 7, 10, 12, 16, 19. That divergence is left alone rather than “fixed”: tests/krb5_tgs_ap.js uses PAC_ATTRIBUTES_INFO to tell apart the three PAC-request states, which is a real property worth testing, and the Windows side of it is a single observation of one service ticket. tests/krb5_windows_vectors.js pins what Windows actually sent, so if the two ever need reconciling the evidence is on file.

Since 2026-09-01 that service asks before it issues. The first time a given username signs in to a given client_id for a given scope, /oauth2/consent is drawn listing the scopes that are new; nothing is issued until somebody presses Allow, and Deny returns access_denied to the client. oauth2.consentRequired governs it and is ON by default, which is the only policy in that service that is — the argument being that consent is not a refusal but the screen every real authorization server draws on a first sign-in, and a client that has never met one has never run the code that survives it.

For this suite that means one more hop between the sign-in screen and the authorization response, in the middle of a redirect chain several jobs walk by hand. tests/consent_screen.js is the shared module that passes it — settleAuthorization() for the jobs that follow redirects with redirect: "manual", passInBrowser(driver, By) for the Selenium ones — and docs/test-suite-map.md carries the whole note, including why neither of them asserts that the screen appeared and which two jobs are deliberately not part of the sweep.

Two things about the feature are worth knowing when reading a failure. The answer is recorded per (person, application, scope) on the person’s own entry under ou=users, so the second sign-in in a run is silent and the first is not — a job whose usernames are minted per run meets the screen every time, and one that reuses a name meets it once. And oauthGlobalConsent on an application’s entry consents a scope for everybody who signs in to it without writing anything about anybody, which is what krb5_mit_client.js registers its application with rather than teaching curl to press a button.

The authentication service is its own endpoint

Until 2026-08-19 the sign-in screen was rendered inside GET /oauth2/authorize: no session meant a 200 with the login form in the body, at the authorization endpoint’s own URL. It is now /authn/login — its own endpoint, in its own module (authn.js), which owns the session store, the screen and the WebAuthn second factor beside it. The protocol endpoint redirects:

GET /oauth2/authorize?response_type=code&client_id=…   no session
  302 -> /authn/login?authn=<id>                        the request is stashed with a return URL
  POST /authn/login  username=alice                     Set-Cookie: sts_mock_session=…
  302 -> /oauth2/authorize?<the original query, minus prompt>
  302 -> https://localhost:3000/callback?code=…         answered per spec

What this changes for a test driving the flow over HTTP — the browser tests are unaffected, since a browser follows the redirect and still finds #username, #password and #kc-login where they were:

tests/oauth2_sts_endpoints.js and tests/sts_dpop.js are the two files here that drive it at that level, and both were updated with the change. WS-Federation keeps a sign-in screen of its own (section 13.2.1’s cross-site POST cannot survive a redirect chain, and SameSite=Lax keeps the cookie off it), but it lands in the same session — sign in at either and the other is signed in too, which tests/wsfed_sso.js covers.

The mock STS’s index of itself

GET /admin/sts-metadata answers “what does this thing speak, what can I call, what may I call it with, and which specification is it pretending to implement” — a page the service needed once it had grown to thirteen protocol families across twenty-six modules. ?format=json gives the same document machine-readably, and the Download button at the top of the page is that URL asked for as a file.

It was GET /sts-metadata until 2026-08-24, and it is a page of the admin console now. Three things came with the move. It is drawn by admin.js’s page(), so it has the console’s sidebar — it used to be a cul-de-sac with no way back to anything — its breadcrumb and its gate banner, and sts/sts_metadata.js now builds only the body (a second <style> inside <body> is markup no validator accepts, so its classes live in admin.js beside .tile’s). It is behind admin.authRequired, on by default: a browser with no session is sent to /authn/login and a ?format=json caller is refused 401 login_required rather than redirected, which is why the mock’s own sts-metadata test now signs in the way its admin_api.js does — anything of yours that fetched the old path unauthenticated needs the same, or ADMIN_AUTH_REQUIRED=false. And fifteen protocol cards sit at the top: OAuth2/OIDC, Federation, SAML 2.0, SAML 1.1, WS-Federation, WS-Trust, Kerberos, SPNEGO, SPIFFE, SCIM, Shared Signals, LDAP, PKI/X.509, WebAuthn/CTAP and Verifiable Credentials. That list is the one part of the page that cannot be derived — SAML 2.0 and SAML 1.1 register no route at all, and four of the others live mostly on raw sockets — so three drift checks keep it honest, the sharpest being a group of endpoints no card claims, which is what a sixteenth family added without a card looks like.

The endpoint list is read from the running Express router, not written down. That is the whole design: a hand-kept list of endpoints in a file beside the endpoints goes stale the first time somebody adds a route, and the failure is silent in the worst direction — the page still looks complete. app._router.stack is walked per request (not at require time, where the answer would depend on module load order) and the table in sts_metadata.js only supplies the name and the description for a path the router reports. Both kinds of drift are then reported on the page itself and fail the mock’s own sts-metadata test:

The drift check earned its keep immediately: on first run it caught the OPTIONS * CORS preflight (registered by app.options, described nowhere) and a reference to a spec id that did not exist. The test additionally catches an idle claim — a specification listed that no endpoint links to — which found two, rfc6750 and the RDF canonicalization used by Data Integrity, both genuinely implemented and both unlinked.

Each path is a link to that path — but only where that is honest, which is 22 of the 41. A link is issued as a GET, so a path the router answers only for POST would land the reader on Express’s own Cannot GET /oauth2/token (reads as a broken service), and a route pattern carrying a :parameter or a * is not the address of anything. Those are listed unlinked with the reason shown — “POST only”, “takes :id”, “wildcard” — because that reason is the most useful thing on the row. The four followable endpoints that do something when clicked (/oauth2/authorize, /oauth2/logout, /issuer/offer, /oid4vp/start) carry an effect note; the first of those answers 400 when followed bare, since it needs client_id and redirect_uri. Links are root-relative so they follow whichever host the page was reached at, and open in a new tab so the index survives the click. The mock’s own sts-metadata test follows every link and fails if one does not reach a handler, which is what stops the page advertising a dead one.

Two details worth knowing before changing the test. A 404 is ambiguous and the distinction matters: several endpoints answer 404 correctly for a resource that does not exist (an unknown offer id, an unknown presentation state), which proves the route is registered, while Express’s own 404 for an unregistered path is an HTML page reading Cannot GET /path. Treating them alike either fails on healthy endpoints or passes on missing ones. And the coverage notes must start full, partial or mock and say what is missing, because a list of twenty-seven specifications that did not mention that this service checks no passwords and validates no access tokens would be the most misleading thing in the repository.

The management API — the admin console with the HTML taken off

GET /admin-api is every /admin page’s ?format=json view and every one of its forms, at a path a script can use, with an OpenAPI 3.1 document at /admin-api/openapi.json and an explorer that calls it at /admin-api/docs. Thirty-seven operations across nine tags: service status, metrics, users, groups, issued tokens with their six revocations, the four custom claim sets, the credential claim set, what the mock OID4VP Verifier asks for, and — since the configuration work below — every setting the service has. Added 2026-08-19.

It exists because reading was never the problem and changing was. Every console page has answered ?format=json since it was written; the forms were the half a caller without a browser could not reach, leaving a test either parsing a 303’s query string for the message or knowing which hidden input a particular form carried.

The rule it is written under is about the future: a control added to /admin gets an operation on /admin-api in the same commit. An API covering eight of nine controls is worse than one covering none — the ninth is found by somebody who has already written the code that assumed it. Three things hold that up, and only two of them are code:

Four POST routes serve twenty-four URLs. Express registers /admin-api/tokens/:action once and the document lists the six real URLs behind it, each with its own body schema and example. One pattern keeps GET /admin/sts-metadata to one row per resource — that page reads the router, and twenty-four rows of near-identical prose would bury the rest of the service — while the document describes addresses a caller can use. An unknown action is not a 404: it reaches the console’s own handler and comes back as its refusal naming the ones that exist, which is both the friendliest error and the sentence the parity check reads.

/admin-api/docs is the only page in that service with a script on it, and it is the one thing this costs. app.js sets script-src 'none' service-wide; the explorer relaxes it on its own two routes in exactly two clauses (script-src 'self', plus connect-src 'self' so the page can call the API it documents), with default-src 'none' untouched and the console next door still script-src 'none' — which the test asserts, because a middleware change that widened the exception would show up there first. The script is a separate resource precisely so 'self' suffices; an inline block would have needed 'unsafe-inline', which is the clause that would make the relaxation matter.

It is the mock’s own explorer rather than Swagger UI, weighed rather than skipped: swagger-ui-dist is 11.7 MB with an install-time telemetry dependency, in a service that is deliberately dependency-light and whose image builds in containers with no network beyond the registry — against a familiar look for an API with no authentication, no OAuth flows and nobody generating a client from it. admin_api_explorer.js is ~250 lines, has no dependency, and shows the equivalent curl line beside each operation, which is what an operator of a mock actually copies. It is the one file in that repository that is not a node module: admin_api_docs.js reads it off disk and serves it verbatim, so it carries no require and no process — the same trap client/src/coverage_beacon.js fell into here, and the mock’s own admin_api.js checks for it the same way.

IT WAS NOT PROTECTED AND SINCE 2026-09-09 IT IS, which reverses what this paragraph said for as long as it existed — the argument it made for the API being open survives as the argument for the OFF SWITCH, so read both halves. What it is now: every call presents an OAuth 2.0 access token this service issued, audienced to this API, carrying admin:read for a read and admin:write for anything that changes state, verified against the DEFAULT realm’s key wherever it is reached (a per-realm signing key would let anybody who can create a realm mint that realm’s administrator credential). One middleware on the base path, so the 232 operations are covered by construction rather than by 232 remembered checks. The API explorer moved with it, from /admin-api/docs to /admin/api-explorer — a page of the console behind its session and roles, because a browser navigating to a URL carries no token, so the one page written to be opened in a browser had become the one page a browser could not open.

The console next door has been gated since 2026-08-24 and the two credentials are different on purpose: a console session is not an API credential and a token is not a console session. admin.authRequired ships on — every /admin page and form needs a browser sign-on session from /authn/login and one of two console roles, and a caller asking for ?format=json is refused 401 login_required rather than redirected. So a test configures this mock through /admin-api, never through an /admin form; two here were doing the latter and both failed in under a second on the bump that turned that gate on, naming a credential rather than a gate.

THE THREE REASONS IT WAS OPEN SURVIVE, AND TWO OF THEM INTACT. A test still drives it — tests/tools/admin-api-token.js mints one token per run in this repository and tests/tools/attach-admin-token.js puts it on the fifteen jobs that need it, so no job holds a secret. It is still the way back in, through adminApi.authRequired, which restores the open API exactly. What is no longer true is the third: anybody who can reach the port can no longer grant themselves both roles here.

AND IT ACQUIRED A BOOTSTRAP HOLE THAT CONFIGURATION HAS TO CLOSE. The seeded sts-management-api client’s secret is minted at every start and is readable only THROUGH the API it unlocks, so a service that has restarted is a service nobody can get a token for. adminApi.clientSecret pins it; common/common.sh here generates one per launcher run as ADMIN_API_CLIENT_SECRET and hands it to that container, and a deployment that does not set it has an administrative surface it cannot reach.

None of this makes the service safe to expose. No password is checked anywhere in it and /oauth2/token will still mint a token for any username asked of it. Do not put this service on a public address.

One thing to know before writing a test that uses it. The mock’s admin state survives between jobs, so anything changed through this API — a custom claim, the credential claim set, the verifier’s request, a revoked token — changes what every later job sees. The mock’s own admin_api.js reads every setting it touches first and puts it back at the end, including the tokens its bulk revocations touched, which it restores one jti at a time because revoke-all has no opposite.

The delegation register names APPLICATIONS rather than addresses, in both protocol families

/admin/delegation and the map drawn from it are ONE model for six mechanisms — Kerberos’s S4U flavours, WS-Trust’s OnBehalfOf and ActAs, and RFC 8693’s impersonation and delegation — because the question somebody brings to that page is protocol-independent: alice never touched the back end, so why is there a credential to it in her name, and who asked for it? Two things had to be true before a three-tier chain actually drew as a chain, and both are lookups in the applications registry rather than anything about the protocols.

A row is filed against the application, not against the address it was asked for. The register is keyed by the identifier a party PRESENTS — a client_id, a UsernameToken name — while what a hop asks for is a RESOURCE: audience=https://esb1.example.com in an RFC 8693 exchange, <wsp:AppliesTo>https://esb.example.com in a RequestSecurityToken. Filed raw, that draws a box nothing else in the picture mentions, and a two-hop chain comes out as two unconnected halves — the address hop 1 reached and the name hop 2 authenticated AS are one application under two names. So the mock resolves it: applications.forAudience() reads oauthAudience for the token endpoint (2026-08-26) and applications.forAppliesTo() reads wstrustAppliesTo and then samlEntityId for WS-Trust (2026-08-27), and the raw string stays in the sentence beside the target either way, because what was asked for is a fact about the request. Neither is a permission: an address nobody registered is issued for exactly as before and recorded verbatim. The WS-Trust one has one rule its neighbour does not need — it skips the entry named by the address itself — because wstrust.js files every AppliesTo it accepts as an identifier, so that registry always holds an entry called https://esb.example.com a moment after the first request for it, and resolving to that one would answer “the application for this address is the address”.

And an act records what it was delegated WITH. /admin/tokens/credential walks a lineage by joining what one act produced to what the next consumed, on the identifier and nothing else. A WS-Trust act named only the requester’s WS-Security credential, which that service never issued and cannot name, so every one of those trails stopped a generation in at a wall; it now also records the ID of the assertion inside <wst:OnBehalfOf> / <wst14:ActAs>, which is what lets a chain of assertion-for-assertion hops be followed back to the browser sign-in it rests on. The wall is still recorded beside it — “it began somewhere this register cannot name” and “this is where it began” are different answers.

tests/oauth2_delegation_chain.js and tests/wstrust_delegation_chain.js are what hold both up, and each fails by NAME against a mock STS older than the change it needs rather than three screens later as a picture with a URL in it. See docs/test-suite-map.md.

And since 2026-09-01 there is a SECOND register beside it, which is the CONFIGURED one, and this suite now populates it. /admin/delegation draws both: the acts above are evidence — one row per thing that happened — and a delegated PERMISSION is intent, typed before anybody asks for anything. The model is Microsoft Entra ID’s by name: a resource application exposes an API (oauthPermissionBaseUri + oauthPermission, Entra’s Application ID URI and oauth2PermissionScopes), a client application is granted some of them (oauthDelegatedPermission, Entra’s requiredResourceAccess), a permission is identified by the two joined — https://apigw1.example.com/ and read make https://apigw1.example.com/read — and a client asks for it as an ordinary OAuth scope, getting back a token AUDIENCED to the base carrying the bare name on its scope claim. tests/sts_applications.js’s delegate() is where this suite configures it, read and write by default, wherever an OAuth 2.0 or OIDC job asks for a token whose audience is not its own client_id. The bare-client_id-as-a-scope spelling that every job here still sends is unaffected and is the DEFAULT permission — the whole API, unnamed. None of it refuses anything yet: oauth2.delegatedPermissionsEnforced is off by default, so an ungranted permission is honoured and recorded as ungranted, which is exactly why the grants are made now rather than when the flag goes on — a suite that only ever ran against the permissive default cannot tell a service that consulted the register from one that has no register. Kerberos and WS-Trust delegate too and are deliberately not wired to it: a delegated permission is an OAuth scope, and those two families have none.

UserInfo answers in four layers, and two of them arrived on 2026-08-27

/oauth2/userinfo used to be the scope-driven set and sub. It now composes four things, and the order matters because the last two can contradict the first two: a claim set of its own, configured at /admin/userinfo-claims (and mirrored, as everything in that console is, at GET|POST /admin-api/userinfo-claims) beside the two JWT sets already at /admin/claims; the scope-driven set (profile, email, address, phone); OIDC Core section 5.5’s claims request; and sub, which beats all of them. The configured set is the interesting one to a test for a reason that has nothing to do with UserInfo: a claim added to it reaches a client that already holds its token, with no new sign-in, because that endpoint is read at request time and a JWT is not.

The response can be SIGNED, ENCRYPTED, or both, and what a client gets is decided entirely by what it registered (OIDC Core section 5.3.2). userinfo_signed_response_alg produces a JWS — fourteen algorithms — every one the JWS registry defines: RS256/384/512 and PS256/384/512 against the RSA key, ES256/384/512, ES256K and EdDSA against the P-256, P-384, P-521, secp256k1 and Ed25519 keys generated beside it, or HS256/384/512 against that client’s own client_secret, which is why the symmetric ones need no published key; userinfo_encrypted_response_alg produces a JWE over any of the six RFC 7518 content encryption algorithms; both together produce a Nested JWT, signed and THEN encrypted, with cty: "JWT" on the outer header. Four things about it are worth knowing here. enc defaults to A128CBC-HS256 when a client registers an alg and no enc, which is what the registration spec says and is the commonest encrypted response there is. The recipient key comes from an inline jwks member only, never a jwks_uri — a mock that can be made to fetch a URL its caller chose, while answering that caller, is a mock with a capability it should not have. A signed response gains iss and aud, without which a signed profile issued for one client is one any other client would also believe. And an algorithm this service cannot perform is refused rather than downgraded to JSON, because a client that registered protection and got an unprotected 200 has no way to notice. All six signing keys are generated at startup and published in /oauth2/jwks (the RSA one first: tokens signed by default are RS256 and more than one test here reads keys[0]). ES*, ES256K and EdDSA were missing until 2026-08-28 for two reasons, and neither was that the service could not do them: no EC key was being made, and jsonwebtoken has no EdDSA and no ES256K. Both are now signed directly on node’s OpenSSL — ES256K with the DER-to-R   S conversion RFC 7518 section 3.4 requires — so neither a missing key pair nor a library’s limits withholds an algorithm. Twenty-five signing algorithms since 2026-08-28, eleven of them post-quantum — ML-DSA at three parameter sets, SLH-DSA at two, and the six composite ML-DSA + traditional algorithms of draft-ietf-jose-pq-composite-sigs, published as kty: "AKP" JWKs. They live in sts/common/pq_jose.js and are deliberately NOT vendored from the debugger: the primitive is @noble/post-quantum because there is no second implementation of ML-DSA, but the JWS framing, the AKP JWK, the composite message and the traditional half of every composite are written there independently, the last on node’s OpenSSL rather than the debugger’s curve library. That is what makes tests/sts_userinfo_protected.js a cross-check rather than a round trip. They are generated LAZILY — all eleven cost ~1.9s, nearly all of it one SLH-DSA-SHAKE keygen — so the first JWKS fetch on a realm is slow once and free afterwards. Ed448 arrived with them and needed oauth2.eddsaCurve, because RFC 8037 registers one alg for both Edwards curves and gives a client no member to choose with; both keys are published regardless, so a verifier follows the kid. The same keys and the same table serve every JOSE surface in the service, which was the point of doing it once: ID Tokens honour a registered id_token_signed_response_alg across all fourteen, and DPoP proofs, OID4VCI proofs of possession, Key Binding JWTs and client assertions all accept the eleven asymmetric ones. Each of those used to keep its own list — and dpop.js its own nine-row table and verifier, while the OID4VCI proof check hardcoded SHA-256, so ES384 and ES512 would have been verified against the wrong digest and reported as bad signatures. There is now one table in sts/common/crypto.js, one signer, one signature verifier, one claim-checking verifier and one key-selection function, and every metadata list is derived from them. tests/sts_userinfo_protected.js drives every advertised algorithm and opens each response with the DEBUGGER’s own engines, which is what makes it a cross-implementation check rather than a round trip; it reads the algorithm list from the METADATA, so an algorithm advertised without a key to verify it fails there.

The claims request is parsed at the AUTHORIZATION endpoint and then rides in the access token, which is where its two surprises are. It is refused there — invalid_request with the reason, while there is still a client to tell — for a parameter that is not JSON, is not an object, whose userinfo member is a string or a number, whose essential is not a boolean, whose values is not a non-empty array, or that names more than 64 claims; an unknown TOP-LEVEL member is ignored rather than refused (section 5.5 says other members MAY be defined) and named in the reply. But a claim nothing can resolve is simply absent and never an error, essential included — section 5.5.1 makes that a hint, not an obligation — so a client asking for a claim this directory has never heard of gets a 200 with a gap in it, which is the answer to the “UserInfo ignored my request” bug report. Riding in the token is what makes it survive a refresh, and it is why the id_token member changes the ID Token while the userinfo member changes only this endpoint. address returns the whole Address Claim where address.locality returns one member; family_name#ja-Kana-JP comes back under exactly that name; a requested claim beats the scope-driven one; and the federation release policy filters a requested claim exactly as it filters a configured one — a partner does not get to widen what it is allowed to see by asking.

Neither half has a test in either repository yet, and by the mock’s own rule they belong in THIS suite rather than in sts/tests/: every assertion above can be made by driving the running service over HTTP, which is the line that decides where a test goes. See docs/test-suite-map.md.

Configuration: one hundred and ninety-seven settings, one table, and a page that says where each value came from

Every setting the mock has now lives in sts/common/config.js and in the CONFIG_FILE appconfig, and /admin/config is the page that shows them. Before this, configuration was forty-odd process.env.X || 'a default' expressions across twelve of its modules. Each was readable where it stood and the set of them was not: there was no way to ask the service what it was configured with, no way to change anything without restarting it, and no list anywhere of what could be changed at all. The answer was a grep, and the grep only found the ones spelt the way you guessed.

A value can arrive from four places and they are indistinguishable once read, which is the actual problem the page solves — not “what is the issuer”, which a document already answered, but “why is it that”. Highest wins: a runtime override set on the page or through the API; the setting’s own environment variable; the appconfig file; the built-in default. Every row on the page and every object in the JSON carries a source saying which of the four, and the two spellings of “environment” are kept apart (env vs env-legacy) because being told a value comes from the environment while the variable named beside it is unset is the kind of true answer that costs twenty minutes.

An environment variable still beats the file, which is what made this a change with no migration. Nothing in this repository sets any of these variables — the compose files pass CONFIG_FILE and nothing else — but tests/krb5_spnego_http.js sets KRB5_REALM, KRB5_KDC_PORT and KRB5_SERVICE_PORT before requiring the mock’s KDC in-process, and that still wins over everything below it. The three shipped env/*.js files were generated from the table and carry the value each expression used to have, so 4 and 5 agree and a run with the shipped file behaves exactly as one with the old file that had only logLevel in it.

STS_ISSUER was one value doing three jobs. It was the <saml:Issuer> of every SAML assertion, the iss of the WS-Trust JWT, and the entityID in the WS-Federation metadata — three different things that shared a default, so a deployment needing one of them to be its own real name had to change all three. They are now saml.issuer, wstrust.issuer and wsfed.entityId. All three still default to urn:wstrust:mock:sts and all three are still fed by STS_ISSUER when it is set, so nothing that worked before changed. Note which one WS-Federation’s assertions carry: saml.issuer, because wsfed.js builds them by calling saml2.js/saml11.js, and it is also what /wsfed/rp checks a presented assertion against. wsfed.entityId names the IdP and nothing else.

The OAuth 2.0 / OIDC issuer identifier is new, and it is empty by default on purpose. Empty means each response names the base URL the request arrived on, which is what lets one process answer correctly as localhost:8081 from a host run, as sts:8081 from a compose network, and through a published port — a conforming client MUST reject a discovery document whose issuer is not the identifier it fetched from, so a pinned value would break one of those callers. Setting oauth2.issuer pins it anyway, which is how that refusal is produced deliberately; it is worth being able to reach, because a real client reports it as something else entirely. Only the identifier moves — every endpoint in the document stays on the request’s base URL, since an endpoint has to be reachable and a pinned issuer may not be. A pinned value also beats the tenant-path discovery form (/tenant1/.well-known/openid-configuration), because two documents out of one process disagreeing about who issued the tokens they describe is worse than losing the tenant answer.

One hundred and fifty of the one hundred and ninety-seven can be changed while the service runs; the rest cannot, and they are shown anyway. (It was forty-five settings when this table was built on 2026-08-20 and it grows with every workflow — SPIFFE alone is twenty-eight of them, Kerberos nineteen and Shared Signals twenty-seven — so do not trust the number in this sentence over the service’s own: config.js logs it at startup (config: N settings from …, M of them changeable while running) and /admin/config counts what is actually there. The counts here are as of the 2026-08-31 bump.) The ones that cannot were consumed before the service was listening, and they are three distinct kinds: a bound socket (the HTTP port, both TLS ports, both LDAP ports, both Kerberos ports); material derived at startup (the TLS certificate is issued for tls.hostnames/tls.ips at boot; the Kerberos principal database and every long-term key in it comes from the realm, the SIDs and the passwords at require time); and the directory tree, whose root is ldap.baseDn. Each is refused with its reason rather than accepted — an accepted change that does nothing reads as having worked — and each is on the page with its input disabled rather than hidden, because hiding them would answer “what is this configured with?” with three quarters of the answer. Live rebinding was considered and rejected: a failed rebind leaves the service unreachable on the port the caller used to reach it, which includes the API doing the rebinding.

A runtime setting has to be read where it is used, which is why a good deal of this change was module-level consts becoming functions — vciBatchSize(), clockSkewSeconds(), maxEntries(), defaultRequested(). A const captured at require time is exactly the thing the console cannot change, and it fails in the direction that looks like the console is broken rather than like the module is.

Changes are in memory and gone on restart. Nothing writes to the appconfig file, the same arrangement as /admin/claims and /admin/vc next door, and for a sharper reason than symmetry: the mock’s admin state survives between test jobs, and a service that edited a file checked into a repository would make a forgotten change permanent. POST /admin-api/config/reset-all exists for precisely that and is what a test should call to put the service back.

The API half is four operations and one of them is not a convenience. set and reset name one setting; reset-all clears every override; set-many is what the console’s per-section Save posts, and it is all-or-nothing — every value is checked before any is written, because a section is how a person changes configuration and one call per field would make a half-applied section the ordinary outcome of one mistake. The mock’s own admin_api.js asserts that: it posts a section with one bad field and checks the good one did not land.

One check in that test is worth more than the rest of it. Everything else about this resource is self-describing and could be right about a service that ignored it — the value comes back, the source says override, and nothing has moved. So the test sets oid4vci.batchSize, reads batch_credential_issuance.batch_size out of the live OID4VCI issuer metadata, and puts it back. An integer on the wire is not satisfiable by accident the way an echoed string would be.

The TLS endpoint — two HTTPS listeners that report the connection back

sts/tls/tls_server.js (added 2026-08-18) is the third thing here whose real surface is a socket rather than a route, and the one where that is easiest to forget: it speaks HTTP, so it looks as though it belongs on the plain listener — but it is HTTPS on 8443 and 9443, and GET /admin/sts-metadata walks the plain listener’s router and cannot see either. Its four rows there are the plain-HTTP views only.

What it is for. The debugger’s PKI page builds a certificate authority in a browser, issues a client certificate from it, and then has to find out whether anything accepts it. The api opens the socket and reports the handshake — but that is the side that already knows what it sent. Which chain the server built out of what arrived, which anchor it verified against, what it read out of the leaf, and whether it accepted the certificate at all exist only on the far end; and under TLS 1.3 the client has not been told, because it sends its Certificate and Finished last and the verdict arrives afterwards as an alert or as a bare hang-up. So the whole content of these listeners is that answer: GET /tls/whoami over either one returns a message and three sections — the HTTPS request as it arrived, what TLS negotiated underneath it, and the client certificate exactly as presented, chain and all. GET /tls describes the endpoint over plain HTTP, and both take ?format=json.

Two listeners, because “does this server require a client certificate” has two answers and a debugger measures it by connecting twice. 8443 is requestCert: true, rejectUnauthorized: false — it always asks, accepts whatever arrives including nothing, and reports the verdict; it is the one to point a debugger at, because node refuses a client certificate by closing the socket with no alert and this listener can say which check failed instead. 9443 is rejectUnauthorized: true — it refuses during the handshake, the way a real server does, so reaching it is itself the proof. That pair is what makes the api’s five mutual-auth verdicts reachable against a real server: required against 9443 once the issuing CA is trusted here, required-and-rejected before it is (the case an operator hits most and the one a single connection cannot tell from the first), not-required against 8443, which is true.

The client truststore starts EMPTY and is filled at runtime, and it has to be: the CA whose clients this verifies is generated in somebody’s browser minutes before the connection and exists nowhere else, so no configuration file could hold it and no image could bake it in. POST /tls/trust takes PEM (raw, or as the certificates field of a form or JSON body) and tls.Server.setSecureContext() applies it; POST /tls/trust/clear puts it back. Three details are load-bearing: ca: [] means no anchors and is passed explicitly, because omitting ca selects node’s bundled roots — a public root has no business verifying a client certificate from a private CA; the trust endpoint is on the plain port, which is the one reachable before anything is trusted; and the server certificate is self-signed and regenerated on every start, like the signing key, so GET /tls/server-certificate hands out the PEM (Cache-Control: no-store) for a caller to put in its own truststore rather than switching verification off.

A verified client certificate is not a login, and no revocation is checked. It means OpenSSL built a chain from what the client sent to an anchor somebody POSTed — no session, no token, nothing else in the service told, and a revoked certificate verifies here and would not verify anywhere that matters. The report says both in as many words, because a mock that quietly turned a certificate into an identity would teach a client something false about every server it will meet.

One thing about the log: a certificate refused by the strict listener never reaches a handler, so without help it is invisible from both ends. Both listeners log tlsClientError with OpenSSL’s reason, and the strict one’s message names the truststore, the trust endpoint and the permissive port.

tests/pki_mutual_tls.js is the test, and STS_TLS_URL (the plain HTTP base) is what gates it.

The Shared Signals transmitter

/ssf, and the mock’s own notes are in sts/ssf/CLAUDE.md. The two things worth knowing before pointing this debugger’s SSF workflow at it.

IT HAS ITS OWN RFC 9493 SUBJECT GRAMMAR AND THAT IS THE POINT. The debugger has one in client/src/ssf_client.js and the mock has one in sts/ssf/ssf_subjects.js, written independently — the argument sts/common/pq_jose.js makes about the composite construction, applied to a grammar. A subject identifier is JSON, and the defect that matters in it is a READING: an accepted extra member, a missing required one, a format name spelt from memory. If both ends read one implementation, a misunderstanding they share is one neither can see, and the round trip passes while interoperating with nothing. tests/ssf_protocol.js drives one against the other over the wire.

IT CAN BE MADE WRONG ON PURPOSE, IN TWO WAYS. ssf.legacySubClaim adds the deprecated sub claim beside sub_id, which is how a client written against a transmitter that gets this wrong is caught; ssf.breakSetSignature changes one character of the signature after signing, which is how a receiver that does not verify is caught. Both are the device oauth2.breakIdTokenNonce already is, and both are asserted by tests/ssf_protocol.js, which turns them off again in a finally — a setting left on is a later job in the pool failing for a reason nothing names, which is why that test holds the sts-ssf job lock.

It also RECEIVES, at POST /ssf/receive, so that the debugger can be the transmitter. That endpoint accepts a SET whose signature does not verify and reports why, which is exactly right for a debugger: ssf.receiveRequireSignature turns the 400 on, and that is what a real receiver does.

The embedded LDAP directory

Everything above except the KDC is HTTP. This is not: LDAP v3 (RFC 4511) is BER over TCP port 389 — and, over TLS, port 636 — so ldap_server.js opens two raw sockets, and the same four consequences the KDC has apply — the listener is started from listen() rather than at require time (binding a privileged port can fail, and a require that throws takes the whole service down), GET /admin/sts-metadata cannot see it (the two rows it does list, /ldap and /ldap/directory, are this service describing its own store and are not LDAP at all), and the port is coupled to the parent project’s ldapAllowedPorts.

The two sockets are one directory: one store and one set of handlers, registered on two ldapjs server objects because that library chooses between a net.Server and a tls.Server at construction. They bind independently and are reported independently on GET /ldap (listening/listenError for 389, a tls object for 636), because 389 up and 636 down is the ordinary outcome of a host run that is not root. LDAPS serves the same self-signed certificate the mock’s HTTPS listeners present, regenerated on every start — GET /tls/server-certificate is the one fetch that covers 8443, 9443 and ldaps:// alike. There is no StartTLS: it is an extended operation, and ldapjs implements none.

There is a fifth consequence this one has and the KDC does not: the library is a submodule of a submodule. "ldapjs": "file:node-ldapjs" resolves to sts/node-ldapjs, so git submodule update --init sts stops one level short of it and --recursive is required. An uninitialised submodule is an EMPTY DIRECTORY, so the image builds, npm installs a package with no main, and the container dies at startup with Cannot find module 'ldapjs' — naming a package rather than a submodule, from a service whose other eleven protocol families were about to work perfectly. requireMockStsCheckout() in common/common.sh now initialises it and says so.

Every bind succeeds — any DN, any password, anonymous included — except the literal password invalid, which is LDAP_INVALID_CREDENTIALS (49). That is the same convention the password grant, WS-Trust and the WS-Federation sign-in screen already follow, and it is what keeps result code 49 reachable: a directory that could not produce one would make “the bind failed” untestable, and 49 is the code an LDAP client’s error handling is built around.

It is schemaless on purpose, and says so on GET /ldap: no objectClass is enforced, no attribute is checked against a syntax. Four structural rules ARE enforced, because each is real and its absence would teach a client something false — an add needs its parent (32), a delete needs a leaf (66), a modify delete of an absent attribute is 16, and deleting an attribute’s last value deletes the attribute. And one is deliberately not: deleting a user does not remove its DN from the groups that list it. Referential integrity is a directory feature, not a protocol rule, and the dangling member is what a member-based group search should then show.

LDAP_AUTOCREATE_USERS is ON by default — and was set to false in all three of sts/env/*.js until 2026-08-19, which beats the default and is why a person could sign in through any protocol here and find the directory empty; see docs/ldap.md for the four things that kept that invisible. It is the piece worth knowing about: an entry appears at uid=<name>,ou=users,<base> the first time anybody authenticates through ANY of the twelve families here. That is one hook and not twelve, because admin_stats.recordAuthentication() is already the single funnel every one of them passes at the moment a credential is ACCEPTED — and the hook is INVERTED for the reason helpers.js’s setJwtRecorder is, since ldap_server.js requires admin_stats.js and a cycle in node hands back a half-initialised module. Two identities are skipped: an LDAP bind (the identity a bind presents is a DN, which already names an object here) and an OAuth client (not a person). See docs/ldap.md.

The mock KDC, the trust, and the protected service

Everything above is HTTP. This is not: Kerberos speaks DER over TCP and UDP port 88, so krb5_kdc.js opens raw sockets, and krb5_service.js opens another for the service that demands a ticket. Four consequences follow, and none is visible from the code that registers routes.

Requiring the module does not start the listeners. Every other module here registers its endpoints at require time, and for a route that is harmless; binding a privileged port is not, because it can fail and a require that throws takes the whole service down. So server.js calls an exported listen() and reports its failure, and the service still starts without a KDC rather than not starting at all. listen() binds TCP first and follows UDP onto whatever port it got — a KDC presents one port number for both transports, and a client failing over from UDP sends the retry to the same place — so callers must await its whenReady rather than reading a port that is not bound yet.

GET /admin/sts-metadata cannot see a raw socket. That page is built by walking the live Express router, which is exactly why it cannot go stale, and a protocol family that registers no route is the one blind spot the design has. Three Kerberos surfaces are HTTP and therefore listed — POST /KdcProxy (MS-KKDCP, which exists because a browser cannot open a raw socket), GET /krb5/principals and GET /krb5/service, the last two being explanatory pages a real KDC would never publish. The sockets themselves are described in those rows’ text.

Port 88 is privileged. In the container this process is root and binds it directly; a host run is not, so KRB5_KDC_PORT exists — and changing it means the api’s krb5AllowedPorts has to allow the new one or the relay refuses to reach it. That coupling is the price of the port allowlist.

The salt is the point of the principal database. It is not derivable from the principal name: Active Directory salts a user as the realm followed by the sAMAccountName (EXAMPLE.COMalice) and a computer account as the realm, the literal host, the short name in lower case and the DNS domain (EXAMPLE.COMhostws01.example.com). An implementation that derives the salt from the principal name works until the first machine account — which is exactly when somebody is debugging a service rather than a user. The KDC hands whichever applies to the client in PA-ETYPE-INFO2, and that is the only way a client can learn it. The database also carries the misconfigurations on purpose: locked, expired, aesonly (a hardened account that offers no RC4), rc4only (the legacy account a 2025 baseline breaks), noreauth (answered in one message instead of two, so both halves of the dance can be seen), and a computer account for the host-shaped salt.

Every ticket carries a PAC ([MS-PAC]) — the account’s SID, its groups and its UserAccountControl flags, signed — because a Kerberos ticket proves who the client is and a Windows service authorizes on groups. Each account’s PAC records what makes it interesting: noreauth’s says DONT_REQUIRE_PREAUTH, locked’s says ACCOUNT_DISABLED, and the computer account’s says WORKSTATION_TRUST_ACCOUNT with Domain Computers as its primary group. Two details are silent when wrong: the server signature is made with the key the TICKET is encrypted under, not the krbtgt key, which is what lets a service verify it alone — and on a TGT those coincide, so only a service ticket distinguishes them; and a TGT carries two of the four signatures while a service ticket carries all four, per sections 2.8.2/2.8.3, so a client that only ever saw four would learn to require signatures a real domain does not send. PA-PAC-REQUEST is honoured in all three of its states, and a client that explicitly declines gets a ticket with no PAC — which is the only way to see how a service behaves without the groups it authorizes on.

It serves TWO realms with a trust between them, which a real KDC never does. The simplification hides finding the other realm’s KDC (DNS and SRV records) and none of the protocol, and what it buys is the whole cross-realm referral without a second container. A trust is not a setting: it is one principal, krbtgt/PARTNER.COM@EXAMPLE.COM, whose key both sides hold — held once, under the issuing realm, because two copies are two secrets that must stay equal and the failure when they drift is “the ticket does not decrypt” at the second KDC. Ask for a service in the other realm and the KDC does not refuse: it issues a ticket-granting ticket for that realm sealed with the trust key, and the reply is an ordinary successful TGS-REP whose only distinguishing feature is that its sname is not what was asked for. A client that does not compare those two hands a ticket-granting ticket to a web server. Three things follow in the code: every principal lookup carries a realm (because krbtgt/PARTNER.COM exists in both databases with different keys — the trust in one, that realm’s own ticket-granting service in the other); the realm being answered as comes from the request rather than a constant; and a PAC arriving across a trust is re-signed, not rebuilt, since the target realm has no copy of the client’s account. SID filtering is not implemented — that is the control which stops the trusted realm asserting membership of groups in this one, and its absence is stated rather than left as a silence.

Any SPN in a configured domain gets a ticket, and that is new as of 2026-08-17. A client derives the SPN from the URL’s host — HTTP/<host>, which is what RFC 4559 clients and every browser do — so it asks for HTTP/localhost, HTTP/sts or HTTP/127.0.0.1 depending on how this stack was reached, while the configured account is HTTP/web.example.com. Every one of those was KDC_ERR_S_PRINCIPAL_UNKNOWN: a real error with a real cause, and exactly the wrong first experience of a workflow trying to teach the protocol rather than this mock’s principal table. So findOrCreateService() registers a service on first sight when its host matches KRB5_SERVICE_DOMAINS (default: the realm’s own domain plus localhost, sts and 127.0.0.1; an entry matches a host that is it or ends with a dot and it), with the shared KRB5_AUTO_SERVICE_PASSWORD, and GET /krb5/principals publishes both the host list and that password.

Four things about it are load-bearing, and three of them are what stops it from being a KDC that says yes to everything:

And the challenge volunteers what SPNEGO will not carry. GET /spnego/protected’s 401 now also sends X-Krb5-Service-Principal and X-Krb5-Accepts-Spn-Hosts, and GET /spnego?format=json reports the same as servicePrincipalName and acceptsAnySpnForHosts. Those are nobody’s standard — an unknown header is ignored by every real client — and they exist because the protocol’s silence about the SPN is the commonest cause of a SPNEGO failure there is. client/src/spnego.js reads them opportunistically and labels them as the mock’s own courtesy; see docs/spnego.md.

The protected service (krb5_service.js) is the other half: a raw TCP acceptor for HTTP/web.example.com that applies nine ordered checks to an AP-REQ, keeps a bounded replay cache keyed on (client, ctime, cusec), and answers mutual authentication with an AP-REP carrying an acceptor subkey. It is what makes “the ticket looks right” into “something decrypted it, checked it, and proved itself back”.

The same acceptor over HTTP is spnego.js, and it adds no protocol code at all. GET /spnego advertises a protected page — the SPN, the realm, the mechanisms and three knobs that break the negotiation one way each — and GET /spnego/protected answers 401 WWW-Authenticate: Negotiate to an unauthenticated request and 200 with an AP-REP in that header to a valid one. Every Kerberos check is krb5_service.js’s accept(), called unchanged; what this module holds is RFC 4178’s negotiation and the HTTP that carries it. Two things about it are worth knowing before changing it. A mechListMIC that does not verify is a REJECT rather than a warning — the MIC is the only thing standing between the mechanism list and an attacker who edited it on the wire, so an acceptor that logs a failure and continues has implemented the syntax and none of the protection. And a half-finished negotiation is held against the client address rather than the connection, which is the one place this mock is structurally unlike a real acceptor: RFC 4559’s authentication is connection-based, Express offers no stable connection identity, and that same property is why real SPNEGO breaks behind connection-pooling proxies. The codec it shares with the browser (krb5_spnego.js) is vendored like the other seven. See docs/spnego.md.

Since the 2026-08-27 bump that handshake can also SIGN SOMEBODY IN, which retires the reason SPNEGO was kept out of the federation matrix. /spnego/protected had authenticated people since the day it was written and then thrown the identity away — it prints a table saying who the ticket named and stops — and /authn/spnego is the same exchange with the last step added: past it the holder of a Kerberos ticket has the one browser session authn.js owns, which /oauth2/authorize, /wsfed, /saml2/sso, /saml11/sso, /federation and /admin all read without being told how it was established. The module split that came with it is what a bump of this submodule has to notice: the negotiation is spnego_exchange.js, the page is spnego.js and the sign-in is spnego_authn.js, so spnego.js now requires a file that did not exist, and tests/Dockerfile had to copy it — miss that and the image builds clean and tests/krb5_spnego_http.js dies at load with Cannot find module './spnego_exchange.js', naming a file this repository has never mentioned. There are three ways in and they are three amounts of configuration: a button on the sign-in screen offered to every application (krb5.spnegoLoginButton, on by default, and nothing is registered for it); appAuthnMechanism: spnego on an application entry, which is home-realm discovery by configuration and skips the screen entirely; and fedAuthnMechanism: spnego on an identity-provider-side federation relationship, which answers the other question — a SAML 2.0 partner asks this service to authenticate somebody and a Kerberos ticket is what does it, with the partner never learning that. krb5.spnegoAuthentication (also on by default) closes the door, and /spnego/protected then still performs the whole handshake and still gives no session, which is the distinction to hold on to: this is the one door in the mock that verifies a credential rather than accepting a name, and it is not a guard — Kerberos simply cannot be permissive the way the rest of the service is, because the password there IS the key. The account policy behind it is as open as everywhere else: one shared password, an account minted for any name on first sight. Both settings are runtime, so POST /admin-api/config/set reaches them and a test that needs the old behaviour does not need a restart.

Delegation is configured two different ways on purpose, because the two are the same protocol messages with opposite trust and that is the whole security story. HTTP/frontend is trusted for classic constrained delegation: the permission is msDS-AllowedToDelegateTo on the FRONT-END account, listing what it may reach, and only a domain admin can set it. HTTP/rbcd authorizes resource-based delegation itself: the permission is msDS-AllowedToActOnBehalfOfOtherIdentity on the BACK-END account, listing who may act on its behalf — and whoever controls that object can set it, which is how “I can write to this computer account” becomes “I can reach this service as anybody”. HTTP/notrusted exists to isolate one attribute: it has the same classic permission as frontend and lacks only TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION, which produces the two-step failure that points at the wrong thing — S4U2Self succeeds and returns a ticket that merely is not forwardable, and classic S4U2Proxy then refuses it complaining about the evidence. Note the asymmetries the KDC reproduces: classic requires forwardable evidence, RBCD does not; RBCD requires PA-PAC-OPTIONS with bit 3, and [MS-SFU] says a KDC MUST answer KDC_ERR_BADOPTION without it — an error that mentions nothing about padata. Every delegated ticket carries S4U_DELEGATION_INFO, which is the only record inside the ticket that a delegation happened at all: the service that asked appears nowhere else in it. S4U2Self is not itself a privilege — the ticket is to yourself — and S4U2Proxy is; what stands between them is one attribute on one account.

Unconstrained delegation is the other half, and the mock draws the contrast deliberately. With FORWARDED, a client asks for a second ticket-granting ticket and hands it to a service in a KRB-CRED — after which that service can obtain tickets to anything as the client, for the ticket’s lifetime, with the KDC never consulted again. There is no list of permitted targets because there is no constraint, which is the whole difference from S4U2Proxy, where every hop returns to the KDC and is checked against an attribute. The service’s ok-as-delegate flag is only advice to the client; nothing enforces it. So the only enforceable control sits on the account being protected: sensitive is flagged NOT_DELEGATED ([MS-SAMR]’s USER_ACCOUNT code 0x4000), and the KDC refuses it a forwardable ticket at the AS exchange — not as an error, but by simply omitting the flag, so the user still logs on and no service anywhere can forward those credentials. It is re-checked at the TGS exchange as well, because a forwardable ticket issued before the flag was set would otherwise keep working until it expired.

FEDERATION, TRUST REALMS, AND THE ONE ATTRIBUTE THAT SENDS SOMEBODY TO A PARTNER

The mock can be either end of a federation relationship in five protocols (SAML 2.0, SAML 1.1, WS-Federation, OpenID Connect and OAuth 2.0), and it is the only feature there that has to be configured before it does anything. Everywhere else that service accepts what it is given — any username, any client_id, any entityID; it cannot do that at an assertion consumer service, because what arrives there is an unauthenticated HTTP request claiming to be a person and the session it would produce is the one /oauth2/authorize, /saml2/sso, /wsfed and /admin all read. So a relationship is created disabled, and an assertion is refused unless it verifies against the certificate configured on that relationship — the gate is on the SIGNER, not on the subject. Configure it through POST /admin-api/federation/{action} (create, set, add-value, enable); the /admin console is gated and a JSON caller is refused 401 there.

A TRUST REALM is a whole logical copy of that service — its own signing key, sessions, tokens, applications, federation register and directory subtree — answering on the same socket under /realm/<id>/…, created at runtime with POST /admin-api/realms/create. That is what lets ONE mock be two identity services, which is how tests/federation_sso.js federates without a second container. Two things about it catch people once: the realms share a browser COOKIE NAME, so a session minted in one realm is presented to the other and must not be honoured there (it is not — the session store is per realm); and the mock mints per-service-provider SAML metadata, so the entityID a partner’s assertions will carry is the one in the document fetched by the service provider’s own entityID, not the one in a document fetched without it.

appFederationRelationship on an application entry is what sends that application’s users to a partner, with appFederationAutoRedirect beside it, both added on 2026-08-26. They are the only two attributes in that registry that the service READS — appAllowedProtocol and the four per-family identifiers say in capitals that nothing does — and what they answer is a question it could not answer before: a relationship says how to reach a foreign identity provider and nothing about WHO should be sent there. Before them the only home realm discovery available was a person pressing a button at the foot of the mock’s sign-in screen, once per sign-in.

With one named and the auto-redirect at its default of TRUE, a sign-in for that application never reaches the mock’s own screen: authn.js answers with the federated flow’s entry point and the browser goes straight to the partner. With the auto-redirect off, the screen appears and that partner is the only button on it. Set them with POST /admin-api/applications/create (in fields) or POST /admin-api/applications/set.

Four checks are made when the attribute is READ, not when it is written — the relationship must exist in that realm, be service-provider-side, be enabled and be fully configured — because the attribute is a string on a directory entry that ldapmodify reaches and the relationship it names can be disabled later by somebody who never looked at the application. A failure of any of them is shown in the sign-in screen’s error banner rather than falling back to the password box, which is the failure worth being loud about: a federated application authenticating people locally looks exactly like a federated application working.

The register also draws now. /admin/federation/map is the second picture in this console — /admin/delegation/map was the first — and it is the same register as /admin/federation, laid out as a three-band left-to-right flow: who asks on the left, this service as the hexagon in the MIDDLE rank, who authenticates on the right, so which side of the hexagon a box sits on is the entire claim the picture makes. It carries per-application and per-relationship sign-in counts, and ?application= narrows it to one application’s paths. Two things about it are worth knowing before changing it. It is server-renderedfederation/federation_graph.js builds the graph, @dagrejs/dagre computes the layout in the mock’s own process, admin-ui/federation_diagram.js emits the shapes, and the SVG arrives inline as ordinary markup, so the console’s script-src 'none' is untouched and what it costs is pan and zoom, which the page answers with ?format=svg. And it is a second renderer rather than delegation_map.render() with a different graph: it borrows that file’s palette, hexagon and text metrics and nothing else, because the delegation map deliberately hoists the hexagon out of the layout into a band above (a chain is a chain) and doing that here would delete the one thing this picture says. The register behind it lives only in the mock’s memory and dies with the container, and unlike the delegation maps — which ./local-run-tests.sh --delegation-only writes to tests/report/delegation/ as SVG — nothing here saves this one, so a test that wants the picture afterwards has to fetch ?format=svg before the stack goes.