7. Cluster Authentication with the Bundled Auth Stack
Status: Accepted
Date: 2026-08-29
Related: ADR 0009 (authentication service),
docs/source/Security.md
7.1. Problem
This section describes the state before this design was implemented.
ADR 0009 defines one logical issuer per cluster. It defines the node roles
standalone, owner, and follower. The runtime already contains the
required pieces: role resolution, the inert identity App on followers,
and bearer validation at the gateway and at the target. Three gaps remain.
Each gap blocks a working multi-node deployment of the bundled stack.
script/docker/docker-compose.yamlstarts a master and three slaves. Every node keeps the default rolestandalone. Each node then runs its own Dex process with its own signing keys. The issuer string is identical on all nodes, but the keys are different. A token from one node fails signature validation on another node. Cross-node forwarding cannot authenticate.Joining a node to the owner requires manual edits of
auth-stack.yamland of environment variables. There is no standard setup action.setup.shhas no role option.Authorization data (
authorization.yaml) is node-local. No sync mechanism exists. The documentation requires “shared authorization data”. It does not say how an operator keeps the nodes consistent.
7.2. Goals
Provide one standard install-time action. The action configures a node as the authentication owner or as a follower of an owner.
Followers must not run Dex. Followers must not create built-in credentials. Followers must verify tokens from the owner’s issuer.
Show the owner/follower model and authenticated forwarding in the packaged docker-compose cluster.
Document the consistency procedure for authorization data.
7.3. Non-goals
Automatic authorization-data replication between nodes. Every mutation endpoint writes node-local state today. Replication needs a separate design. This document defines the manual procedure only.
Active-active authentication. One active writer is allowed. See ADR 0009 and
Security.md.Windows. The bundled stack ships on Linux and macOS. Windows installations stay external-issuer consumers.
setup.ps1stays unchanged.
7.4. Topology
┌─ owner node ────────────────────────────────┐
clients ──────► │ Agent :6060 (public TLS) │
│ └─ /auth/* ──► Dex 127.0.0.1:6062 (loopback) │
│ Engine :6058 HTTPS + WSS / :6059 TCP │
└──────────────┬───────────────────────────────┘
│ forwarded requests (TLS TCP :6059,
│ msgpack, bearer unchanged)
┌─ follower nodes ────────────────────────────┐
│ Engine only. identity App holds inert. │
│ Discovery/JWKS fetched from the owner. │
└──────────────────────────────────────────────┘
Exactly one node is
standaloneorowner(is_auth_owner). Only that node runs Dex. Only that node bootstraps credentials (src/auth/appmesh-auth.sh).Every node configures the same issuer string. Tokens carry
issequal to that string. Every Engine validates tokens locally against it.access_urlis per-node routing. It can differ from the issuer: the owner can use its loopback Dex, and followers use a route to the owner.
Two routes give followers access to the owner:
| Route | issuer / access_url | When to use |
|---|---|---|
| A. Agent proxy (default) | https://owner.example.com:6060/auth |
TLS front door. This route matches the rule "the issuer listener stays on loopback, Agent is the only public proxy". |
| B. Protected network | http://<owner-cluster-address>:6062/auth with APPMESH_AUTH_LISTEN bound to the cluster interface |
Container or compose networks and private networks only. Do not expose the plaintext listener publicly. |
The docker-compose cluster uses route B. The compose network is a protected cluster network.
7.5. Install and configuration
7.5.1. Owner
sudo /opt/appmesh/script/setup.sh --auth-mode builtin --auth-role owner
# Optional public issuer (default: http://127.0.0.1:6062/auth):
sudo /opt/appmesh/script/setup.sh --auth-mode builtin --auth-role owner \
--oidc-issuer https://owner.example.com:6060/auth
--auth-role ownerpersistsAPPMESH_AUTH_ROLE=ownerin the daemon environment file. The resolution order stays: environment first, thenauth-stack.yaml(AuthorizationStore::resolveAuthRole,appmesh-auth.sh).The issuer is one setting that controls three things: the issuer that Dex advertises in discovery, the
issvalue that the Engine verifies, and the path that the Agent reverse proxy mounts.
7.5.2. Follower (the join action)
sudo /opt/appmesh/script/setup.sh --auth-mode builtin --auth-role follower \
--oidc-issuer https://owner.example.com:6060/auth \
--oidc-access-url https://owner.example.com:6060/auth \
--oidc-browser-entry https://owner.example.com
Effects:
The
identityApp stays enabled and inert (hold_system_app). It stays healthy, starts no local Dex, writes no authentication state, and does not trigger the restart policy.The Engine fetches discovery and JWKS through
access_url. It rejects every token whoseissdiffers from the configured issuer.Startup tolerates an unreachable owner for 120 seconds (
AppMeshDaemon::prewarmAuthentication). Startup fails after that period. Start followers after the owner, or rely on the restart policy.
If you omit --auth-role, the setup keeps the current role selection. The
packaged auth-stack.yaml default (standalone) then applies.
7.5.3. Configuration matrix
| Setting | Owner | Follower |
|---|---|---|
AuthStack.role / APPMESH_AUTH_ROLE |
owner |
follower |
OIDC.issuer / APPMESH_AUTH_ISSUER |
canonical issuer | identical string |
OIDC.access_url / APPMESH_AUTH_ACCESS_URL |
loopback http://127.0.0.1:6062/auth is valid |
a route that reaches the owner's Dex |
OIDC.tls_verify, OIDC.ca_path |
as the route requires | as the route requires |
| Dex process | runs as identity System App |
never runs |
| first-admin enrollment window | open until claimed, loopback only | none (AuthorizationStore opens it only for standalone/owner) |
7.6. Forward authentication
The sequence below describes appm -H <gateway> -F <target> ....
The CLI sends
GET /appmesh/auth/configwithX-Target-Host: <target>. Two GET requests can forward without a bearer:GET /appmesh/auth/configandGET /.well-known/oauth-protected-resource. The gateway rejects every other anonymous forward request (Worker::isPublicForwardRequest).The CLI gets tokens directly from the shared authentication service. Issuer-side discovery is a direct client-to-issuer fetch. It never forwards. Session reuse requires a match of issuer, client ID, and audience with the stored session.
The CLI sends the request with
Authorization: BearerandX-Target-Host: <target>.The gateway validates the bearer before it opens an outbound connection. HTTP and TCP requests are verified at the gateway (
Worker::process). A WSS session is authenticated at the upgrade. A forwarded WSS frame repeats the same bearer. The bearer must resolve to the principal that is pinned to the connection.The gateway erases
X-Target-Hostand injectsX-AppMesh-Forwarded: 1plus a route UUID for event correlation. The target never sees a forwarding selector. The target cannot re-forward. A forwarding loop cannot occur. No TTL and no visited list is necessary.The gateway forwards the request over its TLS TCP (msgpack) channel to the target’s TCP port. The
Authorizationheader is forwarded without a change.The target validates the same bearer through the normal route path. It checks
issfor an exact match, checksaud: appmesh-api, and fetches JWKS from its ownaccess_url, which points to the owner. It resolves the principal from its local authorization data and applies permissions.X-AppMesh-Forwardedmarks the request as forwarded. Direct-only operations reject it. Examples: first-admin enrollment and workflow capability.
Gateway validation is admission control. It prevents anonymous use of the outbound connection. Target validation is the authorization decision. The gateway and the target share no secret. They only agree on the issuer.
7.8. docker-compose cluster
script/docker/docker-compose.yaml changes:
appmesh_master:APPMESH_AUTH_ROLE=owner,APPMESH_AUTH_ISSUER=http://appmesh_master:6062/auth, andAPPMESH_AUTH_LISTEN=0.0.0.0:6062. The compose network only reaches this listener. Do not publish the port publicly. A container healthcheck callsappmesh-auth.sh service-health.appmesh_slave*:depends_on: appmesh_master: service_healthy,APPMESH_AUTH_ROLE=follower, the identical issuer, and an explicitAPPMESH_AUTH_ACCESS_URL=http://appmesh_master:6062/auth. The packagedoidc.yamldefault points at loopback. That default is wrong on a follower.Clients reach the issuer in-network at
http://appmesh_master:6062/auth. Host-side CLI usage publishes the master REST port. For password and device flows only, bind the Dex port to host loopback. The CLI then passes--auth-access-url. This argument is routing-only. The published endpoints still must sit on the issuer origin.
7.8.1. First sign-in in the compose cluster
The compose network runs the issuer over plain HTTP. The enrollment accepts a loopback client only. Do the first sign-in inside the master container. Use the loopback WSS listener and the packaged administrator password:
docker exec appmesh_master sh -c 'printf "%s\n" "$(grep "^password=" /opt/appmesh/work/auth/secrets/initial-admin-credentials | cut -d= -f2-)" | /opt/appmesh/bin/appm -H wss://127.0.0.1:6058 logon --username admin@appmesh.local --password-stdin --auth-allow-http'
--auth-allow-httpis necessary. The issuer uses plain HTTP on the protected compose network.The server certificate of the master covers
127.0.0.1, the container address,appmesh_master, andlocalhost.Then copy the owner’s
work/config/authorization.yamlto every follower and restart the follower daemons. See “Authorization data consistency” above. A fresh follower has nowork/configdirectory. Create the directory before the first copy.
7.8.2. Web UI entry
The appmesh_ui service shares the network namespace of
appmesh_master and serves HTTPS on port 443. Point a browser at
https://<host>/. The UI proxies /appmesh/ to the master agent on
6060 and /auth/ to the master Dex listener. The UI image waits for
the API upstream before nginx starts. If the upstream stays
unreachable for 60 seconds, the container exits. The restart policy
then attaches a new container to the live namespace of the master.
7.9. Verification
The verification runs in the appmesh-e2e environment against a
make pack output (package root). Two instances share one host. Different
port ranges separate the instances.
The owner uses the default ports. The follower uses offset ports. Both instances come from the same package root.
Check the follower process list for a Dex process. Check the authentication state directory. It must stay empty of identity files.
Run the first-admin sign-in on the owner with the built-in password method:
appm logon --username admin@appmesh.local. Use a loopback connection.Send a forwarded request from the owner Engine to the follower. Use
X-Target-Host: 127.0.0.1:<follower-tcp-port>. Expect a successful response. Check the follower log for its own validation of the same bearer.Send the same token directly to the follower. Expect a successful response.
Send a tampered token to both nodes. Expect rejection.
This procedure was verified in the appmesh-e2e environment against the
packaged compose cluster.
7.10. Limitations
HTTP-protocol SDK clients append their own base port to
X-Target-Host. The Go Agent handles this case: port 6060 becomes an HTTPS hop. The Engine gateway does not handle this case: it selects a TCP-msgpack forward to port 6060, and no listener serves that protocol on the port. WSS clients, TCP clients, and explicit:6059targets work against the Engine gateway. This limitation is documented and is not changed here.Browser flows (authorization code with PKCE) require browser access to the issuer origin. Off-network clients are limited to password and device flows through
--auth-access-urlrewriting.Owner failover stays the manual fence-and-promote sequence in
Security.md.