Identity and SSO
nanoinfra is not an OIDC client. It runs no login flow, holds no client secret and knows nothing about your realm. You put an authenticating proxy in front of it, the proxy asserts who the person is in a header, and nanoinfra verifies that assertion. So the identity provider is interchangeable, and configuring SSO means setting three values.
That is the whole model. The rest of this page names those three values. It also names the two mistakes that make a verified identity worthless. It ends with a worked Cloudflare Access setup.
| You want to | Read |
|---|---|
| Understand why a shared token is not enough | A Proxy in Front |
| Know which list admits people and which grants authority | Two Lists, Two Jobs |
| Name the people whose approval counts | Capability Gates |
| Follow one end-to-end setup | Named Approvers with a Proxy |
| Serve more than one customer from one host | Multi-Tenancy |
The process boundaries this sits on top of — which account runs what, and what each one may reach — are in Deployment.
A Proxy in Front, and an Identity You Can Trust
A shared token authenticates a client. It does not name a person, so an audit record says "the WebUI approved it" and never who did. A proxy in front closes that: it authenticates the person, and it asserts who they are in a header the gateway verifies.
nanoinfra never talks to the identity provider. It reads one header and it fetches one key set. It runs no OIDC flow, holds no client secret and knows no realm. So the provider is interchangeable, and three values of config change:
| 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 third value, and it is the client id the proxy uses, or the AUD
tag for Cloudflare Access.
For a worked stack you can run, see examples/auth/ in the repository. It wires
Dex and oauth2-proxy in front of the gateway, and its README names the three
values above in one place.
Two Requirements, Not Two Suggestions
The proxy must strip client-supplied authentication headers. If it forwards a header the browser set, the peer CIDR still matches. The browser then forges the identity. oauth2-proxy strips these headers. You must check a hand-written nginx or Caddy configuration yourself.
The proxy must have its own allowlist. --email-domain=* with a public
provider admits the internet. See below.
Two Lists, Two Jobs
This is the part that is easy to get wrong, because the two lists sound alike and protect different things.
| List | Decides |
|---|---|
the proxy's allowlist, and allowedIdentities | who reaches the agent at all |
gates.approvers | whose approval counts for a suspended action |
In trusted-proxy mode the assertion alone authorizes the WebSocket handshake and the REST routes. So every admitted person gets a chat session with the agent. A chat session is read access and local writes. A verified stranger is still a stranger. With a public provider, any account can complete the flow with your client id. That account then holds a token whose signature, issuer and audience all check out. Verification is working correctly, and that person is not somebody you invited.
They cannot approve a remote action, because they are not in gates.approvers. So
a deployment that configures the approver list and leaves the proxy open has an
open agent. The approver list gives no warning, because it was never the list for
that job.
nanoinfra does not rely on your proxy configuration for this. A jwt block must
name allowedIdentities or requiredClaims, and a block that names nobody
refuses to load:
"allowedIdentities": ["alberto@example.com"],
"requiredClaims": {"hd": "example.com"}
requiredClaims compares exactly, which covers a whole Google Workspace domain
through hd, or a Keycloak group through a mapped claim, without listing every
person. allowAnyVerifiedIdentity: true is the only way to open it, and the
gateway names that posture in its startup output every time it starts.
Which Claim Names the Person
identityClaim defaults to email, and the resolved actor is webui:<claim value>. That prefixed string is what gates.approvers matches, exactly:
{"channel": "webui", "sender": "webui:alberto@example.com"}
sub is available for a deployment that prefers a stable opaque id. The cost of
email is that a person who changes their address needs a config change before
their approval counts again. For a list that grants authority, that friction is
correct.
What Verification Does Not Cover
A plain assertion carries no signature, so nothing can verify it. On that path
the peer CIDR is the whole barrier and the proxy alone decides who reaches the
agent. The gateway prints a warning at startup and keeps the older behaviour,
because changing it silently would break a working deployment.
The gateway fetches the key set from jwksUrl and caches it. A key rotation at the
provider then recovers with no restart. A deployment that will not allow the gateway
to make that request puts the keys in jwks instead, and accepts a config change
per rotation.
Cloudflare Tunnel + Cloudflare Access
For a local cloudflared process in front of nanoinfra, Cloudflare Access can
authenticate the user before forwarding the request and add
Cf-Access-Jwt-Assertion. Opt in to trusted-proxy no-token mode only when the
direct TCP peer is the tunnel process and the assertion is non-empty:
{
"gateway": { "host": "127.0.0.1" },
"channels": {
"websocket": {
"host": "127.0.0.1",
"port": 8765,
"publicWsUrl": "wss://nanoinfra.example.com/",
"trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion"
}
}
}
}
This is two-part authorization: a trusted direct loopback peer and a
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
For this flow /webui/bootstrap returns connection metadata without a
bootstrap token or REST API token. The proxy assertion authorizes the WebSocket
handshake and REST requests directly.
Set publicWsUrl to the browser-facing wss:// endpoint when the tunnel sends
the origin host header (such as 127.0.0.1:8765). Otherwise the WebUI could
attempt to open its WebSocket directly against the loopback address.
Cloudflare Access must generate the assertion
header after it authenticates the person. The gateway rejects a routing or client
metadata header as an assertionHeader value. It rejects that header at config
load, and not at request time. Those headers are Host, Forwarded,
X-Forwarded-*, X-Real-IP, and CF-Connecting-IP. A forwarded client header
establishes no proxy trust.
The peer check reads only connection.remote_address. nanoinfra never reads
X-Forwarded-For, Forwarded, X-Real-IP, CF-Connecting-IP or
X-Forwarded-Host to decide whether the proxy is trusted. So a client cannot
present a forwarded header and be treated as the proxy.
nanoinfra verifies the assertion now, and the block above is no longer complete.
Cf-Access-Jwt-Assertion is a real JWT. So nanoinfra checks its signature against
the Access key set. It does not trust the header on the peer address alone. Add
four values:
"trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion",
"assertionFormat": "jwt",
"issuer": "https://<team>.cloudflareaccess.com",
"audience": "<the Access application AUD tag>",
"jwksUrl": "https://<team>.cloudflareaccess.com/cdn-cgi/access/certs",
"identityClaim": "email",
"allowedIdentities": ["you@example.com"]
}
The AUD tag is per Access application, and it is what stops a token minted for another application of the same team from authenticating here.
assertionFormat defaults to jwt, so a two-field block from an earlier
version now fails at startup. That is deliberate. An operator reads a startup
failure and acts on it. Nobody reads a handshake that silently keeps trusting an
unverified header. The message names the missing field. To keep the older
behaviour, write "assertionFormat": "plain" and read the warning it prints.
allowedIdentities is not optional on the jwt path, and Two Lists, Two
Jobs says why.