Files
coder/q5vault/operations/auth-model.md
Antigravity 0e54b86dac coder: autostop TTL, nightly backups, Authelia OIDC, wildcard runbook
- docker-compose: CODER_OIDC_* wired to Authelia (password login kept as break-glass)
- scripts/backup-stacks.sh + stacks-backup.timer: nightly pg_dump, Continue state, stack configs
- vault: verification items 13/14, auth-model OIDC section, limits DNS-01 runbook
2026-08-20 11:05:47 +02:00

8.8 KiB

title, description, source, reviewed
title description source reviewed
🔐 Auth model Why code.loca.zone is deliberately not behind Authelia, why a browser-only gate is impossible, why the cookies do not collide, and how the initial Owner was bootstrapped over loopback. verified deployment on the loca operator host, 2026-08-19; first-user bootstrap verified 2026-08-20 2026-08-20

🔐 Auth model

🧾 Decision

  • Coder is the identity provider for its own deployment; the code.loca.zone vhost has no auth_request gate and no Authelia access rule
  • this is the only loca.zone application host that is intentionally ungated at the proxy, and the reason is protocol, not laziness
  • Authelia was not modified for this stack: its default_policy is bypass, so a host with no rule is simply passed through. Adding nothing was the correct action

💥 Precise failure mode if an Authelia rule were added

  • the site-wide gate answers any request without a valid authelia_session cookie with a 302 to https://auth.loca.zone
  • what that breaks, concretely:
    • the coder CLI, which authenticates with the Coder-Session-Token header and has no cookie jar; coder login, coder templates push, coder ssh and coder port-forward all receive a redirect where they expect JSON
    • workspace agents, which dial CODER_ACCESS_URL and hold long-lived /api/v2 websockets; a redirect on the upgrade request means the agent never connects and the workspace sits in a connecting state forever
    • relayed workspace traffic, since the built-in DERP relay rides the same vhost
  • upstream is explicit that all clients and agents must be able to reach the server over HTTP or HTTPS and that any proxy in between must support websockets; an unconditional redirect violates both

🚫 Why a partial browser-only gate is impossible

  • the dashboard is a single-page application served from the same origin as the API; the browser and the agents both call /api/v2/...
  • there is no stable path prefix that separates human traffic from machine traffic, and no header an nginx gate could trust to tell them apart, since a browser can be made to send anything a header-based rule would key on
  • gating / while exempting /api/v2 would exempt the entire attack surface and leave only the static bundle protected, which is security theatre
  • the workspace app and web terminal routes (/@user/workspace.agent/apps/..., /@user/workspace.agent/terminal) are proxied by the same server and are used by both planes
  • conclusion: either the whole vhost is gated, which breaks the product, or none of it is, which is what is deployed

🍪 Why the cookies do not collide

  • Authelia's session cookie is domain-scoped to loca.zone, so authelia_session is presented on requests to code.loca.zone as well
  • Coder ignores it; it reads only its own coder_session_token, which Coder sets host-scoped for code.loca.zone
  • nothing writes both names, so no overwrite, no truncation and no cross-application session confusion
  • practical consequence: being logged into auth.loca.zone grants nothing on this host, and being logged into Coder grants nothing on the Authelia-gated hosts

👤 First-user bootstrap is done

  • state now, verified 2026-08-20: the initial Owner exists, so nothing here is waiting on a setup form
    • username loca, email admin@loca.zone, role Owner, status active
    • organization coder, the default one, id 9c28ebd1-4fe6-446b-83e2-f723954fec10
    • created with POST /api/v2/users/first against http://127.0.0.1:52226, so the credential never traversed the public vhost
    • GET /api/v2/users/first flipped from 404 to 200 at that moment, which is upstream's own existence check for the initial user
  • how the password was handled, which is the only acceptable shape for an agent-run bootstrap:
    • randomly generated, 32 characters, written straight into /home/loca/dev/coder/.admin-credentials at mode 600 in a git-ignored path
    • never displayed on a terminal, in a log, in a ticket, in a chat message or in this wiki, and never read back into any document
    • that file also holds the session token minted by the same call, which is the second reason it stays at 600 and out of git
  • rotation is the operator's responsibility and nobody else's: sign in at https://code.loca.zone and set a new password under Account then Security, after which the stored copy is stale by design
  • what was verified alongside the bootstrap, none of it credential-bearing:
    • curl -sSI https://code.loca.zone/ returns 200 with no auth redirect
    • curl -sf http://127.0.0.1:52226/api/v2/buildinfo reports v2.35.3+65e2bfb with dashboard_url https://code.loca.zone

🔒 No open signup

  • POST /api/v2/users/first is the only account-creating endpoint reachable without authentication and it exists solely to create the initial Owner
  • POST /api/v2/users requires authentication, so every later account is created by an admin, either with coder users create or through Users then New user in the dashboard
  • there is no self-service registration in Coder OSS with password auth; a user appears only because an admin made it
  • password reset for the initial Owner is a host-side operation: coder reset-password <username> only works when run on the machine running the control plane, which here means inside the coder container

🔗 Authelia as OIDC provider — configured

Implemented 2026-08-20. Identity is unified at the application layer: Coder consumes Authelia over OIDC. The proxy is still ungated — everything in the sections above still holds, and adding an auth_request gate would still break agents.

Authelia side, /home/loca/dev/services/authelia/config/configuration.yml, second entry under identity_providers.oidc.clients:

  • client_id: 'coder', client_name: 'Coder', public: false
  • client_secret is a pbkdf2-sha512 digest read via {{ secret "/config/secrets/oidc/coder-client-secret.digest" }}; that file is 600 root:root, and the container runs as root so the strict mode is safe
  • authorization_policy: 'one_factor', consent_mode: 'implicit', token_endpoint_auth_method: 'client_secret_basic'
  • single redirect URI https://code.loca.zone/api/v2/users/oidc/callback
  • scopes openid, profile, email, groups; grant authorization_code; response type code

Coder side, environment: on the coder service in docker-compose.yml:

variable value
CODER_OIDC_ISSUER_URL https://auth.loca.zone
CODER_OIDC_CLIENT_ID coder
CODER_OIDC_CLIENT_SECRET ${CODER_OIDC_CLIENT_SECRET}, interpolated from .env (mode 600, gitignored)
CODER_OIDC_SCOPES openid,profile,email
CODER_OIDC_SIGN_IN_TEXT Sign in with loca.zone SSO

Deliberately unset:

  • CODER_DISABLE_PASSWORD_AUTH — password login stays as break-glass. If Authelia or the OIDC client breaks, the Owner can still log in with the credential in .admin-credentials
  • CODER_OIDC_EMAIL_DOMAIN — no domain restriction; the Authelia user database is the gate

Agent and CLI traffic is unaffected: both authenticate with Coder-Session-Token bearer tokens against /api/v2/..., a path OIDC never touches. Verified after the change — ws1 agent main still connected, token GET /api/v2/users/me still 200.

Verification actually performed:

  • GET /api/v2/users/authmethods returns password.enabled: true and oidc.enabled: true with signInText: "Sign in with loca.zone SSO"
  • https://code.loca.zone/login renders the SSO button next to the password form
  • clicking it produces the chain /api/v2/users/oidc/callback?redirect=%2F → https://auth.loca.zone/api/oidc/authorization?...client_id=coder...redirect_uri=https%3A%2F%2Fcode.loca.zone%2Fapi%2Fv2%2Fusers%2Foidc%2Fcallback&response_type=code&scope=openid+profile+email with PKCE S256 → the Authelia login form at ?flow=openid_connect. Authelia raising the form rather than invalid_client is the proof that the client id and secret digest are accepted
  • the final credential entry at the Authelia form is the operator's own SSO login and was not performed here: users_database.yml stores only a password hash, so no automated agent can complete it. First interactive SSO login remains an operator action, and it will create a second Coder account unless the OIDC email matches admin@loca.zone

Rotation: regenerate with authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 64 --random.charset alphanumeric, install the Digest to the digest file, put the Random Password in .env, then docker compose restart Authelia and docker compose up -d --force-recreate coder.

📚 Sources