mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
047680d669
* Add user identity, JWT auth, and admin console UI (#23) JWT-based authentication with three token types: config-file (hmac, backward-compat), API tokens (ts_ prefix, SHA-256 hashed), and JWTs (HS256, 24h expiry). Username:password login via bcrypt. Hierarchical scopes: read < write < approve. New tables: users (username, password_hash), api_tokens (token_hash, scopes, expires), channel_users (future channel integrations). user_id column added to sessions and workstreams for attribution. Console owns admin CRUD (6 endpoints under /api/admin/). Server validates JWTs locally with shared signing secret. Public /api/auth/setup endpoint for first-time admin creation (atomic, only works with zero users). turnstone-admin CLI for user/token management. Admin console UI: Users and Tokens tabs with full CRUD modals, scope badges, token show-once with clipboard copy, keyboard accessibility (focus traps, Escape, arrow key tabs, ARIA roles). Login UI redesigned: username:password primary, token toggle for legacy, setup wizard auto-detected via /api/auth/status. Python + TypeScript SDKs updated with login(username, password), authStatus(), setup(). New docs/security.md + diagram 15-auth-architecture.puml. All existing docs updated. OpenAPI specs include all new endpoints. 64 new tests (1023 total). Dependencies: PyJWT, bcrypt. * Fix auth bugs, XSS vector, and doc inaccuracies from PR #23 review Address Copilot review feedback: escape double quotes in escapeHtml() to prevent XSS in HTML attributes, add JWT validation fallback so config tokens containing dots still work, add user_id to AuthLoginResponse schema, return created field from admin_create_user, and correct five documentation files to match actual API behavior.
377 lines
12 KiB
Markdown
377 lines
12 KiB
Markdown
# Security and Authentication
|
|
|
|
Turnstone uses a layered authentication system with three token types,
|
|
hierarchical scopes, and a split architecture where the console manages
|
|
credentials while individual server nodes validate JWTs locally.
|
|
|
|
---
|
|
|
|
## Token Types
|
|
|
|
### Config-file tokens
|
|
|
|
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
|
environment variable. Validated in-memory using `hmac.compare_digest`
|
|
(timing-safe). Each token maps to a role that determines its scopes.
|
|
|
|
```toml
|
|
[[auth.tokens]]
|
|
value = "tok_legacy"
|
|
role = "full" # full → {read, write, approve}
|
|
```
|
|
|
|
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
|
|
|
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
|
on every request. No JWT exchange is needed.
|
|
|
|
### API tokens
|
|
|
|
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
|
(`turnstone-admin create-token`) or the console admin API. Stored as
|
|
SHA-256 hashes — the raw token is shown exactly once at creation and
|
|
never persisted in plaintext.
|
|
|
|
```
|
|
$ turnstone-admin create-token --user abc123 --scopes read,write --name "CI bot"
|
|
Token created: ts_a1b2c3d4e5f6...
|
|
(save this — it will not be shown again)
|
|
```
|
|
|
|
API tokens can be used directly as `Bearer ts_xxx` headers or exchanged
|
|
for a JWT via the login endpoint.
|
|
|
|
### JWTs
|
|
|
|
Short-lived session tokens (24 hours by default). Issued after
|
|
authenticating with username/password or by exchanging an API token.
|
|
HS256-signed with a shared secret. Validated locally on every service
|
|
node — no database call per request.
|
|
|
|
Claims:
|
|
|
|
| Claim | Description |
|
|
|-------|-------------|
|
|
| `sub` | User ID |
|
|
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
|
| `src` | Token source (`password`, `api_token`, `config`) |
|
|
| `iat` | Issued-at timestamp |
|
|
| `exp` | Expiry timestamp |
|
|
|
|
---
|
|
|
|
## Scope Model
|
|
|
|
Scopes are hierarchical — higher scopes imply all lower ones.
|
|
|
|
| Scope | Grants | Implies |
|
|
|-------|--------|---------|
|
|
| `read` | View workstreams, sessions, history | — |
|
|
| `write` | Send messages, create/close workstreams | `read` |
|
|
| `approve` | Approve tool calls, admin endpoints | `read`, `write` |
|
|
|
|
### Path-to-scope mapping
|
|
|
|
| Method | Path pattern | Required scope |
|
|
|--------|-------------|----------------|
|
|
| GET | Any protected path | `read` |
|
|
| POST | `/api/send`, `/api/plan`, `/api/command` | `write` |
|
|
| POST | `/api/workstreams/new`, `/api/workstreams/close` | `write` |
|
|
| POST | `/api/cluster/workstreams/new` | `write` |
|
|
| POST | `/api/approve` | `approve` |
|
|
| Any | `/api/admin/*` | `approve` |
|
|
|
|
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
|
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
|
|
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
|
|
|
|
---
|
|
|
|
## Login Flows
|
|
|
|
### Username and password
|
|
|
|
```
|
|
POST /v1/api/auth/login
|
|
Content-Type: application/json
|
|
|
|
{"username": "admin", "password": "s3cret"}
|
|
```
|
|
|
|
Returns a JWT in the response body and sets an `HttpOnly` session cookie.
|
|
|
|
### API token exchange
|
|
|
|
```
|
|
POST /v1/api/auth/login
|
|
Content-Type: application/json
|
|
|
|
{"token": "ts_a1b2c3d4e5f6..."}
|
|
```
|
|
|
|
The API token is hashed, looked up in the database, and exchanged for a
|
|
JWT with the token's scopes. This is the recommended flow for SDKs and
|
|
automated clients that need cookie-based sessions.
|
|
|
|
### Config-file tokens (direct)
|
|
|
|
Config tokens are validated per-request via `hmac.compare_digest`. No
|
|
login exchange is needed — include the token as a `Bearer` header:
|
|
|
|
```
|
|
Authorization: Bearer tok_legacy
|
|
```
|
|
|
|
### First-time setup
|
|
|
|
When no users exist in the database:
|
|
|
|
1. `GET /v1/api/auth/status` returns `{"setup_required": true}`
|
|
2. The UI presents a setup wizard
|
|
3. `POST /v1/api/auth/setup` creates the first admin user and returns a
|
|
JWT in one atomic step (no auth required — this is a public endpoint)
|
|
4. The endpoint returns `409 Conflict` if setup has already been completed
|
|
(i.e. users already exist in the database)
|
|
5. Subsequent admin requests require `approve` scope
|
|
|
|
The `/api/auth/setup` endpoint is available on both the server and
|
|
console. It validates input before creating the user:
|
|
|
|
- **username**: 1-64 ASCII characters
|
|
- **display_name**: required (non-empty)
|
|
- **password**: minimum 8 characters
|
|
|
|
```
|
|
POST /v1/api/auth/setup
|
|
Content-Type: application/json
|
|
|
|
{"username": "admin", "display_name": "Admin", "password": "strongpass"}
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"user_id": "u_abc123",
|
|
"username": "admin",
|
|
"role": "full",
|
|
"scopes": "approve,read,write",
|
|
"jwt": "eyJhbGciOiJIUzI1NiIs..."
|
|
}
|
|
```
|
|
|
|
The response also sets an `HttpOnly` session cookie containing the JWT,
|
|
so the browser is immediately authenticated after setup completes.
|
|
|
|
---
|
|
|
|
## Token Detection Order
|
|
|
|
The auth middleware inspects the `Authorization: Bearer <token>` header
|
|
and classifies the token:
|
|
|
|
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
|
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
|
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
|
each configured token
|
|
|
|
If a session cookie is present and no `Authorization` header is sent,
|
|
the cookie value is treated as a JWT (step 1).
|
|
|
|
---
|
|
|
|
## Password Storage
|
|
|
|
Passwords are hashed with **bcrypt** using a random salt per password.
|
|
Plaintext passwords are only accepted over HTTPS in production
|
|
deployments.
|
|
|
|
---
|
|
|
|
## Cookie Security
|
|
|
|
| Attribute | Value | Purpose |
|
|
|-----------|-------|---------|
|
|
| `HttpOnly` | `true` | Prevents JavaScript access |
|
|
| `SameSite` | `Lax` | CSRF protection |
|
|
| `Path` | `/` | Available to all routes |
|
|
| `Max-Age` | 30 days | Session lifetime |
|
|
| `Secure` | conditional | Set when served over HTTPS |
|
|
|
|
---
|
|
|
|
## JWT Configuration
|
|
|
|
| Setting | Config key | Env var | Default |
|
|
|---------|-----------|---------|---------|
|
|
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
|
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
|
| Algorithm | — | — | HS256 (not configurable) |
|
|
|
|
All service nodes that need to validate JWTs must share the same signing
|
|
secret. If no secret is configured, an ephemeral key is generated at
|
|
startup and a warning is logged — JWTs will not survive restarts or work
|
|
across nodes.
|
|
|
|
---
|
|
|
|
## Admin API Endpoints
|
|
|
|
All admin endpoints require `approve` scope.
|
|
|
|
### Users
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| POST | `/v1/api/admin/users` | Create user (username, display_name, password) |
|
|
| GET | `/v1/api/admin/users` | List all users |
|
|
| DELETE | `/v1/api/admin/users/{user_id}` | Delete user and cascade tokens |
|
|
|
|
### API tokens
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| POST | `/v1/api/admin/users/{user_id}/tokens` | Create API token (returns raw value once) |
|
|
| GET | `/v1/api/admin/users/{user_id}/tokens` | List tokens (prefix only, no hashes) |
|
|
| DELETE | `/v1/api/admin/tokens/{token_id}` | Revoke token |
|
|
|
|
---
|
|
|
|
## CLI Administration
|
|
|
|
The `turnstone-admin` command provides offline user and token management:
|
|
|
|
```
|
|
turnstone-admin create-user --username admin --name "Admin" [--password] [--token]
|
|
turnstone-admin create-token --user <user_id> --scopes read,write --name "CI bot"
|
|
turnstone-admin list-users
|
|
turnstone-admin list-tokens
|
|
turnstone-admin revoke-token <token_id>
|
|
```
|
|
|
|
When `--password` is omitted, the CLI prompts interactively. When
|
|
`--token` is passed to `create-user`, an API token is created alongside
|
|
the user and printed to stdout.
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
```sql
|
|
CREATE TABLE users (
|
|
user_id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE api_tokens (
|
|
token_id TEXT PRIMARY KEY,
|
|
token_hash TEXT NOT NULL, -- SHA-256 of raw token
|
|
token_prefix TEXT NOT NULL, -- first 8 chars for display
|
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
name TEXT NOT NULL,
|
|
scopes TEXT NOT NULL, -- comma-separated
|
|
created TEXT NOT NULL,
|
|
expires TEXT -- nullable, ISO 8601
|
|
);
|
|
|
|
CREATE UNIQUE INDEX ix_api_tokens_hash ON api_tokens(token_hash);
|
|
|
|
CREATE TABLE channel_users (
|
|
channel_type TEXT NOT NULL,
|
|
channel_user_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
created TEXT NOT NULL,
|
|
PRIMARY KEY (channel_type, channel_user_id)
|
|
);
|
|
```
|
|
|
|
The `sessions` and `workstreams` tables have a nullable `user_id`
|
|
column for attribution when auth is enabled.
|
|
|
|
---
|
|
|
|
## Revocation
|
|
|
|
- **API tokens**: Deleting a token via the admin API or CLI prevents new
|
|
JWTs from being issued with that token. Existing JWTs derived from the
|
|
token remain valid until they expire (at most 24 hours).
|
|
- **Config-file tokens**: Remove the token from `config.toml` and
|
|
restart the service. No JWTs are involved, so revocation is immediate.
|
|
- **JWTs**: Cannot be individually revoked. Rely on short expiry (24h)
|
|
and revoke the underlying credential to prevent renewal.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Console (cluster-wide) Server (per-node)
|
|
┌──────────────────────┐ ┌──────────────────────┐
|
|
│ User/Token CRUD (DB) │ │ JWT validation only │
|
|
│ Login: creds → JWT │ │ (shared signing key) │
|
|
│ Admin API endpoints │ │ Config tokens: hmac │
|
|
│ Storage: users, │ │ No auth DB needed │
|
|
│ api_tokens tables │ │ │
|
|
└──────────────────────┘ └──────────────────────┘
|
|
```
|
|
|
|
The console owns the credential database and handles all user/token
|
|
CRUD. Individual server nodes only need the JWT signing secret to
|
|
validate session tokens. Config-file tokens are validated locally
|
|
without any database.
|
|
|
|
### Proxy auth forwarding
|
|
|
|
When the console proxies requests to server nodes (via `/node/{id}/...`
|
|
routes), it extracts the user's JWT from the incoming request's cookie
|
|
or `Authorization` header and forwards it as `Authorization: Bearer`
|
|
to the upstream server. This means a single login on the console
|
|
grants access to all server UIs without re-authentication — the shared
|
|
`TURNSTONE_JWT_SECRET` ensures tokens are valid on every node.
|
|
|
|
---
|
|
|
|
## Configuration Reference
|
|
|
|
### config.toml
|
|
|
|
```toml
|
|
[auth]
|
|
enabled = true
|
|
jwt_secret = "your-secret-key-here"
|
|
jwt_expiry_hours = 24
|
|
|
|
[[auth.tokens]]
|
|
value = "tok_legacy"
|
|
role = "full"
|
|
```
|
|
|
|
### Environment variables
|
|
|
|
| Variable | Description |
|
|
|----------|-------------|
|
|
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
|
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
|
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
|
|
|
---
|
|
|
|
## Security Properties
|
|
|
|
- **Timing-safe comparison** for config-file tokens via
|
|
`hmac.compare_digest` — no timing side-channel.
|
|
- **Hash-based lookup** for API tokens — the database stores only
|
|
SHA-256 hashes, eliminating timing attacks on token comparison.
|
|
- **Local JWT validation** — no network call or database query needed
|
|
per request on server nodes.
|
|
- **One-time display** of raw API tokens at creation. The plaintext is
|
|
never stored; `token_hash` never appears in API responses or logs.
|
|
- **Structured logging audit trail** — `ctx_user_id` is set on every
|
|
authenticated request and injected into all log events.
|
|
- **Scope enforcement** at the middleware layer before any handler
|
|
executes. Path-to-scope mapping is defined statically.
|