Ctrl K
ComunidadGuías…

docs/src/authentication.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.