# Authentication

> Applies to: ChengOS v0.2.0+ ｜ Last verified: 2026-09-25 ｜ Source: `crates/cheng-api/src/rest/handlers/auth/mod.rs`, `crates/cheng-api/src/rest/handlers/decision/mod.rs`, `crates/cheng-api/src/rest/dto/credential_provider_schema.rs`, `crates/cheng-api/src/middleware/channel_auth.rs`, `crates/cheng-api/src/services/channel_socket_ticket.rs`, `crates/cheng-api/src/middleware/workspace_context.rs`

ChengOS has four credential types, each with a specific job. Most `401` and `403` confusion comes
from using one where another belongs, so it is worth reading the whole table before debugging.

| Credential | Prefix | Authenticates | Scope |
|---|---|---|---|
| **JWT access token** | — | A user | The whole API |
| **Channel key** | `chk_` | An external channel adapter | `/api/v1/channel/*` only |
| **Socket ticket** | `chtk_` | One WebSocket upgrade | A single connection, once |
| **MCP token** | — | An MCP client | The MCP server |

## JWT access tokens

The normal path. Log in, get a token, send it on every request.

```bash
curl -X POST http://127.0.0.1:19225/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "password": "…"}'
```

```json
{
  "token": "eyJhbGciOi…",
  "refreshToken": "eyJhbGciOi…",
  "expiresIn": 3600,
  "user": { "id": "…", "email": "…", "username": "…" }
}
```

```
Authorization: Bearer <token>
```

### Claims

| Claim | Meaning |
|---|---|
| `sub` | User id |
| `email` | User email |
| `tenant_id` | Tenant, when applicable |
| `iat` / `exp` | Issued-at and expiry |
| `token_version` | Matches `users.token_version` |

`token_version` is the revocation mechanism. It is **incremented on password change**, which
invalidates every previously issued token for that user at once. This is what makes "change your
password to log out everywhere" actually work — without it, a stolen token would stay valid until
expiry.

### Lifecycle

Access tokens last **one hour** (`expiresIn: 3600`). Exchange the refresh token at
`POST /api/v1/auth/refresh` rather than re-authenticating.

`POST /api/v1/auth/logout` returns `204` and is **client-side only** — it tells you to discard the
token; it does not revoke it server-side. To actually invalidate tokens, change the password
(bumping `token_version`).

### The full auth surface

| Endpoint | Purpose |
|---|---|
| `GET /auth/bootstrap-status` | `needsSetup` and `userCount` — whether the installation has any users |
| `POST /auth/register` | Create an account, subject to the deployment's registration policy |
| `POST /auth/login` | Obtain tokens |
| `POST /auth/refresh` | Exchange a refresh token |
| `POST /auth/logout` | Client-side discard |
| `GET /auth/validate` | Check a token and nothing else |
| `GET /auth/me` | The current user |
| `POST /auth/password/forgot` · `/reset` · `/change` | Password lifecycle |
| `GET /setup/status` · `POST /setup/initialize` | First-run initialization |
| `POST /auth/demo` | Demo deployments only: a session for the configured demo account, without credentials |

## Workspace authorization

Authentication says who you are; `X-Workspace-Id` says which tenant you are acting in:

```
X-Workspace-Id: <workspace-uuid>
```

The middleware runs **after** authentication, specifically so it can read the caller's claims and
authorize the header against them. A workspace you have no access to is a `403` — never a silent
fallback to a default workspace.

## Channel keys (`chk_`)

Channel keys authenticate server-side channel adapters — WhatsApp, Telegram, Slack, DingTalk,
WeCom — with no user session involved.

```text
chk_{credential_id_hex_32}_{secret_hex_32}
```

The embedded credential id is a UUID without hyphens, which gives an O(1) database lookup; the
secret half is verified against the encrypted credential record. Keys are stored as credentials
with `provider = "channel_adapter"`, and the secret plus workspace metadata are encrypted in the
`cipher` field.

Manage them at `/api/v1/workspaces/:workspace_id/channel-keys` (create, list, delete).

**Do not use a channel key from a browser.** It is a long-lived shared secret and client-side code
cannot keep it. Browsers use JWTs — see [the gateway model](/docs/sdk-gateway-model).

## Socket tickets (`chtk_`)

A channel key must never appear in a URL, because query strings are logged by proxies and stored
in browser and CLI history. So a channel client exchanges its key for a ticket over the
authenticated REST surface and presents the ticket once during the WebSocket upgrade:

```bash
POST /api/v1/channel/socket-ticket     # authenticated with chk_
# → chtk_…
```

```text
wss://host/ws/executions?ticket=chtk_…
```

Ticket properties:

- **Random and short-lived** — a 60-second TTL.
- **Single-use** — consumed on first use.
- **Scope-bounded** — carries the key's workspace and optional app/channel scope, which then bounds
  every subscription on the resulting socket.
- **Deliberately distinct prefix** — `chtk_` rather than `chk_`, so a leaked ticket is *visibly*
  not a long-lived credential.

Outstanding tickets are capped in bounded process memory, protecting the server from a client that
mints tickets in a loop without consuming them. Set `CHANNEL_SOCKET_TICKET_REDIS_URL` to hold them
in Redis with a TTL and atomic consume instead — which you need for a multi-instance deployment,
since an in-memory ticket minted on one instance cannot be consumed on another.

## MCP tokens

With `MCP_ENABLED=true`, the app-level MCP server mounts at `/mcp`. Two auth modes:

- **Database-backed tokens** (preferred) — the same model as per-workflow dynamic MCP servers.
- **Static `MCP_SERVER_TOKEN`** — a compatibility mode. If set, it is honoured.

Leave `MCP_SERVER_TOKEN` unset to use the database-backed path, which supports issuing and
revoking individual tokens rather than sharing one secret.

## Unauthenticated endpoints

Deliberately open:

| Endpoint | Why |
|---|---|
| `GET /health` · `/ready` | Load balancers and health checks |
| `GET /metrics` | Standard Prometheus practice |
| `GET /workflows/templates` | A template gallery loads before sign-in |
| `GET /nodes` (reads) | The node catalog is not sensitive |
| `GET /auth/bootstrap-status` | A fresh install must be able to say it needs setup |
| Webhook routes | External platforms call them; they carry their own verification |

Restrict `/metrics` at the network layer if your deployment treats operational metrics as
sensitive.

## Decision provider credentials

Decision providers for `ai/decision_branch` are not chat LLM providers, and their credentials
are a separate type: `type_id: "jev"` ("TypeSafe Jev").

| Field | Required | Meaning |
|---|---|---|
| `api_key` | Yes | Sent to TypeSafe as a Bearer token; stored encrypted like every credential |
| `endpoint` | No | An approved HTTPS proxy or test endpoint; empty means `https://api.typesafe.ai` |
| `model` | No | A pinned model for everything using this credential |

Test a key without saving it:

```text
POST /api/v1/decision/credentials/test
{ "provider": "jev", "api_key": "…", "endpoint": "…" }
```

Send `credential_id` instead of `api_key` (or a masked placeholder) to re-test a stored key
against an edited endpoint. The probe is JWT-authenticated, rate-limited per user and client
address, stores nothing, and returns the models the key can use — never the key or a raw
provider response. Only credential-based providers can be tested this way; Laya is deployment
configuration, not a credential.

A deployment can instead provide one key for everyone with `JEV_API_KEY`. See
[Decision Providers & TypeSafe Jev](/docs/decision-providers-and-jev).

## Secrets that make all this work

| Variable | Consequence of changing it |
|---|---|
| `JWT_SECRET` | Every issued session becomes invalid |
| `CREDENTIAL_MASTER_KEY_1` | Existing stored credentials become unreadable |

Generate both with `openssl rand -hex 32` before exposing an installation, and treat
`CREDENTIAL_MASTER_KEY_1` as unrecoverable — losing it loses every stored credential. See
[the environment reference](/docs/config-env-reference).

## Demo mode

With `CHENG_DEMO_MODE=true`, visitors enter through `POST /auth/demo`, which signs them in as
`CHENG_DEMO_USER_EMAIL` (never a platform administrator). Login, registration, and password
changes are refused, and every write and most sensitive reads return
`403 DEMO_MODE_RESTRICTED`. That is configuration, not a permission problem with the account.
See [Public Read-only Demo Mode](/docs/demo-mode).

## Next steps

- [REST API overview](/docs/api-rest-overview) — the endpoints these credentials reach.
- [WebSocket API](/docs/api-websocket) — where tickets are used.
- [Gateway model](/docs/sdk-gateway-model) — which credential a gateway should use.
