Architecture
Select the diagram for full size. It shows one node. The layers below go through it from top to bottom.
The leader process and its listeners
One node process owns every socket. That covers the HTTPS main port, LDAP and LDAPS, the Kerberos KDC, the SPIFFE gRPC ports and the Workload API’s domain sockets. A socket can’t be handed to another process, so every listener stays in the leader, including when worker pools are turned on.
| Listener | What is on it |
|---|---|
| 88 TCP / UDP | The Kerberos KDC |
| 389, 636 | The embedded directory, over plain LDAP and over LDAPS |
| 8081 HTTPS | The main port: every path-based protocol, /admin, /portal and /admin-api. It asks every connection for a client certificate and requires none; GET /tls/sign-in signs the holder of a verified one in (there are no separate 8443 and 9443 TLS endpoints) |
| 8888 TCP | The Kerberized test service that accepts an AP-REQ |
| 8092, 8181 gRPC | The SPIFFE Workload API, and the SPIRE Server API (always mutual TLS) |
| Domain sockets | The Workload API at /tmp/spire-agent/public/api.sock (on), and the SPIRE Server API’s private socket at /tmp/spire-server/private/api.sock (off by default) |
The diagram leaves out two listeners. 8082 is plain HTTP and serves only
/pki/: CRLs, OCSP and CA certificates. 8444 is the embedded protocol
debugger. Getting started lists every port with
its setting.
A listener that fails to bind is recorded, not fatal. The rest of the service keeps running, and each socket reports its own state. The persistence store is the one exception: if it can’t be opened, the service doesn’t start.
The request dispatcher and the worker pools
The dispatcher hands work from the leader to worker threads — each a
worker_threads Worker inside the same process, with its own JavaScript heap
and event loop (#364; they were separate child processes until then):
| Pool | What a worker runs | Setting | Default |
|---|---|---|---|
| Request | The whole protocol stack, the admin console and the user portal included | workers.requestCount |
1 where the store coordinates (PostgreSQL); 0 on the memory or LDIF store |
| Admin | Only the admin console and the user portal, when you want them kept apart from protocol traffic | workers.surfaceCount |
0 (off: they go to the request workers) |
workers.dispatch (default *, everything) says what goes to a worker, and
workers.readYourWrite (default on) makes a worker catch up with what the
leader wrote before it answers. Every worker is a whole second copy of the
service’s memory, so one is the default; add more only where the node has
the cores to use them.
Post-quantum signing, verification and key generation, and password hashing, are not a pool. They run natively on Node’s OpenSSL, on libuv’s thread pool, so they neither freeze the listeners nor start a process of their own (#363; a third pool of forked processes did this until then).
With the admin and request pools off, the leader answers every request itself.
When they are on, their workers start one at a time
(workers.startConcurrency, default 1), because each one loads the whole
store into its own memory as it starts. The listener opens once the first
worker of each pool is up, and the rest start behind it. Every thread has a
heap limit derived from the container’s memory limit and the number of
threads (workers.heapLimitMb: 0 to derive, -1 for none). Each thread logs its
memory every five minutes (the process.memory-report job). A worker whose
heap reaches its limit ends on its own and is replaced, and the service goes
on (STS-WORKER-0046); a container that reaches its memory limit loses the
whole process, which is why few threads are better than many.
Monitoring → Worker Pools (/admin/worker-pools) shows each pool of the node:
its workers now, busy and free, its maximum and initial size, its crashes and
restarts, and its average response time. Monitoring → Node Health
(/admin/node-health) shows the container’s CPU and memory, the process’s,
and each worker thread’s heap.
When they are on, the leader and the workers share state through the persistence store and not through memory. For that reason, asking for workers explicitly without coordination is refused at startup: it would give wrong answers, not just slow ones. The default of one worker simply becomes none on a store that can’t coordinate.
Several nodes work the same way. A cluster is several containers against one PostgreSQL store. A cluster in AWS shows three of them behind a load balancer.
The caches (45 and counting) are what the service remembers instead of working out or fetching again, plus the replay stores that make one-time values work only once. Caches lists each one, how long it holds a value, and the setting that bounds it.
Protocol subsystems
Each box is a protocol family, or a group of closely related ones, and each has a directory of its own in the repository. One file registers every family’s routes, in one order, and that order is the order in which routes match; see How the code is put together.
| Subsystem | Read more |
|---|---|
| OAuth2, OIDC | The authorization server and OpenID provider: /oauth2/*. JWT assertions, GNAP |
| SAML profiles | SAML 2.0 and SAML 1.1, each with its own identity provider. SAML assertions |
| WS-Trust, WS-Federation | /wstrust, /wsfed |
| Kerberos, SPNEGO | A KDC per trust realm on the shared port 88, told apart by the Kerberos realm name in each request, plus the protected service and MS-KKDCP. SPNEGO also signs a person in with a Kerberos ticket. Trust realms |
| LDAP protocol | The directory’s socket view of the embedded LDAP store below |
| SCIM | /scim/v2, provisioning into the same directory with no store of its own |
| SPIFFE | A trust domain per realm: the bundle endpoint, the Workload API and the SPIRE Server API |
| PKI / X.509 | One Root, an Intermediate per realm, CRLs and OCSP. PKI |
| ACME / EST / SCEP | Certificate enrollment through the PKI. ACME, EST, SCEP |
| VC, DID | OpenID4VCI, OpenID4VP and DID Core |
| WebAuthn / CTAP | The second factors: WebAuthn, TOTP and recovery codes |
| SSF / CAEP / RISC | Shared Signals: a transmitter, with this service’s console and portal as receivers. CAEP events, Signals received |
| XACML / ALFA | The PDP, the policy repository and the embedded PEPs. Remote PEP |
| GNAP | RFC 9635, with its resource-server connections. GNAP |
The live list is at /admin/sts-metadata. It reads every protocol and endpoint
off the running router, so it can’t go out of date. Endpoints
explains how to read it.
Hosted surfaces
| Surface | Where |
|---|---|
| Admin UI | /admin, the operator console |
| Management API | /admin-api: every console control, for machines, behind an OAuth 2.0 access token. Its OpenAPI document is at /admin-api/openapi.json |
| User Portal | /portal, the pages that belong to the person signed in |
| Authentication Service | /authn, the sign-in screen and the second factors. It owns the session |
| Debugger UI + API | The embedded protocol debugger, on a listener and origin of its own (8444), open only to console administrators |
The admin console and the user portal are OpenID Connect clients of this service’s own authorization server. They sign people in through the same code flow any other application uses.
Tokens, assertions, tickets and DIDs, over sessions
An authenticated identity is a session, not any one protocol’s token. A session holds a subject, the authentication events that established it, and a stable id. Every artifact the service issues (an ID Token, a SAML assertion, a Kerberos ticket, a verifiable credential) is a projection of that session. Every credential it accepts is evidence recorded on an authentication event. That is why signing in once works for every protocol, and why signing out has one list to end. Sessions defines the session.
Shared services
| Service | |
|---|---|
| Audit | One audit log per trust realm. Every failure is recorded with an error code, which is never sent to a client |
| Logging | Structured logs, info by default and debug for the full record |
| Crypto library | The one module in the service that signs, verifies, encrypts and decrypts |
| System metadata | /admin/sts-metadata and /admin/crypto-metadata, both generated from the running service |
| AppConfig | Five layers of configuration, some settings changeable at runtime. Configuration |
| Monitoring / Metrics | The Monitoring section of the admin console: per-protocol counters, the database, encryption, dead letters |
The directory and the key material, over their stores
The embedded LDAP directory holds people, groups, applications and roles. It
has one tree per trust realm, rooted at the realm’s DNS domain (iyasec.io is
dc=iyasec,dc=io; the default realm’s is global.domain). LDAP on
389/636, SCIM and the admin console are three views of the same store. It is
written to the persistence store: memory, ldif or postgres.
Persistence covers what survives a restart in each.
Crypto material, meaning signing keys and the certificate authority, depends on the mode. In development mode it is regenerated on every start. In product mode it is generated once, kept in the persistence store and sealed under a key-encryption key that the service never generates. That key is read from secure secrets storage: OpenBao in the compose stack, or AWS Secrets Manager in the AWS cluster. Encryption at rest covers both.
What the diagram does not show: trust realms
A trust realm is a separate logical copy of the service, with its own
configuration, keys, sessions, directory subtree and audit log. All realms
share this one process and its sockets, and a path prefix tells them apart
(/realm/acme/oauth2/token). The process-level parts are shared by every
realm: the listeners and worker pools, the two TLS endpoints, the persistence
store and the key-encryption key.
A shared socket does not always mean a shared service. Where the protocol
carries a name of its own, a realm gets its own service on the same port: the
directory is told apart by DN on 389 and 636, and Kerberos by the realm name
inside each request — a realm with its own krb5.realm has a KDC, a principal
database and keys of its own on port 88. SPIFFE is the other way round, told
apart by the address its sockets are bound to. Trust realms
covers what a realm separates and what it doesn’t.
How the code is put together
The service is split by protocol family into directories (the table above), and
at the package root there are only two modules. server.js is the shell: it
loads the protocol stack and listens. sts_metadata.ts reads the router to
list what everything else registered, so it is loaded last.
One composition root registers every endpoint, in one order.
common/protocol_stack.ts requires each module and registers its routes
against the shared express app from common/app.js. A module written in
TypeScript registers nothing when it is required: it exports
registerRoutes(app), and the composition root calls it at the place in the
sequence where its routes belong. The modules still written in JavaScript (the
locked Kerberos files, tls/tls_server.js and ldap/ldap_server.js) still
register when they are required, and the composition root requires them at
their places, so the two kinds interleave in a fixed order. That order is the
order in which routes match. The middleware lives in app.js because express
applies middleware only to routes added after it. The request workers load the
same file without binding any socket, so the leader and the workers cannot
disagree about which handler wins.
Some modules exist to break require cycles, not to group code.
oid4vc/vc_configs.ts holds the credential configurations, which both the
issuer and the authorization server read. oid4vc/vc_offers.ts holds the
Credential Offer’s pre-authorized codes, which are minted by the offer pages
and redeemed at the token endpoint, so that state cannot live in either
OID4VCI or OAuth 2.0 without the 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 common/helpers.js for the same reason, not because they are
especially general.
Some modules are libraries: they register nothing. Their place in the require order does not matter, and they are kept small in what they require so that they cannot join a cycle.
oauth-oidc/dpop.tsrequires onlyhelpers.jsand npm packages.common/admin_stats.jsneeds the property more: it is called fromapp.js’s call log, fromsignJwt(), from both SAML assertion builders, from the KDC and from the credential issuer, so anything it required, all of those would require in turn. Where it needs something it cannot require, the owner of that thing fills a slot in it instead.authn/webauthn.jsfalls back to a silent logger whenhelpers.jsis not resolvable. The parent project’s cross-implementation test copies that one file next to its own scripts, and a verifier written to be checked by somebody else has no business dragging the service in behind it.
One file in the tree is not a module at all: mgmt-api/admin_api_explorer.js
is browser code, read off disk and served verbatim at
/admin/api-explorer/explorer.js. Nothing in node requires it.
The Kerberos files are a stack
Bottom up, the codec files in kerberos/ are:
| File | What it holds |
|---|---|
krb5_primitives.js |
What no runtime provides: CTS, RC4, MD4, MD5 |
krb5_crypto.js |
The RFC 3961 framework and the encryption types |
krb5_asn1.js |
DER for RFC 4120’s ASN.1 |
krb5_messages.js |
The messages, the pre-authentication and the [MS-SFU] structures |
krb5_ndr.js, krb5_pac.js |
The PAC, which arrives in Windows’ RPC marshalling rather than in ASN.1 |
krb5_principals.js |
The principal database, salts and PAC identities |
krb5_gss.js |
The RFC 4121 framing a real service is handed |
krb5_kdc.js |
The KDC |
krb5_service.js |
The acceptor |
Only the last two register anything. All of these files come from the parent project and are not edited here. Kerberos covers what the KDC does.
Kerberos bends two rules. Its modules register their HTTP views like
everything else, but their sockets are started by an exported listen()
that server.js calls: a route cannot fail to register, and binding a
privileged port can. The same holds for every socket owner (LDAP, SPIFFE, the
plain-HTTP revocation listener, the embedded debugger), which is why a listener
that fails to bind is recorded rather than fatal.
And the codec files that also run in the browser (everything except
krb5_principals.js, krb5_kdc.js and krb5_service.js, which reach for
helpers.js, net and dgram) must not require("crypto") at all. They are
staged into the parent project’s client tree and bundled by browserify, which
substitutes a bare require("crypto") with crypto-browserify and ships
elliptic (GHSA-848j-6mx2-7j84, no patched version) into the bundle. So the
codec is written against globalThis.crypto.subtle, which is why every
function in krb5_crypto.js is async, and why MD5 and RC4 are written out by
hand in krb5_primitives.js even though node has both: Web Crypto does not,
and one module that behaves differently in the two places is worse than one
that is slower in both.
That sharing is the opposite arrangement from webauthn.js and bbs2023.js,
deliberately. A codec has to produce the same bytes wherever it runs, so the
tests over it are a round-trip oracle (re-encoding what was read) and
byte-level pinning, rather than two implementations agreeing with each other.
Signing keys
In development mode a realm’s signing keys are regenerated on every start; in product mode they are generated once and kept, sealed, in the persistence store (see the directory and the key material). Two consequences follow, and both are deliberate.
- The
kidis derived from the key material (sts-and the start of a SHA-256 digest of its certificate) rather than being a constant. Two instances, such as a stale container beside a fresh one, cannot both claim the samekidover different keys and make “the signature does not verify” look like a corrupt document instead of the wrong issuer.keys.kidFormatcan name keys by their RFC 9278 JWK Thumbprint URI instead. - Every document that carries or describes a key is served
Cache-Control: no-store: RFC 8414 metadata, OID4VCI credential issuer metadata,jwt-vc-issuer, the JWKS, the DID document and the DID Configuration. A cached copy outlives the key it describes.
Every JWT this service issues is signed with the realm’s key, so it verifies
against the realm’s advertised JWKS. Access tokens are RS256 by default
(oauth2.accessTokenSigningAlg; PS256 under FAPI 1.0 Advanced).
One crypto module
Every signature, verification, encryption and decryption in this service goes
through one module, common/crypto.js. Scattered implementations (XML
signers and verifiers, the halves of a JWE, JWK thumbprints, self-signed
certificate builders, constant-time comparisons) can each be correct on the day
they are written; what they cannot do is stay correct together.
Three things it holds are worth knowing as a user of this service:
- The XML signer is the debugger’s own, vendored byte-identical into
common/vendored/xmldsig.js. Both ends of a SAML or WS-Federation exchange with this service canonicalize with the same code. That matters because a disagreement about canonicalization is invisible until it is a signature that verifies on one side and not the other. - A verifier is always told which element it is checking. A SAML Response carrying a signed assertion has two signatures. Asking “is this Response signed by us” and being answered about the assertion is a step away from accepting a response whose assertion was swapped. A signature whose reference names a different element is refused outright.
- The reference always names the element’s real id:
ID,AssertionID,ResponseIDorRequestID. Nothing is ever invented.
The OpenID4VCI endpoints read this service’s own access tokens back with the
configured oauth2.clockSkewS allowance, like every other reader, so a token
that introspects active is not refused at a credential endpoint seconds early.
tests/crypto_module.js checks the whole surface against xml-crypto, an
independent implementation, in both directions.
The JSON-LD contexts are not optional
common/vendored/bbs2023.js reads the three files in
common/vendored/contexts/ when it is loaded, at module scope. A missing
one is not a degraded feature: the service does not start at all. They are
vendored rather than fetched because a signature is computed over canonicalized
statements. A one-byte difference in a context fails every signature later,
which looks like a crypto bug and is not one.
Origins
This service was extracted from the OAuth2/OIDC Debugger. Two things were adapted rather than copied:
- the Dockerfile, whose
COPYpaths were relative to the parent repository’s root and are now relative to this one; - the JSON-LD contexts, which live in the parent project’s client tree.
bbs2023.jsresolves two layouts, and a siblingcontexts/directory is its second candidate, so no code changed. That is also why the contexts sit besidebbs2023.jsincommon/vendored/rather than anywhere else: keeping them a sibling is what lets a vendored file stay byte-identical.
The protocol suite is written in the parent project and a copy of it runs here,
in tests/vendored/; jobs that only this service needs are written here and
marked local: true. Some of the parent’s tests are interesting for how they
check rather than what: the Kerberos codec tests use the codec’s own symmetry as
a round-trip oracle, pin every byte of the 0x8003 checksum and cover the RFC
3961 encryption types against published test vectors, and the WebAuthn
cross-implementation test runs webauthn.js and the debugger’s own independent
decoder over the same real ceremonies and requires the same verdict from both.
That last one is why webauthn.js must stay loadable on its own.
WS-Federation’s tests are thin, which is worth saying plainly because the
mock relying party makes it look covered: /wsfed/rp verifies a sign-in
response check by check and shows every verdict, but a person has to click it
and read the page. tests/wsfed_wauth_step_up.js holds the wauth step-up and
its refusals. What a real test would add is the other negatives: a wctx that
came back altered, wfresh read as seconds, and an assertion whose signature
reference does not resolve because the SAML 1.1 id attribute was not named. A
passive requestor that issues a good token and posts it to a working relying
party looks finished and proves almost nothing.
