docs/src/authentication.md
Rama main · réplica de lectura del repositorio original.
# Authentication
Moltis uses a unified authentication gate that protects all routes with a
single source of truth. This page explains how authentication works, the
decision logic, and the different credential types.
## Architecture
All HTTP requests pass through a single `auth_gate` middleware before
reaching any handler. The middleware calls `check_auth()` — the **only**
function in the codebase that decides whether a request is authenticated.
This eliminates the class of bugs where different code paths disagree on
auth status.
```
Request
│
▼
auth_gate middleware
│
├─ Public path? (/health, /assets/*, /api/auth/*, ...) → pass through
│
├─ No credential store? → pass through
│
└─ check_auth()
│
├─ Allowed → insert AuthIdentity into request, continue
├─ SetupRequired → 401 (API/WS) or redirect to /onboarding (pages)
└─ Unauthorized → 401 (API/WS) or serve SPA login page (pages)
```
WebSocket connections also use `check_auth()` for the HTTP upgrade
handshake. After the upgrade, the WS protocol has its own param-based auth
(API key or password in the `connect` message) for clients that cannot set
HTTP headers.
## Decision Matrix
`check_auth()` evaluates conditions in order and returns the first match:
| # | Condition | Result | Auth method |
|---|-----------|--------|-------------|
| 1 | `auth_disabled` is true | **Allowed** | Loopback |
| 2 | Setup not complete + local connection | **Allowed** | Loopback |
| 3 | Setup not complete + remote connection | **SetupRequired** | — |
| 4 | Valid session cookie | **Allowed** | Password |
| 5 | Valid Bearer API key | **Allowed** | ApiKey |
| 6 | None of the above | **Unauthorized** | — |
```admonish info title="What is 'setup complete'?"
Setup is complete when at least one credential (password or passkey) has
been registered. The `setup_complete` flag is recomputed whenever
credentials are added or removed, so it correctly reflects passkey-only
setups — not just password presence.
```
## Three-Tier Model
The decision matrix above implements a three-tier authentication model:
| Tier | Condition | Behaviour |
|------|-----------|-----------|
| **1 — Full auth** | Password or passkey is configured | Auth **always** required (any IP) |
| **2 — Local dev** | No credentials + direct local connection | Full access (dev convenience) |
| **3 — Remote setup** | No credentials + remote/proxied connection | Setup flow only |
### Practical scenarios
| Scenario | No credentials | Credentials configured |
|----------|---------------|----------------------|
| Local browser on `localhost:18789` | Full access (Tier 2) | Login required (Tier 1) |
| Local CLI/wscat on `localhost:18789` | Full access (Tier 2) | Login required (Tier 1) |
| Internet via reverse proxy | Onboarding only (Tier 3) | Login required (Tier 1) |
| `MOLTIS_BEHIND_PROXY=true`, any source | Onboarding only (Tier 3) | Login required (Tier 1) |
### How "local" is determined
A connection is classified as **local** only when **all four** checks pass:
1. `MOLTIS_BEHIND_PROXY` env var is **not** set
2. No proxy headers present (`X-Forwarded-For`, `X-Real-IP`,
`CF-Connecting-IP`, `Forwarded`)
3. The `Host` header resolves to a loopback address (or is absent)
4. The TCP source IP is loopback (`127.0.0.1`, `::1`)
If **any** check fails, the connection is treated as remote.
#### Docker and check 4
Docker's default bridge networking rewrites the container-side TCP source to
a bridge-network address via DNAT, even when the published port is bound
only to `127.0.0.1` on the host (`-p 127.0.0.1:PORT:PORT`). Check 4 above can
never pass in that mode, so Tier 2 (local dev convenience, including
`auth_disabled`) never applies to a default Docker deployment — only
`--network host` avoids this by sharing the host's network namespace
directly. If you've confirmed your own deployment publishes the port
loopback-only and want checks 1–3 to be sufficient on their own, set
`MOLTIS_TRUST_DOCKER_LOOPBACK=true`. This does **not** relax checks 1–3 — a
proxy header, a non-loopback `Host`, or `MOLTIS_BEHIND_PROXY=true` still
force the connection to be treated as remote.
## Credential Types
### Password
- Set during initial setup or added later via Settings
- Hashed with Argon2id before storage
- Minimum 12 characters
- Verified against `auth_password` table
### Passkey (WebAuthn)
- Registered during setup or added later via Settings
- Supports hardware keys (YubiKey), platform authenticators (Touch ID,
Windows Hello), and cross-platform authenticators
- Stored in `passkeys` table as serialized WebAuthn credential data
- Multiple passkeys can be registered per instance
- Passkeys are bound to the hostname you visit. If you add a new public host
later, for example a Tailscale name or ngrok URL, you may need to log in
with a password once and register a new passkey for that host
### Session cookie
- HTTP-only `moltis_session` cookie, `SameSite=Strict`
- Created on successful login (password or passkey)
- 30-day expiry
- Validated against `auth_sessions` table
- When the request arrives on a `.localhost` subdomain (e.g.
`moltis.localhost`), the cookie includes `Domain=localhost` so it is
shared across all loopback hostnames
### API key
- Created in Settings > Security > API Keys
- Prefixed with `mk_` for identification
- Stored as SHA-256 hash (the raw key is shown once at creation)
- Passed via `Authorization: Bearer <key>` header (HTTP) or in the
`connect` handshake `auth.api_key` field (WebSocket)
- **Must have at least one scope** — keys without scopes are denied
## API Key Scopes
| Scope | Permissions |
|-------|-------------|
| `operator.read` | View status, list jobs, read history |
| `operator.write` | Send messages, create jobs, modify configuration |
| `operator.admin` | All permissions (superset of all scopes) |
| `operator.approvals` | Handle command approval requests |
| `operator.pairing` | Manage device/node pairing |
```admonish tip
Use the minimum necessary scopes. A monitoring integration only needs
`operator.read`. A CI pipeline that triggers agent runs needs
`operator.read` and `operator.write`.
```
## Public Paths
These paths are accessible without authentication, even when credentials
are configured:
| Path | Purpose |
|------|---------|
| `/health` | Health check endpoint |
| `/api/auth/*` | Auth status, login, setup, passkey flows |
| `/assets/*` | Static assets (JS, CSS, images) |
| `/auth/callback` | OAuth callback |
| `/manifest.json` | PWA manifest |
| `/sw.js` | Service worker |
| `/ws` | Node WebSocket endpoint (device token auth at protocol level) |
## Request Throttling
Moltis applies built-in endpoint throttling per client IP only when auth is
required for the current request.
Requests bypass IP throttling when:
- The request is already authenticated (session or API key)
- Auth is not currently enforced (`auth_disabled = true`)
- Setup is incomplete and the request is allowed by local Tier-2 access
Default limits:
| Scope | Default |
|------|---------|
| `POST /api/auth/login` | 5 requests per 60 seconds |
| Other `/api/auth/*` | 120 requests per 60 seconds |
| Other `/api/*` | 180 requests per 60 seconds |
| `/share/*` | 90 requests per 60 seconds |
| `/ws/*` | 30 requests per 60 seconds |
When a limit is exceeded:
- API endpoints return `429 Too Many Requests`
- Responses include `Retry-After` header
- JSON API responses also include `retry_after_seconds`
```admonish note
When `MOLTIS_BEHIND_PROXY=true`, throttling is keyed by forwarded client IP
headers (`X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`) instead of the
direct socket address.
```
## Setup Flow
On first run (no credentials configured):
1. A random 8-character **setup code** is printed to the terminal. When Moltis
opens the browser for you, the code is passed in the URL fragment
(`#setup=…`) and pre-filled on the onboarding page
2. Local connections get full access (Tier 2) — no setup code needed
3. Remote connections are redirected to `/onboarding` (Tier 3) — the
setup code is required to set a password or register a passkey
4. After setting up, the setup code is cleared and a session is created
```admonish warning
The setup code is single-use and only valid until the first credential is
registered. If you lose it, restart the server to generate a new one.
```
## Removing Authentication
The "Remove all auth" action in Settings:
1. Deletes all passwords, passkeys, sessions, and API keys
2. Sets `auth_disabled = true` in config
3. Generates a new setup code for re-setup
4. All subsequent requests are allowed through (Tier 1 check: `auth_disabled`)
To re-enable auth, complete the setup flow again with the new setup code.
## WebSocket Authentication
WebSocket connections are authenticated at two levels:
### 1. HTTP upgrade (header auth)
The WebSocket upgrade request passes through `check_auth()` like any
other HTTP request. If the browser has a valid session cookie, the
connection is pre-authenticated.
### 2. Connect message (param auth)
After the WebSocket is established, the client sends a `connect` message.
Non-browser clients (CLI tools, scripts) that cannot set HTTP headers
authenticate here:
```json
{
"method": "connect",
"params": {
"client": { "id": "my-tool", "version": "1.0.0" },
"auth": {
"api_key": "mk_abc123..."
}
}
}
```
The `auth` object can contain `api_key` or `password`. If neither is
provided and the connection was not pre-authenticated via headers, the
connection is rejected.
## Reverse Proxy Considerations
When running behind a reverse proxy, authentication interacts with the
local-connection detection:
- **Most proxies** add `X-Forwarded-For` or similar headers, which
automatically classify connections as remote
- **Bare proxies** (no forwarding headers) can appear local — set
`MOLTIS_BEHIND_PROXY=true` to force all connections to be treated as
remote
- The proxy must preserve the browser origin host for WebSocket CSWSH
protection (forward `Host`, or `X-Forwarded-Host` when rewriting `Host`)
- TLS termination typically happens at the proxy
- Passkeys are tied to the RP ID/host identity; host/domain changes usually
require registering new passkeys on the new host
See [Security Architecture](security.md#reverse-proxy-deployments) for
detailed proxy deployment guidance, including a Nginx Proxy Manager
header snippet and passkey migration guidance.
## Session Management
| Operation | Endpoint | Auth required |
|-----------|----------|---------------|
| Check status | `GET /api/auth/status` | No |
| Set password (setup) | `POST /api/auth/setup` | Setup code |
| Login with password | `POST /api/auth/login` | No (validates password) |
| Login with passkey | `POST /api/auth/passkey/auth/*` | No (validates passkey) |
| Logout | `POST /api/auth/logout` | Session |
| Change password | `POST /api/auth/password/change` | Session |
| List API keys | `GET /api/auth/api-keys` | Session |
| Create API key | `POST /api/auth/api-keys` | Session |
| Revoke API key | `DELETE /api/auth/api-keys/{id}` | Session |
| Register passkey | `POST /api/auth/passkey/register/*` | Session |
| Remove passkey | `DELETE /api/auth/passkeys/{id}` | Session |
| Remove all auth | `POST /api/auth/reset` | Session |
| Vault status | `GET /api/auth/vault/status` | No |
| Vault unlock | `POST /api/auth/vault/unlock` | Session |
| Vault recovery | `POST /api/auth/vault/recovery` | Session |
| Vault initialize | `POST /api/auth/vault/initialize` | Session |
| Vault disable | `POST /api/auth/vault/disable` | Session |
## Encryption at Rest
Environment variables and other sensitive data are encrypted at rest using
the vault. The vault initializes automatically during password setup and
unseals on login. See [Encryption at Rest (Vault)](vault.md) for details.