Single sign-on for the agent apps
How the console issues identity to NuFi Studio and NuFi Works, what to set on each of the three sides, and the two constraints that will bite you later if you skip them.
Without this, NuFi Studio and NuFi Works each have their own accounts and their own passwords, and a member who already signed in to NuFi meets two more login screens. With it, they click through and are already in.
The console does the vouching. It already verifies a NuFi session and issues gateway keys from it; issuing an identity is the same job with a different output, which is why it lives there rather than in a fourth service. One place decides who may enter which product.
The shape
chat.nufi.me ──"Agents"──▶ agents.nufi.me (a route on the console)
│
├──▶ studio.nufi.me reads a signed cookie
└──▶ works.nufi.me runs an OAuth flow
│
console.nufi.me ── identity issuer ─────────────┘The two products consume identity differently because they already had different mechanisms, and using each one's native path meant almost no forked code:
- Studio validates a signed token it finds in a cookie, checking it against the console's published keys, and creates the local user the first time it sees them.
- Works runs an ordinary authorization-code flow against the console and ends with its own session cookie.
agents.nufi.me is a second hostname on the console service, not a
service of its own. The page it serves is static, and idle memory is
most of a hosting bill.
Console
OIDC_ISSUER=https://console.nufi.me
OIDC_PRIVATE_KEY_PEM= # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
OIDC_CLIENTS=[{"clientId":"nufi-works","clientSecret":"…","redirectUris":["https://works.nufi.me/api/auth/oauth2/callback/nufi"],"product":"works"}]
AGENT_ENTITLEMENTS={"studio":["@dudaji.vn"],"works":["@dudaji.vn"]} # optional
CHOOSER_HOST=agents.nufi.me
STUDIO_URL=https://studio.nufi.me
VITE_WORKS_URL=https://works.nufi.me
IDENTITY_TTL_SECONDS=28800 # 8 hours
IDENTITY_COOKIE_DOMAIN=.nufi.me
CHAT_BASE_URL=https://chat.nufi.me
CHAT_PUBLIC_URL=https://chat.nufi.me # optional; defaults to CHAT_BASE_URLThis is the whole configuration surface of the console's half. Every variable, what it decides, and what happens if you leave it out:
| Variable | What it decides | Left unset |
|---|---|---|
OIDC_CLIENTS | Which OAuth clients exist, and — through "product" — which product each one is the front door to. A client with no "product" is admitted without an entitlement check, which is correct for a federation client and silently disables the gate for a member-facing one. The console logs a warning at boot naming any client in that state. | No client can sign in; NuFi Works keeps its own login. |
AGENT_ENTITLEMENTS | Who may enter each product, as JSON: {"studio":["@dudaji.vn"],"works":["a@b.c"]}. An entry is a full address or an @domain suffix; * is everyone. A product with no list of its own stays open. A NuFi administrator is always entitled. | Both products open to every member. Deliberate: deploying the gate must not lock out the people already using it. A malformed value closes both to everyone but an administrator. |
CHAT_BASE_URL | Where the console asks chat who the member is. Needs to be reachable from the console — a private-network host is fine. | https://chat.nufi.me. |
CHAT_PUBLIC_URL | Where the console sends a member's browser to sign back in. Needs to be resolvable by that browser. | Falls back to CHAT_BASE_URL. |
CHOOSER_HOST | The hostname that serves the two-product chooser, and where a member who is refused a product is sent to read why. | agents.nufi.me. |
STUDIO_URL | Where /enter/studio redirects once it has minted the cookie. | https://studio.nufi.me. |
VITE_WORKS_URL | The NuFi Works link on the chooser. Build-time, not runtime: it is baked into the SPA by Vite, so changing it needs a rebuild. | https://works.nufi.me. |
CHAT_BASE_URL has to be reachable from the console. The chat session
cookie carries an id and a session id — no email and no role — so the
console asks the chat app who the member actually is before it issues
anything. Without that lookup every identity it mints has no email, and
the failure is not obvious: NuFi Works refuses the sign-in with
email_is_missing at the end of a round trip that looks like it worked,
NuFi Studio accepts it and provisions an account called
external-<hash> for a real person, and everyone arrives as an editor
because the role is missing too.
CHAT_PUBLIC_URL has to be resolvable by the member's browser — a
separate requirement from the one above. An expired Studio session now
bounces the member's own browser to ${CHAT_PUBLIC_URL}/login so they can
sign back in without hunting down agents.nufi.me from memory. On a deploy
that reaches chat over private networking (say,
CHAT_BASE_URL=http://chat.railway.internal:3080), that address satisfies
the requirement above but is not something a browser can resolve.
CHAT_PUBLIC_URL defaults to CHAT_BASE_URL, so a deployment with a single
public chat host has nothing new to set — only a private-networking one
needs to set it separately.
VITE_WORKS_URL is inlined into the browser bundle when the image is
built, like the console's other VITE_* values; it is a build argument of
apps/console/Dockerfile, not something the running container reads.
This adds four routes: /.well-known/jwks.json publishes the public
key, /oidc/authorize, /oidc/token and /oidc/userinfo serve the
OAuth flow, and /enter/studio mints the Studio cookie and redirects.
Also append the Works origin to the existing connect allow-list:
AGENTS_ALLOWED_ORIGINS=…,https://works.nufi.meOIDC_PRIVATE_KEY_PEM is not optional in production. Left unset,
the console generates a key at boot. Every token it signed stops
verifying the moment it restarts, and two replicas never agree on the
key at all — so sessions break on deploy, intermittently, in a way that
looks like anything but a missing variable.
Set it with the whole PEM, newlines included. Some CLI tools truncate a multi-line value at the first newline and report success; read the value back after setting it.
Run the console as a single instance. Authorization codes are held in memory, so a code issued by one replica is not found by another and the sign-in fails for whoever the load balancer sent elsewhere. Scaling out means moving them to a shared store first.
NuFi Studio
LANGFLOW_AUTO_LOGIN=false
LANGFLOW_EXTERNAL_AUTH_ENABLED=true
LANGFLOW_EXTERNAL_AUTH_TOKEN_COOKIE=nufi_id
LANGFLOW_EXTERNAL_AUTH_JWKS_URL=https://console.nufi.me/.well-known/jwks.json
LANGFLOW_EXTERNAL_AUTH_ISSUER=https://console.nufi.me
LANGFLOW_EXTERNAL_AUTH_AUDIENCE=nufi-studio
LANGFLOW_EXTERNAL_AUTH_SUBJECT_CLAIM=sub
LANGFLOW_EXTERNAL_AUTH_EMAIL_CLAIM=email
LANGFLOW_EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=true
LANGFLOW_EXTERNAL_AUTH_ACCESS_CLAIM=access
LANGFLOW_EXTERNAL_AUTH_DEFAULT_ACCESS_LEVEL=editorThe superuser account still works, which is what you use if the console is ever unreachable.
Leave LANGFLOW_EXTERNAL_AUTH_TRUSTED_JWT_DECODE unset. It accepts
the token without checking the signature, which is only safe when a
proxy in front has already checked it. There is no such proxy here.
Turning it on means anyone who can reach Studio can hand it a token
they wrote themselves.
NuFi Works
NUFI_OIDC_ISSUER=https://console.nufi.me
NUFI_OIDC_CLIENT_ID=nufi-works
NUFI_OIDC_CLIENT_SECRET= # the same value as in OIDC_CLIENTS
PAPERCLIP_AUTH_DISABLE_SIGN_UP=trueThe client is off unless both the issuer and the client ID are set, so a half-configured instance keeps its own login rather than presenting a broken button.
PAPERCLIP_AUTH_DISABLE_SIGN_UP=true closes local registration, making
the console the only way in. Set it last, and only after you have
signed in through the console once — with sign-up closed and no working
client, nobody can get in, including you.
Order
Identity goes on last, on both products, for the same reason each time: pointing an app at an issuer that is not answering yet locks everyone out of it.
- Both products running with their own accounts. Confirm each serves and carries the right product name.
- Console: the
OIDC_*block,CHOOSER_HOST,STUDIO_URL, the extra origin. - Studio's identity block. Test a sign-in.
- Works' identity block. Test a sign-in, then close sign-up.
Access levels
A NuFi administrator arrives as an admin. Everyone else arrives as an editor. That mapping is fixed in the console.
Studio treats it as a ceiling rather than an assignment, so a local account cannot be promoted past what the console vouched for.
Who may enter
AGENT_ENTITLEMENTS decides. The console enforces it at both doors a
member can walk through — GET /enter/studio and GET /oidc/authorize
— and reports it, without enforcing anything, at GET /enter/products,
which the chooser asks before it renders so a member sees a card they
cannot click with a reason on it rather than discovering the refusal by
clicking into it.
Unset, both products are open to every member — the same behaviour as before there was a gate, so turning this on is a decision you make rather than one a deploy makes for you. A malformed value closes both products to everyone except a NuFi administrator; an administrator is always entitled, so a typo in one Railway variable cannot lock out the person who has to fix it.
A refusal is shaped by who asked. A browser navigation is sent to the
chooser, which explains itself; a scripted caller gets 403 and JSON.
A member with no NuFi session at all is sent to chat's login page, and
gets 401 if it was a script that asked — which is what the standing
check in deploy/railway/verify-agents.sh asserts.
/oidc/federated-token deliberately carries no entitlement check. It
is not a third door into either product: a trusted federation client
authenticates as itself and asserts a subject for a different audience —
typically a member of an on-premises deployment who has no NuFi account
at all — so a product entitlement list has nothing to say about it. The
gate covers the two endpoints a member can navigate to, and only those.
What this does not do
It does not sign people out. Signing out of NuFi does not revoke a
session already issued to Studio or Works; those expire on their own
schedule, eight hours by default. Shorten IDENTITY_TTL_SECONDS if
that window is too long for your risk tolerance, and tell people to
close the tabs on shared machines.
The Studio cookie is scoped to the parent domain. A response from
the console cannot set a cookie that only Studio receives, so it is
scoped to .nufi.me and every NuFi subdomain gets a copy. It is
audience-scoped and short-lived — another subdomain can do nothing with
it except send it to Studio, which is where it was going. This holds
only while every *.nufi.me host is operated by you. If one ever is
not, this needs an authenticating proxy per host instead.
Verifying
Beyond signing in, check the things a successful login does not prove:
# The published keys must carry no private component.
curl -fsS https://console.nufi.me/.well-known/jwks.json | grep -q '"d"' \
&& echo "PRIVATE KEY LEAKED" || echo "ok: public only"
# The identity routes must refuse an anonymous caller.
curl -s -o /dev/null -w '%{http_code}\n' https://console.nufi.me/enter/studio
# expect 401Then confirm a tampered token is rejected: change one character in the
nufi_id cookie and load Studio. It must not let you in. A guard that
has never been observed failing is not yet known to work.
deploy/railway/verify-agents.sh runs these checks and the reachability
and branding ones for both products in one go:
deploy/railway/verify-agents.sh --base-suffix .nufi.meInstalling NuFi Studio
What NuFi Studio needs to run, the two settings that decide whether it survives a redeploy, and how to check the install actually took.
Agent egress
Keeping the agent products' model traffic on the gateway — what is measured today, what is only configured, and how to prove which is which.