DATABASE_URL required at startup; schema self-initializes. Compose gains a postgres service for standalone dev. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.9 KiB
cnats — chat over the bus
A realtime web chat built with Leptos (SSR + hydration on Axum) where every room is a NATS subject and sign-in is Kanidm SSO (OIDC authorization-code + PKCE).
browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room>
browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room>
browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm
axum ◀─JetStream (CHAT / cnats-postgres)─ NATS
└──INSERT──▶ Postgres (history archive)
The browser never talks to NATS directly: the server publishes on behalf of the
signed-in session (sender identity comes from the session, never the client)
and fans messages out to browsers over Server-Sent Events. Any other NATS
client on the bus can publish/subscribe to chat.room.* and participate.
Prerequisites
- Rust (stable) with the
wasm32-unknown-unknowntarget cargo-leptos:cargo install cargo-leptos --locked- A NATS server:
docker compose up -d nats(ornats-serverlocally) - A running Kanidm instance you administer
Kanidm setup
Register the app as an OAuth2 client (confidential, with PKCE — Kanidm's default). Replace the URLs with yours:
kanidm login --name idm_admin
# create the client
kanidm system oauth2 create cnats "cnats chat" http://localhost:3000
# register the redirect URL used by this app
kanidm system oauth2 add-redirect-url cnats http://localhost:3000/auth/callback
# map which Kanidm groups may sign in, and grant the scopes the app requests
kanidm group create cnats_users
kanidm group add-members cnats_users your_username
kanidm system oauth2 update-scope-map cnats cnats_users openid profile email
# if you serve the app over plain http in dev, allow insecure redirect urls
kanidm system oauth2 enable-localhost-redirects cnats
# read back the client secret for .env
kanidm system oauth2 show-basic-secret cnats
Kanidm's OIDC discovery endpoint is per-client:
https://<kanidm>/oauth2/openid/<client_id>/.well-known/openid-configuration. The app derives this fromKANIDM_URL+OAUTH2_CLIENT_IDautomatically.
Configuration
cp .env.example .env # then edit
| Variable | Meaning |
|---|---|
NATS_URL |
NATS server, default nats://127.0.0.1:4222 |
DATABASE_URL |
Postgres for message history, default postgres://cnats:cnats@127.0.0.1:5432/cnats |
KANIDM_URL |
Base URL of Kanidm, e.g. https://idm.example.com |
OAUTH2_CLIENT_ID |
The Kanidm oauth2 client name (cnats above) |
OAUTH2_CLIENT_SECRET |
Output of show-basic-secret |
PUBLIC_URL |
Where browsers reach this app; ${PUBLIC_URL}/auth/callback must be a registered redirect URL |
COOKIE_SECURE |
true behind HTTPS (production) |
Run
docker compose up -d nats postgres
cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000
cargo leptos build --release # production build (binary + target/site)
Or run the whole stack (app image included) with docker compose up.
Open two browser windows, sign in, and chat — or join from the CLI:
nats sub 'chat.room.*'
nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00","ts":0}'
CLI-published messages are archived too — the JetStream consumer sees
everything on chat.room.*, not just what this app publishes.
Layout
src/
main.rs axum entrypoint: routes, sessions, NATS + OIDC bootstrap
lib.rs hydrate entrypoint (wasm)
app.rs Leptos UI (login gate + chat console)
auth.rs shared User type + current_user server fn
chat.rs shared ChatMessage/rooms + send_message server fn (publishes to NATS)
server/
oidc.rs Kanidm OIDC login/callback/logout handlers
sse.rs NATS → browser SSE bridge (one subscription per client)
store.rs JetStream → Postgres archive + room history queries
style/main.css the console theme
Notes & production hardening
- Sessions are in-memory (
tower-sessionsMemoryStore): restart logs everyone out, and it is single-instance. Swap in a Redis/SQL store for multiple replicas. - History: a durable JetStream pull consumer (
CHATstream,cnats-postgresconsumer) archiveschat.room.*into Postgres; joining a room backfills the last 100 messages. Inserts are idempotent on message id (JetStream is at-least-once). TheCHATstream keeps default limits — cap it withnats stream edit CHAT --max-age=…if the bus is chatty, since Postgres already holds the archive. - The OIDC client verifies ID-token signature, nonce, and CSRF state, and requires PKCE — but there is no token refresh; the app session lives independently of the Kanidm token lifetime.