DocsSign-in and safety

Authentication

View source on GitHubdocs/auth.md

On this page

Providers prove who someone is. The core decides what they may do. Nothing is open by default: until a first admin exists, every route except /setup answers 401.

Roles#

viewer < operator < admin. A role is computed from the auth.users rules on every request, so removing a rule removes access on the next click. The one exception is deliberate: the admin made by first-run setup, and an admin made by recovery (below), is stored on the hub, so it works with no rule. Removing a rule does not touch that account; remove the person instead (Config, Users and roles, Remove; or yarrix-hub auth user remove <user>). The effective role is the highest of the stored role, the matching rules and default_role.

auth:
  providers:
    - { type: supabase, url: https://<project>.supabase.co, anon_key: "${env:SUPABASE_ANON_KEY}" }
    - { type: tailscale, mode: whois }
  users:
    - { match: ann@example.com, role: admin }
    - { match: bob@example.com, role: operator, terminal: [atlas] }   # terminal: host ids or "*"
  default_role: none        # signed in but unlisted: denied (or "viewer")

match is compared case-insensitively against the verified email (Supabase) or tailnet login (Tailscale). An admin needs no terminal grant; a viewer can never have one. A terminal needs a browser session no older than 12 hours, so a long-lived session has to sign in again first.

Providers#

  • supabase: OAuth PKCE. The hub verifies the access token (project JWKS, or jwt_secret for HS256), then asks Supabase itself (/auth/v1/user) whether the email is confirmed. Nothing in the token's user_metadata is trusted. The Supabase tokens never reach the browser.
  • tailscale, mode: whois: asks the local tailscaled who owns the TCP peer. socket: overrides the LocalAPI socket path. Tagged devices are refused.
  • tailscale, mode: headers: trusts Tailscale-User-Login only from trusted_proxies (default loopback, for tailscale serve). The hub refuses to start on a non-loopback listener unless allow_remote_proxy: true.
  • local (password): a username and password kept on the hub, on by default. The password is checked with OPAQUE (RFC 9807), so the hub never receives it; see Local passwords. A provider is one AuthProvider object in apps/hub/src/auth/providers/; see the note in types.ts.

There are four ways in: Supabase, Tailscale identity, a local password, and the recovery login link.

Setting up Supabase#

Supabase here is only an identity service; Yarrix keeps everything else on your own host. A free project works.

  1. Create a project at supabase.com (or run your own Supabase). Note the project URL and the anon public key (Project Settings, API).

  2. Authentication, Providers: enable the sign-in method you want. Yarrix sends people to github unless you set oauth_provider to another one Supabase offers (for example google).

  3. Authentication, URL Configuration: add https://<your-yarrix-host>/api/v1/auth/supabase/callback to the allowed redirect URLs. The host must be the address people open (see Plain HTTP and HTTPS).

  4. In yarrix.yaml:

    auth:
      providers:
        - type: supabase
          url: https://abcdefgh.supabase.co
          anon_key: ${env:SUPABASE_ANON_KEY}
          oauth_provider: github
      users:
        - { match: ann@example.com, role: admin }
      default_role: none
    

    Leave out jwt_secret to verify tokens against the project's published keys (JWKS). Set it only if your project signs with a shared HS256 secret.

  5. Set YARRIX_PUBLIC_URL to the address people open. A person who signs in with an email that no auth.users rule matches is denied while default_role is none.

Only emails that Supabase reports as confirmed are accepted.

Setting up Tailscale#

Nothing to create on the Tailscale side except the machine itself being on your tailnet.

  • mode: whois (the hub runs on a machine with tailscaled, and people reach it by its tailnet address): auth.providers: [{ type: tailscale, mode: whois }]. The hub asks the local daemon who owns each connection, so there is nothing to log in to. Set socket: if tailscaled's socket is not in the platform default place (a container needs the socket mounted in).
  • mode: headers (the hub sits behind tailscale serve on the same machine): the proxy sets Tailscale-User-Login, and the hub trusts it only from trusted_proxies (loopback by default). Keep the hub's own listener on loopback, or the hub refuses to start.

Then grant by tailnet login: auth.users: [{ match: ann@example.com, role: admin }]. Tagged devices are refused. Because tailscale serve terminates HTTPS, its session cookie is Secure (see Plain HTTP and HTTPS).

Local passwords#

First run. While no admin exists, whoever opens the hub (or /setup) first chooses a username and password and becomes the admin. There is no PIN and no host name to know. The window lasts one hour from when the hub starts (CLAIM_WINDOW_S), restarting the hub opens it again, and yarrix-hub auth setup-pin opens a PIN-gated claim at any time. Anyone who can reach the hub can claim it during that window, so claim it right away. The hub prints "Setup is open to the first visitor until HH:MM" at start, and /connect and the setup page say so too. The admin's role is stored in the hub's database, so it does not depend on an auth.users rule. Setup and recovery are refused for a Host that is not an IP, localhost, a single-label name, *.local, *.ts.net or the YARRIX_PUBLIC_URL host (DNS rebinding), with a 421.

What OPAQUE protects, and what it does not. The browser stretches the password (the memory-hard step runs there) and sends only blinded values, a proof, or a registration record. The hub never sees the password, and a stolen database yields records that still need an offline guess per password. It does not encrypt the connection: over plain HTTP anyone on the network path can read or change what is sent, including the sign-in page itself, so a hostile network can serve a page that captures the password. Use HTTPS. Treat a copy of the database like a copy of password hashes: the records and the server secret (auth_meta) are in it.

Wrong passwords. A wrong password is detected in the browser, so the hub counts every sign-in start as one failed attempt; a finished sign-in clears the count. Five from one address on one username lock that pair for 15 minutes (the lock survives a restart). 100 in a row switch the password off (423) until recovery. Starts are counted, not finishes, on purpose: each start is one OPRF evaluation, and a client can test a password guess against the response without ever calling finish, so a start is a guess. The trade is that anyone who can reach the hub can burn the cap and force the owner to run recovery. That costs the owner a PIN; counting finishes would cost a guesser nothing.

Recovery. On the hub's machine, yarrix-hub auth setup-pin (after setup) prints a recovery PIN: single use, 15 minutes, it signs out every session and sets a new password for an existing username (or makes a new admin for an unknown one). Enter it on /recover. It also clears that account's lockout. The new admin is intended: recovery is break-glass, for an owner who is locked out and may have no rule to fall back on. That admin is stored (not governed by a rule), appears in Users and roles marked "Stored by setup or recovery", and is removed like anyone else. One exception keeps the role rules in charge: if an auth.users rule already matches the new username, the account gets that rule's role and nothing is stored, so deleting the rule ends its access. A lost server secret fails closed (sign-in returns 503) until recovery.

Changing a password. /account asks for the current password (a two-minute single-use ticket), stores the new record, and signs other sessions out. An account that has no password yet (made before this feature) can set one without a current password only from a session opened in the last 10 minutes; an older session gets reauth_required and must sign in again or use recovery.

Removing a person. Config, Users and roles has a Remove button on each row (admin browser session; never your own row), and the hub's own shell has yarrix-hub auth user list and yarrix-hub auth user remove <username-or-id>. Removing ends their sessions on their next request, deletes their local password and their sign-in links, and writes a user_removed audit row. If a rule still matches them they can sign in again as a new account, so delete the rule too (the answer says when that is the case). The last admin cannot be removed: make another first with auth setup-pin.

Later, an admin who cannot reach a provider can mint a 15 minute, single-use link on the host: yarrix-hub auth login-link <user>.

Routes#

The setup page is GET /setup (a browser page, outside the API). Claiming is two calls, POST /api/v1/setup/start (a blinded message) and POST /api/v1/setup ({username, name, record}, plus pin when a PIN is needed); recovery is /api/v1/recover/start and /api/v1/recover; sign-in is /api/v1/auth/local/start and /finish; the account page uses /api/v1/account/password[...]. Everything the pages call sits under /api/v1: GET /api/v1/auth/providers, GET /api/v1/auth/session, POST /api/v1/auth/logout, GET /api/v1/auth/link?token=... (login link), and per provider /api/v1/auth/<kind>/start and /callback. Register https://<your-host>/api/v1/auth/supabase/callback as the redirect URL in Supabase. Under a YARRIX_BASE_PATH these all gain that prefix.

Plain HTTP and HTTPS#

Sign-in works over plain HTTP, so a hub on your own network works without Tailscale or a certificate (for example http://192.168.1.20:PORT). The session cookie is marked Secure only when the browser reaches the hub over HTTPS: the request arrived over TLS, a proxy sends X-Forwarded-Proto: https, or YARRIX_PUBLIC_URL is an https:// address. Otherwise the cookie is not Secure, and the browser keeps it. A TLS proxy in front of the hub must send X-Forwarded-Proto (or YARRIX_PUBLIC_URL must be set to the https address) for the cookie to stay Secure.

Plain HTTP is not encrypted. Anyone on the network path can read or change what is sent, including the sign-in page, so a password, the session cookie and the login links are all exposed to them. The web UI shows a "Not encrypted" banner on every page opened over plain HTTP from another machine (localhost is exempt). For anything beyond your own network, put Yarrix behind HTTPS (a reverse proxy, or tailscale serve) and set YARRIX_PUBLIC_URL to the https address.

The browser terminal still needs HTTPS or localhost, and refuses plain HTTP even on the local network. Password-based SSH install works over plain HTTP: the page seals the password in the browser before it is sent, and the hub opens it once. Someone who can change the page in transit can still capture the password, so use HTTPS for that. See Passwords on a plain-http hub.

First-run setup works from the network too. With no YARRIX_PUBLIC_URL and a listen address of 0.0.0.0 (or ::), the hub prints a setup address for localhost and one per LAN address. With a plain-HTTP YARRIX_PUBLIC_URL, it prints that link and a startup warning that the connection is not encrypted.

Sessions and tokens#

  • Session cookie yx_session_<hub id> (the first 8 characters of the hub's id; older hubs used plain yx_session, which is no longer read, so a browser signed in before the upgrade signs in once more): HttpOnly; SameSite=Lax, plus Secure when the browser reached the hub over HTTPS (see Plain HTTP and HTTPS). Stored hashed, 12 h idle, 7 d absolute.
  • Cookies ignore the port. Two hubs on the same hostname with different ports (for example localhost:5872 and localhost:7797) share one cookie jar, but each hub names its session cookie after its own id, so signing in to one does not sign you out of the other.
  • Mutating requests (and WebSocket upgrades) with a foreign Origin or Sec-Fetch-Site: cross-site are refused. Set YARRIX_PUBLIC_URL when a proxy changes the host.
  • API tokens (yx_<id>_<secret>, stored as sha256) carry scopes: read (viewer reads; a read-only key), operate (a read-write key for an AI agent, see mcp-and-cli.md), report (backup run reports, optionally limited to named backups) and admin. An admin makes a key in Settings and picks read-only or read-write. Managing tokens, enrollments and the SSH probe need an admin browser session, not a token.
  • An admin token can edit config files, but not the parts of yarrix.yaml that decide who gets in: the sign-in providers, the auth.users rules (match, role, terminal), default_role, and terminal.enabled / terminal.record. Like commands and SSH targets, those change only from an admin browser session or on disk (403 exec_field_requires_admin_session otherwise), so a token cannot raise anyone's access, its owner's included.
  • The config API shows the role rules and provider types as written. A provider's url, anon_key and jwt_secret are masked.
  • yarrix-hub auth token create|list|revoke does the same from the host.
  • The API screen (#api) lists, creates and revokes tokens for admins. A new token's secret is shown once, in the page only; it is never logged or put in a URL, and the hub keeps only its hash. The same screen lists every endpoint from /api/v1/openapi.json with a runnable curl ($YARRIX_TOKEN from the environment), a live sample fetched with your session, the MCP tools and CLI examples. Its base URL is YARRIX_PUBLIC_URL when set (the OpenAPI servers entry carries it), otherwise the page's own address.
  • Failed guesses (setup PIN, agent PIN, login link, API token) lock the (address, name) pair for 15 minutes; one client cannot lock others out.
  • The six-digit PINs have only a million values, so they get more. Their lockouts and counts are kept in the database, so restarting the hub does not reset them. Across all addresses, 30 wrong guesses in 15 minutes burn the live PINs (every unused agent PIN expires; the setup PIN is replaced and the new one printed). Setup has a total cap of 100 wrong PINs over every PIN and every restart; past it the hub makes no new setup PIN and the log says to run yarrix-hub auth setup-pin on the hub's machine, which prints a new one and resets the count.
  • Every start of the agent PIN exchange counts toward the 30-in-15-minutes budget, including a start that proves the right PIN. An unauthenticated client on the LAN can therefore spend the budget and make the live agent PINs expire, which denies enrollment until an admin mints a new PIN from the Installer tab. That is accepted (decision D-27 in docs/DECISIONS.md): guessing resistance wins over enrollment availability during an attack.

Backup report tokens are now ordinary report-scoped yx_ tokens, re-minted from the backup's drawer (Report token) or with yarrix-hub auth token create --scope report --backup <id>. The older yxb_ tokens no longer work.