Skip to content

Latest commit

 

History

History
749 lines (637 loc) · 42.3 KB

management-api.md

File metadata and controls

749 lines (637 loc) · 42.3 KB
title Management API

Management API

/admin-api is the administrative console at /admin over JSON, for a script, a CI job or a test rather than a person. Whatever the console shows can be read here, and whatever it can change can be changed here. It is not a protocol, but it is protected like one: every call carries an OAuth 2.0 access token that this service issued, in the RFC 9068 format and audienced to the API by an RFC 8707 resource indicator. The API works per trust realm, and each realm's administrators have a token of their own that is confined to their realm.

The OpenAPI 3.1 document is generated from the same table that registers the routes, so an operation cannot exist without being documented. Read the document rather than any list on this page: GET /admin-api/openapi.json, or the explorer on the console at /admin/api-explorer.

Getting a token

The service seeds an application called sts-management-api in every realm. It is a confidential OAuth client that can only use the client-credentials grant. It has no redirect URI, it authenticates with client_secret_basic, and its registered scope is admin:read admin:write.

Scope Lets the token
admin:read make any GET request
admin:write make anything else, meaning every POST that changes state

To get a token, call the token endpoint with the client-credentials grant, the scopes you need, and resource=<base>/admin-api. The resource value sets the token's audience, and the API refuses a token audienced to anything else.

On a stack you run, getting in takes two steps. Use sts-management-api for the first token only (The first token). With that token, create an application of your own that holds the admin roles, and use that application from then on (An application of your own).

BASE=https://localhost:8081          # the main port is HTTPS by default

TOKEN=$(curl -sk -u "sts-management-api:$ADMIN_API_CLIENT_SECRET" \
  --data-urlencode grant_type=client_credentials \
  --data-urlencode 'scope=admin:read admin:write' \
  --data-urlencode "resource=$BASE/admin-api" \
  "$BASE/oauth2/token" | jq -r .access_token)

curl -sk -H "Authorization: Bearer $TOKEN" "$BASE/admin-api/status" | jq

curl -sk -X POST "$BASE/admin-api/config/set" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"key":"groups.claimName","value":"roles"}'

(-k is there because development mode signs the listener's certificate with an authority it generates at each start. See TLS.)

The client secret

adminApi.clientSecret sets the secret. If it is empty, a new secret is generated at every start. That secret can only be read through the API it unlocks, so after a restart nobody can get a token wherever secrets are checked. Any deployment, and any launcher that starts the service for a test run, should set adminApi.clientSecret before the service starts, because the seeded client reads the setting when it is created. After that, the setting is the only way to change the secret: the default realm's regenerate-secret action on this client is refused while the secret is pinned.

You can hand the setting over in two ways:

  • ADMIN_API_CLIENT_SECRET holds the secret itself, in the environment. This works, but the secret then shows in docker inspect and in the process's environment.
  • ADMIN_API_CLIENT_SECRET_FILE holds the path of a file that contains the secret. The service reads the file once at start, before it loads the protocol stack, and then deletes the file. The value is never put in the process's environment. If the file is empty or cannot be read, the service does not start, because starting without the secret would mint a different one. The same _FILE form works for KRB5_KRBTGT_PASSWORD and KRB5_SERVICE_PASSWORD, and for no other setting.

The compose stack in this repository uses the second form, so the secret is never in the service's environment (see the next section).

The first token, on a stack you run

In the compose stack (docker compose up), the secret is kept in OpenBao, at secret/sts-admin in the field adminApiClientSecret. The seeder generates it on the run that initialises the store. To choose the value yourself, set ADMIN_API_CLIENT_SECRET in the host's environment before that first up. Compose passes it to the seeder, which stores it. It does not pass it to the service.

The service's own OpenBao identity cannot read secret/sts-admin. Instead, each up gives the service a single-use token that reads the secret once, at start, and the value reaches the service as a file that it reads and deletes.

To read the secret yourself, use the operator token that the seeder prints each time it runs. That token can read secret/sts-admin and nothing else, and it lasts 24 hours (STS_BAO_OPERATOR_TTL). Find it in the seeder's log, then read the secret with it:

docker compose logs --no-log-prefix openbao-seed \
  | grep 'OPERATOR TOKEN' | tail -1 | jq -r .msg
#   an OPERATOR TOKEN for the first /admin-api token on this stack — read
#   on secret/sts-admin and nothing else, for 24h (docs/management-api.md): <token>

BAO_OPERATOR_TOKEN='<token>'   # the value at the end of that line

ADMIN_API_CLIENT_SECRET=$(docker compose exec -T \
  -e BAO_TOKEN="$BAO_OPERATOR_TOKEN" openbao \
  bao kv get -address=https://127.0.0.1:8200 \
    -ca-cert=/openbao/file/tls/server.crt \
    -field=adminApiClientSecret secret/sts-admin)

Then mint the token as sts-management-api, as the example at the top of this section does.

  • If no operator token is printed, STS_BAO_PRINT_CREDENTIALS=false is set. Run docker compose up again without that setting, and the seeder prints a new token.
  • If the operator token has expired, run docker compose up again. Every seeder run prints a new one.
  • docker compose restart sts does not run the seeder again, so the service starts without its single-use token and without the pinned secret. It then mints a secret of its own that nobody can read. Run docker compose up to start it with the secret again.
  • On the run that initialises the store, the seeder also prints OpenBao's recovery key, once, and nothing keeps a copy of it. The seeder revokes the root token when it finishes, so nothing on disk holds root. The recovery key is what bao operator generate-root needs to make a root token again, for example to change a policy. Store it somewhere safe the first time you see it.

An application of your own, for every token after that

Use the first token to set up the credential you will keep using: create an application, give it a client secret and the ADMIN_READ and ADMIN_WRITE roles, and get its tokens with the OAuth 2.0 client credentials grant. Each token it gets carries the admin scopes that its roles allow. Do not keep using sts-management-api for routine work: its secret is the stack's bootstrap, and reading it means unlocking the secret store.

The realm you create the application in decides what it may administer:

Created in Its token is minted at It may administer
the default realm <base>/oauth2/token, with resource=<base>/admin-api every realm: /admin-api and every /realm/<id>/admin-api
a trust realm <id> <base>/realm/<id>/oauth2/token, with resource=<base>/realm/<id>/admin-api that realm only, under /realm/<id>/admin-api, and with the same limits as that realm's console administrators (Trust realms and per-realm administrators)

API. These calls use the first token, in $TOKEN. For an application in a trust realm, put the realm's prefix on every path ($BASE/realm/<id>/admin-api/…): the roles, the application and the grant all belong to one realm.

API=$BASE/admin-api               # or $BASE/realm/<id>/admin-api
APP=ops-automation                # the client_id of your application

api() {   # api <path under the API> '<json body>'
  curl -sk -X POST "$API/$1" \
    -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
    -d "$2"
}

# 1. A random secret, made by the service. This call stores nothing.
APP_SECRET=$(api applications/generate-secret '{}' | jq -r .clientSecret)

# 2. The application: an OAuth 2.0 client on client_credentials that
#    declares the two admin scopes. The secret becomes its credential.
api applications/create '{
  "identifier": "'"$APP"'", "name": "'"$APP"'",
  "protocols": ["oauth2"],
  "fields": {
    "oauthClientId": ["'"$APP"'"],
    "oauthClientSecret": "'"$APP_SECRET"'",
    "oauthGrantType": ["client_credentials"],
    "oauthTokenEndpointAuthMethod": "client_secret_basic",
    "oauthAllowedScope": ["admin:read", "admin:write"]
  }}'

# 3. The two roles.
api roles/add-member '{"role":"ADMIN_READ","kind":"application","member":"'"$APP"'"}'
api roles/add-member '{"role":"ADMIN_WRITE","kind":"application","member":"'"$APP"'"}'

From then on, the application gets its own tokens. For an application in the default realm:

TOKEN=$(curl -sk -u "$APP:$APP_SECRET" \
  --data-urlencode grant_type=client_credentials \
  --data-urlencode 'scope=admin:read admin:write' \
  --data-urlencode "resource=$BASE/admin-api" \
  "$BASE/oauth2/token" | jq -r .access_token)

For an application in realm <id>, call $BASE/realm/<id>/oauth2/token, with resource=$BASE/realm/<id>/admin-api.

To give an application that already exists a new secret, call POST …/applications/add-secret with {"application": "<client_id>"}. The new secret is in clientSecret in the reply, and this reply is the only place the act hands it out.

Console. On the realm's console (/admin, or /realm/<id>/admin):

  1. Go to Directory → Applications → New application › (/admin/applications/new). Set Identifier to the client_id, tick OAuth 2.0, and click Generate Secret in The client secret. Copy the value before you create the application.
  2. On the application's page, in Change what it is allowed to do: tick client_secret_basic under oauthTokenEndpointAuthMethod and nothing else, add client_credentials to oauthGrantType, and add admin:read and admin:write to oauthAllowedScope.
  3. On /admin/roles, under Give a person, a group or an application a role, choose the role ADMIN_READ, the kind application and your application's identifier, and click Add. Do the same for ADMIN_WRITE.

Why it needs both the scopes and the roles. The token endpoint issues admin:read and admin:write only to a client whose oauthAllowedScope declares them, and each one only while the client is a member of the role that authorizes it: ADMIN_READ for admin:read, ADMIN_WRITE for admin:write. The API checks both again on every call. So if you take the application out of a role, or remove a scope from its oauthAllowedScope, the tokens it already holds stop working at their next call. That is also how to retire the application.

In development mode, outside RFC 9700 and OAuth 2.1 mode, the token endpoint does not check client credentials. In product mode it does. See what is not checked.

What the API checks on the token

It checks these in order, in both modes, and each failure gets its own refusal:

  • Present at all. If there is no token, the answer is 401 with WWW-Authenticate: Bearer, naming both scopes and the resource to ask for.
  • Signed by this service, meaning the default realm's key, or the realm's own key under a realm prefix (see below). Then not expired, and not revoked: a token this service has since revoked is refused, and so is one issued to a person whose account is now disabled.
  • An access token. The typ header must be at+jwt (RFC 9068 section 4), so an ID Token or refresh token signed with the same key is not accepted.
  • An issuer this service publishes, at the address you are calling. An issuer is an address, so a token minted under one host name is refused under another. Mint the token at the address you will call the API at, or set global.publicBaseUrl.
  • The audience. A token for a different audience gets 403. Bearer tokens minted for other resource servers must not be replayable here.
  • Its sender constraint, if it has one. A certificate-bound token (cnf["x5t#S256"]) must arrive on a connection that presents that certificate. A DPoP-bound token (cnf.jkt) must be sent as Authorization: DPoP <token> with a valid proof, not as a Bearer token. oauth2.accessTokenRequireDpop and oauth2.accessTokenRequireMtls apply here as they do at every other resource server in the service (OAuth security).
  • The scope the method needs. The scopes become the built-in ADMIN_READ and ADMIN_WRITE roles, and the XACML access-control policy decides whether that role is enough. A read-only token that tries a POST gets a 403 from the policy, and the message names the scopes the token has.

The refusals are in the STS-API-* rows of error codes. The code is recorded in the audit log and the service log, and is never sent to the caller.

Who a call is recorded as

Every call is written to the audit log, and the row names the subject of the access token: the person the token was issued for, or the client's id for a client_credentials token. The rows an operation writes about what it changed name the same subject. A caller cannot choose the name: an operation's schema refuses an actor member in a request body, and the name always comes from the token.

Where no token is required (development mode with adminApi.authRequired off), a call names nobody.

GET /admin-api/me answers what the API decided about the caller: who the token names, which realm issued it and whether that makes the caller a service or a realm administrator, the scopes and roles in effect, whether it may read and write, and the console pages it may reach. The roles are read on every call, so a role granted or revoked since the token was minted shows at once.

A token ends with its sign-on session

An access token issued to a person through a sign-in is tied to that sign-on session. When the session ends, by a sign-out or by running out, the API refuses the token (401 invalid_token), even though the token itself has not expired. A person's other sessions, and the tokens issued on them, are not affected.

A token that no sign-in is behind is not tied to anything: a client_credentials token works until it expires or is revoked.

What it covers

Every console control, for a machine

Every control added to /admin gets an operation on /admin-api in the same change. An API that covers eight of nine controls is worse than one that covers none, because the missing one is found by a caller who has already written code that assumed it was there. Parity is kept structurally:

  • The API decides nothing on its own. Each POST calls the same action function the console's form posts to, with the action taken from the URL (POST /admin-api/tokens/revoke) rather than from a hidden form field. Each console page is DRAWN from its GET, and each console form is SENT to its POST, so the two doors cannot disagree about what is allowed.
  • A page with no form has only a GET. The audit log is an example. There is nothing to change, so there is no operation to mirror.
  • Each operation's description ends by naming the console control it mirrors, so you can look up the page that shows the same thing.
  • An unknown action is not a 404. It gets the console's own "Unknown action" refusal, which lists the actions that exist.
  • One route per resource, one documented operation per action. Express registers /admin-api/tokens/:action once; the OpenAPI document lists /admin-api/tokens/revoke, /restore, /revoke-kind, /revoke-subject, /revoke-user and /revoke-all as separate operations, each with its own body schema and example. /admin/sts-metadata, which reads the router, shows one row per resource, while the document describes the URLs a caller actually uses.

Nothing in the service can see a form appear on a page, so parity is also checked from outside, by this repository's tests/vendored/admin_api.js. It reads the facts from the running service rather than from a list in the test: the console's page list comes back in GET /admin-api/status, and each action handler, asked for an action that does not exist, replies with the names of the ones that do ("Unknown action "x". The four are: add, remove, clear, replace."). Add an action to the console and that sentence grows, and the test fails until the API has an operation for it. The same test checks every property the document describes against a live reply.

The areas, broadly

The operations are grouped in the OpenAPI document by tag. Broadly, they cover:

  • The service itself. Status and totals, metrics, configuration (read, set, set-many, reset, reset-all), trust realms, token lifetimes, persistence, the database, encryption at rest, secrets, caches, the worker pools, node health, the scheduler, the cluster, the error-code table and the audit log. GET /admin-api/worker-pools and GET /admin-api/node-health answer every cluster node by name, with ?node=<name> for one.
  • Identities. People and groups in the directory, second factors (TOTP, WebAuthn, recovery codes), password policies, sessions and sign-out. Each security key on GET /admin-api/users?user= carries attestation — what its attestation statement proved (format, type, verified, trusted, the anchor, the model the FIDO metadata names) — and GET /admin-api/webauthn reports the attestation policy in force and the FIDO MDS BLOB (#105).
  • Applications and what they are issued. The application register, consent, permissions, roles, delegation and who may delegate (GET /admin-api/delegation/policy), issued tokens, the claim and SAML attribute sets, and signing keys.
  • Each protocol family's settings and state. OAuth 2.0 / OIDC and authorization servers, SAML 2.0 and 1.1, WS-Trust, WS-Federation, federation relationships, Kerberos principals, LDAP, SCIM, SPIFFE, Shared Signals (CAEP, RISC), OpenID4VCI, OpenID4VP and credential status, XACML, GNAP, and certificate enrollment (ACME, EST, SCEP).
  • The PKI. Certificate authorities, key pairs, certificates and the TLS truststore.
  • Administration. The two console roles (/admin-api/rbac) and the API explorer's own report.

For the full list, with request and response schemas and examples, see GET /admin-api/openapi.json. The index at GET /admin-api returns the version and build, whether the API is protected, and a one-line summary of every operation. Both describe the management operations alone; the console's own are below.

The console's own operations

The admin console is a static application whose one data source is this API, and some operations exist only to draw it: its frame, its drawings and its form helpers. Their answers follow the console's pages, which change, so they are not part of the management API and live apart from it under /admin-api/console (#454):

Operation What it is for
GET /admin-api/console The frame around every page: the sidebar, banners, realm switcher and footer
GET /admin-api/console/operations Every operation of both kinds, for resolving a console form to its operation
GET /admin-api/console/openapi.json The OpenAPI document for these operations
GET /admin-api/console/dashboard The front page: GET /admin-api/status with the base URL, persistence and the visible sections
GET /admin-api/console/api-explorer The explorer page's own facts
GET /admin-api/console/delegation-settings The permissions register as its page lays it out
GET /admin-api/console/delegation/{map,cluster,allowed,chain}, GET /admin-api/console/federation/map The drawings
GET /admin-api/console/delegation/{user,application} One person's or application's delegations, drawn
GET /admin-api/console/applications/new The new application form's layout
POST /admin-api/console/pki/{apply-profile,generate-keys,generate-alt-keys,use-key} Fill the certificate form; write nothing
POST /admin-api/console/applications/generate-secret Mint a secret for the new application form; write nothing

Every console control is still a management operation. A console operation is a read or a form helper that writes nothing, and the data behind a drawing is a management operation too: GET /admin-api/delegation/user, /delegation/application, /applications/new and /status answer the same data as their console twins without the drawing or the form's layout, and refuse format=svg (STS-API-0129). A form helper asked at its old address is answered 404 (STS-API-0128).

They take the scope admin:console, which is issued only to the console's client: the ADMIN_CONSOLE role authorizes it, and it is conferred by sts-admin-console on every person who signs in to the console rather than held by anybody (roleConferredBy, managed on /admin/roles and with POST /admin-api/roles/add-conferring-client and remove-conferring-client). The roles a person holds themselves and the roles their client confers are mixed on every call. The frame and the operation list need ADMIN_CONSOLE alone, so a person with no console role is still told so; every other console operation needs ADMIN_READ too, or ADMIN_WRITE for a form helper. A token without admin:console, such as sts-management-api's, is refused 403 (STS-API-0127).

A console client entry persisted before #454 does not declare admin:console. Add it to that entry's oauthAllowedScope and oauthGlobalConsent and sign in again.

Request bodies

A POST takes a JSON body. Before the operation runs, the body is checked against the schema the OpenAPI document publishes for it. An unknown member, a value of the wrong type, or a value outside the enum a field declares gets 400 { "ok": false, "errors": [...] } (STS-API-0009) and nothing changes. Every POST replies with the console action's own result: ok is always present, and the other members depend on the action.

Closed sets

A field that takes only certain values declares them as an enum in the OpenAPI document, and that one list is enforced everywhere the value can be typed: a request body, a query parameter (STS-API-0124), and the console form that the operation mirrors (STS-ADMIN-0820). Each refusal names the field, the value, and every value it accepts:

"deliver" is "fax", which is not one of the 2 values it accepts: "show", "mail".

Case must match exactly. An empty string counts as "not given". A setting written through /config/set-many is held to its own list: an enum setting's enumValues, and for a comma-separated setting, every entry against its csvValues. GET /admin-api/config publishes both.

The explorer

/admin/api-explorer (Server configuration → API explorer) is a console page, behind the console's session and roles. It shows the management API's document — the console's own operations are not in it — a form for each operation, the response, and the equivalent curl command. Its Try it button calls /admin-api with a token the page mints for you. That token carries only the scopes your console roles grant (admin:read for Admin Read, admin:write for Admin Write). The API still checks it on every call, so a reader with only Admin Read who tries a POST gets the same 403 as from a terminal.

The explorer mints a default-realm token, which is a service credential, so a realm's own administrators cannot see it. It also stops working while oauth2.accessTokenRequireDpop is on, because its script sends a plain Bearer header and has no DPoP key to prove. With that setting on, use curl and a proof.

The explorer is the one console page with a script. The console is script-src 'none' like the rest of the service, which is what makes the whole family of reflected-content problems moot rather than merely unlikely. The explorer relaxes that on its own two routes and in exactly two clauses: script-src 'self', and connect-src 'self' so the page can call the API it documents. Everything else, default-src 'none' included, stays as it is, and the console next door is still script-src 'none' — which the tests assert. The script is a separate file because 'self' is enough for a file where an inline block would need 'unsafe-inline', the clause that would make the relaxation matter. It is served verbatim from disk, is not a node module, and builds every element with createElement rather than assigning innerHTML, because it renders response bodies that are not always this service's own.

Trust realms and per-realm administrators

The API is scoped to a realm by the same path prefix as every other endpoint. /admin-api/config is the default realm's configuration and /realm/acme/admin-api/config is acme's. A set posted to the second changes acme alone. Every operation works per realm.

The five operations under /admin-api/realms manage the realm registry, and there is one registry per process. GET /admin-api/realms returns the same list under any prefix, with current naming the realm the call arrived in. remove refuses to remove that realm, because the caller would be left calling a prefix that no longer exists.

Two kinds of token are accepted:

Token Minted at Accepted at May do
The service token the default realm's /oauth2/token, with resource=<base>/admin-api /admin-api and every /realm/<id>/admin-api everything its scopes allow
A realm's own token /realm/<id>/oauth2/token as that realm's sts-management-api, with resource=<base>/realm/<id>/admin-api /realm/<id>/admin-api only that realm's operations only

Either token must have been issued to a client that declares the scope it uses (#110). admin:read and admin:write are issued only to a client whose oauthAllowedScope lists them, in both modes — any other client is refused invalid_scope at the token endpoint — and the API asks again on every call, in the realm that issued the token: a token whose client no longer declares the scope an operation needs is refused 403 (STS-API-0123), so withdrawing the declaration cuts off tokens already issued. The seeded sts-management-api and sts-admin-console declare both in every realm; an administrator may declare them on another client with POST /admin-api/applications/add. A dynamic registration may not.

The subject of a token must also hold the role the scope goes with (#302, #303). ADMIN_READ and ADMIN_WRITE are roles on /admin/roles that authorize admin:read and admin:write. They are seeded in every realm, and sts-management-api is a member of both.

  • A person holds them through the console's Admin Read and Admin Write groups. A person signing in through a client that declares the scopes (by the authorization code flow, a refresh, a token exchange or another grant) gets only the scopes their console roles cover. While the console is still open to everybody (in development, before the bootstrap administrator's first sign-in), everybody gets both. The bootstrap administrator gets neither until that first sign-in to /admin with its password.
  • An application on client_credentials gets a scope only while it is a member of the role. To let another client use the API, declare the scope on it and add it to the role: POST /admin-api/roles/add-member with {"role": "ADMIN_READ", "kind": "application", "member": "<client>"}.

A scope the subject's roles do not cover is left off the token, and the token response's scope says what was issued. If nothing else was requested, the request is refused with invalid_scope. The API checks again on every call: a token whose subject has since lost the role is refused with 403 (STS-API-0125), even before it expires.

A realm's token is tried only under that realm's prefix. It must carry the realm's issuer and audience. The token is then refused whatever the console refuses the realm's administrators (STS-API-0112), and the same rule decides both doors:

  • service pages: persistence, database, encryption, secrets, the debugger, TLS and its truststore, the LDAP service page, and the API explorer;
  • creating or removing a realm, or naming another realm;
  • build-root or a * scope on the PKI, and exporting the tls-server key;
  • any setting that belongs to the process: every admin.*, adminApi.*, realms.*, workers.*, persistence.*, debugger.*, tls.* and keys.* row, global.mode, global.publicBaseUrl and the listener settings, among others.

A realm's client secret is generated at each start and is not pinned by adminApi.clientSecret, which applies only to the default realm's client. A realm administrator reads or regenerates it on their realm's console at /realm/<id>/admin/applications?application=sts-management-api.

Trust realms covers realm administrators on the console side.

Development and product mode

With adminApi.authRequired on (the default), both modes require the token and check it the same way. Only development mode can turn it off. What global.mode changes around the API:

Development Product
The token, when adminApi.authRequired is on required and verified required and verified
The client secret at /oauth2/token not checked, outside RFC 9700 / OAuth 2.1 mode checked (product mode implies RFC 9700 mode)
adminApi.authRequired off the API is open to anybody who can reach the port ignored: the token is still required. Writing false is refused (STS-CORE-0103), and a false already stored is read as true and logged once (STS-CORE-0106)

The off switch exists because it is the recovery path in development. If nobody can mint a token, it restores the open API so that POST /admin-api/rbac/grant can give somebody a console role again. That also means anybody who can reach the port can grant themselves both roles, which is as dangerous as it sounds.

Product mode has no off switch (since 2026-10-05). This API's gate is becoming the admin console's only gate, so a setting that opened the API would open the console too. Until then product fell back to the console's own session and roles when the switch was off; a console session is no longer a credential here in either mode. The recovery path in product is the seeded sts-management-api client: set adminApi.clientSecret before the service starts, so a token can always be minted. In the compose stack that secret is in OpenBao, and the seeder's operator token reads it (The first token). An application of your own that holds both roles is a second way in, and it does not depend on the secret store.

Whatever the state, whoever can call this API can revoke every token this service has issued and change what the next one contains. Do not expose a development-mode instance on a public address.

The policy layer runs wherever the token is required. In development with the switch off there is no credential and so no subject, and a policy that refused an unauthenticated subject would close the recovery path, so it is not asked there.

Configuration

adminApi.* and admin.* apply to the whole process: a trust realm cannot override them, because they decide who administers the service.

Setting Environment variable Default Runtime? What it does
adminApi.authRequired ADMIN_API_AUTH_REQUIRED true yes Require an access token on every /admin-api call; off restores the open API. Off is honoured in development mode only: product refuses it and ignores it where it is stored.
adminApi.clientSecret ADMIN_API_CLIENT_SECRET empty (generated per start) no The client_secret of the default realm's seeded sts-management-api client; set it so the secret survives a restart. Secret. ADMIN_API_CLIENT_SECRET_FILE names a file to read it from instead; the file is deleted once read.
adminApi.audience ADMIN_API_AUDIENCE derived: global.publicBaseUrl + /admin-api, or this process's scheme, host and port + /admin-api yes The aud a token must carry. At its default, /admin-api under the host the request arrived on is accepted as well; any other value pins that one value.
admin.readGroup ADMIN_READ_GROUP admin-read yes The directory group whose members hold Admin Read, which lets them read the console and, with the token gate off in product mode, GET the API.
admin.writeGroup ADMIN_WRITE_GROUP admin-write yes The directory group whose members hold Admin Write; write implies read.
admin.openWhenEmpty ADMIN_OPEN_WHEN_EMPTY true yes Keep the console open to every signed-in person until the bootstrap administrator first signs in. Development mode only: product never opens it.
admin.bootstrapUsername STS_ADMIN_BOOTSTRAP_USERNAME admin no The administrator seeded at startup in every realm, a member of both roles, with a forced password change.
admin.bootstrapPassword STS_ADMIN_BOOTSTRAP_PASSWORD empty no In product mode, the bootstrap administrator's password instead of a generated one that is logged once. Secret; held to the password policy.
xacml.enforceAccess STS_XACML_ENFORCE_ACCESS true yes Ask the XACML policy before letting a caller through; on the token path this is what checks the scope.
oauth2.accessTokenRequireDpop STS_OAUTH2_ACCESS_TOKEN_REQUIRE_DPOP false yes Refuse any access token that is not DPoP-bound and proved, here and at every other resource server; the explorer stops working.
oauth2.accessTokenRequireMtls STS_OAUTH2_ACCESS_TOKEN_REQUIRE_MTLS false yes Refuse any access token that is not bound to the certificate the connection presents (RFC 8705).
global.publicBaseUrl STS_PUBLIC_BASE_URL empty (read from each request) yes The base of every issuer and address the service builds, including this API's default audience.
global.mode STS_MODE development yes development or product; decides whether adminApi.authRequired=false is honoured. Per trust realm.

The adminApi.* and admin.* groups are edited on /admin/rbac. See Configuration for how values resolve, and for changing them on the console or with POST /admin-api/config/set.

Design decisions

  • An API, because a form is the right shape for a person and the wrong one for anything else. Every console page answered ?format=json from the start, so reading was never the problem; changing something was. Since the console became a static application over this API (2026-10-05), the API is not a second door beside the console but the only one: the console is its client. Without the API a script that wanted to revoke a token, or a CI job narrowing the issuer's claim set before running a wallet against it, would have to parse a 303 redirect for its message or know which hidden input a form carried — driving a browser without one.
  • The API mirrors the console and decides nothing. Both doors call one action function and one read view, so they cannot differ on what is allowed. The console page is drawn from the same model the API returns. The way to see it is not to read the code: revoke a token through the API and RFC 7662 introspection calls it inactive, because there is one set of revoked jtis in the service and /oauth2/revoke writes to the same one.
  • The OpenAPI document is generated, not written. It is built from the route table, so it cannot describe an operation that does not exist or omit one that does. The explorer's copy is built by the same function.
  • A token, not a console session. /admin-api is a machine surface with no browser and no sign-in screen, so it is reached the way a machine reaches any resource server. A console session is not an API credential, and a token is not a console session.
  • One gate on the base path. It covers all of the several hundred operations. A check in each handler would give the next operation a chance to be added without one.
  • Scopes become roles, and the policy decides. admin:read and admin:write become the built-in ADMIN_READ and ADMIN_WRITE roles, and the XACML access-control document asks for the role each method needs. The requirement is stated where every other access decision in the service is stated, and a policy can name the roles without knowing about OAuth. With xacml.enforceAccess off, that question is not asked, so any valid token audienced to the API gets through whatever its scope.
  • The audience is checked, and so is the token type. A Bearer token for another resource server must not be replayable here. Because every token the service signs uses the same key, without the at+jwt check an ID Token whose audience happened to name the API would be an administrative credential.
  • The service token is verified with the default realm's key, wherever it is presented. If a token minted in a realm counted as the service credential, anybody who could create a realm could mint a token for everything. A realm's own token is therefore believed only in that realm.
  • admin:* is tied to the client, in both realms and both modes (#110). Otherwise any client that could use client_credentials could mint Admin Write. The token endpoint issues them only to a client whose oauthAllowedScope declares them, a registration cannot declare them, and the gate asks the same question of every token (STS-API-0123).
  • The secret is a configuration setting, not an action. A secret generated per start is readable only through the API it unlocks. That is a bootstrap hole, so adminApi.clientSecret pins it, and regenerating a pinned secret is refused rather than letting the entry and the setting disagree.
  • An off switch that restores the open API. It is the way back in when nobody can mint a token and nobody holds a console role. It is a separate setting from anything on the console, so turning the API's gate off never opens the console.
  • The policy layer cannot remove the recovery path. With the token gate off, the policy is asked only in product mode, where a session supplies the subject.
  • Where an API write records who acted, it records via: api and no person. The API authenticates a client, not a person, and has no session to name one.
  • POST /admin-api/logout/end with an empty selection is refused. Global logout is its own named operation, global, because an empty list arriving at end usually means a caller built a list and got nothing.
  • The explorer is the service's own, not Swagger UI. It is about 450 lines with no dependency, where swagger-ui-dist would add about 11.7 MB and an install-time telemetry package, in an image built where there may be no network beyond the registry. It does what was needed — read the document, fill a form, show the response — plus the equivalent curl line beside each operation, which is what an operator actually copies. It is one of the few pages allowed a script, and it loads the script as a file (script-src 'self'), never inline.
  • The document declares the credential. It carries security, two securitySchemes, and the scope on every operation. security: [] would be OpenAPI for no credential is needed, and a client generated from it would send none and be refused by every operation. tests/admin_api_document_security.js and tests/vendored/sts_admin_api_auth.js hold the document to the gate from both ends.
  • A test still needs no secret of its own. The test launchers mint one token before any job runs and hand it to every job.

In the running service

Where What
GET /admin-api The index: name, version and build, whether the API is protected, where the document and the explorer are, and a summary of every operation
GET /admin-api/openapi.json The OpenAPI 3.1 document, with servers[0].url set to the address the request reached. Behind the gate like everything else
GET /admin-api/status The cheapest call, and the one to poll: the issuer, the start time and the running totals
GET /admin-api/me What the API decided about the caller: who, which authority, the roles in effect and the pages they may reach
/admin/api-explorer The explorer, on the console; GET /admin-api/api-explorer reports where the document is, how many operations it describes and what your roles would grant
/admin/rbac The two console roles, and the adminApi.* and admin.* settings; GET /admin-api/rbac and POST /admin-api/rbac/{grant,revoke}
/admin/applications?application=sts-management-api The seeded client in this realm, including its secret
/admin/xacml The access-control policy the scope check runs through
/admin/error-codes The STS-API-* codes behind each refusal, also at GET /admin-api/error-codes
/admin/audit The audit log, also at GET /admin-api/audit

At startup the service logs how many operations the API has and whether a token is currently required.

Related