Endpoints

There is no list of endpoints in this documentation, and that is deliberate. There are 264 of them, they change, and a list written here would be wrong within a month with nothing to say so — this number was 144 when the sentence was written, which is the point it is making.

Ask the service instead:

curl -s localhost:8081/admin/sts-metadata            # a page
curl -s 'localhost:8081/admin/sts-metadata?format=json' | jq

Both are behind the console gate: with no browser sign-on session the first is a 302 to the sign-in screen and the second is a 401 login_required, because a redirect to an HTML login screen is not an answer a program can read. The gate cannot be turned off; sign in at /authn/login (any username in development mode, where no password is checked). Everything under /admin-api needs an OAuth 2.0 access token audienced to that API (admin:read to read, admin:write to write); ADMIN_API_AUTH_REQUIRED=false turns that off.

What that page is

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”. It is a page of the admin console, with the console’s sidebar, breadcrumb and gate banner. ?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 (an <a download>, since the page runs no script).

It reads the endpoint list off the running Express router, walked per request, so it cannot go stale by omission; the table in sts_metadata.ts supplies only the name and description for a path the router reports. Each row carries the method, the path, a sentence about what the endpoint is for, and a link to the specification it implements. Every coverage note starts full, partial or mock and says what is missing — a list of fifty specifications that did not mention that development mode checks no passwords and validates no access tokens would be the most misleading thing in the service.

Each path is a link to that path, where that is honest — about half of them. A link is a GET, so a path the router answers only for POST would land on Express’s own Cannot GET /oauth2/token, and a route pattern with a :parameter or a * is not the address of anything. Those are listed unlinked with the reason shown — “POST only”, “takes :id”, “wildcard”. The followable endpoints that do something when clicked (/oauth2/authorize, /oauth2/logout, /oauth2/userinfo, /issuer/offer, /oid4vp/start) carry an effect note: the first answers 400 when followed bare, since it needs client_id and redirect_uri, and UserInfo answers 401, being a protected resource. Links are root-relative, so they follow whichever host the page was reached at, and open in a new tab. The test follows every link and fails if one does not reach a handler — telling a route that correctly answers 404 for a resource that does not exist (which proves the route is registered) from Express’s own Cannot GET /path for an unregistered one.

It also reports drift, in six arrays, and this repository’s own tests/vendored/sts_metadata.js fails on all six:

Field Means
undocumentedPaths A route is registered and nothing describes it
stalePaths Something describes a path that is not registered — what a rename produces
unknownSpecIds A description cites a specification the page does not know
unknownProtocolGroups A protocol card at the top names an endpoint group with no rows
unknownProtocolSpecIds A protocol card cites a specification the page does not know
unclaimedGroups A group of endpoints no protocol card claims — a family added to the service and not to the page

The last three are about the one part of the page that is not derived: the thirteen protocol cards at the top. Two of those families register no route at all and four live mostly on a raw socket, so the list cannot be read off the router — and a hand-written list on a derived page is exactly the thing that needs checking.

A route registered and undescribed still appears, marked UNDOCUMENTED, with its methods, because the page’s first duty is to be a true list of what is callable. A description whose path is not registered is the more dangerous half: the page would advertise an endpoint that answers 404, and a rename is exactly when nobody thinks to check the index. The test also catches an idle claim — a specification listed that no endpoint links to.

All six empty is the service agreeing with its own description of itself. That is the check worth running after any change that adds or renames a route.

Its one blind spot

A protocol that registers no HTTP route is invisible to the router walk. Four things here are exactly that:

  • the Kerberos KDC on raw TCP and UDP 88
  • the LDAP directory on 389
  • the same directory over TLS on 636
  • the SPIFFE gRPC listeners — a Unix socket and a TCP port each for the Workload API and the SPIRE Server API

Those are described by hand in the page’s own table — the Kerberos sockets in the text of the three HTTP rows the walk can see (/KdcProxy, /krb5/principals, /krb5/service), since a described entry with no route behind it would trip the stale-path check. If you add one, describe it there or it goes unlisted with nothing failing.

The TLS family used to be a milder version of the same thing and no longer is. It had two listeners of its own, 8443 and 9443, which spoke HTTP and so looked as though they belonged on the main listener, while /admin/sts-metadata — walking that listener’s router — could not see them; their rows there were the plain-HTTP views only and the listeners were described in the text. Both have been deleted. Everything that family answers is now a route on the router the page walks: /tls, /tls/sign-in, /tls/server-certificate, /tls/forwarded and the two truststore controls.

The index of its own cryptography

GET /admin/crypto-metadata is the second metadata page, and it is not a summary of the first. Where that one answers what can I call, this one answers the question underneath it: when this service signs, verifies, encrypts or decrypts something, what does it actually use — which digest, which signature algorithm, which cipher, which key, and which envelope (JOSE, XMLDSIG and XML Encryption, WS-Security, COSE, X.509, Kerberos) the primitive is wrapped in. It has one section per protocol family, with the four verbs as four separate columns, because they are four different exposures and this service does a different amount of each. ?format=json and GET /admin-api/crypto are the machine-readable forms.

Every algorithm table on it is read from the module that performs the algorithm — the JWS rows from common/crypto.js’s algorithm table, the XML signature, digest and canonicalization tables from the vendored xmldsig.js, the Kerberos encryption types from the codec, the SPIFFE authority key types, the COSE algorithms from the WebAuthn verifier, and so on across eleven modules — because a hand-kept list of algorithms disagrees with the code silently. Only the per-family prose and the standards list are hand-written, and every coverage note there starts full, partial or mock. It checks its family list against the endpoint page’s in both directions, so a protocol family added without a crypto profile is reported, and so is a profile naming a family that no longer exists.

Its post-quantum section’s headline is deliberately not the flattering one: the signatures are partly post-quantum and the key establishment is entirely classical. ML-DSA, SLH-DSA and the six composite algorithms can sign an ID Token, a UserInfo response and a published JWK. Every key establishment mechanism — RSA-OAEP, RSAES-PKCS1-v1_5, ECDH-ES, TLS’s own key exchange — is broken by Shor’s algorithm, and there is no ML-KEM anywhere. The two halves are reported apart because the threat differs: a signature is checked when it is presented, while ciphertext captured today can be kept and opened later. Symmetric cryptography is a third category, where Grover costs a square root and the answer is key length — which makes Kerberos the family least affected, having no public-key cryptography in it at all. The most instructive row is DPoP, whose list takes ML-DSA-44, -65 and -87 and no other post-quantum algorithm: a proof is bound through a JWK thumbprint, which RFC 9964 defines for AKP keys, and those three are the post-quantum algorithms the JOSE registry names. SLH-DSA and the composites are signed here under draft names, so a proof in one would bind to a name no client could interoperate on.

It publishes no private key and no secret — key types, key identifiers, curve names, certificate fingerprints and validity dates, all already readable from /oauth2/jwks, /tls/server-certificate and the SPIFFE bundle endpoint.

The other things the service publishes about itself

Each of these is generated from the same table the behaviour reads, so none of them can drift from what the service does:

Ask Get
GET /.well-known/openid-configuration The OpenID Provider Configuration
GET /.well-known/oauth-authorization-server The RFC 8414 document
GET /.well-known/{openid-configuration,oauth-authorization-server}/realm/<id>[/<server>] Either document for a realm’s issuer, or a named server inside it (RFC 8414’s inserted form); /realm/<id>[/<server>]/.well-known/openid-configuration is the appended form
GET /.well-known/webfinger?resource=… WebFinger (OIDC Discovery section 2): the issuer for an acct:, e-mail, host or https resource, by realm domain or /realm/<id> path
GET /oauth2/rfc9700 Every Security BCP requirement, with what is and is not enforced
GET /oauth2/oauth21 Every OAuth 2.1 requirement the mode adds, which it inherits from RFC 9700 mode, and what it exempts
GET /oauth2/fapi The FAPI profile in force (oauth2.fapi, or a named authorization server’s own at /{id}/oauth2/fapi) and every FAPI 1.0 Baseline requirement with how it is enforced
GET /admin-api/openapi.json The management API, generated from its operation table
GET /admin/api-explorer The same, in a small explorer that also shows the curl line. A page of the admin console, behind its session and roles — not under /admin-api, which requires an access token a browser cannot carry
GET /spiffe The trust domain, every socket this process has bound — the default realm’s four and two more for each realm whose SPIFFE is turned on, each row naming its realm — and all 42 SPIRE methods with a reason for each of the six that are unimplemented. Reached under a realm prefix it is that realm’s answer
GET /admin/ldap/service The directory’s state, both listeners separately, and the fact that it is schemaless
GET /federation Every configured federation relationship in both directions, and the URL to give each partner
GET /admin/ldap/federations The federation register as the directory holds it, with its schema — and the one container here where an ldapmodify is a security change
GET /tls The certificate the main port presents, the client certificate it asks for, and what a verified one does and does not mean (GET /tls/sign-in turns one into a session)
GET /admin/tls/trust Every client-certificate trust anchor, with where each came from (tls.trustAnchorsFile or added at runtime), and the add and remove controls. An admin console page; GET /admin-api/tls/trust and POST /admin-api/tls/trust/{add,remove} are its management-API twins. An anchor added at runtime is persisted in the default realm’s ou=trustAnchors; one from the file comes back at every start
GET /scim The SCIM authentication schemes that are switched on
GET /krb5/principals The principal database, passwords included in development mode for the reason that page gives; in product mode the passwords are withheld and the page says why. A directory person keyed from their own password is listed with directoryKeys: true and never with a key
GET /admin/kerberos/principals Who the KDC holds a STORED long-term key for: directory people whose keys were derived from their own password (product mode), and service principals created with a random key — with create, rotate, delete and clear controls. A create or a rotate shows an MIT keytab ONCE — a rotate’s carries the previous kvno too. Each row lists the PREVIOUS key versions still accepted for tickets issued under them (kvno, enctypes, expiry; krb5.retainedKeyVersions, krb5.retainedKeyTtlS), with a Drop previous versions control that ends that window at once. And the realm’s krbtgt key (#169) — where it comes from, its kvno, its last and next rotation, the versions kept — with Rotate the krbtgt key and Rotate and invalidate, both queued on the scheduler and neither showing a key. An admin console page; GET /admin-api/kerberos/principals and POST /admin-api/kerberos/principals/{create-service,rotate-service,delete-service,clear-person-keys,drop-previous-service-keys,drop-previous-person-keys,reset-person-keytab,rotate-krbtgt,rotate-krbtgt-invalidate} are its twins, and only the create and rotate replies and a person’s keytab carry key material
OPTIONS /gnap and GET /.well-known/gnap-as-rs GNAP’s two discovery documents — the client’s (RFC 9635 section 9) and the resource server’s (RFC 9767 section 3.1), both read off the gnap.* settings and the authorization server profile. See GNAP
GET /gnap/keys The public keys that verify GNAP’s self-contained token formats without introspection
GET /.well-known/openid-federation The realm’s OpenID Federation Entity Configuration: its Federation Entity Keys, its federation, OpenID Provider, authorization server and verifier metadata, its superiors, and the Trust Marks it carries. The rest of the federation’s endpoints are /oidfed/*. See OpenID Federation
GET /oidfed/historical-keys Every Federation Entity Key the realm has retired or revoked, signed, with why
GET /admin-api/status Which console pages exist — what the parity test reads

Two rows in that table are behind the console’s gate, and their /admin-api twins are not. GET /admin/ldap/service and GET /admin/ldap/federations are admin console pages — they were /ldap and /ldap/federations — along with /admin/ldap/directory, /admin/ldap/applications and /admin/ldap/spiffe. They need a sign-on session and a console role, because a dump of every attribute of every entry prints oauthClientSecret and fedClientSecret in the clear. Every one of them is mirrored by an operation under GET /admin-api/ldap/…, which is not gated: that is the door a script uses, and the one to reach for when nobody holds a role.

Named authorization servers

One process is several authorization servers. Any path component works and is created on first sight:

curl -s localhost:8081/tenant1/.well-known/oauth-authorization-server | jq .issuer

Its endpoints live under that name (/tenant1/oauth2/token), its tokens carry it in iss and aud, and a credential does not cross between them — an authorization code issued by one is refused by another’s token endpoint. The capabilities in its document drive those endpoints rather than describing them, so there is no second table that could disagree.

A named authorization server has a GNAP grant endpoint too, at /tenant1/gnap. Its GNAP members — start modes, finish methods, key proofs, token formats — are set on /admin/authorization-servers beside the OAuth ones and are ENFORCED at that endpoint, and they appear only in its GNAP discovery document, never in its OAuth metadata.