Cross Stream Tenant Auth
Passwordless passkey login for a cross.stream tenant: one http-nu serve.nu, Ed25519 sessions every sibling site verifies offline
#cross-stream-tenant-auth
Passwordless passkey login for one tenant, served at auth.<tenant>.cross.stream.
One serve.nu. No build step, no database, no OAuth provider, no password. It turns
an invite link into a signed session cookie that every other site in the tenant
verifies offline.
Follow this README end to end and you get a working auth.<you>.cross.stream, one
registered passkey, and one of your apps gated behind it.
#How it works
- An Ed25519 keypair is generated on first boot and lives in the site's state dir. The private key never leaves it and is never in git.
- Sites fetch
GET /pubkeyonce, cache it, and verify thecsaJWT cookie offline withstep crypto jwt verify. Auth is not in the request path. This site can be down and your apps keep serving signed-in users until their sessions expire. POST /invites(admin bearer token) mints a one-time link.- Tapping
GET /invite/<code>?back=<url>serves a passkey-registration page. Creating the passkey stores the credential, consumes the invite, and sets thecsacookie onDomain=.<tenant>-- so one tap signs you in across every site. - Thereafter
GET /loginrunsnavigator.credentials.get()and mints a fresh cookie. Face ID, no link, no password. Sessions lastSESSION_DAYS(30).
The RP ID is the tenant domain, so a passkey registered here is valid tenant-wide; only the browser ceremony happens at the auth origin.
#Requirements
- http-nu -- the server. Sites are plain
serve.nuhandlers. - step -- JWT sign and verify. Either on PATH or
vendored at
bin/step; this repo prefers the vendored copy and falls back to PATH. The cross.stream customerenv image already ships/usr/local/bin/step. bin/wauthn-- vendored here (6.6MB, x86-64 static). It is the WebAuthn ceremony CLI wrapping webauthn-rs; source is intools/wauthnand published standalone as cablehead/wauthn.serve.nuowns all state; wauthn only does the crypto.- A wildcard-subdomain host so
auth.<tenant>and your apps share a parent domain. The cookie is set onDomain=.<tenant>, so this only works if your sites really are siblings under one domain.
WebAuthn needs a secure context. The ceremony runs over HTTPS or over localhost --
never over a plain-HTTP .test host.
#1. Configure
cp config.nuon.example config.nuon
config.nuon is gitignored. It is the only tenant-specific data in the repo:
{
tenant: "example.cross.stream"
admin_hash: "xxx" # sha256 of your invite-minting token
registry: [{site: "gallery.example.cross.stream", features: ["upload"]}]
}
Generate the admin token and its hash:
let t = (random chars --length 48) $t | save -f .admin-token # the bearer token -- keep it, it is not recoverable $t | hash sha256 # paste into admin_hash
Only the hash is ever stored. registry is what a signed-in client is told this
tenant offers; an empty list is fine to start.
serve.nu looks for config.nuon in the state dir first, then the repo dir. On
cross.stream the operator provisions it into state, which is why the repo copy is
gitignored rather than committed.
#2. Run it
Locally:
TENANT_DOMAIN=example.test AUTH_SELF_ORIGIN=http://auth.example.test:5300 \ http-nu --dev --store ./store :5300 serve.nu
In production it is an ordinary site: http-nu /run/sites/auth.sock --store <state>/store serve.nu, with CROSS_STREAM_SITE_STATE pointing at a directory that
survives redeploys. cross-stream.nuon ({ store: true }) tells a cross.stream
deploy to pass --store for you.
On first start it generates state/priv.pem and state/pub.pem. Check it:
curl https://auth.<tenant>/pubkey # -> an Ed25519 public key, PEM
curl https://auth.<tenant>/whoami # -> {"anonymous":true}
Back up state/. Losing priv.pem invalidates every live session and every site's
cached key; losing store/ loses the registered passkeys.
#3. On cross.stream specifically
If you are a customerenv tenant, hostname routing is already done for you. The guest Caddy dispatches by subdomain to a unix socket:
reverse_proxy unix//run/sites/{http.request.host.labels.3}.sock
So a site deployed under the label auth is reachable at auth.<tenant>.cross.stream
with no per-site config and no new DNS record -- the wildcard *.<tenant>.cross.stream
already resolves and the cert already covers it. Deploy this repo through
cross-stream-admin with the label
auth, then have your operator drop config.nuon into the site's state dir.
Self-hosting is the same handler; you supply the wildcard DNS record, the TLS cert,
and a proxy rule pointing auth.<tenant> at the socket.
#4. Enrol yourself
let t = (open .admin-token)
http post https://auth.<tenant>/invites --content-type application/json \
--headers {Authorization: $"Bearer ($t)"} \
{sub: "you", role: "family", days: 365, back: "https://gallery.<tenant>/"}
# -> {link: "https://auth.<tenant>/invite/<code>", ...}
sub is the identity your apps will allowlist, so pick a stable handle. Open the
link on the device that should hold the passkey, create it, and you land at back
already signed in. The link is dead after that.
POST /invites/revoke {"code": "..."} kills an unredeemed invite. To remove someone
who already registered, revoke their passkey rather than the invite.
Check yourself:
curl -b "csa=<cookie>" https://auth.<tenant>/whoami
# -> {"sub":"you","role":"family",...}
#5. Gate an app
See how-to/00001-add-auth-to-an-app.md for the
worked example. The short version: vendor client/auth.nu into your app, then
use auth.nu * # exports: session, member
const MEMBERS = ["you" "them"] # identity, not role -- fails closed when empty
(route {method: "POST", path: "/upload"} {|req ctx|
if (member $req $MEMBERS) == null { deny } else { ... }
})
session $req returns verified claims or null. member $req $MEMBERS returns them
only if sub is in your list. Gate only what should be private; public routes stay
untouched.
#Routes
| Route | Auth | Purpose |
|---|---|---|
GET /pubkey |
none | Ed25519 public key. Sites fetch once and cache. |
GET /whoami |
csa cookie |
Echo verified claims. Debugging. |
GET /login |
none | Passkey sign-in page. |
POST /login/begin · /login/finish |
none | The WebAuthn assertion ceremony. |
GET /invite/:code |
invite | Passkey registration page. One-time. |
POST /register/begin · /register/finish |
invite | The WebAuthn registration ceremony. |
POST /invites |
admin bearer | Mint an invite link. |
POST /invites/revoke |
admin bearer | Kill an unredeemed invite. |
GET /registry |
session | What this tenant offers a signed-in client. Cookie or Authorization: Bearer <csa>. |
GET /app-auth · POST /app-auth/finish · POST /app-token |
passkey | Device-code flow for native clients. |
#Trust model
A passkey is a device-bound private key: phishing-resistant, non-replayable, and not
a shared secret. The csa cookie it mints is still a bearer token like any session
cookie -- signed, expiring, and per-person via sub. Steal the cookie and you are
that person until it expires; that is the same trade every session-cookie system
makes, and the reason SESSION_DAYS is finite.
The admin token is a plain bearer secret and the one thing here worth guarding. It mints invites, and an invite mints a passkey. Only its sha256 is stored.
Redirect targets are checked against the tenant domain (back-ok), so ?back= and
?return= cannot be used to walk a fresh session off to another host.
#Testing without a device
The WebAuthn ceremony needs a real authenticator, but everything downstream of it
does not. Mint a csa with the tenant key and drive a handler directly:
let c = source ./serve.nu
let jwt = (^step crypto jwt sign --key state/priv.pem \
--iss auth.<tenant> --aud <tenant> --sub you --exp 9999999999)
"BYTES" | do $c {method: "POST", path: "/upload", uri: "/upload",
headers: {authorization: $"Bearer ($jwt)", host: $"gallery.<tenant>"},
query: {name: "t.jpg"}}
#Rebuilding wauthn
cd tools/wauthn cargo build --release --target x86_64-unknown-linux-musl cp target/x86_64-unknown-linux-musl/release/wauthn ../../bin/wauthn
#Related
- cablehead/wauthn -- the ceremony CLI, standalone
- cablehead/http-nu -- the server
- cablehead/xs -- the event store behind
--store - cablehead/cross-stream-admin -- tenant admin and push-to-deploy