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.approversentries 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.
| Part | Job |
|---|---|
| an identity provider | authenticates the person and signs a token that says who they are |
| a proxy | runs the browser login flow, and passes that token to the gateway in a header |
| the gateway | verifies 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:
| Provider | issuer | jwksUrl |
|---|---|---|
| Keycloak | https://<host>/realms/<realm> | <issuer>/protocol/openid-connect/certs |
https://accounts.google.com | https://www.googleapis.com/oauth2/v3/certs | |
| Dex | http://<host>:5556/dex | <issuer>/keys |
| Cloudflare Access | https://<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.
| List | Decides |
|---|---|
the proxy's own allowlist, and allowedIdentities | who reaches the agent at all |
gates.approvers | whose 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.
Related
- Capability Gates — the policy the approval belongs to, and what a second person as a second factor gives up
- Identity and SSO — the reference for every field
- WebSocket channel options — the full
trustedProxyAuthtable - Secure a local AI agent — the controls to review first