2026-07-08 21:53:56 +02:00
|
|
|
# cnats — chat over the bus
|
|
|
|
|
|
|
|
|
|
A realtime web chat built with [Leptos](https://leptos.dev) (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
|
2026-07-08 22:14:26 +02:00
|
|
|
axum ◀─JetStream (CHAT / cnats-postgres)─ NATS
|
|
|
|
|
└──INSERT──▶ Postgres (history archive)
|
2026-07-08 21:53:56 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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-unknown` target
|
|
|
|
|
- [`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos): `cargo install cargo-leptos --locked`
|
|
|
|
|
- A NATS server: `docker compose up -d nats` (or `nats-server` locally)
|
|
|
|
|
- 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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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 from `KANIDM_URL` + `OAUTH2_CLIENT_ID` automatically.
|
|
|
|
|
|
|
|
|
|
## Configuration
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
cp .env.example .env # then edit
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
| Variable | Meaning |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| `NATS_URL` | NATS server, default `nats://127.0.0.1:4222` |
|
2026-07-08 22:14:26 +02:00
|
|
|
| `DATABASE_URL` | Postgres for message history, default `postgres://cnats:cnats@127.0.0.1:5432/cnats` |
|
2026-07-08 21:53:56 +02:00
|
|
|
| `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
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-07-08 22:14:26 +02:00
|
|
|
docker compose up -d nats postgres
|
2026-07-08 21:53:56 +02:00
|
|
|
cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000
|
|
|
|
|
cargo leptos build --release # production build (binary + target/site)
|
|
|
|
|
```
|
|
|
|
|
|
2026-07-08 22:14:26 +02:00
|
|
|
Or run the whole stack (app image included) with `docker compose up`.
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
Open two browser windows, sign in, and chat — or join from the CLI:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
nats sub 'chat.room.*'
|
2026-07-08 22:14:26 +02:00
|
|
|
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}'
|
2026-07-08 21:53:56 +02:00
|
|
|
```
|
|
|
|
|
|
2026-07-08 22:14:26 +02:00
|
|
|
CLI-published messages are archived too — the JetStream consumer sees
|
|
|
|
|
everything on `chat.room.*`, not just what this app publishes.
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
## 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)
|
2026-07-08 22:14:26 +02:00
|
|
|
store.rs JetStream → Postgres archive + room history queries
|
2026-07-08 21:53:56 +02:00
|
|
|
style/main.css the console theme
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Notes & production hardening
|
|
|
|
|
|
|
|
|
|
- Sessions are in-memory (`tower-sessions` `MemoryStore`): restart logs
|
|
|
|
|
everyone out, and it is single-instance. Swap in a Redis/SQL store for
|
|
|
|
|
multiple replicas.
|
2026-07-08 22:14:26 +02:00
|
|
|
- History: a durable JetStream pull consumer (`CHAT` stream, `cnats-postgres`
|
|
|
|
|
consumer) archives `chat.room.*` into Postgres; joining a room backfills the
|
|
|
|
|
last 100 messages. Inserts are idempotent on message id (JetStream is
|
|
|
|
|
at-least-once). The `CHAT` stream keeps default limits — cap it with
|
|
|
|
|
`nats stream edit CHAT --max-age=…` if the bus is chatty, since Postgres
|
|
|
|
|
already holds the archive.
|
2026-07-08 21:53:56 +02:00
|
|
|
- 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.
|