Skip to content

Web sign-in

Zimmer can put a login wall in front of its web UI. Sign-in goes through Google, and only accounts in the Google Workspace domains you name get in. After that comes a code from an authenticator app. Then the browser stays signed in for months.

It is off unless you turn it on. A deployment that sets nothing behaves exactly as it always has: no login page, and the network perimeter is the only wall. It does not replace the perimeter either. Keep the tailnet; this is a second wall behind it.

All of it is configuration, read through the secret-provider chain: the Parameter Store first, then Rails credentials, then the process environment. Turning the wall on or off is a secret-store write. It takes no deploy and no shell, and each Puma process picks the change up within a minute.

VariableWhat it doesDefault
ZIMMER_WEB_AUTH_GOOGLE_CLIENT_IDThe switch. Set means the wall is upnot set, so the wall is off
ZIMMER_WEB_AUTH_GOOGLE_CLIENT_SECRETThe OAuth client’s secret. Read only when a sign-in comes back from Googlenot set
ZIMMER_WEB_AUTH_ALLOWED_DOMAINSGoogle Workspace domains allowed in, comma- or space-separated, e.g. tadasant.comnot set
ZIMMER_WEB_AUTH_SECOND_FACTORtotp asks for an authenticator code after Google. google asks for nothing more (see below). Any other value still requires the code, and is listed as a problem on the login pagetotp
ZIMMER_WEB_AUTH_SESSION_DAYSHow long a browser stays signed in without being used90
ZIMMER_WEB_AUTH_TRUSTED_DEVICE_DAYSHow long a browser that passed the second factor skips it at its next Google sign-in365
ZIMMER_WEB_AUTH_SECOND_FACTOR_RESET_BEFOREAn ISO 8601 time. Any authenticator set up before it no longer counts. See lost your second factor. A value that does not parse stops new sign-ins and is named on the login page, rather than being ignorednot set

If the secret store stops answering, each Puma process keeps the configuration it last read. A process that boots during the outage uses the last configuration any process wrote to Rails.cache (everything but the client secret). Existing sign-ins keep working, and new ones wait for the store, because finishing one needs the secret. Only a process with neither answers 503 on browser pages. The machine paths never read this configuration.

Set the client ID last, or all three in one write. Once the client ID is set the wall is up, and if the secret or the domains are missing, nobody gets in. The login page lists what is missing. It fails closed on purpose: once a deployment has asked for a wall, a typo must not quietly leave the UI open. Removing the client ID takes the wall down again.

In the Google Cloud project that belongs to your Workspace:

  1. APIs & Services → OAuth consent screen. Choose Internal. Internal already limits sign-in to your Workspace’s own accounts, and Zimmer checks the domain again on its side. Scopes: openid, email and profile, nothing else.
  2. APIs & Services → Credentials → Create credentials → OAuth client ID, type Web application.
  3. Authorized redirect URI: your deployment’s base URL plus /auth/google/callback, for example https://zimmer.example.com/auth/google/callback. The base URL is the one AppUrl resolves (ZIMMER_PROD_BASE_URL in production). Google takes only https redirect URIs, apart from http://localhost, so a deployment reached only at a plain-HTTP tailnet name such as http://zimmer needs an HTTPS name before this can work.
  4. Put the client ID and secret in the secret store as the two variables above.

A browser that starts on another hostname, say the tailnet name, still signs in, but it comes back on the base URL’s host. Cookies belong to one host, so that is where it stays signed in.

Zimmer runs Google’s OpenID Connect authorization-code flow itself (WebAuth::GoogleOauth). It is the same shape as strad’s console login.

  • The domain is checked against the ID token’s hd claim, not the email address. A personal Google account can carry an address on someone else’s domain, and Google can mark it verified. Google sets hd only for accounts that really belong to that Workspace. The hd parameter on the consent URL is only a hint to Google’s account picker, and Zimmer never trusts it.
  • email_verified must be true. An unverified email is refused, whatever its domain.
  • Issuer, audience and expiry must be Google’s, this client’s, and not past, with five minutes allowed for clock skew.
  • PKCE and state. The sign-in starts with a POST that carries the CSRF token, so another site cannot start one in your browser. The callback must carry the state this browser was given within the last 15 minutes.
  • The token’s signature is not checked against Google’s keys. That is allowed here: the token never passes through the browser. Zimmer gets it in the response to its own authenticated POST to Google’s token endpoint, over TLS with certificate checking, and OpenID Connect Core §3.1.3.7 lets that stand in for the signature check.

A refused sign-in answers 403 and says why: another Workspace, a personal account, or an unverified email. It logs one WARN line with the reason. No web_identities row is written.

Google Workspace can require 2-Step Verification, and you should turn that on. But Zimmer cannot see whether it happened: Google’s ID tokens do not reliably carry an amr claim. A wall whose second factor can’t be checked is a wall in name only. So by default Zimmer asks for its own: a six-digit code from an authenticator app (TOTP, RFC 6238). It works with 1Password, Google Authenticator, Authy, or anything else that takes a setup key.

ZIMMER_WEB_AUTH_SECOND_FACTOR=google turns Zimmer’s own factor off and trusts the Workspace’s 2SV policy instead, with the gap above. Switching back to totp signs out every browser that never passed a code.

Passkeys (WebAuthn) would resist phishing better than TOTP does. They are not here yet.

The first sign-in sets it up. After Google, the setup page shows a key and an otpauth:// link. On a phone, the link opens the authenticator app directly. You confirm with the code the app shows. Then Zimmer shows ten recovery codes, once. Each code stands in for the authenticator for a single sign-in. Put them in your password manager.

A browser that has passed Google but not the second factor gets 15 minutes to finish. It cannot set up a new authenticator while one already stands. Otherwise anyone holding the Google account could replace the authenticator and walk past it.

A code is accepted once: Zimmer remembers the last time step it took. Clocks may be one 30-second step off either way. Every five wrong answers in a row, authenticator or recovery code, lock the second factor: 15 minutes the first time, then 30, then an hour, doubling up to a day. Only a right answer clears the count, so slow guessing gets slower rather than resetting. Each lockout logs a WARN line.

These numbers are chosen to be generous, so you rarely see the login page.

  • The sign-in cookie rolls. It lasts ZIMMER_WEB_AUTH_SESSION_DAYS (90) from the last time the browser was used. Zimmer re-issues it at most once a day, so a browser you open at least once a quarter never signs in again.
  • The trusted-device cookie is set when you pass the second factor with “Trust this browser” ticked, which is the default. For ZIMMER_WEB_AUTH_TRUSTED_DEVICE_DAYS (365) after that, the next Google sign-in in that browser skips the code. Signing out keeps this cookie, so signing back in is one Google click.

Both cookies are encrypted with secret_key_base, HttpOnly and SameSite=Lax, and Secure whenever the request is HTTPS, including HTTPS that a local proxy reports in X-Forwarded-Proto (see behind a proxy). The expiry is checked inside the cookie as well as by the browser. Rotating secret_key_base signs everyone out.

A sign-in cookie stops counting when:

  • its identity is deleted, or signs out everywhere,
  • its domain is no longer in ZIMMER_WEB_AUTH_ALLOWED_DOMAINS,
  • the deployment requires TOTP and that browser never passed a code.

Setting up a new authenticator signs that person out everywhere except the browser that confirmed it, in case the old one was compromised. A new authenticator, signing out everywhere, or a second-factor reset voids every trusted-device cookie for that person. Signing out everywhere, and deleting the row, also close that person’s open live-update connections at once.

The flip side of a 90-day rolling session: suspending someone’s Google account does not sign them out of Zimmer. To end someone’s access now, delete their row at /supervisor/web_identities, or take their domain out of the allowed list.

Settings → Sign-in shows who the browser is signed in as and how many recovery codes are left. It has three buttons:

  • Set up a new authenticator. The old one keeps working until the new one is confirmed. Confirming issues a fresh set of recovery codes and signs you out of every other browser. This is how you move to a new phone.
  • Sign out. This browser only.
  • Sign out everywhere. Every browser signed in as you, this one included.

/supervisor/web_identities lists everyone who has signed in. The rows are read-only. Deleting one signs that person out everywhere and voids their authenticator, so their next sign-in sets up a new one.

Try these in order. None of them needs a shell on the box.

  1. A recovery code. Type it into the code field. Case and dashes don’t matter. Then set up a new authenticator from Settings.
  2. Any browser that is still signed in. Sessions last months. Open Settings there and choose Set up a new authenticator.
  3. Someone else who can sign in deletes your row at /supervisor/web_identities.
  4. The deployment’s secret store. Set ZIMMER_WEB_AUTH_SECOND_FACTOR_RESET_BEFORE to the current time, for example 2026-10-07T18:00:00Z. Any authenticator set up before that instant stops counting. Your next Google sign-in goes to the setup page. You can leave the variable set afterwards, because an authenticator set up later is unaffected.

The last step is only as safe as the store it is written to. Whoever can write that store can reset the factor, and the next sign-in with the Google account sets up a new one. The same is true of removing ZIMMER_WEB_AUTH_GOOGLE_CLIENT_ID, which takes the whole wall down.

What the wall covers, and what it does not

Section titled “What the wall covers, and what it does not”
SurfaceWith the wall up
Every page and form of the web UI, /settings, /inference, /health and its buttonsSign-in required. A page load is redirected to /login and back afterwards. A form post, Turbo fetch or JSON request gets a bare 401
/supervisor (Administrate)Sign-in required
/jobs (GoodJob)Sign-in required
/cable (Turbo Streams)The connection is refused without a valid sign-in cookie
/mcp_oauth/*, /supervisor/x_oauth/* callbacksSign-in required. They are browser hops, and a signed-out browser comes back to the callback after signing in
REST API /api/v1/*Unchanged. X-API-Key
POST /mcp, /mcp/external_appUnchanged. Bearer API key, or on /mcp an OAuth access token
/oauth/authorize (the MCP consent screen)Sign-in required, and back to the full /oauth/authorize?… URL afterwards. See Connecting to /mcp over OAuth
/.well-known/oauth-*, POST /oauth/register, /oauth/token, /oauth/revokeOpen. Machine endpoints for MCP clients, authenticated by PKCE or a token the client holds
/webhooks/slack, /webhooks/githubUnchanged. Request signatures
/up, /up/deepUnchanged. Open, as the deploy gates need
GET /health/export_diagnosticsOpen to the host’s own loopback only, for the on-host fleet-telemetry collector. Everyone else signs in. See the limitation
/.well-known/oauth-*, /oauth/register, /oauth/token, /oauth/revoke (the MCP authorization server’s machine endpoints)Never walled. They answer MCP clients, not browsers
/oauth/authorize (the MCP authorization server’s consent page)Sign-in required. A signed-out browser goes through Google and the code, then comes back to the full authorize URL, query string intact
A route that does not exist404, as before

test/integration/web_sign_in_route_audit_test.rb holds this table to the code. It walks every route Zimmer draws. A walled route must refuse a signed-out request. The rest must be on a short, named list. The machine bases are named too: Api::BaseController, Webhooks::BaseController, and the MCP authorization server’s OauthServer::BaseController once it exists. A new controller that is neither walled nor under one of those fails the build, so a new API base has to be added to the list, with its credential, before it ships.

The machine paths are also exempt by path (WebSignInRequired::MACHINE_PATHS: /mcp, /mcp/external_app, /api, /webhooks, /up, /up/deep, /.well-known/oauth-, and /oauth/register|token|revoke, each with or without a format suffix such as .json). That is a second line of defence: if a machine endpoint ever ended up on a controller that includes the wall, it would still not answer with a redirect to /login. The audit fails if a walled browser page sits under one of those paths.

When TLS ends at a proxy in front of Zimmer (cloudflared, kamal-proxy), Rails sees plain HTTP from 127.0.0.1 with X-Forwarded-Proto: https. The wall is built for that:

  • The sign-in and trusted-device cookies are Secure because the request is HTTPS, and Rails reads that from X-Forwarded-Proto. They do not depend on force_ssl. The Rails session cookie does: it carries the OAuth state, the browser that has passed Google but not yet the code, and where to return. So keep assume_ssl and force_ssl on behind the proxy (the production default) rather than setting DISABLE_SSL=true, which also drops HSTS.
  • Google’s redirect_uri comes from the configured base URL (ZIMMER_PROD_BASE_URL, through AppUrl), never from the request’s Host. A forged or rewritten Host cannot move where Google sends the authorization code.
  • The proxy must pass the public Host through. Rails’ CSRF check compares the browser’s Origin with the scheme and host it sees. If the proxy rewrites Host to localhost, every form in the UI, the sign-in button included, is refused with a 422.

test/integration/web_sign_in_behind_proxy_test.rb covers all three, including the 422 when Host is rewritten. It also covers returning to a realistic MCP authorize URL with its query string byte for byte, through authenticator setup, the code step and a trusted browser.

A return path is kept only while it fits in the session cookie (RETURN_TO_MAX_BYTES, 1500 bytes as stored). A longer one is dropped and the browser lands on / after signing in. A realistic authorize URL is about 450 bytes.

The wall keeps out the tailnet and the open internet. It does not keep out agent sessions on the same host. A session’s casual curl meets /login, and it has no Google account to pass it with. But a session runs as the same user, in the same container, as the Rails app. It can read SECRET_KEY_BASE, the key that encrypts the sign-in cookie, and forge one. It also holds API_KEYS, so whatever the REST API and /mcp can do, it can do. See the limitation.

The same wall stops an agent that drives the production UI in a browser for QA. Local dev servers, where the wall is off, are unaffected. A sign-in route for automated UI driving is tracked in #220.

PieceFile
The wallapp/controllers/concerns/web_sign_in_required.rb, included by ApplicationController, Supervisor::ApplicationController and GoodJob’s base controller (config/initializers/web_auth.rb)
The cable checkapp/channels/application_cable/connection.rb
Googleapp/services/web_auth/google_oauth.rb
TOTPapp/services/web_auth/totp.rb
Cookiesapp/services/web_auth/cookies.rb
Configurationapp/services/web_auth/configuration.rb
Who signed in, their second factorWebIdentity (web_identities)
PagesWebSignInsController, WebSecondFactorsController