Room-code sign-in for applications, powered by Dex.
Room Pass started inside Voter, the live-voting demo it was built for, and was extracted with its history on 2026-09-30. Voter is now a consumer like any other; docs/origin.md tells that story and who Room Pass is for. The product vision and extraction plan describe where it is going; the implementation and operating limits below are current.
Room Pass enrolls a browser with a room code and an unverified display name, derives a synthetic address from that name, supplies the stable identity to Dex, and lets Kubernetes enforce RBAC. It has no quiz/coffee dependencies, database, JWT signing key, impersonation permission, or participant grants.
The initial implementation serves one Room from one replica. Kubernetes stores the
Room, rolling codes and Participants; a Secret stores the cookie keys. Deployment uses
Recreate. Do not increase replicas or introduce another enrollment writer.
In the devcontainer (.devcontainer/), from the repository root:
task test
task integration
task e2e-up
task e2e
task load # optional 300-enrollment rehearsal; cleans up its recordsPrerequisites: Docker, k3d, kubectl, Go 1.25+, Task, Python 3, OpenSSL,
controller-gen and setup-envtest. The fixture pins K3s v1.31.5-k3s1 and Dex
v2.45.1. Dex v2.45.0 has an authproxy interface regression fixed in
v2.45.1.
The dedicated room-pass-e2e cluster uses an explicit kubeconfig under
.local/; setup never selects another cluster or applies to the default
context. It leaves the cluster running for exploration. Images and local CA/keys stay
local. The fixture uses a Docker volume, so it also works with the devcontainer's
sibling Docker daemon. On this host, the runtime inotify instance limit was raised
from 128 to 1,024 to accommodate the additional cluster. Its TLS port is 18443 on the Docker host.
For a browser, resolve demo.room-pass.test and login.room-pass.test to the Docker
host (or 127.0.0.1 with a local tunnel forwarding port 18443). Trust the generated
.local/tls.crt in a dedicated test browser profile, then open:
https://demo.room-pass.test:18443/app/
Choose Join the demo, enter the projected code and a name, then press Write a
message to Kubernetes. The tiny example OIDC client uses authorization code + PKCE,
validates the token, and sends it to the real Kubernetes API. It has no ServiceAccount
credential. The demo session lasts five minutes; signing in again in the same browser
reuses Room Pass enrollment. No real email address is collected: the join page shows the
name@koudijs.dev.test address it will issue, updating as the name is typed, so nobody has to
wonder what will end up on the commit.
Read only the code needed for projection:
export KUBECONFIG="$PWD/.local/kubeconfig"
kubectl -n room-pass get room demo \
-o jsonpath='{.spec.title}{"\n"}{.status.joinCode.code}{"\n"}{.status.joinCode.expiresAt}{"\n"}'
kubectl -n demo get configmapsOr project a QR code instead, so nobody has to type anything but a name:
task present BASE=https://demo.room-pass.test:18443 NEXT=/answer/round-1That follows the rotating code and redraws, under your own kubeconfig — a join code is operator-only credential material, so Kubernetes decides who may see one. The scan lands on the application's login URL carrying both the code and the page to finish on.
BASE, ROOM_NAMESPACE (default room-pass) and LOGIN_PATH (default /auth/login)
belong to the application, not to Room Pass: the login endpoint is the application's, and
the QR contract it has to implement is the two query parameters code and return.
That works through a general Room Pass integration point: an application sharing the
join host may pre-supply a room code in the __Host-room-pass-joincode cookie, and the
join page then asks only for a display name. Room Pass owns the name and the rules, any
application can use it, and nothing in the channel knows what a QR code is — a QR is just
the most convenient way to get a code into a phone. Room Pass never trusts the value: it
becomes a prefill and is checked against the Room's valid codes through the ordinary
POST. Joining by QR code has the contract and a worked integration.
The typed path is unchanged and still works for anyone who cannot scan.
Six letters may be shown as BCD-FGH; case, display hyphens and outer whitespace are
ignored. Defaults rotate every 15 seconds and expire each code after 30 seconds:
two overlapping codes, with 15 seconds to finish typing the previous displayed code.
A Room can instead choose a longer overlap, up to four retained codes. Desired-state
changes start a fresh code epoch; a rapid close/reopen cannot revive stale codes. Validity uses
now < expiresAt, including when reconciliation is delayed. Rotation never signs out
an enrolled browser. See requirements.md for the broader contract.
- Unit/race tests: rotation, exact expiry, restart history, randomness failures and collisions; serialized enrollment limits; UID binding; revocation; CSRF; forged headers; cross-host binding and one-time handoff replay.
integration: envtest runs a real Kubernetes 1.31 API server and etcd, installs the generated CRDs, and checks defaults, immutable fields, irreversible stop/revoke, status updates and reconcile idempotence. Ordinarygo testskips this suite unlessKUBEBUILDER_ASSETSis set; the Task target sets it explicitly.e2e: real HTTPS Traefik → Room Pass → Dex authorization code + PKCE, cryptographic token verification, stable subject after Room Pass restart, device authorization without a local callback listener, native OIDC ConfigMap write, denied work Secrets and Room reads, forged callback headers/aliases, direct Dex network isolation, audit identity/extras, and grant withdrawal against an already-issued token. It restores its temporary RBAC changes.
The test client models a browser's redirects and cookie jars; it is not a mobile browser rendering test. The fixture demonstrates attribution in Kubernetes audit events. It does not install Flux/ConfigButler, verify a resulting Git commit, migrate Voter/Coffee, or provide a work identity provider. Those remain platform integration work.
To install Room Pass for an event, follow docs/install.md. This section describes the pieces it uses.
An installation is a few kustomize components, each usable as a remote base at a
release tag (https://github.com/sunib/room-pass//deploy/base?ref=vX.Y.Z; config/crd
and the components below from the first release after 2.0.0):
| Component | What it is |
|---|---|
| config/crd | the Room and Participant CRDs; apply first and wait for them to be Established |
| deploy/base | Room Pass, its ServiceAccount and RBAC, with no hostnames |
| deploy/dex | Dex on SQLite with a NetworkPolicy admitting only Room Pass; its config is yours |
| deploy/edge/traefik | the edge rate limit and identity-header stripping, as Traefik Middlewares |
| deploy/apiserver | the kube-apiserver's structured authentication config for the issuer |
| deploy/example | all of the above for one event, with placeholder hosts, plus a Room |
The local fixture deploys the same components (test/e2e/room-pass),
so CI runs them on every change. JOIN_ORIGIN, ISSUER_ORIGIN and ALLOWED_RETURN_URLS
are required, and Room Pass refuses to start without them; add them with a
strategic-merge patch on the room-pass container's env, which merges by name, so a
variable added to the base later still arrives. A Room needs a future end time, a
demo:-prefixed group, and exact HTTPS return URLs that also appear in
ALLOWED_RETURN_URLS. Required cookie Secret:
umask 077
openssl rand 32 > hash-key
openssl rand 32 > block-key
kubectl -n room-pass create secret generic room-pass-cookie \
--from-file=hash-key --from-file=block-key
rm hash-key block-keyKeep keys out of Git, logs and image layers. Back up the Secret together with Rooms and
Participants using your Kubernetes/etcd backup process. A restart preserves enrollment;
losing or replacing keys signs everyone out. Rotation with multiple verification keys is
not implemented. The local fixture preserves these keys on repeated e2e-up runs.
deploy/dex keeps Dex's signing keys and pending logins in SQLite on a volume, so a Dex
restart does not sign applications out. e2e-up restarts Room Pass to load source changes.
Build Room Pass independently:
docker build -t room-pass:dev room-pass/Its multi-stage Dockerfile produces a static binary in a non-root distroless image.
The manifest supplies a read-only root filesystem, dropped capabilities and separate
/healthz and /readyz probes. Readiness checks live Room configuration, its reconciled generation, and Participant
storage. The ServiceAccount can read Rooms, update Room status, read/create Participants,
and get only the named cookie Secret. It cannot create RBAC, impersonate, or edit apps.
Room and Participant read access is operator-only: Room status contains valid credentials and Participant records contain unverified labels. The fixture's metadata-only audit policy keeps enrollment codes and cookie Secret bodies out of audit logs.
Regenerate checked-in CRDs and deepcopy implementations with:
task generateCEL and OpenAPI validate bounds, enums, immutability and irreversible transitions at the
API server. allowedReturnURLs is a set and conditions are a map keyed by type.
2.0.0 moved the API group from roompass.configbutler.ai to room-pass.koudijs.dev;
the CHANGELOG lists the steps. Two things it does not say:
- Both groups can stay installed. 2.x ignores the old objects, so you may keep the
1.x Room and Participants as a record. While both CRDs exist,
kubectl get roomsilently picks one of the two kinds namedRoom; use the full name,kubectl get rooms.room-pass.koudijs.dev(orrooms.roompass.configbutler.aifor the old records). - Removing the old CRDs discards the old records. Deleting
rooms.roompass.configbutler.aiandparticipants.roompass.configbutler.aideletes every object of those kinds with them. Under a GitOps tool that prunes, dropping the old CRDs from Git does the same. Export what you want to keep first.
See the handoff protocol. All public issuer traffic must go through Room Pass, including callback aliases. Do not expose Dex with another Ingress, NodePort, LoadBalancer or port-forward. deploy/example routes the whole issuer host to Room Pass, and deploy/dex admits Dex traffic only from Room Pass pods. This requires a CNI that enforces NetworkPolicy; k3s's network policy controller is enabled. Treat permission to label/create Room Pass pods or alter these routes/policies as trusted operator access.
Dex uses one authproxy connector with ID room-pass, which makes its callback
/callback/room-pass. Optional Dex browser sessions remain disabled; authproxy does not
issue refresh tokens. Configure the exact header names in
deploy/example/dex-config.yaml. Room Pass overwrites the entire X-Remote-*
contract from fresh Room/Participant reads. No public forward-auth header endpoint exists.
deploy/apiserver, which the fixture's
apiserver also runs, configures native JWT authentication, demo: usernames, Room-selected demo
groups and configbutler.ai/claims/display-name / configbutler.ai/claims/email audit
extras. Kubernetes uses Dex's opaque subject, not the display name, as identity.
Structured authentication
is independent of Room Pass's cookie session. The generated email is synthetic even
though Dex authproxy marks it verified.
| Setting | Default |
|---|---|
| Traefik edge bucket | 200 requests/second, burst 500 per source |
JOIN_RATE / JOIN_BURST |
20 attempts/second, burst 150 per serving process |
HANDOFF_RATE / HANDOFF_BURST |
20 starts/second, burst 150 per serving process |
MAX_HANDOFFS |
1,000 pending transactions |
| Handoff lifetime | 10 minutes |
COOKIE_LIFETIME_SECONDS |
86,400 (24 hours), maximum 7 days |
| Browser form | 4 KiB maximum; name 1–64 UTF-8 bytes |
KUBE_QPS / KUBE_BURST |
100 QPS, burst 200; 8-second individual API timeout |
| Request context | 20 seconds |
Service limits are global, not keyed by forwarded IP: a conference NAT does not become a
single-person quota, and attacker-supplied forwarding headers cannot select a bucket.
Rate limits reject with a retry response; no participant or room is permanently locked.
The local real-cluster rehearsal completed 300/300 enrollments in three batches
of 100 from one source IP, with the final Traefik rate limit enabled: p50 2.427s, p95 8.812s, max 10.119s. The initial
50-QPS/10-second configuration achieved only 265/300, which motivated the measured
budget change. This is a local fixture result, not a conference capacity guarantee.
task load repeats it and removes only its own test participants afterwards.
The separate race test uses a fake Kubernetes client and is not a capacity benchmark. Enrollment reads the current Room, lists retained Participants and
creates one record under a single-process mutex; authorization reads Room + Participant.
Revoked records still consume capacity. Status participantCount is observational only.
API errors fail closed. An uncertain create response is looked up using the original random name, never retried immediately with a new ID. Requests already in flight may finish during a stop. Pending logins are in bounded memory and restart after pod replacement; enrolled identities persist. Signing out clears the cookie, not the Participant or Dex tokens, and joining again consumes a new slot.
# Close new joins while allowing enrolled browsers to log in again.
kubectl -n room-pass patch room demo --type=merge -p '{"spec":{"enrollment":"Closed"}}'
# Reopen; the controller publishes a fresh code.
kubectl -n room-pass patch room demo --type=merge -p '{"spec":{"enrollment":"Open"}}'
# Permanently stop this Room object.
kubectl -n room-pass patch room demo --type=merge -p '{"spec":{"stopped":true}}'
# Stop already-issued tokens from making new demo writes as well.
kubectl -n demo delete rolebinding demo-editorRoom stop blocks new enrollment and identity assertions. It does not revoke a signed Dex token. Withdraw grants, close routes and drain long-running connections to shut down platform access. If Flux owns these resources, suspend the owning reconciliation, commit the stopped desired state and removed grants, then resume. Shared groups share permissions.
Deleting/recreating a Room gives it a new UID and invalidates its old sessions; owner
references allow Kubernetes to garbage-collect its Participants. To reset the local
fixture after an irreversible stop, delete its Room before rerunning e2e-up, or rebuild
only this disposable cluster:
task e2e-down
task e2e-upOn a Docker host with many clusters, failed to create fsnotify watcher: too many open files can mean fs.inotify.max_user_instances is exhausted. Check the node's containerd
log; increase that host runtime limit before retrying. Setup does not silently tune host
sysctls or stop unrelated clusters. Local TLS certificates expire after seven days;
recreate the fixture to generate a new CA and trust it in the test browser.
Room Pass now serves a separate Prometheus endpoint on port 9090. See metrics for meanings, privacy guarantees and troubleshooting queries.
Run task e2e-up, then task browser to watch the
room authentication contract exercised in Chromium. The
browser suite retains videos and a successful-login screenshot.