Skip to main content

How to Make an Approver a Person

By default nanoinfra authenticates a token, not a person. Two operators who share that token are one credential, so an audit record says "the WebUI approved it" and never who did.

This guide puts an identity provider in front of the gateway. An approval then names a person, and the gateway verifies that name rather than trusting it.

What you will build​

  • a login that authenticates a person before the gateway sees the request
  • a gateway that verifies the signature of the assertion it receives
  • gates.approvers entries that name people
  • an audit log that answers who asked and who approved

When to use this​

Use it when more than one person operates the gateway, or when an approval has to be attributable. Skip it for a single-operator homelab: a shared token is honest there, and this guide adds two processes.

The shape, before any configuration​

Three parts, and each one does a job no other one can do.

PartJob
an identity providerauthenticates the person and signs a token that says who they are
a proxyruns the browser login flow, and passes that token to the gateway in a header
the gatewayverifies the signature, and reads the person out of the token

nanoinfra never talks to the identity provider except to fetch its public keys. It runs no OIDC flow, holds no client secret and knows no realm. So the provider is interchangeable, and Keycloak, Google, GitHub, Azure AD, Authentik, Dex and Cloudflare Access are all the same amount of work.

Two pieces is the floor, and that is worth knowing before you count containers. Signing a token and running a browser flow are different jobs. Dex signs and proxies nothing. Authelia does both. Its forward-auth mode asserts a plain header with no signature, so a verifying deployment needs a proxy in front of it as well.

Install​

Start from the worked example in the repository, which runs all three parts:

git clone https://github.com/nanoinfraorg/nanoinfra
cd nanoinfra/examples/auth
cat README.md

That directory holds Dex, oauth2-proxy and the gateway in one compose file. Its README lists the four placeholders to replace and the three files that name the example person. It is an example and not a deployment: every port binds to loopback, and two of the placeholders refuse to start rather than work.

Minimal working example​

The gateway side is one config block. This is the whole surface:

{
"channels": {
"websocket": {
"host": "127.0.0.1",
"port": 8765,
"trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32"],
"assertionHeader": "X-Nanoinfra-Assertion",
"assertionFormat": "jwt",
"issuer": "https://accounts.google.com",
"audience": "<the OAuth client id>",
"jwksUrl": "https://www.googleapis.com/oauth2/v3/certs",
"identityClaim": "email",
"allowedIdentities": ["you@example.com"]
}
}
},
"gates": {
"approvalPaths": ["webui", "telegram"],
"approvers": [{ "channel": "webui", "sender": "webui:you@example.com" }]
}
}

Three of those values are the whole provider choice:

ProviderissuerjwksUrl
Keycloakhttps://<host>/realms/<realm><issuer>/protocol/openid-connect/certs
Googlehttps://accounts.google.comhttps://www.googleapis.com/oauth2/v3/certs
Dexhttp://<host>:5556/dex<issuer>/keys
Cloudflare Accesshttps://<team>.cloudflareaccess.com<issuer>/cdn-cgi/access/certs

audience is the client id the proxy uses, or the Access application AUD tag.

The resolved actor is webui:<claim value>, and gates.approvers matches that whole string exactly. No prefix is added for you: you have to be able to read that list and predict the match.

The mistake to avoid​

The proxy allowlist and gates.approvers are two lists with two jobs, and mixing them up is the one failure mode that matters here.

ListDecides
the proxy's own allowlist, and allowedIdentitieswho reaches the agent at all
gates.approverswhose approval counts

In this mode the assertion alone authorizes the WebSocket handshake and the REST routes. So whoever the proxy admits gets a chat session with the agent. That means read access and local writes.

With a public provider this is sharper than it looks. Any Google account that completes the flow with your client id holds a token whose signature, issuer and audience are all valid. Verification is doing its job correctly. That person is still a stranger. They cannot approve a remote action, because they are not in gates.approvers. They can talk to your agent.

So a deployment that completes gates.approvers and leaves the proxy open has an open agent. The approver list gives no warning, because it was never the list for that job.

The gateway does not rely on your proxy configuration for this. A jwt block must name allowedIdentities or requiredClaims. A block that names nobody refuses to load. requiredClaims covers a whole Google Workspace domain through hd, or a Keycloak group through a mapped claim. It needs no list of every person:

"requiredClaims": { "hd": "example.com" }

Two requirements on the proxy side are requirements and not suggestions:

  • It must strip client-supplied authentication headers. If it forwards a header the browser set, the peer CIDR still matches. and the header forges the identity. oauth2-proxy strips those headers. A hand-written nginx or Caddy configuration needs checking.
  • It must have its own allowlist. --email-domain=* with a public provider admits the internet.

Verify it​

Log in through the proxy and open the WebUI. The connection badge names the identity the gateway resolved, so a misconfigured proxy is visible before an approval fails rather than after. A deployment with no proxy reads webui there, which is normal and not an error.

The gateway also names its posture at every start. Read the identity: lines in the log. They report four things. Whether a proxy is configured. Whether the gateway verifies an assertion, and against which issuer. Whether the gateway trusts a plain assertion on the peer address alone. And whether allowAnyVerifiedIdentity is on.

Then ask the agent for something that needs an approval, answer it, and read the audit log in Settings → Gates. The record names the person who answered under Actor and the person who raised it under Raised by.

One caution the viewer repeats. The raised-by name is a claim the agent made, and nothing verified it. The executor has no independent channel to whoever typed the message. The name of the person who answered is the verified one.

Upgrading an older trustedProxyAuth block​

assertionFormat defaults to jwt, so a block with only trustedPeerCidrs and assertionHeader fails at startup with the missing field named. That is deliberate: an operator reads a startup failure and acts on it, and nobody reads a handshake that silently keeps trusting an unverified header.

To keep the older behaviour, write "assertionFormat": "plain". A plain string carries no signature, so nothing can verify it. The peer CIDR is then the whole barrier, and the gateway warns about that at every start.

What this does not give you​

No screen for accounts. nanoinfra has no user interface for adding people. It will not grow one: whoever ships an authentication server owns password hashing, sessions and token signing for as long as the project lives. The identity provider owns accounts.

In the example, adding a person is a bcrypt hash, an edit and a restart. A deployment that wants accounts from a user interface runs Authentik, Zitadel or Keycloak. The same three values of config point at any of them.