Pular para o conteúdo
← Voltar para projetos

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 /pubkey once, cache it, and verify the csa JWT cookie offline with step 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 the csa cookie on Domain=.<tenant> -- so one tap signs you in across every site.
  • Thereafter GET /login runs navigator.credentials.get() and mints a fresh cookie. Face ID, no link, no password. Sessions last SESSION_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.nu handlers.
  • 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 in tools/wauthn and published standalone as cablehead/wauthn. serve.nu owns 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 on Domain=.<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

Nova versão disponível.