Getting started

Clone it with --recursive

git clone --recursive https://github.com/rcbj/iya-sts.git

The LDAP directory is built on rcbj/node-ldapjs, which is a git submodule pinned as "ldapjs": "file:node-ldapjs" in package.json. Without --recursive the directory exists but is empty, npm install installs a package with no main, and the failure arrives at startup as Cannot find module 'ldapjs' — a message that names a package rather than a submodule.

If you have already cloned:

git submodule update --init --recursive

--recursive rather than --init alone matters if you reached this repository through the OAuth2/OIDC Debugger, where it is itself a submodule: a plain --init there stops one level short of this one.

Run it

docker-compose up

or

docker compose up

or

docker build -t iya-sts .
docker run --rm -p 8081:8081 -e CONFIG_FILE=./env/local.js iya-sts

From an image, not from a checkout. Part of the service is written in TypeScript and is compiled only while the image is built, so node server.js on a checkout stops and says so. Every setting below that is shown as an environment variable is passed with -e.

CONFIG_FILE selects a file in env/ — local.js, test.js or docker-tests.js. At the default debug level every endpoint call and every artifact before and after signing is logged. That is the point of a mock, so resist quietening it; env/test.js is the quiet one if you need it — and it is what the three test launchers select for themselves, since a suite that passes throws its log away and the level is about half of this service’s CPU. Ask any of them for --sts-log-level=debug (or STS_LOG_LEVEL=debug) and the whole record comes back, appconfig file and all.

The path is relative to the repository root, wherever you run node from and whichever module reads it. That has been true since the modules moved into subdirectories, and it is common/config_file.js that keeps it true.

The ports

Only the first is HTTP. The rest are separate listeners, and several of them will not bind on an ordinary user account.

Port What Setting Environment
8081 The main service — every protocol endpoint, the console, the API. HTTPS, since every appconfig file here sets global.https; STS_HTTPS=false makes it plain HTTP. It asks every connection for a client certificate and requires none, so it is also where mutual TLS happens global.port STS_PORT
88 (TCP+UDP) The Kerberos KDC krb5.kdcPort KRB5_KDC_PORT
8888 The Kerberos-protected test service krb5.servicePort KRB5_SERVICE_PORT
389 The LDAP directory ldap.port LDAP_PORT
636 The same directory over TLS (LDAPS) ldap.tlsPort LDAPS_PORT
8092 The SPIFFE Workload API over gRPC (product mode: only where spiffe.workloadTcpSourceAuthenticated is on) spiffe.workloadPort STS_SPIFFE_WORKLOAD_PORT
8181 The SPIRE Server API over gRPC spiffe.serverPort STS_SPIFFE_SERVER_PORT
— The Workload API’s Unix socket, at /tmp/spire-agent/public/api.sock spiffe.workloadSocket STS_SPIFFE_WORKLOAD_SOCKET

A port that will not bind does not stop the service. 88, 389 and 636 all need root, and a host run is usually not root. The failure is RECORDED rather than thrown — a require that throws would take the whole service down where a route cannot — and each listener publishes its own result, because “389 is up and 636 is not” is the ordinary outcome and one flag could only report one of them:

  • GET /admin/ldap/service — listening / listenError, and a tls object with its own pair (an admin console page, so it needs a session; GET /admin-api/ldap/service is the same object and is not gated)
  • GET /spiffe — all four SPIFFE sockets, separately
  • GET /krb5/principals — the KDC

So a page answering 200 is not evidence that the listener behind it came up. Read the flag.

Two TLS ports have left this table. 8443 (tls.port) asked for a client certificate and never required one; 9443 (tls.mutualPort) required one at the handshake. Both listeners and both settings were deleted, and neither setting has a replacement — a deployment that still sets one gets an “unknown setting” warning at startup. The main port already asked for a client certificate and required none, so that half moved nowhere; GET /tls/sign-in signs the holder of a verified one in. Nothing requires a certificate at the handshake any more, deliberately: the port that would have to do it carries every other protocol, so a certificate that does not verify is refused where it is USED — at the token endpoint under RFC 8705, at /xacml, at /scim/v2 and at /tls/sign-in.

Running two copies

Everything is in memory and nothing is shared, so a second instance is just a second process — but every default port collides. Give the second one its own:

docker run --rm -p 8091:8091 \
  -e CONFIG_FILE=./env/local.js \
  -e STS_PORT=8091 -e LDAP_PORT=3891 -e LDAPS_PORT=6391 \
  -e KRB5_KDC_PORT=8891 -e KRB5_SERVICE_PORT=8891 \
  -e STS_SPIFFE_WORKLOAD_PORT=8093 -e STS_SPIFFE_SERVER_PORT=8182 \
  -e STS_SPIFFE_WORKLOAD_SOCKET=/tmp/spire-agent-2/public/api.sock \
  iya-sts

In separate containers the default ports no longer collide inside them; the different values matter for what you publish with -p.

The SPIFFE Unix socket is the one thing this service puts on a filesystem, and two instances sharing a path is the one collision that is not a bind error: the second unlinks the first’s socket as “stale” and takes it over. It says so in the log when it does.

In a container

docker build -t rcbj/sts .
docker run --rm -p 8081:8081 rcbj/sts

The image copies the whole build context (.dockerignore decides what is in it) so that adding a protocol directory cannot be forgotten, installs with --omit=dev, and defaults CONFIG_FILE to ./env/local.js. EXPOSE documents every port above; publishing them is the caller’s decision.

The image’s JavaScript carries no comments. The build takes them out of every .js it ships, compiled from TypeScript or not, because Node keeps the source text of every module in memory for as long as the process runs, and this repository’s comments were about 80 MB of every process’s heap. Nothing else is changed: no name is shortened and no module is bundled. Every line break is kept, so a line number in a stack trace from the image is the line number in the repository — read the comments there. A column number can differ on a line that had a comment in the middle of it. The copyright and licence headers go with the other comments; LICENSES/ and REUSE.toml are still in the image. The build also writes characters such as — in string literals as \u2014 escapes, which gives the same strings and halves the memory Node needs for each file’s source; a line in the image can therefore read differently from the repository while meaning the same.

The Workload API’s Unix socket is inside the container. To reach it from the host or another container, mount its directory as a volume — publishing 8092 is the alternative and needs the client pointed at tcp://host:8092 explicitly.

With PostgreSQL and OpenBao, from published images

docker compose up in a checkout builds the image and starts the service the way a deployment runs it: product mode, its store in PostgreSQL, and its key-encryption key and database password in an OpenBao secret store. The same stack can be run from published images alone, with no checkout and nothing built:

Image From
iyasec/iya-sts Docker Hub; the same image is ghcr.io/rcbj/iya-sts
postgres:18 Docker Hub (official)
openbao/openbao Docker Hub

The database’s TLS and schema scripts and OpenBao’s configuration and seeder are in the iya-sts image, so two one-shot containers of that image copy them into volumes before anything else starts.

Put this in a directory of its own as docker-compose.yml:

# IYA STS with its PostgreSQL store and an OpenBao secret store, from
# published images only: no checkout and nothing built.
#
# The files the database and OpenBao need (postgres/ and openbao/ in the
# repository) are inside the iya-sts image, so two one-shot containers of
# that image copy them into named volumes before anything else starts.
#
# Set three secrets in a .env file beside this one first (see the page).
name: iya-sts

services:
  # Copies the database's TLS, require-TLS and schema scripts into volumes.
  postgres-init:
    image: iyasec/iya-sts:${IYA_STS_TAG:-latest}
    restart: "no"
    volumes:
      - db-initdb:/out/initdb
      - db-share:/out/share
    command:
      - sh
      - -c
      - |
        cp postgres/require-tls.sh /out/initdb/00-require-tls.sh
        cp postgres/apply-schema.sh /out/initdb/10-apply-schema.sh
        cp postgres/generate-tls.sh postgres/schema.sql /out/share/
        chmod 755 /out/initdb/*.sh /out/share/generate-tls.sh
        chmod 644 /out/share/schema.sql

  postgres:
    image: postgres:18
    depends_on:
      postgres-init:
        condition: service_completed_successfully
    environment:
      POSTGRES_USER: sts
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: sts
      STS_DB_APP_USER: sts_app
      STS_DB_APP_PASSWORD: ${STS_DB_APP_PASSWORD:?set STS_DB_APP_PASSWORD in .env}
    volumes:
      - db-data:/var/lib/postgresql
      - db-initdb:/docker-entrypoint-initdb.d:ro
      - db-share:/usr/local/share/sts:ro
    command:
      - bash
      - -c
      - >-
        /usr/local/share/sts/generate-tls.sh &&
        exec docker-entrypoint.sh postgres
        -c ssl=on
        -c ssl_cert_file=/var/lib/postgresql/tls/server.crt
        -c ssl_key_file=/var/lib/postgresql/tls/server.key
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U sts -d sts"]
      interval: 5s
      timeout: 5s
      retries: 10

  # OpenBao's listener certificate and its configuration file.
  openbao-tls:
    image: iyasec/iya-sts:${IYA_STS_TAG:-latest}
    restart: "no"
    environment:
      STS_BAO_TLS_DIR: /openbao/file/tls
      STS_BAO_TLS_NAMES: openbao,localhost
      STS_BAO_TLS_IPS: 127.0.0.1
    volumes:
      - bao-file:/openbao/file
    command:
      - sh
      - -c
      - |
        node openbao/generate-tls.js
        mkdir -p /openbao/file/config
        cp openbao/bao.hcl /openbao/file/config/bao.hcl
        chown -R 100:1000 /openbao/file

  openbao:
    image: openbao/openbao:latest
    hostname: openbao
    depends_on:
      openbao-tls:
        condition: service_completed_successfully
    cap_add:
      - IPC_LOCK
    environment:
      BAO_STATIC_SEAL_CURRENT_KEY_ID: sts
      BAO_STATIC_SEAL_CURRENT_KEY: ${STS_BAO_SEAL_KEY:?set STS_BAO_SEAL_KEY in .env}
    volumes:
      - bao-file:/openbao/file
    command: ["server", "-config=/openbao/file/config/bao.hcl"]

  # Initialises OpenBao, writes the database password and the Transit
  # key-encryption key, and issues iya-sts its read-only client certificate.
  openbao-seed:
    image: iyasec/iya-sts:${IYA_STS_TAG:-latest}
    restart: "no"
    depends_on:
      openbao:
        condition: service_started
    environment:
      STS_BAO_ADDR: https://openbao:8200
      STS_BAO_CA_FILE: /openbao/file/tls/server.crt
      STS_BAO_POLICY_FILE: /usr/src/sts/openbao/read-only.hcl
      STS_BAO_CLIENT_CN: sts
      STS_DB_APP_PASSWORD: ${STS_DB_APP_PASSWORD:?set STS_DB_APP_PASSWORD in .env}
    volumes:
      - bao-file:/openbao/file
      - bao-client:/openbao/client
    command: ["node", "openbao/seed.js"]

  sts:
    image: iyasec/iya-sts:${IYA_STS_TAG:-latest}
    hostname: sts
    depends_on:
      postgres:
        condition: service_healthy
      openbao-seed:
        condition: service_completed_successfully
    ports:
      - "${STS_PORT:-8081}:8081"   # every HTTP protocol, /admin, /portal, /admin-api
      - "${PKI_PORT:-8082}:8082"   # plain HTTP /pki/: CRLs, OCSP, CA certificates
    environment:
      STS_MODE: product
      STS_PERSISTENCE_MODE: postgres
      STS_DATABASE_URL: postgres://sts_app@postgres:5432/sts?sslmode=require
      STS_DATABASE_PASSWORD_PROVIDER: vault
      STS_DATABASE_PASSWORD_REF: secret/data/sts
      STS_DATABASE_PASSWORD_FIELD: databasePassword
      STS_KEYS_KEK_PROVIDER: vault-transit
      STS_KEYS_KEK_VAULT: https://openbao:8200
      STS_KEYS_KEK_REF: sts-kek
      STS_KEYS_VAULT_CLIENT_CERT: /run/secrets/bao/client.crt
      STS_KEYS_VAULT_CLIENT_KEY: /run/secrets/bao/client.key
      STS_KEYS_VAULT_CA_CERT: /run/secrets/bao/bao-ca.crt
      PKI_DISTRIBUTION_PORT: "${PKI_PORT:-8082}"
      # The first console password; empty, one is generated and logged once.
      STS_ADMIN_BOOTSTRAP_PASSWORD: ${STS_ADMIN_BOOTSTRAP_PASSWORD:-}
    volumes:
      - bao-client:/run/secrets/bao:ro
      - sts-data:/usr/src/sts/data
      - sts-run:/run/sts
    command:
      - sh
      - -c
      - |
        # The secret of the seeded `sts-management-api` client, which mints
        # /admin-api tokens: generated on the first start, kept in a volume.
        if [ ! -s /run/sts/admin-api-secret ]; then
          head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24 \
            > /run/sts/admin-api-secret
          chmod 600 /run/sts/admin-api-secret
        fi
        export ADMIN_API_CLIENT_SECRET="$$(cat /run/sts/admin-api-secret)"
        exec node server.js
    healthcheck:
      test: ["CMD-SHELL", "node -e \"require('https').get({host:'localhost',port:8081,path:'/healthcheck',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 20s

volumes:
  db-data:
  db-initdb:
  db-share:
  bao-file:
  bao-client:
  sts-data:
  sts-run:

Beside it, a .env file with three secrets of your own. The seal key unseals OpenBao at every start: lose it and nothing sealed under it can be read again.

cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')
STS_DB_APP_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')
STS_BAO_SEAL_KEY=$(openssl rand -base64 32)
EOF
chmod 600 .env
docker compose up -d --wait

--wait returns once sts is healthy, a minute or two on a first start.

  • Signing in. The console’s first account is admin, with a password generated on the first start and printed once in the log; it has to be changed at the first sign-in. To choose it instead, put STS_ADMIN_BOOTSTRAP_PASSWORD=... in .env before the first start.

    docker compose logs sts | grep -A6 'PRODUCT MODE BOOTSTRAP'
    

    Then open https://localhost:8081/admin and accept the certificate.

  • /admin-api. The seeded sts-management-api client’s secret is generated on the first start:

    SECRET=$(docker compose exec -T sts cat /run/sts/admin-api-secret)
    curl -sk -u "sts-management-api:$SECRET" \
      --data-urlencode grant_type=client_credentials \
      --data-urlencode 'scope=admin:read admin:write' \
      --data-urlencode resource=https://localhost:8081/admin-api \
      https://localhost:8081/oauth2/token
    
  • Stopping it. docker compose down keeps everything in the named volumes, and the next up comes back with the same directory, signing keys and sessions. docker compose down -v deletes it all.
  • Which build. IYA_STS_TAG picks the image tag: latest is the newest build of main, and every build also has its M.N.O. STS_PORT and PKI_PORT move the two published ports.

What the checkout’s own docker-compose.yml has and this does not: the optional remote XACML PEP (the xacml profile), and the extra addresses a realm’s own SPIFFE listeners bind. Kerberos and LDAP are running but not published; add 88, 389 or 636 to ports to reach them from the host. Configuration covers every setting the environment block can take.

Confirming it works

curl -sk https://localhost:8081/healthcheck
curl -sk -L https://localhost:8081/admin/sts-metadata | head -40

The -k is the bootstrap and not a shrug. The main port is TLS on a self-signed certificate this service generates at every start, so nothing that existed before this process can verify it — and with global.https on there is no plain listener left to fetch it from either. One unverified call gets it, and everything after that can be verified:

curl -k https://localhost:8081/tls/server-certificate > /tmp/sts.pem
curl --cacert /tmp/sts.pem https://localhost:8081/healthcheck
export NODE_EXTRA_CA_CERTS=/tmp/sts.pem      # for a node client

It is the same certificate LDAPS 636 and the embedded debugger’s listener serve, so that is one trust decision for the whole service rather than three.

The second one is behind the console gate, which is unconditional: with no session it answers a 302 to the sign-in screen, which is why the -L is there and why what comes back is that screen rather than the page. Open it in a browser and sign in — any username, since this service checks no password in its default development mode. There is no setting that opens the console; admin.authRequired is gone; global.mode answers that question. /admin-api reads the same service for a program, and takes an OAuth 2.0 access token of its own.

A protocol you can drive end to end in a browser with nothing else installed is SAML 2.0: open https://localhost:8081/saml2/sp, pick one of the three bindings, sign in with any username, and the mock service provider verifies the response it gets back check by check. https://localhost:8081/saml2/metadata is the identity provider metadata; https://localhost:8081/saml2/metadata/anything-you-like is a document of its own for a service provider by that name, minted on the spot — in development mode only. In product mode that is a 404 until the service provider is registered.

/admin/sts-metadata is the sharper of the two. It reads the endpoint list off the live Express router, so it answers only once every protocol module has registered its routes — a module that loaded but registered nothing shows up there and not in the liveness probe. Add ?format=json and look at undocumentedPaths, stalePaths and unknownSpecIds: all three empty is the service agreeing with its own description of itself.

Which build am I running?

Every page says so, and so does the log. The version is M.N.O — a release from the repo-root VERSION file plus a build number:

0.1.20260906143205
│ │ └── the build: the UTC instant the image was built (or BUILD_NUMBER)
│ └──── minor
└────── major

The quickest ways to read it:

curl -sk https://localhost:8081/admin-api | head -8   # version, build, commit, stamped
curl -sk https://localhost:8081/                      # the front page's version line
node common/version.js                                # from a checkout, without running it

It is also in the foot of every admin console page and every user portal page, in the first paragraph of /admin/sts-metadata, and in the first line the service logs when it starts.

The remote XACML PEP reports one too, if you are running it (docker compose --profile xacml up). It is on that container’s own GET / and in the Version column of /admin/xacml/peps on this service’s console:

curl -s http://localhost:9090/ | head -20      # the PEP's own page

Its M.N always matches this service’s — both images are built from one tree and one VERSION file — while its build number is its own, because they are two images built at two instants. So a different release on that row is a PEP left behind across an upgrade, and a different build number is just two artifacts. Pass one BUILD_NUMBER to both builds to say they are one release.

stamped is the field worth knowing about. A container reports a build number that was fixed when the image was built, so restarting it reports the same one. A checkout run with node server.js was never built at all — it computes a number when the process starts and reports stamped: false, and every page says so in as many words. Two instances of the same release with different build numbers mean nothing if neither was ever built.

To give an image a build number and a commit of your own:

BUILD_NUMBER=1234 GIT_COMMIT=$(git rev-parse HEAD) docker compose build sts

The commit is a build argument rather than something the image works out, because the build context deliberately carries no .git.

First-Time Admin Login

Go to: https://localhost:8081

Enter admin as the username.

Search the through startup logs for the following line: sts | {"name":"sts","hostname":"sts","pid":1,"level":40,"msg":"=======================================================\nPRODUCT MODE BOOTSTRAP — THIS IS SHOWN ONCE AND NEVER AGAIN.\n\n username: admin\n password: *****************\n\nNobody in this realm's directory held a credential, so this one was generated so that the service is reachable. It is stored as a scrypt hash and CANNOT be recovered — only reset.\n\nCHANGE IT. Sign in at /admin, or POST /admin-api/users/set-password.\n=======================================================","time":"2026-09-25T21:39:17.999Z","v":0}

Take note of the temporary password.

Remember the admin password.

Enter the temporary password into the password field.

Click the Login button.

You will be prompted to change the password. The password must comply with the default password policy, which can be changed later.