Skip to main content

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 toRead
Understand why a shared token is not enoughA Proxy in Front
Know which list admits people and which grants authorityTwo Lists, Two Jobs
Name the people whose approval countsCapability Gates
Follow one end-to-end setupNamed Approvers with a Proxy
Serve more than one customer from one hostMulti-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:

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 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.

ListDecides
the proxy's allowlist, and allowedIdentitieswho reaches the agent at all
gates.approverswhose 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.