Skip to content

Latest commit

 

History

73 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Room Pass

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.

Run the complete local example

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 records

Prerequisites: 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 configmaps

Or 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-1

That 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.

What is tested

  • 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. Ordinary go test skips this suite unless KUBEBUILDER_ASSETS is 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.

Kubernetes API and deployment

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-key

Keep 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 generate

CEL 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.

Upgrading from 1.x

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 room silently picks one of the two kinds named Room; use the full name, kubectl get rooms.room-pass.koudijs.dev (or rooms.roompass.configbutler.ai for the old records).
  • Removing the old CRDs discards the old records. Deleting rooms.roompass.configbutler.ai and participants.roompass.configbutler.ai deletes 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.

Dex and routing trust boundary

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.

Limits and failure behavior

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.

Operate, stop and reset

# 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-editor

Room 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-up

On 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.

Observability and browser verification

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.

About

Let a room full of people sign in to your demo with OIDC: show a rolling code or QR, they pick a name. No accounts or personal data. Powered by Dex. Be carefull with technical audiances however: they will try your limits.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages