diff --git a/.env.example b/.env.example index 7660ddef..0af5a660 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,7 @@ OPENAI_API_KEY=sk-... # -- Authentication ------------------------------------------------------------ # TURNSTONE_AUTH_ENABLED=true # TURNSTONE_AUTH_TOKEN=your-secret-token +# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))" # -- Ports --------------------------------------------------------------------- # SERVER_PORT=8080 diff --git a/compose.yaml b/compose.yaml index 67adec52..30bd6231 100644 --- a/compose.yaml +++ b/compose.yaml @@ -109,6 +109,7 @@ services: - SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-} - TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-} - MODEL=${MODEL:-} - TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite} - TURNSTONE_DB_URL=${DATABASE_URL:-} @@ -149,6 +150,7 @@ services: environment: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-} networks: - turnstone-net depends_on: @@ -178,6 +180,9 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-} + - TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite} + - TURNSTONE_DB_URL=${DATABASE_URL:-} networks: - turnstone-net depends_on: diff --git a/docs/api-reference.md b/docs/api-reference.md index 6d2e246f..75d48dad 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -56,6 +56,170 @@ console.log(result.content); --- +## Authentication + +When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token. + +### Sending Credentials + +Include a token in one of two ways: + +- **Bearer header**: `Authorization: Bearer ` +- **Cookie**: `turnstone_auth=` (set automatically by the login endpoint) + +The server accepts three token types: + +| Type | Format | Example | +|------|--------|---------| +| JWT | Base64 segments separated by dots | `eyJhbG...` | +| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` | +| Config token | Arbitrary string from `config.toml` | `my-secret-token` | + +JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments. + +### `POST /v1/api/auth/login` + +Authenticate with credentials and receive a JWT. Accepts two credential formats: + +**Username + password:** + +```json +{"username": "alice", "password": "hunter2"} +``` + +**API token:** + +```json +{"token": "ts_a1b2c3d4e5f6..."} +``` + +**Response (success):** `200` + +```json +{ + "status": "ok", + "role": "full", + "scopes": "approve,read,write", + "jwt": "eyJhbGciOiJIUzI1NiIs...", + "user_id": "u_abc123" +} +``` + +The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT. + +**Response (failure):** `401` + +```json +{"error": "Invalid credentials"} +``` + +--- + +### `POST /v1/api/auth/logout` + +Clears the `turnstone_auth` cookie. No request body required. + +**Response:** `200` + +```json +{"status": "ok"} +``` + +The response includes a `Set-Cookie` header that expires the auth cookie. + +--- + +### `GET /v1/api/auth/status` + +Returns the current authentication state. Works with or without a valid token. + +**Response (authenticated):** `200` + +```json +{ + "authenticated": true, + "user_id": "u_abc123", + "scopes": ["approve", "read", "write"], + "source": "jwt" +} +``` + +**Response (not authenticated):** `200` + +```json +{ + "authenticated": false, + "user_id": null, + "scopes": [], + "source": null +} +``` + +**Response (auth disabled):** `200` + +```json +{ + "authenticated": false, + "auth_enabled": false +} +``` + +--- + +### `POST /v1/api/auth/setup` + +Creates the first admin user when no users exist in the database. This is a +public endpoint (no authentication required) that only succeeds when auth is +enabled and the user database is empty. Both the server and console expose +this endpoint. + +**Request body:** + +```json +{ + "username": "admin", + "display_name": "Admin", + "password": "strongpass" +} +``` + +| Field | Type | Required | Validation | +|----------------|--------|----------|-----------------------------| +| `username` | string | yes | 1-64 ASCII characters | +| `display_name` | string | yes | Non-empty | +| `password` | string | yes | Minimum 8 characters | + +**Response (success):** `200` + +```json +{ + "status": "ok", + "user_id": "u_abc123", + "username": "admin", + "role": "full", + "scopes": "approve,read,write", + "jwt": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT. + +**Response (already set up):** `409` + +```json +{"error": "Setup already completed"} +``` + +Returned when one or more users already exist in the database. + +**Response (auth disabled):** `400` + +```json +{"error": "Auth is not enabled"} +``` + +--- + ## Endpoints ### `GET /` diff --git a/docs/architecture.md b/docs/architecture.md index 0945a481..7932e2ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -917,6 +917,98 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with --- +## User Identity and Authentication + +Turnstone supports three authentication mechanisms, unified behind an +`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`: + +1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]` + or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via + `hmac.compare_digest`. Map to scopes through their role (`read` or `full`). +2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes + in the `api_tokens` table. Can be exchanged for JWTs via + `POST /v1/api/auth/login`. +3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after + successful credential validation. Contain `sub` (user_id), `scopes`, and + `src` (origin) in claims. + +### Scope Model + +Three hierarchical scopes control endpoint access: + +| Scope | Grants | Endpoints | +|-------|--------|-----------| +| `read` | SSE streams, workstream listing, sessions | GET endpoints | +| `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. | +| `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` | + +### Middleware Flow + +`AuthMiddleware` (ASGI) intercepts every request: + +1. **Public path check** — `/`, `/static/*`, `/shared/*`, `/health`, + `/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup` + are always allowed. +2. **Token extraction** — `Authorization: Bearer ` header first, then + `turnstone_auth` cookie as fallback. +3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix + indicates API token; otherwise config-file token. +4. **Validation** — JWT signature check, API token hash lookup in storage, or + config-token hmac comparison. +5. **Scope check** — `required_scope(method, path)` determines the minimum + scope; the request is rejected with 403 if the token lacks it. +6. **Context propagation** — on success, `ctx_user_id` is set so structured + logging includes the authenticated identity on every log event. + +### Architecture Split + +- **Console** is the auth management hub — it hosts the admin endpoints for + creating users, issuing API tokens, and managing channel mappings. User + records and token hashes live in the shared storage backend. The console + dashboard includes an **admin panel** (Users and Tokens tabs) for managing + credentials through the browser. +- **Server** is a JWT validator only — it validates tokens on each request but + never creates users or tokens. Both processes share the same `jwt_secret` + (via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config). +- **First-time setup** — both server and console expose + `POST /v1/api/auth/setup`, a public endpoint that creates the initial admin + user when no users exist. This avoids the chicken-and-egg problem of needing + `approve` scope to create the first user via `/api/admin/users`. + +### Auth Storage Tables + +Three tables in `storage/_schema.py` support identity: + +```sql +users + user_id TEXT PRIMARY KEY + username TEXT NOT NULL UNIQUE + display_name TEXT NOT NULL + password_hash TEXT NOT NULL -- bcrypt + created TEXT NOT NULL + +api_tokens + token_id TEXT PRIMARY KEY + token_hash TEXT NOT NULL UNIQUE -- SHA-256 of raw token + token_prefix TEXT NOT NULL -- first 8 chars for display + user_id TEXT NOT NULL + name TEXT NOT NULL -- human-readable label + scopes TEXT NOT NULL -- comma-separated + created TEXT NOT NULL + expires TEXT -- optional expiry timestamp + +channel_users + channel_type TEXT NOT NULL -- e.g. "slack", "discord" + channel_user_id TEXT NOT NULL -- platform-specific user ID + user_id TEXT NOT NULL -- FK to users + PRIMARY KEY (channel_type, channel_user_id) +``` + +See [docs/security.md](security.md) for full security details including token +lifecycle, password hashing, and deployment hardening. + +--- + ## Threading Model ### CLI diff --git a/docs/console.md b/docs/console.md index 3ddc6699..72d4baa0 100644 --- a/docs/console.md +++ b/docs/console.md @@ -148,7 +148,7 @@ Single node detail with all its workstreams. ### `POST /v1/api/cluster/workstreams/new` -Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `"full"` auth role. +Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `write` scope. Request: @@ -207,6 +207,86 @@ Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should } ``` +### Admin API + +User and token management endpoints. All admin endpoints require `approve` scope, except for the setup endpoint which is public. + +#### `POST /v1/api/auth/setup` + +Creates the first admin user when no users exist. Public endpoint (no auth required). Returns a JWT and sets a session cookie. Returns `409` if users already exist. See [Security: First-time setup](security.md#first-time-setup) for full details. + +#### `POST /v1/api/admin/users` + +Create a new user. + +```json +{ + "username": "alice", + "password": "s3cret", + "scopes": ["read", "write"] +} +``` + +#### `GET /v1/api/admin/users` + +List all users. + +```json +{ + "users": [ + {"user_id": "u_abc123", "username": "alice", "scopes": ["read", "write"], "created": "2026-03-01T12:00:00Z"} + ] +} +``` + +#### `DELETE /v1/api/admin/users/{user_id}` + +Delete a user and revoke all their tokens. + +#### `POST /v1/api/admin/users/{user_id}/tokens` + +Create an API token for the given user. Returns a `ts_`-prefixed token string that can be used for Bearer auth or passed to `client.login(token="ts_xxx")`. + +```json +{ + "name": "CI pipeline", + "scopes": ["read", "write"] +} +``` + +#### `GET /v1/api/admin/users/{user_id}/tokens` + +List active tokens for a user (token strings are not returned, only metadata). + +#### `DELETE /v1/api/admin/tokens/{token_id}` + +Revoke a specific API token. + +#### `GET /v1/api/auth/status` + +Public endpoint for login UI state detection. Returns auth configuration, not +current-user identity. + +```json +{ + "auth_enabled": true, + "has_users": true, + "setup_required": false +} +``` + +### Auth Scopes + +The auth system uses three scopes instead of the earlier read/full role model: + +| Scope | Grants | +|-------|--------| +| `read` | Read-only access: dashboards, workstream lists, SSE streams, health | +| `write` | Send messages, create/close workstreams, approve tool calls | +| `approve` | Admin operations: manage users and API tokens | + +Scopes are cumulative — a user with `approve` scope can also perform `write` and `read` operations. + --- ## Reverse Proxy @@ -240,13 +320,13 @@ SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied by creating ### Authentication -The proxy forwards requests to server nodes using the console's `--auth-token`. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require the `"full"` auth role, preventing read-only tokens from escalating to write operations. +The proxy forwards the user's JWT to upstream server nodes — it extracts the token from the incoming request's cookie (or `Authorization` header) and adds it as a `Bearer` header on the proxied request. Since all services share the same `TURNSTONE_JWT_SECRET`, the user's JWT is valid on every node without re-authentication. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require `write` scope, preventing read-only tokens from escalating via proxy. The static `--auth-token` / `proxy_auth_token` is used as a fallback when no user JWT is present. --- ## Browser Dashboard -The web UI has four views, toggled client-side: +The web UI has five views, toggled client-side: ### 1. Cluster Overview (landing) @@ -276,7 +356,46 @@ Triggered by the "+ new" header button. A modal dialog with: On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard. -All four views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators. +All five views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators. + +### 5. Admin Panel + +Accessed via the "admin" button in the header (visible when authenticated +with `approve` scope). Provides user and API token management with two tabs: + +**Users tab:** + +- Grid table listing all users (username, display name, role, creation date) +- "Create User" button opens a modal with fields for username, display name, + and password (validated: username 1-64 ASCII, password min 8 characters) +- Delete button on each row removes the user and cascades to revoke all + their tokens + +**Tokens tab:** + +- User selector dropdown to pick which user's tokens to manage +- Grid table listing tokens for the selected user (name, prefix, scopes, + creation date) +- Scope badges rendered as colored pills for visual clarity +- "Create Token" button opens a modal with fields for token name and scope + checkboxes +- On creation, a "Token Created" modal displays the raw `ts_`-prefixed + token with a copy button. The token is shown once and cannot be retrieved + again. +- Revoke button on each row deletes the token + +**Accessibility:** + +- Full keyboard navigation: focus traps in modals, Escape to close, arrow + keys for tab switching +- Responsive layout with column hiding at 700px breakpoint + +**First-time setup:** + +The console also exposes `POST /v1/api/auth/setup` for first-time +bootstrap. When no users exist, the setup wizard calls this public endpoint +to create the initial admin user and receive a JWT in one step. See +[Security: First-time setup](security.md#first-time-setup) for details. --- diff --git a/docs/diagrams/14-storage-architecture.puml b/docs/diagrams/14-storage-architecture.puml index 1795c935..242268ca 100644 --- a/docs/diagrams/14-storage-architecture.puml +++ b/docs/diagrams/14-storage-architecture.puml @@ -37,6 +37,11 @@ interface "StorageBackend" as SB <> { +kv_search(query) → list[(str, str)] +search_history(query, limit) → list +search_history_recent(limit) → list + +create_user(user_id, username, display_name, pw_hash) + +get_user(user_id) / get_user_by_username(username) + +list_users() / delete_user(user_id) + +create_api_token(...) / get_api_token_by_hash(hash) + +list_api_tokens(user_id) / delete_api_token(id) +close() } @@ -63,9 +68,12 @@ class "_schema.py" as Schema <> { +metadata: MetaData +memories: Table +conversations: Table - +sessions: Table (node_id, ws_id) - +workstreams: Table (node_id, state) + +sessions: Table (node_id, ws_id, user_id) + +workstreams: Table (node_id, user_id, state) +session_config: Table + +users: Table (username, password_hash) + +api_tokens: Table (token_hash, scopes) + +channel_users: Table (channel_type) -- SQLAlchemy Core Single source of truth @@ -82,6 +90,7 @@ class "_migrate.py" as Migrate <> { class "migrations/" as Versions <> { 001_initial_schema.py + 002_user_identity.py } ' -- Registry -- diff --git a/docs/diagrams/15-auth-architecture.puml b/docs/diagrams/15-auth-architecture.puml new file mode 100644 index 00000000..d91d0616 --- /dev/null +++ b/docs/diagrams/15-auth-architecture.puml @@ -0,0 +1,179 @@ +@startuml +!theme plain +title Turnstone — Authentication Architecture + +skinparam class { + BackgroundColor<> #E8EAF6 + BackgroundColor<> #C8E6C9 + BackgroundColor<> #B3E5FC + BackgroundColor<> #FFE0B2 + BackgroundColor<> #F3E5F5 +} + +' -- Core Auth -- +class "AuthConfig" as AC <> { + +enabled: bool + +tokens: dict[str, str] + +check(token) → role | None + -- + Static config-file tokens + hmac.compare_digest +} + +class "AuthResult" as AR <> { + +user_id: str + +scopes: frozenset[str] + +token_source: str + +has_scope(scope) → bool +} + +class "check_request()" as CR <> { + auth_config, method, path, + auth_header, cookie_header, + jwt_secret, storage + → (allowed, status, msg, AuthResult) + -- + 1. Auth disabled → allow + 2. Public path → allow + 3. Extract Bearer / cookie + 4. Detect token type + 5. Validate → AuthResult + 6. Check scope vs path +} + +' -- Token Types -- +class "JWT (HS256)" as JWT <> { + sub: user_id + scopes: "read,write,approve" + src: "password" | "database" + iat, exp (24h default) + -- + Detected by: contains "." + Validated locally + No DB call +} + +class "API Token" as AT <> { + Format: ts_ + 64 hex + Stored: SHA-256 hash + -- + Detected by: starts with "ts_" + Lookup by hash in DB + Expiry check +} + +class "Config Token" as CT <> { + Raw value in memory + Role: "read" | "full" + -- + Detected by: fallback + hmac.compare_digest + No DB needed +} + +' -- Scopes -- +class "Scope Hierarchy" as SH <> { + read: {read} + write: {read, write} + approve: {read, write, approve} + -- + GET → read + POST write paths → write + POST /api/approve → approve + /api/admin/* → approve +} + +' -- Storage -- +class "users" as UT <> { + user_id (PK) + username (unique) + display_name + password_hash (bcrypt) + created +} + +class "api_tokens" as TT <> { + token_id (PK) + token_hash (SHA-256, unique) + token_prefix + user_id → users + name, scopes + created, expires +} + +' -- Endpoints -- +class "POST /api/auth/login" as Login <> { + {username, password} + OR {token: "ts_xxx"} + → {jwt, role, scopes, user_id} + -- + Sets HttpOnly cookie +} + +class "GET /api/auth/status" as Status <> { + → {auth_enabled, has_users, + setup_required} + -- + Public (no auth) + Drives UI setup wizard +} + +class "POST /api/auth/setup" as Setup <> { + {username, display_name, password} + → {jwt, user_id, scopes} + -- + Public (no auth) + Only when zero users exist + Returns 409 if already set up +} + +class "Admin API (Console)" as Admin <> { + POST/GET/DELETE users + POST/GET tokens + DELETE tokens/{id} + -- + Requires approve scope +} + +' -- Relationships -- +CR --> AC : config tokens +CR --> JWT : validate +CR --> AT : hash lookup +CR --> CT : hmac check +CR --> AR : returns +CR --> SH : checks + +Login --> JWT : issues +Login --> UT : verify password +Login --> TT : verify API token + +Setup --> UT : create first user +Setup --> JWT : issues + +AT --> TT : lookup by hash +Admin --> UT : CRUD +Admin --> TT : CRUD + +AR --> SH : scopes from + +JWT ..> AR : produces +AT ..> AR : produces +CT ..> AR : produces + +note right of CR + **Middleware Flow** + AuthMiddleware on every request: + 1. Extract token from header/cookie + 2. Detect type (JWT / ts_ / config) + 3. Validate → AuthResult + 4. Set ctx_user_id for logging + 5. Store auth_result in scope state +end note + +note bottom of SH + **Console** owns admin endpoints + **Server** validates JWT + config only + Both share JWT signing secret +end note + +@enduml diff --git a/docs/diagrams/png/14-storage-architecture.png b/docs/diagrams/png/14-storage-architecture.png index 8f3ae227..9bd0b0d3 100644 --- a/docs/diagrams/png/14-storage-architecture.png +++ b/docs/diagrams/png/14-storage-architecture.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd776a34b50d3fe194e2a34d3b4d04ec71af31e1154b5fc98223636b1844e6b8 -size 226686 +oid sha256:733aa17cbfdab60a601cac6adf439c657dd3535e3d6c33c69c2ef93ba8ec5989 +size 251042 diff --git a/docs/diagrams/png/15-auth-architecture.png b/docs/diagrams/png/15-auth-architecture.png new file mode 100644 index 00000000..79d4e9b1 --- /dev/null +++ b/docs/diagrams/png/15-auth-architecture.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4b2a2010335f986511c8dabaf49ec046ac02e577f9bc9924897e045f860bb13 +size 248808 diff --git a/docs/docker.md b/docs/docker.md index c96cc5da..beea6c36 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -84,8 +84,26 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl | Variable | Default | Description | |----------|---------|-------------| -| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require Bearer token auth | -| `TURNSTONE_AUTH_TOKEN` | — | Shared auth token for server/bridge/console | +| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication | +| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/bridge/console (backward compat, works alongside JWT) | +| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) | + +### Database + +| Variable | Default | Description | +|----------|---------|-------------| +| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` | +| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` | + +The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage. + +> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container: +> +> ```bash +> docker compose exec server turnstone-admin create-user --username admin --name "Admin" +> ``` +> +> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token. ### Simulator diff --git a/docs/sdk.md b/docs/sdk.md index 66ed70b7..5086f99c 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -15,8 +15,10 @@ The Python SDK is included in the `turnstone` package — no extra install requi ```python from turnstone.sdk import TurnstoneServer -# Synchronous client -with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client: +# Synchronous client — login with username/password +with TurnstoneServer("http://localhost:8080") as client: + client.login(username="alice", password="s3cret") + # Create a workstream ws = client.create_workstream(name="Analysis") @@ -33,6 +35,15 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client: client.close_workstream(ws.ws_id) ``` +Alternatively, authenticate with an API token: + +```python +with TurnstoneServer("http://localhost:8080") as client: + client.login(token="ts_abc123...") + ws = client.create_workstream(name="CI run") + result = client.send_and_wait("Run the test suite.", ws.ws_id) +``` + ### Async Client ```python @@ -40,7 +51,8 @@ import asyncio from turnstone.sdk import AsyncTurnstoneServer async def main(): - async with AsyncTurnstoneServer("http://localhost:8080", token="tok_xxx") as client: + async with AsyncTurnstoneServer("http://localhost:8080") as client: + await client.login(username="alice", password="s3cret") ws = await client.create_workstream(name="demo") async for event in client.stream_events(ws.ws_id): if event.type == "content": @@ -67,8 +79,10 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose: | | `stream_global_events()` | `Iterator[ServerEvent]` | | **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` | | **Sessions** | `list_sessions()` | `ListSessionsResponse` | -| **Auth** | `login(token)` | `AuthLoginResponse` | +| **Auth** | `login(username=..., password=...)` | `AuthLoginResponse` | +| | `login(token="ts_xxx")` | `AuthLoginResponse` | | | `logout()` | `StatusResponse` | +| | `auth_status()` | `AuthStatusResponse` | | **Health** | `health()` | `HealthResponse` | ### Console Client API @@ -83,7 +97,8 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose: | | `node_detail(node_id)` | `NodeDetailResponse` | | | `create_workstream(*, node_id, name, model, initial_message)` | `ConsoleCreateWsResponse` | | **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` | -| **Auth** | `login(token)` / `logout()` | `AuthLoginResponse` / `StatusResponse` | +| **Auth** | `login(username=..., password=...)` / `login(token="ts_xxx")` | `AuthLoginResponse` | +| | `logout()` | `StatusResponse` | | **Health** | `health()` | `ConsoleHealthResponse` | ### Event Types @@ -165,10 +180,11 @@ Located at `sdk/typescript/`. Zero runtime dependencies for browsers; uses nativ ```typescript import { TurnstoneServer } from "@turnstone/sdk"; -const client = new TurnstoneServer({ - baseUrl: "http://localhost:8080", - token: "tok_xxx", -}); +const client = new TurnstoneServer({ baseUrl: "http://localhost:8080" }); + +// Login with username/password or API token +await client.login({ username: "alice", password: "s3cret" }); +// or: await client.login({ token: "ts_abc123..." }); // Create workstream and send message const ws = await client.createWorkstream({ name: "demo" }); @@ -188,16 +204,14 @@ for await (const event of client.streamEvents(ws.ws_id)) { ```typescript import { TurnstoneConsole } from "@turnstone/sdk"; -const console = new TurnstoneConsole({ - baseUrl: "http://localhost:8081", - token: "tok_xxx", -}); +const client = new TurnstoneConsole({ baseUrl: "http://localhost:8090" }); +await client.login({ username: "alice", password: "s3cret" }); -const overview = await console.overview(); +const overview = await client.overview(); console.log(`Nodes: ${overview.nodes}, Workstreams: ${overview.workstreams}`); // Stream cluster events -for await (const event of console.clusterEvents()) { +for await (const event of client.clusterEvents()) { console.log(event.type, event); } ``` @@ -256,3 +270,38 @@ sdk/typescript/ TypeScript SDK (npm package) The Python SDK reuses Pydantic models from `turnstone/api/` directly — no schema duplication. The TypeScript SDK has hand-written interfaces matching those models. Both SDKs follow the same design: typed methods for REST endpoints, async iterators for SSE streams, and a high-level `send_and_wait` method for simple request-response patterns. + +--- + +## Authentication + +When auth is enabled on the server, the SDK handles JWT-based authentication automatically. + +### Login Flow + +There are two ways to authenticate: + +1. **Username + password** — calls `POST /v1/api/auth/login` with credentials. The server validates against the user database and returns a JWT. + +2. **API token** — calls `POST /v1/api/auth/login` with a `ts_`-prefixed token string. The server looks up the token, resolves the associated user, and returns a JWT. + +In both cases the server returns the JWT in the response body and as a `Set-Cookie` header. The SDK extracts the JWT and includes it as a `Bearer` token in the `Authorization` header on all subsequent requests. + +```python +# Username + password +client.login(username="alice", password="s3cret") + +# API token (created via admin API or turnstone-admin CLI) +client.login(token="ts_abc123...") +``` + +### Token Lifecycle + +- JWTs have a configurable expiry (default: 24 hours). +- `client.auth_status()` returns the current user identity and scopes without refreshing the token. +- `client.logout()` clears the stored JWT from the client. +- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating. + +### Backward Compatibility + +The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..1a1fbd6f --- /dev/null +++ b/docs/security.md @@ -0,0 +1,376 @@ +# 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 ` 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 --scopes read,write --name "CI bot" +turnstone-admin list-users +turnstone-admin list-tokens +turnstone-admin revoke-token +``` + +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. diff --git a/pyproject.toml b/pyproject.toml index 240faf32..c5df39bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dependencies = [ "sqlalchemy>=2.0", "alembic>=1.14", "structlog>=24.1", + "PyJWT>=2.8", + "bcrypt>=4.0", ] [project.urls] @@ -57,6 +59,7 @@ turnstone-server = "turnstone.server:main" turnstone-bridge = "turnstone.mq.bridge:main" turnstone-console = "turnstone.console.server:main" turnstone-sim = "turnstone.sim.cli:main" +turnstone-admin = "turnstone.admin:main" [tool.hatch.build.targets.wheel] include = [ @@ -133,6 +136,10 @@ ignore_missing_imports = true module = ["structlog", "structlog.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["jwt", "jwt.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["anthropic", "anthropic.*"] ignore_missing_imports = true diff --git a/sdk/typescript/src/console.ts b/sdk/typescript/src/console.ts index 1a99ac4f..a9cc6f51 100644 --- a/sdk/typescript/src/console.ts +++ b/sdk/typescript/src/console.ts @@ -2,6 +2,8 @@ import { BaseClient, type ClientOptions } from "./base.js"; import type { ClusterEvent } from "./events.js"; import type { AuthLoginResponse, + AuthSetupResponse, + AuthStatusResponse, ClusterNodesResponse, ClusterOverviewResponse, ClusterWorkstreamsResponse, @@ -70,9 +72,33 @@ export class TurnstoneConsole extends BaseClient { // -- Auth ----------------------------------------------------------------- - async login(token: string): Promise { - return this.request("POST", "/v1/api/auth/login", { - json: { token }, + async login(opts: { + token?: string; + username?: string; + password?: string; + }): Promise { + const body = + opts.username && opts.password + ? { username: opts.username, password: opts.password } + : { token: opts.token ?? "" }; + return this.request("POST", "/v1/api/auth/login", { json: body }); + } + + async authStatus(): Promise { + return this.request("GET", "/v1/api/auth/status"); + } + + async setup(opts: { + username: string; + displayName: string; + password: string; + }): Promise { + return this.request("POST", "/v1/api/auth/setup", { + json: { + username: opts.username, + display_name: opts.displayName, + password: opts.password, + }, }); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index b8f52627..31f89ca1 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -90,6 +90,8 @@ export type { HealthResponse, AuthLoginRequest, AuthLoginResponse, + AuthStatusResponse, + AuthSetupResponse, StatusResponse, ErrorResponse, ClusterOverviewResponse, diff --git a/sdk/typescript/src/server.ts b/sdk/typescript/src/server.ts index 5a1745d9..9c2ce33f 100644 --- a/sdk/typescript/src/server.ts +++ b/sdk/typescript/src/server.ts @@ -2,6 +2,8 @@ import { BaseClient, type ClientOptions } from "./base.js"; import type { ServerEvent } from "./events.js"; import type { AuthLoginResponse, + AuthSetupResponse, + AuthStatusResponse, CreateWorkstreamRequest, CreateWorkstreamResponse, DashboardResponse, @@ -184,9 +186,33 @@ export class TurnstoneServer extends BaseClient { // -- Auth ----------------------------------------------------------------- - async login(token: string): Promise { - return this.request("POST", "/v1/api/auth/login", { - json: { token }, + async login(opts: { + token?: string; + username?: string; + password?: string; + }): Promise { + const body = + opts.username && opts.password + ? { username: opts.username, password: opts.password } + : { token: opts.token ?? "" }; + return this.request("POST", "/v1/api/auth/login", { json: body }); + } + + async authStatus(): Promise { + return this.request("GET", "/v1/api/auth/status"); + } + + async setup(opts: { + username: string; + displayName: string; + password: string; + }): Promise { + return this.request("POST", "/v1/api/auth/setup", { + json: { + username: opts.username, + display_name: opts.displayName, + password: opts.password, + }, }); } diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 4103b399..24484e62 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -17,6 +17,24 @@ export interface AuthLoginRequest { export interface AuthLoginResponse { status: string; role: string; + scopes?: string; + jwt?: string; + user_id?: string; +} + +export interface AuthStatusResponse { + auth_enabled: boolean; + has_users: boolean; + setup_required: boolean; +} + +export interface AuthSetupResponse { + status: string; + user_id: string; + username: string; + role: string; + scopes: string; + jwt?: string; } // --------------------------------------------------------------------------- diff --git a/tests/test_auth.py b/tests/test_auth.py index a8434122..b7ca0249 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -306,68 +306,78 @@ class TestCheckRequest: ) def test_disabled_allows_all(self, disabled): - allowed, status, msg = check_request(disabled, "POST", "/api/send", None) + allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None) assert allowed is True assert status == 200 def test_disabled_allows_no_header(self, disabled): - allowed, status, msg = check_request(disabled, "GET", "/api/workstreams", None) + allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None) assert allowed is True def test_public_path_no_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/health", None) + allowed, status, msg, _result = check_request(enabled, "GET", "/health", None) assert allowed is True assert status == 200 def test_public_root_no_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/", None) + allowed, status, msg, _result = check_request(enabled, "GET", "/", None) assert allowed is True def test_public_static_no_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/static/style.css", None) + allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None) assert allowed is True def test_api_no_token_401(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", None) + allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None) assert allowed is False assert status == 401 assert "Unauthorized" in msg def test_api_invalid_token_401(self, enabled): - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "GET", "/api/workstreams", "Bearer wrong_token" ) assert allowed is False assert status == 401 def test_api_read_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_read") + allowed, status, msg, _result = check_request( + enabled, "GET", "/api/workstreams", "Bearer tok_read" + ) assert allowed is True assert status == 200 def test_api_full_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_full") + allowed, status, msg, _result = check_request( + enabled, "GET", "/api/workstreams", "Bearer tok_full" + ) assert allowed is True def test_write_read_token_403(self, enabled): - allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_read") + allowed, status, msg, _result = check_request( + enabled, "POST", "/api/send", "Bearer tok_read" + ) assert allowed is False assert status == 403 assert "Forbidden" in msg def test_write_full_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_full") + allowed, status, msg, _result = check_request( + enabled, "POST", "/api/send", "Bearer tok_full" + ) assert allowed is True assert status == 200 def test_approve_read_token_403(self, enabled): - allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_read") + allowed, status, msg, _result = check_request( + enabled, "POST", "/api/approve", "Bearer tok_read" + ) assert allowed is False assert status == 403 def test_proxy_write_read_token_403(self, enabled): """Read tokens cannot escalate to write ops via proxy routes.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/api/send", "Bearer tok_read" ) assert allowed is False @@ -375,7 +385,7 @@ class TestCheckRequest: def test_proxy_write_trailing_slash_read_token_403(self, enabled): """Trailing slash must not bypass write-role check on proxy routes.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read" ) assert allowed is False @@ -383,20 +393,22 @@ class TestCheckRequest: def test_direct_write_trailing_slash_read_token_403(self, enabled): """Trailing slash must not bypass write-role check on direct routes.""" - allowed, status, msg = check_request(enabled, "POST", "/api/send/", "Bearer tok_read") + allowed, status, msg, _result = check_request( + enabled, "POST", "/api/send/", "Bearer tok_read" + ) assert allowed is False assert status == 403 def test_proxy_write_full_token_ok(self, enabled): """Full tokens pass through proxy write routes.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/api/send", "Bearer tok_full" ) assert allowed is True def test_proxy_v1_write_read_token_403(self, enabled): """Read tokens cannot escalate to write ops via v1 proxy routes.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read" ) assert allowed is False @@ -404,14 +416,14 @@ class TestCheckRequest: def test_proxy_v1_write_full_token_ok(self, enabled): """Full tokens pass through v1 proxy write routes.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full" ) assert allowed is True def test_proxy_v1_cluster_ws_new_read_403(self, enabled): """Read tokens cannot create workstreams via v1 proxy.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/node/node-a/v1/api/cluster/workstreams/new", @@ -422,25 +434,27 @@ class TestCheckRequest: def test_proxy_read_endpoint_read_token_ok(self, enabled): """Read tokens can access proxy read endpoints.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read" ) assert allowed is True def test_console_create_ws_read_token_403(self, enabled): """Read tokens cannot create workstreams.""" - allowed, status, msg = check_request( + allowed, status, msg, _result = check_request( enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read" ) assert allowed is False assert status == 403 def test_approve_full_token_ok(self, enabled): - allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_full") + allowed, status, msg, _result = check_request( + enabled, "POST", "/api/approve", "Bearer tok_full" + ) assert allowed is True def test_no_auth_header_string(self, enabled): - allowed, status, msg = check_request(enabled, "GET", "/api/dashboard", "") + allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "") assert allowed is False assert status == 401 @@ -461,7 +475,7 @@ class TestCheckRequestWithCookie: ) def test_cookie_fallback_when_no_bearer(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "GET", "/api/workstreams", @@ -473,7 +487,7 @@ class TestCheckRequestWithCookie: def test_bearer_takes_precedence_over_cookie(self, enabled): # Bearer is full, cookie is read — Bearer should win - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "POST", "/api/send", @@ -483,7 +497,7 @@ class TestCheckRequestWithCookie: assert allowed is True def test_invalid_cookie_401(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "GET", "/api/workstreams", @@ -494,7 +508,7 @@ class TestCheckRequestWithCookie: assert status == 401 def test_cookie_read_on_write_403(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "POST", "/api/send", @@ -505,7 +519,7 @@ class TestCheckRequestWithCookie: assert status == 403 def test_cookie_full_on_write_ok(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "POST", "/api/send", @@ -515,7 +529,7 @@ class TestCheckRequestWithCookie: assert allowed is True def test_no_cookie_no_bearer_401(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "GET", "/api/workstreams", @@ -526,7 +540,7 @@ class TestCheckRequestWithCookie: assert status == 401 def test_login_path_public(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "POST", "/api/auth/login", @@ -535,7 +549,7 @@ class TestCheckRequestWithCookie: assert allowed is True def test_logout_path_public(self, enabled): - allowed, status, _ = check_request( + allowed, status, _, _r = check_request( enabled, "POST", "/api/auth/logout", @@ -552,12 +566,28 @@ class TestCheckRequestWithCookie: class TestLoadAuthConfig: """Tests for load_auth_config with mocked config + env vars.""" - def test_default_disabled(self): + def test_default_enabled(self): with patch("turnstone.core.config.load_config", return_value={}): cfg = load_auth_config() - assert cfg.enabled is False + assert cfg.enabled is True assert cfg.tokens == {} + def test_explicit_disable(self): + with ( + patch("turnstone.core.config.load_config", return_value={"enabled": False}), + patch.dict(os.environ, {}, clear=True), + ): + cfg = load_auth_config() + assert cfg.enabled is False + + def test_env_disable(self): + with ( + patch("turnstone.core.config.load_config", return_value={}), + patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "0"}, clear=True), + ): + cfg = load_auth_config() + assert cfg.enabled is False + def test_config_file_tokens(self): mock_cfg = { "enabled": True, diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py new file mode 100644 index 00000000..45f85300 --- /dev/null +++ b/tests/test_auth_identity.py @@ -0,0 +1,357 @@ +"""Tests for user identity, API tokens, JWT, and scoped auth.""" + +from __future__ import annotations + +import time + +import pytest + +from turnstone.core.auth import ( + AuthConfig, + AuthResult, + _authenticate_token, + check_request, + create_jwt, + generate_token, + hash_password, + hash_token, + parse_scopes, + required_scope, + token_prefix, + validate_jwt, + verify_password, +) + +# --------------------------------------------------------------------------- +# AuthResult +# --------------------------------------------------------------------------- + + +class TestAuthResult: + def test_frozen(self): + r = AuthResult(user_id="u1", scopes=frozenset({"read"}), token_source="config") + with pytest.raises(AttributeError): + r.user_id = "u2" # type: ignore[misc] + + def test_has_scope(self): + r = AuthResult(user_id="", scopes=frozenset({"read", "write"}), token_source="config") + assert r.has_scope("read") + assert r.has_scope("write") + assert not r.has_scope("approve") + + def test_empty_scopes(self): + r = AuthResult(user_id="", scopes=frozenset(), token_source="config") + assert not r.has_scope("read") + + +# --------------------------------------------------------------------------- +# Token generation and hashing +# --------------------------------------------------------------------------- + + +class TestTokenHelpers: + def test_generate_token_format(self): + tok = generate_token() + assert tok.startswith("ts_") + assert len(tok) == 3 + 64 # ts_ + 64 hex chars + + def test_generate_token_unique(self): + tokens = {generate_token() for _ in range(10)} + assert len(tokens) == 10 + + def test_hash_token_deterministic(self): + assert hash_token("ts_abc") == hash_token("ts_abc") + + def test_hash_token_hex(self): + h = hash_token("test") + assert len(h) == 64 # SHA-256 hex + int(h, 16) # valid hex + + def test_token_prefix(self): + assert token_prefix("ts_abcdefgh1234") == "ts_abcde" + + +# --------------------------------------------------------------------------- +# Password hashing (bcrypt) +# --------------------------------------------------------------------------- + + +class TestPasswordHashing: + def test_hash_and_verify(self): + pw = "hunter2" + hashed = hash_password(pw) + assert verify_password(pw, hashed) + + def test_wrong_password(self): + hashed = hash_password("correct") + assert not verify_password("wrong", hashed) + + def test_hash_is_different_each_time(self): + h1 = hash_password("same") + h2 = hash_password("same") + assert h1 != h2 # different salts + + +# --------------------------------------------------------------------------- +# Scope parsing +# --------------------------------------------------------------------------- + + +class TestParseScopes: + def test_single_scope(self): + assert parse_scopes("read") == frozenset({"read"}) + + def test_hierarchy_write(self): + assert parse_scopes("write") == frozenset({"read", "write"}) + + def test_hierarchy_approve(self): + assert parse_scopes("approve") == frozenset({"read", "write", "approve"}) + + def test_comma_separated(self): + assert parse_scopes("read,write") == frozenset({"read", "write"}) + + def test_redundant_scopes(self): + # approve already includes read,write + assert parse_scopes("read,approve") == frozenset({"read", "write", "approve"}) + + def test_empty_string(self): + assert parse_scopes("") == frozenset() + + def test_invalid_scope_filtered(self): + assert parse_scopes("bogus") == frozenset() + + def test_mixed_valid_invalid(self): + assert parse_scopes("read,bogus,approve") == frozenset({"read", "write", "approve"}) + + +# --------------------------------------------------------------------------- +# JWT create / validate +# --------------------------------------------------------------------------- + + +class TestJWT: + SECRET = "test-secret-key-for-jwt" + + def test_round_trip(self): + scopes = frozenset({"read", "write"}) + token = create_jwt("user123", scopes, "database", self.SECRET, expiry_hours=1) + result = validate_jwt(token, self.SECRET) + assert result is not None + assert result.user_id == "user123" + assert result.scopes == frozenset({"read", "write"}) + + def test_expired_token(self): + import jwt + + payload = { + "sub": "user1", + "scopes": "read", + "src": "database", + "iat": int(time.time()) - 7200, + "exp": int(time.time()) - 3600, + } + token = jwt.encode(payload, self.SECRET, algorithm="HS256") + assert validate_jwt(token, self.SECRET) is None + + def test_invalid_signature(self): + token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET) + assert validate_jwt(token, "wrong-secret") is None + + def test_malformed_token(self): + assert validate_jwt("not.a.jwt", self.SECRET) is None + + def test_contains_dots(self): + """JWTs contain dots, used for detection.""" + token = create_jwt("u1", frozenset({"read"}), "db", self.SECRET) + assert "." in token + + +# --------------------------------------------------------------------------- +# required_scope +# --------------------------------------------------------------------------- + + +class TestRequiredScope: + def test_get_read(self): + assert required_scope("GET", "/api/workstreams") == "read" + + def test_post_write(self): + assert required_scope("POST", "/api/send") == "write" + + def test_post_approve(self): + assert required_scope("POST", "/api/approve") == "approve" + + def test_admin_prefix(self): + assert required_scope("GET", "/api/admin/users") == "approve" + assert required_scope("POST", "/api/admin/users") == "approve" + assert required_scope("DELETE", "/api/admin/users/abc") == "approve" + + def test_versioned_path(self): + assert required_scope("POST", "/v1/api/send") == "write" + assert required_scope("POST", "/v1/api/approve") == "approve" + + def test_proxy_write(self): + assert required_scope("POST", "/node/n1/api/send") == "write" + + def test_proxy_approve(self): + assert required_scope("POST", "/node/n1/api/approve") == "approve" + + +# --------------------------------------------------------------------------- +# _authenticate_token +# --------------------------------------------------------------------------- + + +class TestAuthenticateToken: + def test_config_token_read(self): + cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) + result = _authenticate_token("tok_read", cfg) + assert result is not None + assert result.scopes == frozenset({"read"}) + assert result.token_source == "config" + + def test_config_token_full(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + result = _authenticate_token("tok_full", cfg) + assert result is not None + assert result.scopes == frozenset({"read", "write", "approve"}) + + def test_jwt_token(self): + secret = "test-secret" + jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret) + cfg = AuthConfig(enabled=True) + result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret) + assert result is not None + assert result.user_id == "user1" + assert result.token_source == "db" + + def test_api_token_with_storage(self): + """API tokens are looked up by hash in storage.""" + raw = generate_token() + + class MockStorage: + def get_api_token_by_hash(self, token_hash): + expected = hash_token(raw) + if token_hash == expected: + return { + "token_id": "tid", + "token_prefix": "ts_abcde", + "user_id": "user1", + "name": "test", + "scopes": "read,write", + "created": "2026-01-01T00:00:00", + } + return None + + cfg = AuthConfig(enabled=True) + result = _authenticate_token(raw, cfg, storage=MockStorage()) + assert result is not None + assert result.user_id == "user1" + assert result.has_scope("write") + assert result.token_source == "database" + + def test_api_token_expired(self): + """Expired API tokens are rejected.""" + raw = generate_token() + + class MockStorage: + def get_api_token_by_hash(self, token_hash): + return { + "token_id": "tid", + "token_prefix": "ts_abcde", + "user_id": "user1", + "name": "test", + "scopes": "read", + "created": "2020-01-01T00:00:00", + "expires": "2020-01-02T00:00:00", + } + + cfg = AuthConfig(enabled=True) + result = _authenticate_token(raw, cfg, storage=MockStorage()) + assert result is None + + def test_unknown_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok": "full"}) + result = _authenticate_token("unknown", cfg) + assert result is None + + +# --------------------------------------------------------------------------- +# check_request with scopes +# --------------------------------------------------------------------------- + + +class TestCheckRequestScopes: + def test_config_read_on_write_403(self): + cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) + allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read") + assert not allowed + assert status == 403 + assert "write" in msg + + def test_config_read_on_approve_403(self): + cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) + allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read") + assert not allowed + assert status == 403 + assert "approve" in msg + + def test_config_full_on_approve_ok(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full") + assert allowed + assert result is not None + assert result.has_scope("approve") + + def test_jwt_with_scopes(self): + secret = "test" + jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret) + cfg = AuthConfig(enabled=True) + allowed, status, msg, result = check_request( + cfg, + "POST", + "/api/send", + f"Bearer {jwt_tok}", + jwt_secret=secret, + ) + assert allowed + assert result is not None + assert result.user_id == "u1" + + def test_jwt_insufficient_scope(self): + secret = "test" + jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret) + cfg = AuthConfig(enabled=True) + allowed, status, msg, _ = check_request( + cfg, + "POST", + "/api/send", + f"Bearer {jwt_tok}", + jwt_secret=secret, + ) + assert not allowed + assert status == 403 + + def test_admin_path_requires_approve(self): + cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) + allowed, status, msg, _ = check_request( + cfg, + "GET", + "/v1/api/admin/users", + "Bearer tok_read", + ) + assert not allowed + assert status == 403 + + def test_backward_compat_role_full(self): + """Config tokens with role='full' get all scopes.""" + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + allowed, _, _, result = check_request( + cfg, + "GET", + "/v1/api/admin/users", + "Bearer tok_full", + ) + assert allowed + assert result is not None + assert result.has_scope("approve") diff --git a/tests/test_user_storage.py b/tests/test_user_storage.py new file mode 100644 index 00000000..c7f0fb46 --- /dev/null +++ b/tests/test_user_storage.py @@ -0,0 +1,169 @@ +"""Tests for user identity storage operations (SQLite backend).""" + +from __future__ import annotations + +import pytest + +from turnstone.core.storage._sqlite import SQLiteBackend + + +@pytest.fixture() +def db(tmp_path): + """Create a fresh SQLite backend for each test.""" + return SQLiteBackend(str(tmp_path / "test.db")) + + +class TestUserCRUD: + def test_create_and_get(self, db): + db.create_user("u1", "admin", "Admin User", "$2b$hash") + user = db.get_user("u1") + assert user is not None + assert user["user_id"] == "u1" + assert user["username"] == "admin" + assert user["display_name"] == "Admin User" + assert user["password_hash"] == "$2b$hash" + + def test_get_nonexistent(self, db): + assert db.get_user("missing") is None + + def test_get_by_username(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + user = db.get_user_by_username("admin") + assert user is not None + assert user["user_id"] == "u1" + + def test_get_by_username_nonexistent(self, db): + assert db.get_user_by_username("nope") is None + + def test_create_duplicate_noop(self, db): + db.create_user("u1", "admin", "First", "$2b$hash1") + db.create_user("u1", "admin2", "Second", "$2b$hash2") + user = db.get_user("u1") + assert user is not None + assert user["display_name"] == "First" + + def test_list_users(self, db): + db.create_user("u1", "admin", "Admin", "$2b$h1") + db.create_user("u2", "reader", "Reader", "$2b$h2") + users = db.list_users() + assert len(users) == 2 + assert "password_hash" not in users[0] + + def test_delete_user(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + assert db.delete_user("u1") + assert db.get_user("u1") is None + + def test_delete_nonexistent(self, db): + assert not db.delete_user("missing") + + def test_delete_cascades_tokens(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token("t1", "hash1", "ts_abcde", "u1", "tok1", "read,write") + db.create_api_token("t2", "hash2", "ts_fghij", "u1", "tok2", "read") + assert len(db.list_api_tokens("u1")) == 2 + db.delete_user("u1") + assert len(db.list_api_tokens("u1")) == 0 + + +class TestApiTokenCRUD: + def test_create_and_lookup_by_hash(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token("t1", "tokenhash123", "ts_abcde", "u1", "My Token", "read,write") + tok = db.get_api_token_by_hash("tokenhash123") + assert tok is not None + assert tok["token_id"] == "t1" + assert tok["user_id"] == "u1" + assert tok["scopes"] == "read,write" + + def test_lookup_missing_hash(self, db): + assert db.get_api_token_by_hash("nonexistent") is None + + def test_list_tokens_excludes_hash(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token("t1", "secret_hash", "ts_abcde", "u1", "tok1", "read") + tokens = db.list_api_tokens("u1") + assert len(tokens) == 1 + assert "token_hash" not in tokens[0] + assert tokens[0]["token_prefix"] == "ts_abcde" + + def test_list_tokens_by_user(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_user("u2", "reader", "Reader", "$2b$hash") + db.create_api_token("t1", "h1", "ts_a", "u1", "tok1", "read") + db.create_api_token("t2", "h2", "ts_b", "u2", "tok2", "read") + assert len(db.list_api_tokens("u1")) == 1 + assert len(db.list_api_tokens("u2")) == 1 + + def test_delete_token(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token("t1", "h1", "ts_a", "u1", "tok1", "read") + assert db.delete_api_token("t1") + assert db.get_api_token_by_hash("h1") is None + + def test_delete_nonexistent_token(self, db): + assert not db.delete_api_token("missing") + + def test_token_with_expiry(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token( + "t1", + "h1", + "ts_a", + "u1", + "tok1", + "read", + expires="2030-01-01T00:00:00", + ) + tok = db.get_api_token_by_hash("h1") + assert tok is not None + assert tok["expires"] == "2030-01-01T00:00:00" + + def test_token_without_expiry(self, db): + db.create_user("u1", "admin", "Admin", "$2b$hash") + db.create_api_token("t1", "h1", "ts_a", "u1", "tok1", "read") + tok = db.get_api_token_by_hash("h1") + assert tok is not None + assert "expires" not in tok + + +class TestSessionWorkstreamUserId: + def test_register_session_with_user_id(self, db): + db.register_session("s1", user_id="u1") + # Verify via raw SQL that user_id is stored + import sqlalchemy as sa + + from turnstone.core.storage._schema import sessions + + with db._engine.connect() as conn: + row = conn.execute( + sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1") + ).fetchone() + assert row is not None + assert row[0] == "u1" + + def test_register_workstream_with_user_id(self, db): + db.register_workstream("ws1", user_id="u1") + import sqlalchemy as sa + + from turnstone.core.storage._schema import workstreams + + with db._engine.connect() as conn: + row = conn.execute( + sa.select(workstreams.c.user_id).where(workstreams.c.ws_id == "ws1") + ).fetchone() + assert row is not None + assert row[0] == "u1" + + def test_register_session_without_user_id(self, db): + db.register_session("s1") + import sqlalchemy as sa + + from turnstone.core.storage._schema import sessions + + with db._engine.connect() as conn: + row = conn.execute( + sa.select(sessions.c.user_id).where(sessions.c.session_id == "s1") + ).fetchone() + assert row is not None + assert row[0] is None diff --git a/turnstone/admin.py b/turnstone/admin.py new file mode 100644 index 00000000..c9f7b69b --- /dev/null +++ b/turnstone/admin.py @@ -0,0 +1,186 @@ +"""CLI admin commands for user and token management. + +Entry point: turnstone-admin +""" + +from __future__ import annotations + +import argparse +import os +import sys +import uuid +from typing import Any + + +def _get_storage() -> Any: + """Initialize and return the storage backend.""" + from turnstone.core.storage import init_storage + + db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite") + db_url = os.environ.get("TURNSTONE_DB_URL", "") + db_path = os.environ.get("TURNSTONE_DB_PATH", "") + return init_storage(db_backend, path=db_path, url=db_url) + + +def _cmd_create_user(args: argparse.Namespace) -> None: + import getpass + + from turnstone.core.auth import ( + generate_token, + hash_password, + hash_token, + is_valid_username, + token_prefix, + ) + + if not is_valid_username(args.username): + print("Error: invalid username (1-64 chars: letters, digits, . _ -)", file=sys.stderr) + sys.exit(1) + + storage = _get_storage() + user_id = uuid.uuid4().hex + + # Prompt for password + password = args.password + if not password: + password = getpass.getpass("Password: ") + confirm = getpass.getpass("Confirm password: ") + if password != confirm: + print("Error: passwords do not match", file=sys.stderr) + sys.exit(1) + + pw_hash = hash_password(password) + storage.create_user(user_id, args.username, args.name, pw_hash) + print(f"Created user: {user_id}") + print(f" Username: {args.username}") + print(f" Name: {args.name}") + + if args.token: + scopes = args.scopes or "read,write,approve" + raw = generate_token() + tid = uuid.uuid4().hex + storage.create_api_token( + token_id=tid, + token_hash=hash_token(raw), + token_prefix=token_prefix(raw), + user_id=user_id, + name="initial", + scopes=scopes, + ) + print(f"\n Token: {raw}") + print(f" Token ID: {tid}") + print(f" Scopes: {scopes}") + print(" (Save this token now — it cannot be retrieved again)") + + +def _cmd_create_token(args: argparse.Namespace) -> None: + from turnstone.core.auth import generate_token, hash_token, token_prefix + + storage = _get_storage() + + if storage.get_user(args.user) is None: + print(f"Error: user {args.user} not found", file=sys.stderr) + sys.exit(1) + + expires = None + if args.expires_days: + from datetime import UTC, datetime, timedelta + + expires = (datetime.now(UTC) + timedelta(days=args.expires_days)).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + + raw = generate_token() + tid = uuid.uuid4().hex + storage.create_api_token( + token_id=tid, + token_hash=hash_token(raw), + token_prefix=token_prefix(raw), + user_id=args.user, + name=args.name or "", + scopes=args.scopes, + expires=expires, + ) + print(f"Token: {raw}") + print(f" ID: {tid}") + print(f" Scopes: {args.scopes}") + if expires: + print(f" Expires: {expires}") + print(" (Save this token now — it cannot be retrieved again)") + + +def _cmd_list_users(args: argparse.Namespace) -> None: + storage = _get_storage() + users = storage.list_users() + if not users: + print("No users found.") + return + for u in users: + print(f" {u['user_id'][:12]}.. {u['display_name']} ({u['created']})") + + +def _cmd_list_tokens(args: argparse.Namespace) -> None: + storage = _get_storage() + tokens = storage.list_api_tokens(args.user) + if not tokens: + print(f"No tokens found for user {args.user}.") + return + for t in tokens: + exp = f" expires={t['expires']}" if t.get("expires") else "" + print( + f" {t['token_id'][:12]}.. {t['token_prefix']}.. scopes={t['scopes']}" + f" name={t['name']}{exp}" + ) + + +def _cmd_revoke_token(args: argparse.Namespace) -> None: + storage = _get_storage() + if storage.delete_api_token(args.token_id): + print(f"Revoked token {args.token_id}") + else: + print("Token not found", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + """Entry point for turnstone-admin CLI.""" + parser = argparse.ArgumentParser( + prog="turnstone-admin", + description="Turnstone user and token administration", + ) + sub = parser.add_subparsers(dest="command") + + p_cu = sub.add_parser("create-user", help="Create a new user") + p_cu.add_argument("--username", required=True, help="Login username") + p_cu.add_argument("--name", required=True, help="Display name") + p_cu.add_argument("--password", default="", help="Password (prompted if not provided)") + p_cu.add_argument("--token", action="store_true", help="Also create an initial API token") + p_cu.add_argument("--scopes", default="read,write,approve", help="Scopes for initial token") + + p_ct = sub.add_parser("create-token", help="Create an API token for a user") + p_ct.add_argument("--user", required=True, help="User ID") + p_ct.add_argument("--name", default="", help="Human label for the token") + p_ct.add_argument("--scopes", default="read,write", help="Comma-separated scopes") + p_ct.add_argument("--expires-days", type=int, default=None, help="Days until expiry") + + sub.add_parser("list-users", help="List all users") + + p_lt = sub.add_parser("list-tokens", help="List tokens for a user") + p_lt.add_argument("--user", required=True, help="User ID") + + p_rt = sub.add_parser("revoke-token", help="Revoke an API token") + p_rt.add_argument("--token-id", required=True, help="Token ID to revoke") + + args = parser.parse_args() + if not args.command: + parser.print_help() + sys.exit(1) + + dispatch = { + "create-user": _cmd_create_user, + "create-token": _cmd_create_token, + "list-users": _cmd_list_users, + "list-tokens": _cmd_list_tokens, + "revoke-token": _cmd_revoke_token, + } + dispatch[args.command](args) diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 63dc6b1a..c01017bd 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -20,8 +20,17 @@ from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi from turnstone.api.schemas import ( AuthLoginRequest, AuthLoginResponse, + AuthSetupRequest, + AuthSetupResponse, + AuthStatusResponse, + CreateTokenRequest, + CreateTokenResponse, + CreateUserRequest, ErrorResponse, + ListTokensResponse, + ListUsersResponse, StatusResponse, + UserInfo, ) CONSOLE_ENDPOINTS: list[EndpointSpec] = [ @@ -103,6 +112,22 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ error_codes=[401], tags=["Auth"], ), + EndpointSpec( + "/v1/api/auth/setup", + "POST", + "Create first admin user", + request_model=AuthSetupRequest, + response_model=AuthSetupResponse, + error_codes=[400, 409, 503], + tags=["Auth"], + ), + EndpointSpec( + "/v1/api/auth/status", + "GET", + "Return auth state", + response_model=AuthStatusResponse, + tags=["Auth"], + ), EndpointSpec( "/v1/api/auth/logout", "POST", @@ -110,6 +135,53 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ response_model=StatusResponse, tags=["Auth"], ), + # --- Admin --- + EndpointSpec( + "/v1/api/admin/users", + "GET", + "List all users", + response_model=ListUsersResponse, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/users", + "POST", + "Create a user", + request_model=CreateUserRequest, + response_model=UserInfo, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/users/{user_id}", + "DELETE", + "Delete a user and their tokens", + response_model=StatusResponse, + error_codes=[404], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/users/{user_id}/tokens", + "GET", + "List tokens for a user", + response_model=ListTokensResponse, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/users/{user_id}/tokens", + "POST", + "Create an API token (raw token shown once)", + request_model=CreateTokenRequest, + response_model=CreateTokenResponse, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/tokens/{token_id}", + "DELETE", + "Revoke an API token", + response_model=StatusResponse, + error_codes=[404], + tags=["Admin"], + ), # --- Observability --- EndpointSpec( "/health", @@ -125,6 +197,15 @@ _ALL_MODELS: list[type[BaseModel]] = [ StatusResponse, AuthLoginRequest, AuthLoginResponse, + AuthSetupRequest, + AuthSetupResponse, + AuthStatusResponse, + CreateUserRequest, + UserInfo, + ListUsersResponse, + CreateTokenRequest, + CreateTokenResponse, + ListTokensResponse, ClusterOverviewResponse, ClusterNodesResponse, ClusterWorkstreamsResponse, diff --git a/turnstone/api/schemas.py b/turnstone/api/schemas.py index f03135de..c2655e4f 100644 --- a/turnstone/api/schemas.py +++ b/turnstone/api/schemas.py @@ -35,13 +35,123 @@ class StatusResponse(BaseModel): class AuthLoginRequest(BaseModel): - """POST /v1/api/auth/login request body.""" + """POST /v1/api/auth/login request body. - token: str = Field(description="Bearer token to authenticate") + Either username+password or token must be provided. + """ + + username: str = Field(default="", description="Login username") + password: str = Field(default="", description="Login password") + token: str = Field(default="", description="Legacy: bearer token to authenticate") class AuthLoginResponse(BaseModel): """POST /v1/api/auth/login success response.""" status: str = Field(default="ok") - role: str = Field(description="Assigned role", examples=["full", "read"]) + user_id: str = Field(default="", description="Authenticated user ID") + role: str = Field(description="Legacy role", examples=["full", "read"]) + scopes: str = Field( + default="", description="Comma-separated scopes", examples=["read,write,approve"] + ) + jwt: str = Field(default="", description="JWT session token (if JWT auth is configured)") + + +# --------------------------------------------------------------------------- +# Admin — User identity + API tokens +# --------------------------------------------------------------------------- + + +class CreateUserRequest(BaseModel): + """POST /v1/api/admin/users request body.""" + + username: str = Field(description="Login username (unique)") + display_name: str = Field(description="Human-readable display name") + password: str = Field(description="Initial password") + + +class UserInfo(BaseModel): + """User record (no password_hash).""" + + user_id: str + username: str + display_name: str + created: str + + +class ListUsersResponse(BaseModel): + """GET /v1/api/admin/users response.""" + + users: list[UserInfo] + + +class CreateTokenRequest(BaseModel): + """POST /v1/api/admin/users/{user_id}/tokens request body.""" + + name: str = Field(default="", description="Human label for the token") + scopes: str = Field( + default="read,write,approve", + description="Comma-separated scopes: read, write, approve", + ) + expires_days: int | None = Field( + default=None, + description="Days until expiry (null = no expiry)", + ) + + +class TokenInfo(BaseModel): + """Token metadata (never includes the hash or raw token).""" + + token_id: str + token_prefix: str + name: str + scopes: str + created: str + expires: str | None = None + + +class CreateTokenResponse(BaseModel): + """POST /v1/api/admin/users/{user_id}/tokens response (raw token shown once).""" + + token: str = Field(description="Raw API token — save this, it cannot be retrieved again") + token_id: str + token_prefix: str + scopes: str + + +class ListTokensResponse(BaseModel): + """GET /v1/api/admin/users/{user_id}/tokens response.""" + + tokens: list[TokenInfo] + + +# --------------------------------------------------------------------------- +# Auth — Setup + status +# --------------------------------------------------------------------------- + + +class AuthSetupRequest(BaseModel): + """POST /v1/api/auth/setup request body.""" + + username: str = Field(description="Login username (1-64 ASCII characters)") + display_name: str = Field(description="Display name") + password: str = Field(description="Password (minimum 8 characters)") + + +class AuthSetupResponse(BaseModel): + """POST /v1/api/auth/setup success response.""" + + status: str = Field(default="ok") + user_id: str + username: str + role: str = Field(default="full") + scopes: str = Field(default="approve,read,write") + jwt: str = Field(default="", description="JWT session token") + + +class AuthStatusResponse(BaseModel): + """GET /v1/api/auth/status response.""" + + auth_enabled: bool + has_users: bool + setup_required: bool diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index 95f78056..e0416383 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -11,6 +11,9 @@ if TYPE_CHECKING: from turnstone.api.schemas import ( AuthLoginRequest, AuthLoginResponse, + AuthSetupRequest, + AuthSetupResponse, + AuthStatusResponse, ErrorResponse, StatusResponse, ) @@ -137,6 +140,22 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [ error_codes=[401], tags=["Auth"], ), + EndpointSpec( + "/v1/api/auth/setup", + "POST", + "Create first admin user", + request_model=AuthSetupRequest, + response_model=AuthSetupResponse, + error_codes=[400, 409, 503], + tags=["Auth"], + ), + EndpointSpec( + "/v1/api/auth/status", + "GET", + "Return auth state", + response_model=AuthStatusResponse, + tags=["Auth"], + ), EndpointSpec( "/v1/api/auth/logout", "POST", @@ -159,6 +178,9 @@ _ALL_MODELS: list[type[BaseModel]] = [ StatusResponse, AuthLoginRequest, AuthLoginResponse, + AuthSetupRequest, + AuthSetupResponse, + AuthStatusResponse, SendRequest, SendResponse, ApproveRequest, diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 2eb97c43..09478a21 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -107,15 +107,33 @@ class AuthMiddleware: from turnstone.core.auth import check_request auth_config = request.app.state.auth_config + jwt_secret = getattr(request.app.state, "jwt_secret", "") + storage = getattr(request.app.state, "auth_storage", None) method = request.method path = request.url.path auth_header = request.headers.get("Authorization") cookie_header = request.headers.get("Cookie") - allowed, status, msg = check_request(auth_config, method, path, auth_header, cookie_header) + allowed, status, msg, auth_result = check_request( + auth_config, + method, + path, + auth_header, + cookie_header, + jwt_secret=jwt_secret, + storage=storage, + ) if not allowed: response = JSONResponse({"error": msg}, status_code=status) await response(scope, receive, send) return + + if auth_result and auth_result.user_id: + from turnstone.core.log import ctx_user_id + + ctx_user_id.set(auth_result.user_id) + if "state" not in scope: + scope["state"] = {} + scope["state"]["auth_result"] = auth_result await self.app(scope, receive, send) @@ -167,6 +185,34 @@ _CONSOLE_PROXY_STYLE = "" _VALID_NODE_ID = re.compile(r"^[a-zA-Z0-9._-]+$") +def _proxy_auth_headers(request: Request) -> dict[str, str]: + """Build auth headers for proxied requests to upstream servers. + + Forwards the user's JWT (from cookie or Bearer header) so that + upstream servers with auth enabled accept the proxied request. + Falls back to the static proxy_auth_token if configured. + """ + # Prefer the incoming Authorization header (e.g. Bearer JWT) + auth_header = request.headers.get("Authorization", "") + if auth_header: + return {"Authorization": auth_header} + + # Extract JWT from cookie + from turnstone.core.auth import AUTH_COOKIE, _extract_cookie + + cookie_header = request.headers.get("Cookie", "") + cookie_token = _extract_cookie(cookie_header, AUTH_COOKIE) + if cookie_token: + return {"Authorization": f"Bearer {cookie_token}"} + + # Fall back to static proxy_auth_token + static_token = getattr(request.app.state, "proxy_auth_token", "") + if static_token: + return {"Authorization": f"Bearer {static_token}"} + + return {} + + def _get_server_url(request: Request, node_id: str) -> str | None: """Resolve node_id to its server_url via the collector.""" if not node_id or not _VALID_NODE_ID.match(node_id) or len(node_id) > 256: @@ -298,20 +344,69 @@ async def health(request: Request) -> JSONResponse: async def auth_login(request: Request) -> Response: - from turnstone.core.auth import make_set_cookie + """Authenticate via username:password or legacy token, return JWT.""" + from turnstone.core.auth import ( + AuthResult, + _authenticate_token, + create_jwt, + make_set_cookie, + verify_password, + ) try: body: dict[str, Any] = await request.json() except (ValueError, json.JSONDecodeError): return JSONResponse({"error": "Invalid JSON body"}, status_code=400) - token = body.get("token", "") + auth_config = request.app.state.auth_config - role = auth_config.check(token) - if role: - response = JSONResponse({"status": "ok", "role": role}) - response.headers["Set-Cookie"] = make_set_cookie(token) - return response - return JSONResponse({"error": "Invalid token"}, status_code=401) + jwt_secret = getattr(request.app.state, "jwt_secret", "") + storage = getattr(request.app.state, "auth_storage", None) + + result: AuthResult | None = None + username = body.get("username", "") + password = body.get("password", "") + + if username and password and storage is not None: + user = storage.get_user_by_username(username) + if user and verify_password(password, user["password_hash"]): + result = AuthResult( + user_id=user["user_id"], + scopes=frozenset({"read", "write", "approve"}), + token_source="password", + ) + elif body.get("token"): + result = _authenticate_token( + body["token"], + auth_config, + jwt_secret=jwt_secret, + storage=storage, + ) + + if result is None: + return JSONResponse({"error": "Invalid credentials"}, status_code=401) + + jwt_token = "" + if jwt_secret: + jwt_token = create_jwt( + user_id=result.user_id, + scopes=result.scopes, + source=result.token_source, + secret=jwt_secret, + ) + + role = "full" if result.has_scope("write") else "read" + scopes_str = ",".join(sorted(result.scopes)) + resp_body: dict[str, str] = {"status": "ok", "role": role, "scopes": scopes_str} + if jwt_token: + resp_body["jwt"] = jwt_token + if result.user_id: + resp_body["user_id"] = result.user_id + + response = JSONResponse(resp_body) + cookie_value = jwt_token if jwt_token else body.get("token", "") + if cookie_value: + response.headers["Set-Cookie"] = make_set_cookie(cookie_value) + return response async def auth_logout(request: Request) -> Response: @@ -322,6 +417,102 @@ async def auth_logout(request: Request) -> Response: return response +async def auth_status(request: Request) -> JSONResponse: + """GET /v1/api/auth/status — public endpoint for login UI state detection.""" + auth_config = request.app.state.auth_config + storage = getattr(request.app.state, "auth_storage", None) + + has_users = False + if storage is not None: + try: + users = storage.list_users() + has_users = len(users) > 0 + except Exception: + pass + + return JSONResponse( + { + "auth_enabled": auth_config.enabled, + "has_users": has_users, + "setup_required": auth_config.enabled and not has_users, + } + ) + + +async def auth_setup(request: Request) -> JSONResponse: + """POST /v1/api/auth/setup — create first admin user (public, one-time only). + + Only works when auth is enabled and zero users exist. Returns JWT on success. + """ + import uuid + + from turnstone.core.auth import create_jwt, hash_password, make_set_cookie + + storage = getattr(request.app.state, "auth_storage", None) + jwt_secret = getattr(request.app.state, "jwt_secret", "") + + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + + try: + body: dict[str, Any] = await request.json() + except (ValueError, json.JSONDecodeError): + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + username = body.get("username", "").strip() + display_name = body.get("display_name", "").strip() + password = body.get("password", "") + + from turnstone.core.auth import is_valid_username + + if not is_valid_username(username): + return JSONResponse( + {"error": "Invalid username (1-64 chars: letters, digits, . _ -)"}, + status_code=400, + ) + if not display_name: + return JSONResponse({"error": "display_name is required"}, status_code=400) + if len(password) < 8: + return JSONResponse({"error": "Password must be at least 8 characters"}, status_code=400) + + user_id = uuid.uuid4().hex + pw_hash = hash_password(password) + + # Atomic: insert only if no users exist (prevents TOCTOU race) + try: + created = storage.create_first_user(user_id, username, display_name, pw_hash) + except Exception: + return JSONResponse({"error": "Storage error"}, status_code=503) + if not created: + return JSONResponse({"error": "Setup already completed"}, status_code=409) + + # Issue JWT automatically + scopes = frozenset({"read", "write", "approve"}) + jwt_token = "" + if jwt_secret: + jwt_token = create_jwt( + user_id=user_id, + scopes=scopes, + source="password", + secret=jwt_secret, + ) + + resp_body: dict[str, str] = { + "status": "ok", + "user_id": user_id, + "username": username, + "role": "full", + "scopes": ",".join(sorted(scopes)), + } + if jwt_token: + resp_body["jwt"] = jwt_token + + response = JSONResponse(resp_body) + if jwt_token: + response.headers["Set-Cookie"] = make_set_cookie(jwt_token) + return response + + # --------------------------------------------------------------------------- # Route handlers — workstream creation # --------------------------------------------------------------------------- @@ -423,7 +614,7 @@ async def proxy_index(request: Request) -> Response: safe_node = urllib.parse.quote(node_id, safe="") prefix = f"/node/{safe_node}" try: - resp = await client.get(f"{server_url}/") + resp = await client.get(f"{server_url}/", headers=_proxy_auth_headers(request)) if resp.status_code < 200 or resp.status_code >= 300: log.debug("Upstream %s returned status %s", node_id, resp.status_code) return JSONResponse( @@ -460,7 +651,10 @@ async def proxy_static(request: Request) -> Response: client: httpx.AsyncClient = request.app.state.proxy_client try: - resp = await client.get(f"{server_url}/static/{path}") + resp = await client.get( + f"{server_url}/static/{path}", + headers=_proxy_auth_headers(request), + ) return Response( content=resp.content, status_code=resp.status_code, @@ -481,7 +675,10 @@ async def proxy_shared_static(request: Request) -> Response: client: httpx.AsyncClient = request.app.state.proxy_client try: - resp = await client.get(f"{server_url}/shared/{path}") + resp = await client.get( + f"{server_url}/shared/{path}", + headers=_proxy_auth_headers(request), + ) return Response( content=resp.content, status_code=resp.status_code, @@ -533,7 +730,7 @@ async def _proxy_get(request: Request, server_url: str, path: str) -> Response: if request.url.query: target += f"?{request.url.query}" try: - resp = await client.get(target) + resp = await client.get(target, headers=_proxy_auth_headers(request)) return Response( content=resp.content, status_code=resp.status_code, @@ -555,11 +752,9 @@ async def _proxy_post( if request.url.query: target += f"?{request.url.query}" try: - resp = await client.post( - target, - content=body, - headers={"Content-Type": content_type}, - ) + post_headers = {"Content-Type": content_type} + post_headers.update(_proxy_auth_headers(request)) + resp = await client.post(target, content=body, headers=post_headers) return Response( content=resp.content, status_code=resp.status_code, @@ -579,12 +774,13 @@ async def _proxy_sse( target += f"?{request.url.query}" sse_client: httpx.AsyncClient = request.app.state.proxy_sse_client + sse_auth = _proxy_auth_headers(request) async def sse_generator() -> AsyncGenerator[dict[str, str], None]: from httpx_sse import aconnect_sse try: - async with aconnect_sse(sse_client, "GET", target) as source: + async with aconnect_sse(sse_client, "GET", target, headers=sse_auth) as source: if source.response.status_code != 200: log.debug( "SSE proxy received status %s from %s", @@ -632,6 +828,163 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]: app.state.broker.close() +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- +# Admin API endpoints — user + token management +# --------------------------------------------------------------------------- + + +async def admin_list_users(request: Request) -> JSONResponse: + """GET /v1/api/admin/users — list all users.""" + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + return JSONResponse({"users": storage.list_users()}) + + +async def admin_create_user(request: Request) -> JSONResponse: + """POST /v1/api/admin/users — create a new user.""" + import uuid + + from turnstone.core.auth import hash_password + + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + try: + body: dict[str, Any] = await request.json() + except (ValueError, json.JSONDecodeError): + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + username = body.get("username", "").strip() + display_name = body.get("display_name", "").strip() + password = body.get("password", "") + + from turnstone.core.auth import is_valid_username + + if not is_valid_username(username): + return JSONResponse( + {"error": "Invalid username (1-64 chars: letters, digits, . _ -)"}, + status_code=400, + ) + if not display_name: + return JSONResponse({"error": "display_name is required"}, status_code=400) + if not password or len(password) < 8: + return JSONResponse({"error": "Password must be at least 8 characters"}, status_code=400) + + # Check username uniqueness + if storage.get_user_by_username(username) is not None: + return JSONResponse({"error": "Username already taken"}, status_code=409) + + user_id = uuid.uuid4().hex + pw_hash = hash_password(password) + storage.create_user(user_id, username, display_name, pw_hash) + # Read back to get the storage-canonical created timestamp + user = storage.get_user(user_id) + return JSONResponse( + { + "user_id": user["user_id"], + "username": user["username"], + "display_name": user["display_name"], + "created": user["created"], + } + ) + + +async def admin_delete_user(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/users/{user_id} — delete user + cascade tokens.""" + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + user_id = request.path_params["user_id"] + if storage.delete_user(user_id): + return JSONResponse({"status": "ok"}) + return JSONResponse({"error": "User not found"}, status_code=404) + + +async def admin_list_tokens(request: Request) -> JSONResponse: + """GET /v1/api/admin/users/{user_id}/tokens — list tokens for a user.""" + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + user_id = request.path_params["user_id"] + return JSONResponse({"tokens": storage.list_api_tokens(user_id)}) + + +async def admin_create_token(request: Request) -> JSONResponse: + """POST /v1/api/admin/users/{user_id}/tokens — create API token.""" + import uuid + + from turnstone.core.auth import generate_token, hash_token, token_prefix + + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + user_id = request.path_params["user_id"] + + # Verify user exists + if storage.get_user(user_id) is None: + return JSONResponse({"error": "User not found"}, status_code=404) + + try: + body: dict[str, Any] = await request.json() + except (ValueError, json.JSONDecodeError): + body = {} + + name = body.get("name", "") + scopes = body.get("scopes", "read,write,approve") + expires_days = body.get("expires_days") + + # Validate scopes + from turnstone.core.auth import VALID_SCOPES + + requested = {s.strip() for s in scopes.split(",") if s.strip()} + if not requested or not requested.issubset(VALID_SCOPES): + return JSONResponse( + {"error": "Invalid scopes (allowed: read, write, approve)"}, status_code=400 + ) + + expires: str | None = None + if expires_days is not None: + from datetime import UTC, datetime, timedelta + + expires = (datetime.now(UTC) + timedelta(days=int(expires_days))).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + + raw = generate_token() + tid = uuid.uuid4().hex + storage.create_api_token( + token_id=tid, + token_hash=hash_token(raw), + token_prefix=token_prefix(raw), + user_id=user_id, + name=name, + scopes=scopes, + expires=expires, + ) + return JSONResponse( + { + "token": raw, + "token_id": tid, + "token_prefix": token_prefix(raw), + "scopes": scopes, + } + ) + + +async def admin_revoke_token(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/tokens/{token_id} — revoke an API token.""" + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + token_id = request.path_params["token_id"] + if storage.delete_api_token(token_id): + return JSONResponse({"status": "ok"}) + return JSONResponse({"error": "Token not found"}, status_code=404) + + # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- @@ -642,6 +995,8 @@ def create_app( collector: ClusterCollector, broker: RedisBroker, auth_config: Any, + jwt_secret: str = "", + auth_storage: Any = None, proxy_auth_token: str = "", ) -> Starlette: """Build the Starlette ASGI application for the console dashboard.""" @@ -663,6 +1018,16 @@ def create_app( Route("/api/cluster/events", cluster_events_sse), Route("/api/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), + Route("/api/auth/status", auth_status), + Route("/api/auth/setup", auth_setup, methods=["POST"]), + Route("/api/admin/users", admin_list_users), + Route("/api/admin/users", admin_create_user, methods=["POST"]), + Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]), + Route("/api/admin/users/{user_id}/tokens", admin_list_tokens), + Route( + "/api/admin/users/{user_id}/tokens", admin_create_token, methods=["POST"] + ), + Route("/api/admin/tokens/{token_id}", admin_revoke_token, methods=["DELETE"]), ], ), Route("/health", health), @@ -692,6 +1057,8 @@ def create_app( app.state.collector = collector app.state.broker = broker app.state.auth_config = auth_config + app.state.jwt_secret = jwt_secret + app.state.auth_storage = auth_storage app.state.proxy_auth_token = proxy_auth_token return app @@ -798,20 +1165,35 @@ def main() -> None: _load_static() - from turnstone.core.auth import load_auth_config + from turnstone.core.auth import load_auth_config, load_jwt_secret auth_config = load_auth_config() + jwt_secret = load_jwt_secret() if auth_config.enabled else "" + + # Initialize storage for user/token management (optional — requires DB config) + auth_storage = None + try: + from turnstone.core.storage import init_storage + + db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite") + db_url = os.environ.get("TURNSTONE_DB_URL", "") + db_path = os.environ.get("TURNSTONE_DB_PATH", "") + auth_storage = init_storage(db_backend, path=db_path, url=db_url) + except Exception: + log.info("Console storage not available — admin API disabled, JWT-only auth") app = create_app( collector=collector, broker=broker, auth_config=auth_config, + jwt_secret=jwt_secret, + auth_storage=auth_storage, proxy_auth_token=args.auth_token, ) log.info("Console starting on http://%s:%s", args.host, args.port) if auth_config.enabled: - log.info("Auth: enabled (%d token(s) configured)", len(auth_config.tokens)) + log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens)) print("Press Ctrl+C to stop.") import uvicorn diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js new file mode 100644 index 00000000..3b429391 --- /dev/null +++ b/turnstone/console/static/admin.js @@ -0,0 +1,524 @@ +/* Admin panel — user & token management for turnstone console */ + +var _adminTab = "users"; +var _adminUsers = []; +var _adminTokenUserId = ""; +var _lastCreatedToken = ""; +var _cuTrapHandler = null; +var _ctTrapHandler = null; +var _tcTrapHandler = null; + +// --------------------------------------------------------------------------- +// View switching (called from app.js showOverview/drillDown pattern) +// --------------------------------------------------------------------------- + +function showAdmin() { + /* global currentView */ + currentView = "admin"; + document.getElementById("view-overview").style.display = "none"; + document.getElementById("view-node").style.display = "none"; + document.getElementById("view-filtered").style.display = "none"; + document.getElementById("view-admin").style.display = ""; + document.getElementById("breadcrumb").style.display = ""; + document.getElementById("breadcrumb-label").textContent = "Admin"; + document.getElementById("main").scrollTop = 0; + history.pushState({ view: "admin" }, ""); + loadAdminUsers(); +} + +function switchAdminTab(tab) { + _adminTab = tab; + var tabs = document.querySelectorAll(".admin-tab"); + for (var i = 0; i < tabs.length; i++) { + var isActive = tabs[i].getAttribute("data-tab") === tab; + tabs[i].classList.toggle("active", isActive); + tabs[i].setAttribute("aria-selected", isActive ? "true" : "false"); + tabs[i].setAttribute("tabindex", isActive ? "0" : "-1"); + } + document.getElementById("admin-users").style.display = + tab === "users" ? "" : "none"; + document.getElementById("admin-tokens").style.display = + tab === "tokens" ? "" : "none"; + + if (tab === "users") loadAdminUsers(); + if (tab === "tokens") _populateTokenUserSelect(); +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +function loadAdminUsers() { + authFetch("/v1/api/admin/users") + .then(function (r) { + if (!r.ok) throw new Error("Failed to load users"); + return r.json(); + }) + .then(function (data) { + _adminUsers = data.users || []; + _renderUsers(_adminUsers); + _populateTokenUserSelect(); + }) + .catch(function () { + document.getElementById("admin-users-table").innerHTML = + '
Failed to load users
'; + }); +} + +function _renderUsers(users) { + var container = document.getElementById("admin-users-table"); + if (!users.length) { + container.innerHTML = + '
No users yet. Create one to get started.
'; + return; + } + var html = ""; + for (var i = 0; i < users.length; i++) { + var u = users[i]; + html += + '
' + + '' + + escapeHtml(u.username) + + "" + + '' + + escapeHtml(u.display_name) + + "" + + '' + + escapeHtml(u.created || "").slice(0, 10) + + "" + + '' + + '' + + "" + + "
"; + } + container.innerHTML = html; + // Bind delete buttons via delegation (avoids inline JS injection) + var btns = container.querySelectorAll("[data-delete-user]"); + for (var j = 0; j < btns.length; j++) { + btns[j].addEventListener("click", function () { + confirmDeleteUser( + this.getAttribute("data-delete-user"), + this.getAttribute("data-username"), + ); + }); + } +} + +function confirmDeleteUser(userId, username) { + if (!confirm("Delete user '" + username + "' and all their tokens?")) return; + authFetch("/v1/api/admin/users/" + encodeURIComponent(userId), { + method: "DELETE", + }) + .then(function (r) { + if (!r.ok) throw new Error("Delete failed"); + showToast("User '" + username + "' deleted"); + loadAdminUsers(); + }) + .catch(function () { + showToast("Failed to delete user"); + }); +} + +// --------------------------------------------------------------------------- +// Tokens +// --------------------------------------------------------------------------- + +function _populateTokenUserSelect() { + var sel = document.getElementById("admin-token-user"); + var current = sel.value; + sel.innerHTML = ''; + for (var i = 0; i < _adminUsers.length; i++) { + var u = _adminUsers[i]; + var opt = document.createElement("option"); + opt.value = u.user_id; + opt.textContent = u.username + " (" + u.display_name + ")"; + sel.appendChild(opt); + } + if (current) sel.value = current; +} + +function loadAdminTokens() { + var userId = document.getElementById("admin-token-user").value; + _adminTokenUserId = userId; + if (!userId) { + document.getElementById("admin-tokens-table").innerHTML = + '
Select a user to view tokens
'; + return; + } + authFetch("/v1/api/admin/users/" + encodeURIComponent(userId) + "/tokens") + .then(function (r) { + if (!r.ok) throw new Error("Failed to load tokens"); + return r.json(); + }) + .then(function (data) { + _renderTokens(data.tokens || []); + }) + .catch(function () { + document.getElementById("admin-tokens-table").innerHTML = + '
Failed to load tokens
'; + }); +} + +function _renderTokens(tokens) { + var container = document.getElementById("admin-tokens-table"); + if (!tokens.length) { + container.innerHTML = + '
No tokens for this user
'; + return; + } + var html = ""; + for (var i = 0; i < tokens.length; i++) { + var t = tokens[i]; + var expires = t.expires ? escapeHtml(t.expires).slice(0, 10) : "\u2014"; + html += + '
' + + '' + + escapeHtml(t.token_prefix) + + "\u2026" + + '' + + escapeHtml(t.name || "\u2014") + + "" + + '' + + _renderScopeBadges(t.scopes) + + "" + + '' + + escapeHtml(t.created || "").slice(0, 10) + + "" + + '' + + expires + + "" + + '' + + '' + + "" + + "
"; + } + container.innerHTML = html; + // Bind revoke buttons via delegation (avoids inline JS injection) + var rbtns = container.querySelectorAll("[data-revoke-token]"); + for (var j = 0; j < rbtns.length; j++) { + rbtns[j].addEventListener("click", function () { + confirmRevokeToken(this.getAttribute("data-revoke-token")); + }); + } +} + +function _renderScopeBadges(scopes) { + if (!scopes) return ""; + var parts = scopes.split(","); + var html = ""; + for (var i = 0; i < parts.length; i++) { + var s = parts[i].trim(); + if (!s) continue; + var cls = "scope-badge"; + if (s === "approve") cls += " scope-approve"; + else if (s === "write") cls += " scope-write"; + html += '' + escapeHtml(s) + ""; + } + return html; +} + +function confirmRevokeToken(tokenId) { + if (!confirm("Revoke this token? This cannot be undone.")) return; + authFetch("/v1/api/admin/tokens/" + encodeURIComponent(tokenId), { + method: "DELETE", + }) + .then(function (r) { + if (!r.ok) throw new Error("Revoke failed"); + showToast("Token revoked"); + loadAdminTokens(); + }) + .catch(function () { + showToast("Failed to revoke token"); + }); +} + +// --------------------------------------------------------------------------- +// Create User Modal +// --------------------------------------------------------------------------- + +function showCreateUserModal() { + var overlay = document.getElementById("create-user-overlay"); + overlay.style.display = "flex"; + document.getElementById("create-user-error").style.display = "none"; + document.getElementById("cu-username").value = ""; + document.getElementById("cu-displayname").value = ""; + document.getElementById("cu-password").value = ""; + document.getElementById("cu-confirm").value = ""; + document.getElementById("cu-submit").disabled = false; + document.getElementById("cu-submit").textContent = "Create"; + _cuTrapHandler = _installTrap("create-user-overlay", "create-user-box"); + setTimeout(function () { + document.getElementById("cu-username").focus(); + }, 50); +} + +function hideCreateUserModal() { + document.getElementById("create-user-overlay").style.display = "none"; + _cuTrapHandler = _removeTrap(_cuTrapHandler); +} + +function submitCreateUser() { + var username = (document.getElementById("cu-username").value || "").trim(); + var displayName = ( + document.getElementById("cu-displayname").value || "" + ).trim(); + var password = document.getElementById("cu-password").value || ""; + var confirm = document.getElementById("cu-confirm").value || ""; + var errEl = document.getElementById("create-user-error"); + + if (!username) return _showModalError(errEl, "Username is required"); + if (!displayName) return _showModalError(errEl, "Display name is required"); + if (!password) return _showModalError(errEl, "Password is required"); + if (password.length < 8) + return _showModalError(errEl, "Password must be at least 8 characters"); + if (password !== confirm) + return _showModalError(errEl, "Passwords do not match"); + + var btn = document.getElementById("cu-submit"); + btn.disabled = true; + btn.textContent = "Creating\u2026"; + + authFetch("/v1/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: username, + display_name: displayName, + password: password, + }), + }) + .then(function (r) { + if (r.status === 409) throw new Error("Username already taken"); + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + hideCreateUserModal(); + showToast("User '" + username + "' created"); + loadAdminUsers(); + }) + .catch(function (err) { + btn.disabled = false; + btn.textContent = "Create"; + _showModalError(errEl, err.message || "Failed to create user"); + }); +} + +// --------------------------------------------------------------------------- +// Create Token Modal +// --------------------------------------------------------------------------- + +function showCreateTokenModal() { + if (!_adminTokenUserId) { + showToast("Select a user first"); + return; + } + var overlay = document.getElementById("create-token-overlay"); + overlay.style.display = "flex"; + document.getElementById("create-token-error").style.display = "none"; + document.getElementById("ct-name").value = ""; + document.getElementById("ct-scopes").value = "read,write,approve"; + document.getElementById("ct-expires").value = ""; + document.getElementById("ct-submit").disabled = false; + document.getElementById("ct-submit").textContent = "Create"; + _ctTrapHandler = _installTrap("create-token-overlay", "create-token-box"); + setTimeout(function () { + document.getElementById("ct-name").focus(); + }, 50); +} + +function hideCreateTokenModal() { + document.getElementById("create-token-overlay").style.display = "none"; + _ctTrapHandler = _removeTrap(_ctTrapHandler); +} + +function submitCreateToken() { + var name = (document.getElementById("ct-name").value || "").trim(); + var scopes = document.getElementById("ct-scopes").value; + var expiresDays = document.getElementById("ct-expires").value; + var errEl = document.getElementById("create-token-error"); + + var btn = document.getElementById("ct-submit"); + btn.disabled = true; + btn.textContent = "Creating\u2026"; + + var body = { name: name, scopes: scopes }; + if (expiresDays) body.expires_days = parseInt(expiresDays, 10); + + authFetch( + "/v1/api/admin/users/" + encodeURIComponent(_adminTokenUserId) + "/tokens", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function (data) { + hideCreateTokenModal(); + _lastCreatedToken = data.token; + showTokenCreatedModal(data.token); + loadAdminTokens(); + }) + .catch(function (err) { + btn.disabled = false; + btn.textContent = "Create"; + _showModalError(errEl, err.message || "Failed to create token"); + }); +} + +// --------------------------------------------------------------------------- +// Token Created Modal (show-once) +// --------------------------------------------------------------------------- + +function showTokenCreatedModal(token) { + document.getElementById("token-created-value").textContent = token; + document.getElementById("token-created-overlay").style.display = "flex"; + _tcTrapHandler = _installTrap("token-created-overlay", "token-created-box"); +} + +function hideTokenCreatedModal() { + document.getElementById("token-created-overlay").style.display = "none"; + _tcTrapHandler = _removeTrap(_tcTrapHandler); + _lastCreatedToken = ""; +} + +function copyCreatedToken() { + if (!_lastCreatedToken) return; + if (navigator.clipboard) { + navigator.clipboard.writeText(_lastCreatedToken).then(function () { + showToast("Token copied to clipboard"); + }); + } else { + // Fallback: select the text + var el = document.getElementById("token-created-value"); + var range = document.createRange(); + range.selectNodeContents(el); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + showToast("Select and copy the token"); + } +} + +// --------------------------------------------------------------------------- +// Modal focus trap + keyboard +// --------------------------------------------------------------------------- + +function _modalFocusTrap(boxId) { + return function (e) { + if (e.key === "Tab") { + var box = document.getElementById(boxId); + if (!box) return; + var focusable = box.querySelectorAll( + "input:not([disabled]), select:not([disabled]), button:not([disabled])", + ); + var visible = []; + for (var i = 0; i < focusable.length; i++) { + if (focusable[i].offsetParent !== null) visible.push(focusable[i]); + } + if (visible.length === 0) return; + var first = visible[0]; + var last = visible[visible.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; +} + +function _installTrap(overlayId, boxId, trapRef) { + var overlay = document.getElementById(overlayId); + if (overlay) { + overlay.onclick = function (e) { + if (e.target === overlay) { + if (overlayId === "create-user-overlay") hideCreateUserModal(); + else if (overlayId === "create-token-overlay") hideCreateTokenModal(); + else if (overlayId === "token-created-overlay") hideTokenCreatedModal(); + } + }; + } + document.body.style.overflow = "hidden"; + var handler = _modalFocusTrap(boxId); + document.addEventListener("keydown", handler); + return handler; +} + +function _removeTrap(handler) { + if (handler) document.removeEventListener("keydown", handler); + document.body.style.overflow = ""; + return null; +} + +// Global Escape key for admin modals +document.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + var cu = document.getElementById("create-user-overlay"); + if (cu && cu.style.display !== "none") { + e.preventDefault(); + hideCreateUserModal(); + return; + } + var ct = document.getElementById("create-token-overlay"); + if (ct && ct.style.display !== "none") { + e.preventDefault(); + hideCreateTokenModal(); + return; + } + var tc = document.getElementById("token-created-overlay"); + if (tc && tc.style.display !== "none") { + e.preventDefault(); + hideTokenCreatedModal(); + return; + } +}); + +// Tab arrow key navigation +(function () { + var tablist = document.querySelector(".admin-tabs"); + if (!tablist) return; + tablist.addEventListener("keydown", function (e) { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + var tabOrder = ["users", "tokens"]; + var idx = tabOrder.indexOf(_adminTab); + if (e.key === "ArrowRight") idx = (idx + 1) % tabOrder.length; + else idx = (idx - 1 + tabOrder.length) % tabOrder.length; + switchAdminTab(tabOrder[idx]); + var btn = document.querySelector( + '.admin-tab[data-tab="' + tabOrder[idx] + '"]', + ); + if (btn) btn.focus(); + }); +})(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function _showModalError(el, msg) { + el.textContent = msg; + el.style.display = "block"; +} diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index a4b2565d..d6f89f30 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -68,6 +68,9 @@ function connectSSE() { evtSource.onerror = function () { evtSource.close(); evtSource = null; + // Don't show reconnecting state if login overlay is visible + var loginOverlay = document.getElementById("login-overlay"); + if (loginOverlay && loginOverlay.style.display !== "none") return; statusBar.textContent = "Reconnecting\u2026"; statusBar.classList.add("disconnected"); var csb = document.getElementById("cluster-status-bar"); @@ -126,6 +129,8 @@ function showOverview() { document.getElementById("view-overview").style.display = ""; document.getElementById("view-node").style.display = "none"; document.getElementById("view-filtered").style.display = "none"; + var adminView = document.getElementById("view-admin"); + if (adminView) adminView.style.display = "none"; document.getElementById("breadcrumb").style.display = "none"; document.getElementById("main").scrollTop = 0; loadOverview(); @@ -637,6 +642,8 @@ function drillDownToNode(nodeId, serverUrl) { document.getElementById("view-overview").style.display = "none"; document.getElementById("view-node").style.display = ""; document.getElementById("view-filtered").style.display = "none"; + var adminView = document.getElementById("view-admin"); + if (adminView) adminView.style.display = "none"; document.getElementById("breadcrumb").style.display = ""; document.getElementById("breadcrumb-label").textContent = nodeId; var link = document.getElementById("node-link"); @@ -685,6 +692,8 @@ function drillDownByState(state) { document.getElementById("view-overview").style.display = "none"; document.getElementById("view-node").style.display = "none"; document.getElementById("view-filtered").style.display = ""; + var adminView = document.getElementById("view-admin"); + if (adminView) adminView.style.display = "none"; document.getElementById("breadcrumb").style.display = ""; var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; document.getElementById("breadcrumb-label").textContent = @@ -703,6 +712,8 @@ function drillDownByNode(nodeId) { document.getElementById("view-overview").style.display = "none"; document.getElementById("view-node").style.display = "none"; document.getElementById("view-filtered").style.display = ""; + var adminView = document.getElementById("view-admin"); + if (adminView) adminView.style.display = "none"; document.getElementById("breadcrumb").style.display = ""; document.getElementById("breadcrumb-label").textContent = nodeId; document.getElementById("filtered-title").textContent = @@ -915,6 +926,8 @@ window.addEventListener("popstate", function (e) { return; } if (e.state.view === "overview") showOverview(); + else if (e.state.view === "admin" && typeof showAdmin === "function") + showAdmin(); else if (e.state.view === "node" && e.state.nodeId) drillDownToNode(e.state.nodeId, e.state.serverUrl); else if (e.state.view === "filtered" && e.state.filter) { @@ -1082,8 +1095,15 @@ document.addEventListener("keydown", function (e) { }); // --- Init --- +// SSE connects after auth is confirmed — either via onLoginSuccess after +// login, or after the first successful data load (page refresh with valid cookie). +var _sseStarted = false; +function _ensureSSE() { + if (!_sseStarted) { + _sseStarted = true; + connectSSE(); + } +} history.replaceState({ view: "overview" }, ""); initLogin(); -connectSSE(); loadOverview(); -// Try loading — if auth required, login overlay will show diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 7c6a0093..6e39cf25 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -16,6 +16,7 @@ + @@ -72,6 +73,54 @@
+ + +
@@ -123,6 +172,67 @@ window.TURNSTONE_KB_SHORTCUTS = [
+ + + + + + + + + + diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index 9ba8b543..e0269c07 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -678,6 +678,287 @@ #header h1 { font-size: 13px; } } +/* ========================================================================== + Admin panel + ========================================================================== */ + +.admin-tabs { + display: flex; + gap: 2px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} +.admin-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--fg-dim); + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 8px 16px 10px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.admin-tab:hover { color: var(--fg); } +.admin-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.admin-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.admin-action-btn { + margin-left: auto; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + padding: 6px 14px; + cursor: pointer; + letter-spacing: 0.02em; + transition: filter 0.15s; +} +.admin-action-btn:hover { filter: brightness(1.1); } +.admin-action-btn:focus-visible { outline: 2px solid var(--fg-bright); outline-offset: 2px; } +.admin-action-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.admin-toolbar select { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font: inherit; + font-size: 12px; + padding: 5px 30px 5px 10px; + min-width: 180px; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a93ad' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} +.admin-toolbar select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-dim); } + +/* Admin table grid */ +.admin-colheaders { + display: grid; + padding: 0 12px; + margin-bottom: 4px; +} +.admin-colheaders .admin-col { + font-family: var(--font-display); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); +} +.admin-row { + display: grid; + padding: 8px 12px; + align-items: center; + border-radius: var(--radius-sm); + transition: background 0.1s; +} +.admin-row:nth-child(even) { background: var(--row-alt, rgba(255,255,255,0.015)); } +.admin-row:hover { background: var(--bg-highlight); } + +.admin-col { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-col code { font-family: var(--font-mono); font-size: 11px; color: var(--fg-dim); } + +/* Users grid: USERNAME | NAME | CREATED | ACTIONS */ +#admin-users .admin-colheaders, +#admin-users .admin-row { + grid-template-columns: 140px 1fr 100px 80px; +} + +/* Tokens grid: PREFIX | NAME | SCOPES | CREATED | EXPIRES | ACTIONS */ +#admin-tokens .admin-colheaders, +#admin-tokens .admin-row { + grid-template-columns: 100px 1fr 160px 100px 100px 80px; +} + +/* Scope badges */ +.scope-badge { + display: inline-block; + font-family: var(--font-display); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 1px 6px; + border-radius: 2px; + margin-right: 3px; + background: var(--bg-highlight); + color: var(--fg-dim); + border: 1px solid var(--border); +} +.scope-write { color: var(--cyan); border-color: rgba(103, 232, 249, 0.2); } +.scope-approve { color: var(--accent); border-color: var(--accent-dim); } + +/* Action buttons */ +.admin-btn-danger { + background: none; + border: 1px solid var(--red); + color: var(--red); + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0.8; + transition: opacity 0.15s, background 0.15s; +} +.admin-btn-danger:hover { opacity: 1; background: rgba(248, 113, 113, 0.1); } +.admin-btn-danger:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; } + +/* Admin modals (reuse new-ws-overlay pattern) */ +.admin-modal { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 32px; + width: 380px; + max-width: 90vw; + box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 80px -20px var(--accent-dim); + position: relative; +} +.admin-modal::before { + content: ''; + position: absolute; + top: -1px; left: 20%; right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} +.admin-modal h2 { + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + color: var(--accent); + margin-bottom: 16px; + letter-spacing: 0.02em; +} +.admin-modal label { + display: block; + font-family: var(--font-display); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); + margin-bottom: 5px; + margin-top: 12px; +} +.admin-modal label:first-of-type { margin-top: 0; } +.admin-modal input, .admin-modal select { + width: 100%; + padding: 9px 12px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--fg); + font: inherit; + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.admin-modal input:focus, .admin-modal select:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-dim); +} +.admin-modal input::placeholder { color: var(--fg-dim); opacity: 0.6; } +.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } + +.modal-buttons { display: flex; gap: 10px; margin-top: 20px; } +.modal-cancel { + flex: 1; + padding: 9px; + background: var(--bg-highlight); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font: inherit; + font-family: var(--font-display); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} +.modal-cancel:hover { background: var(--bg-elevated); } +.modal-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.modal-submit { + flex: 1; + padding: 9px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font: inherit; + font-family: var(--font-display); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: filter 0.15s; +} +.modal-submit:hover { filter: brightness(1.1); } +.modal-submit:focus-visible { outline: 2px solid var(--fg-bright); outline-offset: 2px; } +.modal-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } + +#create-user-overlay, #create-token-overlay, #token-created-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; +} + +/* Token display (show-once) */ +.token-created-warning { + font-family: var(--font-display); + font-size: 11px; + color: var(--yellow); + margin-bottom: 12px; +} +.token-display { + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-bright); + background: var(--bg); + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + word-break: break-all; + user-select: all; +} + +@media (max-width: 700px) { + #admin-users .admin-colheaders, #admin-users .admin-row { + grid-template-columns: 100px 1fr 80px; + } + .admin-col-created { display: none; } + #admin-tokens .admin-colheaders, #admin-tokens .admin-row { + grid-template-columns: 80px 1fr 100px 80px; + } + .admin-col-created, .admin-col-expires { display: none; } +} + /* ========================================================================== Reduced motion — console-specific ========================================================================== */ @@ -688,4 +969,7 @@ .node-link, .dash-cell-node, .pagination button { transition: none; } .dash-row.has-link::after, .node-group-header::before { transition: none; } #new-ws-box select, #new-ws-box input, #new-ws-buttons button { transition: none; } + .admin-tab, .admin-row, .admin-btn-danger { transition: none; } + .admin-action-btn, .modal-cancel, .modal-submit { transition: none; } + .admin-modal input, .admin-modal select { transition: none; } } diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index c1dcc38c..3d480267 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -1,42 +1,93 @@ """Bearer token authentication and authorization for turnstone HTTP servers. -Opt-in via the ``[auth]`` section in ``config.toml``. When auth is disabled -(the default), all requests pass through unchecked. When enabled, API -requests must include a valid ``Authorization: Bearer `` header or -a ``turnstone_auth`` cookie (set via the ``/v1/api/auth/login`` endpoint). -Each token has a role: ``"read"`` or ``"full"``. +Supports three token types: + +1. **Config-file tokens** — static tokens in ``config.toml`` or the + ``TURNSTONE_AUTH_TOKEN`` env var. Validated in-memory via + ``hmac.compare_digest``. Map to scopes via their role. +2. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256 + hashes. Exchanged for JWTs via ``/api/auth/login``. +3. **JWTs** — short-lived session tokens issued after API token validation. + Validated locally via shared HMAC-SHA256 secret. Contain user_id and + scopes in claims. Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``, ``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are -always accessible without authentication. Paths under ``/v1/`` are -normalised by stripping the version prefix before classification so that -``/v1/api/send`` maps to ``/api/send`` in the path lists. +always accessible without authentication. """ from __future__ import annotations +import hashlib import hmac import logging import os +import re +import secrets +import time from dataclasses import dataclass, field +from typing import Any log = logging.getLogger(__name__) # --------------------------------------------------------------------------- -# Public / write path classification +# Constants # --------------------------------------------------------------------------- AUTH_COOKIE = "turnstone_auth" +TOKEN_PREFIX = "ts_" +TOKEN_BYTES = 32 # 64 hex chars after prefix + +VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve"}) + +_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$") +USERNAME_MAX_LEN = 64 + + +def is_valid_username(username: str) -> bool: + """Return True if *username* contains only safe characters (letters, digits, `.`, `_`, `-`).""" + return ( + bool(username) + and len(username) <= USERNAME_MAX_LEN + and _USERNAME_RE.match(username) is not None + ) + + +# Hierarchical: each scope implies all lower scopes. +SCOPE_HIERARCHY: dict[str, frozenset[str]] = { + "read": frozenset({"read"}), + "write": frozenset({"read", "write"}), + "approve": frozenset({"read", "write", "approve"}), +} + +# Map old role names to scope sets. +_ROLE_TO_SCOPES: dict[str, frozenset[str]] = { + "read": frozenset({"read"}), + "full": frozenset({"read", "write", "approve"}), +} + +# --------------------------------------------------------------------------- +# Path classification +# --------------------------------------------------------------------------- PUBLIC_PATHS: frozenset[str] = frozenset( - {"/", "/health", "/metrics", "/openapi.json", "/docs", "/api/auth/login", "/api/auth/logout"} + { + "/", + "/health", + "/metrics", + "/openapi.json", + "/docs", + "/api/auth/login", + "/api/auth/logout", + "/api/auth/status", + "/api/auth/setup", + } ) PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/") WRITE_PATHS: frozenset[str] = frozenset( { "/api/send", - "/api/approve", "/api/plan", "/api/command", "/api/workstreams/new", @@ -45,16 +96,37 @@ WRITE_PATHS: frozenset[str] = frozenset( } ) +APPROVE_PATHS: frozenset[str] = frozenset({"/api/approve"}) +ADMIN_PREFIX = "/api/admin/" + def _strip_version_prefix(path: str) -> str: - """Strip ``/v1`` prefix for path classification -- keeps path lists unversioned.""" + """Strip ``/v1`` prefix for path classification.""" if path.startswith("/v1/"): return path[3:] return path # --------------------------------------------------------------------------- -# AuthConfig +# AuthResult +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AuthResult: + """Result of successful authentication.""" + + user_id: str # empty string for config-file tokens + scopes: frozenset[str] + token_source: str # "config", "jwt", "database" + + def has_scope(self, scope: str) -> bool: + """Return True if this result includes *scope*.""" + return scope in self.scopes + + +# --------------------------------------------------------------------------- +# AuthConfig (unchanged from before — static config-file tokens) # --------------------------------------------------------------------------- @@ -66,7 +138,7 @@ class AuthConfig: tokens: dict[str, str] = field(default_factory=dict) # token_value → role def check(self, token: str | None) -> str | None: - """Return the role (``"read"`` or ``"full"``) for a valid token, or *None*.""" + """Return the role for a valid config token, or *None*.""" if not token: return None for known_token, role in self.tokens.items(): @@ -75,6 +147,124 @@ class AuthConfig: return None +# --------------------------------------------------------------------------- +# Token generation and hashing +# --------------------------------------------------------------------------- + + +def generate_token() -> str: + """Generate a new API token: ``ts_`` + 64 hex chars (32 random bytes).""" + return TOKEN_PREFIX + secrets.token_hex(TOKEN_BYTES) + + +def hash_token(token: str) -> str: + """Return the SHA-256 hex digest of *token*.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def token_prefix(token: str) -> str: + """Return the first 8 characters of a raw token (for display in listings).""" + return token[:8] + + +# --------------------------------------------------------------------------- +# Password hashing (bcrypt) +# --------------------------------------------------------------------------- + + +def hash_password(password: str) -> str: + """Hash a password with bcrypt. Returns the hash as a string.""" + import bcrypt + + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + """Verify a password against a bcrypt hash.""" + import bcrypt + + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def parse_scopes(scopes_str: str) -> frozenset[str]: + """Parse comma-separated scopes and expand via hierarchy. + + ``"approve"`` expands to ``{"read", "write", "approve"}``. + """ + raw = {s.strip() for s in scopes_str.split(",") if s.strip()} + expanded: set[str] = set() + for scope in raw: + expanded |= SCOPE_HIERARCHY.get(scope, frozenset({scope})) + return frozenset(expanded & VALID_SCOPES) + + +# --------------------------------------------------------------------------- +# JWT helpers +# --------------------------------------------------------------------------- + + +def load_jwt_secret() -> str: + """Load JWT signing secret from env or config, or auto-generate.""" + secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip() + if secret: + return secret + + from turnstone.core.config import load_config + + auth_cfg = load_config("auth") + secret = str(auth_cfg.get("jwt_secret", "")).strip() + if secret: + return secret + + # Auto-generate an ephemeral secret + secret = secrets.token_hex(32) + log.warning( + "No JWT secret configured — using ephemeral secret (tokens will not survive restart)" + ) + return secret + + +def create_jwt( + user_id: str, + scopes: frozenset[str], + source: str, + secret: str, + expiry_hours: int = 24, +) -> str: + """Create a signed JWT with user identity and scopes.""" + import jwt + + now = int(time.time()) + payload = { + "sub": user_id, + "scopes": ",".join(sorted(scopes)), + "src": source, + "iat": now, + "exp": now + expiry_hours * 3600, + } + return jwt.encode(payload, secret, algorithm="HS256") + + +def validate_jwt(token: str, secret: str) -> AuthResult | None: + """Validate a JWT and return an AuthResult, or None on failure.""" + import jwt + + try: + payload = jwt.decode(token, secret, algorithms=["HS256"]) + except jwt.InvalidTokenError: + return None + + user_id = payload.get("sub", "") + scopes_str = payload.get("scopes", "") + source = payload.get("src", "jwt") + + return AuthResult( + user_id=user_id, + scopes=parse_scopes(scopes_str), + token_source=source, + ) + + # --------------------------------------------------------------------------- # Loading # --------------------------------------------------------------------------- @@ -83,24 +273,28 @@ class AuthConfig: def load_auth_config() -> AuthConfig: """Build :class:`AuthConfig` from ``config.toml`` ``[auth]`` + env vars. + Auth is **enabled by default**. Set ``[auth] enabled = false`` or + ``TURNSTONE_AUTH_ENABLED=0`` to disable. + Config format:: [auth] - enabled = true + enabled = false # opt out [[auth.tokens]] value = "tok_abc123" role = "full" - Environment variable fallbacks: + Environment variables: - - ``TURNSTONE_AUTH_ENABLED=1`` — enables auth + - ``TURNSTONE_AUTH_ENABLED=0`` — disables auth + - ``TURNSTONE_AUTH_ENABLED=1`` — enables auth (default) - ``TURNSTONE_AUTH_TOKEN=`` — registers a single full-access token """ from turnstone.core.config import load_config auth_cfg = load_config("auth") - enabled = bool(auth_cfg.get("enabled", False)) + enabled = bool(auth_cfg.get("enabled", True)) tokens: dict[str, str] = {} # Tokens from config file (TOML array-of-tables) @@ -110,16 +304,19 @@ def load_auth_config() -> AuthConfig: if value and role in ("read", "full"): tokens[value] = role - # Environment variable fallbacks - if os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip() in ("1", "true", "yes"): + # Environment variable overrides + env_enabled = os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip().lower() + if env_enabled in ("1", "true", "yes"): enabled = True + elif env_enabled in ("0", "false", "no"): + enabled = False env_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "").strip() if env_token: tokens[env_token] = "full" if enabled and not tokens: - log.warning("Auth enabled but no tokens configured — all API requests will be rejected") + log.info("Auth enabled (no config tokens — use /api/auth/setup or turnstone-admin)") return AuthConfig(enabled=enabled, tokens=tokens) @@ -137,37 +334,61 @@ def is_public_path(path: str) -> bool: return any(normalized.startswith(prefix) for prefix in PUBLIC_PREFIXES) -def required_role(method: str, path: str) -> str: - """Return the minimum role needed for *method* + *path*. +def required_scope(method: str, path: str) -> str: + """Return the minimum scope needed for *method* + *path*. - Returns ``"full"`` for state-modifying POST endpoints, ``"read"`` otherwise. - Handles console proxy routes (``/node/{id}/api/...``) by extracting the - proxied path and checking it against ``WRITE_PATHS``. + Returns ``"approve"`` for the approve endpoint and admin paths, + ``"write"`` for other state-modifying POST endpoints, ``"read"`` otherwise. """ normalized = _strip_version_prefix(path) normalized = normalized.rstrip("/") if normalized != "/" else normalized + + # Admin endpoints require approve scope + if normalized.startswith(ADMIN_PREFIX): + return "approve" + + # Approve endpoint + if method == "POST" and normalized in APPROVE_PATHS: + return "approve" + + # Write endpoints if method == "POST" and normalized in WRITE_PATHS: - return "full" + return "write" + # Console proxy routes: /node/{node_id}/api/{tail} or /node/{node_id}/v1/api/{tail} if method == "POST" and normalized.startswith("/node/"): - parts = normalized.split("/", 4) # ['', 'node', '{id}', 'api'|'v1', ...] - if len(parts) >= 5: - if parts[3] == "api": - proxied_path = "/api/" + parts[4] - if proxied_path in WRITE_PATHS: - return "full" - elif parts[3] == "v1": - # /node/{id}/v1/api/{tail} — re-split the remainder - remainder = parts[4] # "api/send" etc. - if remainder.startswith("api/"): - proxied_path = "/api/" + remainder[4:] - if proxied_path in WRITE_PATHS: - return "full" + proxied = _extract_proxied_path(normalized) + if proxied: + if proxied in APPROVE_PATHS: + return "approve" + if proxied in WRITE_PATHS: + return "write" + return "read" +def required_role(method: str, path: str) -> str: + """Return the minimum role needed (legacy — maps scope to old role name).""" + scope = required_scope(method, path) + return "full" if scope in ("write", "approve") else "read" + + +def _extract_proxied_path(normalized: str) -> str | None: + """Extract the inner API path from a console proxy route.""" + parts = normalized.split("/", 4) # ['', 'node', '{id}', 'api'|'v1', ...] + if len(parts) < 5: + return None + if parts[3] == "api": + return "/api/" + parts[4] + if parts[3] == "v1": + remainder = parts[4] + if remainder.startswith("api/"): + return "/api/" + remainder[4:] + return None + + # --------------------------------------------------------------------------- -# Request checking — single entry point for HTTP handlers +# Request checking # --------------------------------------------------------------------------- @@ -177,36 +398,108 @@ def check_request( path: str, auth_header: str | None, cookie_header: str | None = None, -) -> tuple[bool, int, str]: + *, + jwt_secret: str = "", + storage: Any = None, +) -> tuple[bool, int, str, AuthResult | None]: """Validate a request against the auth config. Checks ``Authorization: Bearer `` first, then falls back to the - ``turnstone_auth`` cookie (set by ``/api/auth/login``). + ``turnstone_auth`` cookie. Token types are auto-detected: - Returns ``(allowed, status_code, message)``. - On success: ``(True, 200, "")``. - On failure: ``(False, 401|403, "error message")``. + - Contains ``.`` → JWT (validated with *jwt_secret*) + - Starts with ``ts_`` → API token (looked up in *storage* by hash) + - Otherwise → config-file token (hmac check) + + Returns ``(allowed, status_code, message, auth_result)``. """ if not auth_config.enabled: - return True, 200, "" + return True, 200, "", None if is_public_path(path): - return True, 200, "" + return True, 200, "", None - # Try Bearer header first, then cookie - token = _extract_bearer(auth_header) - if token is None: - token = _extract_cookie(cookie_header, AUTH_COOKIE) + # Extract token from header or cookie + raw_token = _extract_bearer(auth_header) + if raw_token is None: + raw_token = _extract_cookie(cookie_header, AUTH_COOKIE) + + if not raw_token: + return False, 401, "Unauthorized: missing or invalid token", None + + # Authenticate + result = _authenticate_token(raw_token, auth_config, jwt_secret=jwt_secret, storage=storage) + if result is None: + return False, 401, "Unauthorized: missing or invalid token", None + + # Check scope + needed = required_scope(method, path) + if not result.has_scope(needed): + return False, 403, f"Forbidden: token lacks '{needed}' scope", None + + return True, 200, "", result + + +def _authenticate_token( + token: str, + auth_config: AuthConfig, + *, + jwt_secret: str = "", + storage: Any = None, +) -> AuthResult | None: + """Identify token type and authenticate it.""" + # 1. JWT (contains dots) — attempt validation, fall through on failure + if "." in token and jwt_secret: + try: + jwt_result = validate_jwt(token, jwt_secret) + except Exception: + jwt_result = None + if jwt_result is not None: + return jwt_result + + # 2. API token (starts with ts_) — look up in storage + if token.startswith(TOKEN_PREFIX) and storage is not None: + return _authenticate_api_token(token, storage) + + # 3. Config-file token (hmac comparison) role = auth_config.check(token) + if role is not None: + scopes = _ROLE_TO_SCOPES.get(role, frozenset({"read"})) + return AuthResult(user_id="", scopes=scopes, token_source="config") - if role is None: - return False, 401, "Unauthorized: missing or invalid token" + return None - needed = required_role(method, path) - if needed == "full" and role != "full": - return False, 403, "Forbidden: read-only token cannot access this endpoint" - return True, 200, "" +def _authenticate_api_token(token: str, storage: Any) -> AuthResult | None: + """Validate an API token against the database.""" + tok_hash = hash_token(token) + row = storage.get_api_token_by_hash(tok_hash) + if row is None: + return None + + # Check expiry + expires = row.get("expires") + if expires: + from datetime import UTC, datetime + + now = datetime.now(UTC) + try: + exp_dt = datetime.fromisoformat(expires).replace(tzinfo=UTC) + except (ValueError, TypeError): + return None # malformed expiry → treat as expired + if exp_dt < now: + return None + + return AuthResult( + user_id=row["user_id"], + scopes=parse_scopes(row["scopes"]), + token_source="database", + ) + + +# --------------------------------------------------------------------------- +# Token extraction helpers +# --------------------------------------------------------------------------- def _extract_bearer(header: str | None) -> str | None: @@ -220,10 +513,7 @@ def _extract_bearer(header: str | None) -> str | None: def _extract_cookie(cookie_header: str | None, name: str) -> str | None: - """Extract a named value from a ``Cookie`` header. - - Assumes token values are simple ASCII (no URL-encoding). - """ + """Extract a named value from a ``Cookie`` header.""" if not cookie_header: return None for pair in cookie_header.split(";"): @@ -241,11 +531,7 @@ def _extract_cookie(cookie_header: str | None, name: str) -> str | None: def make_set_cookie(token: str, max_age: int = 86400 * 30, secure: bool = False) -> str: - """Return a ``Set-Cookie`` header value that stores the auth token. - - Set *secure* to ``True`` when serving over HTTPS to add the ``Secure`` - flag (prevents cookie from being sent over plain HTTP). - """ + """Return a ``Set-Cookie`` header value that stores the auth token.""" val = f"{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" if secure: val += "; Secure" diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 15851084..8415abbd 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -9,11 +9,13 @@ from typing import Any import sqlalchemy as sa from turnstone.core.storage._schema import ( + api_tokens, conversations, memories, metadata, session_config, sessions, + users, workstreams, ) from turnstone.core.storage._sqlite import _reconstruct_messages @@ -44,6 +46,7 @@ class PostgreSQLBackend: title: str | None = None, node_id: str | None = None, ws_id: str | None = None, + user_id: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -59,6 +62,7 @@ class PostgreSQLBackend: "title": title, "node_id": node_id, "ws_id": ws_id, + "user_id": user_id, "created": now, "updated": now, }, @@ -354,6 +358,7 @@ class PostgreSQLBackend: node_id: str | None = None, name: str = "", state: str = "idle", + user_id: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -366,6 +371,7 @@ class PostgreSQLBackend: { "ws_id": ws_id, "node_id": node_id, + "user_id": user_id, "name": name, "state": state, "created": now, @@ -467,6 +473,209 @@ class PostgreSQLBackend: ).fetchall() ) + # -- User identity operations ----------------------------------------------- + + def create_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> None: + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + existing = conn.execute( + sa.select(users.c.user_id).where(users.c.user_id == user_id) + ).fetchone() + if not existing: + conn.execute( + sa.insert(users), + { + "user_id": user_id, + "username": username, + "display_name": display_name, + "password_hash": password_hash, + "created": now, + }, + ) + conn.commit() + + def create_first_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> bool: + """Atomically create a user only if no users exist. Returns True if created.""" + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + result = conn.execute( + sa.text( + "INSERT INTO users (user_id, username, display_name, password_hash, created) " + "SELECT :user_id, :username, :display_name, :password_hash, :created " + "WHERE NOT EXISTS (SELECT 1 FROM users)" + ), + { + "user_id": user_id, + "username": username, + "display_name": display_name, + "password_hash": password_hash, + "created": now, + }, + ) + conn.commit() + return result.rowcount > 0 + + def get_user(self, user_id: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.password_hash, + users.c.created, + ).where(users.c.user_id == user_id) + ).fetchone() + if row: + return { + "user_id": row[0], + "username": row[1], + "display_name": row[2], + "password_hash": row[3], + "created": row[4], + } + return None + + def get_user_by_username(self, username: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.password_hash, + users.c.created, + ).where(users.c.username == username) + ).fetchone() + if row: + return { + "user_id": row[0], + "username": row[1], + "display_name": row[2], + "password_hash": row[3], + "created": row[4], + } + return None + + def list_users(self) -> list[dict[str, str]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.created, + ).order_by(users.c.created.desc()) + ).fetchall() + return [ + {"user_id": r[0], "username": r[1], "display_name": r[2], "created": r[3]} + for r in rows + ] + + def delete_user(self, user_id: str) -> bool: + from turnstone.core.storage._schema import channel_users + + with self._engine.connect() as conn: + conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id)) + conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id)) + result = conn.execute(sa.delete(users).where(users.c.user_id == user_id)) + conn.commit() + return result.rowcount > 0 + + def create_api_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + user_id: str, + name: str, + scopes: str, + expires: str | None = None, + ) -> None: + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + conn.execute( + sa.insert(api_tokens), + { + "token_id": token_id, + "token_hash": token_hash, + "token_prefix": token_prefix, + "user_id": user_id, + "name": name, + "scopes": scopes, + "created": now, + "expires": expires, + }, + ) + conn.commit() + + def get_api_token_by_hash(self, token_hash: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + api_tokens.c.token_id, + api_tokens.c.token_prefix, + api_tokens.c.user_id, + api_tokens.c.name, + api_tokens.c.scopes, + api_tokens.c.created, + api_tokens.c.expires, + ).where(api_tokens.c.token_hash == token_hash) + ).fetchone() + if row: + result: dict[str, str] = { + "token_id": row[0], + "token_prefix": row[1], + "user_id": row[2], + "name": row[3], + "scopes": row[4], + "created": row[5], + } + if row[6] is not None: + result["expires"] = row[6] + return result + return None + + def list_api_tokens(self, user_id: str) -> list[dict[str, str]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select( + api_tokens.c.token_id, + api_tokens.c.token_prefix, + api_tokens.c.user_id, + api_tokens.c.name, + api_tokens.c.scopes, + api_tokens.c.created, + api_tokens.c.expires, + ) + .where(api_tokens.c.user_id == user_id) + .order_by(api_tokens.c.created.desc()) + ).fetchall() + result = [] + for r in rows: + entry: dict[str, str] = { + "token_id": r[0], + "token_prefix": r[1], + "user_id": r[2], + "name": r[3], + "scopes": r[4], + "created": r[5], + } + if r[6] is not None: + entry["expires"] = r[6] + result.append(entry) + return result + + def delete_api_token(self, token_id: str) -> bool: + with self._engine.connect() as conn: + result = conn.execute(sa.delete(api_tokens).where(api_tokens.c.token_id == token_id)) + conn.commit() + return result.rowcount > 0 + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 645a3c2d..b5e2ed80 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -21,6 +21,7 @@ class StorageBackend(Protocol): title: str | None = None, node_id: str | None = None, ws_id: str | None = None, + user_id: str | None = None, ) -> None: """Create a sessions row for a new session (no-op if already exists).""" ... @@ -114,6 +115,7 @@ class StorageBackend(Protocol): node_id: str | None = None, name: str = "", state: str = "idle", + user_id: str | None = None, ) -> None: """Create a workstreams row (no-op if already exists).""" ... @@ -144,6 +146,61 @@ class StorageBackend(Protocol): """Return most recent conversation messages.""" ... + # -- User identity operations ----------------------------------------------- + + def create_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> None: + """Create a user row. No-op if user_id already exists.""" + ... + + def create_first_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> bool: + """Atomically create a user only if no users exist. Returns True if created.""" + ... + + def get_user(self, user_id: str) -> dict[str, str] | None: + """Return user dict {user_id, username, display_name, password_hash, created} or None.""" + ... + + def get_user_by_username(self, username: str) -> dict[str, str] | None: + """Lookup user by username. Returns same dict as get_user or None.""" + ... + + def list_users(self) -> list[dict[str, str]]: + """Return all users ordered by created DESC.""" + ... + + def delete_user(self, user_id: str) -> bool: + """Delete user and cascade-delete all their tokens. Returns True if existed.""" + ... + + def create_api_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + user_id: str, + name: str, + scopes: str, + expires: str | None = None, + ) -> None: + """Store a hashed API token.""" + ... + + def get_api_token_by_hash(self, token_hash: str) -> dict[str, str] | None: + """Lookup token by SHA-256 hash. Returns dict with all columns or None.""" + ... + + def list_api_tokens(self, user_id: str) -> list[dict[str, str]]: + """List tokens for a user (no hash in results, prefix only).""" + ... + + def delete_api_token(self, token_id: str) -> bool: + """Revoke/delete a token by ID. Returns True if existed.""" + ... + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index fc0d7b17..b529dbeb 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -40,6 +40,7 @@ sessions = sa.Table( sa.Column("title", sa.Text), sa.Column("node_id", sa.Text), sa.Column("ws_id", sa.Text), + sa.Column("user_id", sa.Text), sa.Column("created", sa.Text, nullable=False), sa.Column("updated", sa.Text, nullable=False), ) @@ -49,12 +50,14 @@ sa.Index("idx_sessions_alias", sessions.c.alias) sa.Index("idx_sessions_updated", sessions.c.updated) sa.Index("idx_sessions_node_id", sessions.c.node_id) sa.Index("idx_sessions_ws_id", sessions.c.ws_id) +sa.Index("idx_sessions_user_id", sessions.c.user_id) workstreams = sa.Table( "workstreams", metadata, sa.Column("ws_id", sa.Text, primary_key=True), sa.Column("node_id", sa.Text), + sa.Column("user_id", sa.Text), sa.Column("name", sa.Text, nullable=False, server_default=""), sa.Column("state", sa.Text, nullable=False, server_default="idle"), sa.Column("created", sa.Text, nullable=False), @@ -63,6 +66,7 @@ workstreams = sa.Table( sa.Index("idx_workstreams_node_id", workstreams.c.node_id) sa.Index("idx_workstreams_state", workstreams.c.state) +sa.Index("idx_workstreams_user_id", workstreams.c.user_id) session_config = sa.Table( "session_config", @@ -72,3 +76,47 @@ session_config = sa.Table( sa.Column("value", sa.Text), sa.PrimaryKeyConstraint("session_id", "key"), ) + +# --------------------------------------------------------------------------- +# User identity tables +# --------------------------------------------------------------------------- + +users = sa.Table( + "users", + metadata, + sa.Column("user_id", sa.Text, primary_key=True), + sa.Column("username", sa.Text, nullable=False, unique=True), + sa.Column("display_name", sa.Text, nullable=False), + sa.Column("password_hash", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), +) + +sa.Index("idx_users_username", users.c.username) + +api_tokens = sa.Table( + "api_tokens", + metadata, + sa.Column("token_id", sa.Text, primary_key=True), + sa.Column("token_hash", sa.Text, nullable=False, unique=True), + sa.Column("token_prefix", sa.Text, nullable=False), + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("name", sa.Text, nullable=False, server_default=""), + sa.Column("scopes", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.Column("expires", sa.Text), +) + +sa.Index("idx_api_tokens_user_id", api_tokens.c.user_id) +sa.Index("idx_api_tokens_token_hash", api_tokens.c.token_hash) + +channel_users = sa.Table( + "channel_users", + metadata, + sa.Column("channel_type", sa.Text, nullable=False), + sa.Column("channel_user_id", sa.Text, nullable=False), + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.PrimaryKeyConstraint("channel_type", "channel_user_id"), +) + +sa.Index("idx_channel_users_user_id", channel_users.c.user_id) diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 4a0fc635..3b1bd94f 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -11,11 +11,13 @@ from typing import Any import sqlalchemy as sa from turnstone.core.storage._schema import ( + api_tokens, conversations, memories, metadata, session_config, sessions, + users, workstreams, ) @@ -88,6 +90,7 @@ class SQLiteBackend: title: str | None = None, node_id: str | None = None, ws_id: str | None = None, + user_id: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -98,6 +101,7 @@ class SQLiteBackend: "title": title, "node_id": node_id, "ws_id": ws_id, + "user_id": user_id, "created": now, "updated": now, }, @@ -423,6 +427,7 @@ class SQLiteBackend: node_id: str | None = None, name: str = "", state: str = "idle", + user_id: str | None = None, ) -> None: now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") with self._engine.connect() as conn: @@ -431,6 +436,7 @@ class SQLiteBackend: { "ws_id": ws_id, "node_id": node_id, + "user_id": user_id, "name": name, "state": state, "created": now, @@ -527,6 +533,205 @@ class SQLiteBackend: ).fetchall() ) + # -- User identity operations ----------------------------------------------- + + def create_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> None: + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + conn.execute( + sa.insert(users).prefix_with("OR IGNORE"), + { + "user_id": user_id, + "username": username, + "display_name": display_name, + "password_hash": password_hash, + "created": now, + }, + ) + conn.commit() + + def create_first_user( + self, user_id: str, username: str, display_name: str, password_hash: str + ) -> bool: + """Atomically create a user only if no users exist. Returns True if created.""" + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + result = conn.execute( + sa.text( + "INSERT INTO users (user_id, username, display_name, password_hash, created) " + "SELECT :user_id, :username, :display_name, :password_hash, :created " + "WHERE NOT EXISTS (SELECT 1 FROM users)" + ), + { + "user_id": user_id, + "username": username, + "display_name": display_name, + "password_hash": password_hash, + "created": now, + }, + ) + conn.commit() + return result.rowcount > 0 + + def get_user(self, user_id: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.password_hash, + users.c.created, + ).where(users.c.user_id == user_id) + ).fetchone() + if row: + return { + "user_id": row[0], + "username": row[1], + "display_name": row[2], + "password_hash": row[3], + "created": row[4], + } + return None + + def get_user_by_username(self, username: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.password_hash, + users.c.created, + ).where(users.c.username == username) + ).fetchone() + if row: + return { + "user_id": row[0], + "username": row[1], + "display_name": row[2], + "password_hash": row[3], + "created": row[4], + } + return None + + def list_users(self) -> list[dict[str, str]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select( + users.c.user_id, + users.c.username, + users.c.display_name, + users.c.created, + ).order_by(users.c.created.desc()) + ).fetchall() + return [ + {"user_id": r[0], "username": r[1], "display_name": r[2], "created": r[3]} + for r in rows + ] + + def delete_user(self, user_id: str) -> bool: + from turnstone.core.storage._schema import channel_users + + with self._engine.connect() as conn: + conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id)) + conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id)) + result = conn.execute(sa.delete(users).where(users.c.user_id == user_id)) + conn.commit() + return result.rowcount > 0 + + def create_api_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + user_id: str, + name: str, + scopes: str, + expires: str | None = None, + ) -> None: + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._engine.connect() as conn: + conn.execute( + sa.insert(api_tokens), + { + "token_id": token_id, + "token_hash": token_hash, + "token_prefix": token_prefix, + "user_id": user_id, + "name": name, + "scopes": scopes, + "created": now, + "expires": expires, + }, + ) + conn.commit() + + def get_api_token_by_hash(self, token_hash: str) -> dict[str, str] | None: + with self._engine.connect() as conn: + row = conn.execute( + sa.select( + api_tokens.c.token_id, + api_tokens.c.token_prefix, + api_tokens.c.user_id, + api_tokens.c.name, + api_tokens.c.scopes, + api_tokens.c.created, + api_tokens.c.expires, + ).where(api_tokens.c.token_hash == token_hash) + ).fetchone() + if row: + result: dict[str, str] = { + "token_id": row[0], + "token_prefix": row[1], + "user_id": row[2], + "name": row[3], + "scopes": row[4], + "created": row[5], + } + if row[6] is not None: + result["expires"] = row[6] + return result + return None + + def list_api_tokens(self, user_id: str) -> list[dict[str, str]]: + with self._engine.connect() as conn: + rows = conn.execute( + sa.select( + api_tokens.c.token_id, + api_tokens.c.token_prefix, + api_tokens.c.user_id, + api_tokens.c.name, + api_tokens.c.scopes, + api_tokens.c.created, + api_tokens.c.expires, + ) + .where(api_tokens.c.user_id == user_id) + .order_by(api_tokens.c.created.desc()) + ).fetchall() + result = [] + for r in rows: + entry: dict[str, str] = { + "token_id": r[0], + "token_prefix": r[1], + "user_id": r[2], + "name": r[3], + "scopes": r[4], + "created": r[5], + } + if r[6] is not None: + entry["expires"] = r[6] + result.append(entry) + return result + + def delete_api_token(self, token_id: str) -> bool: + with self._engine.connect() as conn: + result = conn.execute(sa.delete(api_tokens).where(api_tokens.c.token_id == token_id)) + conn.commit() + return result.rowcount > 0 + # -- Lifecycle ------------------------------------------------------------- def close(self) -> None: diff --git a/turnstone/core/storage/migrations/versions/002_user_identity.py b/turnstone/core/storage/migrations/versions/002_user_identity.py new file mode 100644 index 00000000..fb47c781 --- /dev/null +++ b/turnstone/core/storage/migrations/versions/002_user_identity.py @@ -0,0 +1,70 @@ +"""User identity and API tokens. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-03-04 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "002" +down_revision = "001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- New tables --- + + op.create_table( + "users", + sa.Column("user_id", sa.Text, primary_key=True), + sa.Column("username", sa.Text, nullable=False, unique=True), + sa.Column("display_name", sa.Text, nullable=False), + sa.Column("password_hash", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + ) + op.create_index("idx_users_username", "users", ["username"]) + + op.create_table( + "api_tokens", + sa.Column("token_id", sa.Text, primary_key=True), + sa.Column("token_hash", sa.Text, nullable=False, unique=True), + sa.Column("token_prefix", sa.Text, nullable=False), + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("name", sa.Text, nullable=False, server_default=""), + sa.Column("scopes", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.Column("expires", sa.Text), + ) + op.create_index("idx_api_tokens_user_id", "api_tokens", ["user_id"]) + op.create_index("idx_api_tokens_token_hash", "api_tokens", ["token_hash"]) + + op.create_table( + "channel_users", + sa.Column("channel_type", sa.Text, nullable=False), + sa.Column("channel_user_id", sa.Text, nullable=False), + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.PrimaryKeyConstraint("channel_type", "channel_user_id"), + ) + op.create_index("idx_channel_users_user_id", "channel_users", ["user_id"]) + + # --- Add user_id to existing tables --- + + op.add_column("sessions", sa.Column("user_id", sa.Text)) + op.create_index("idx_sessions_user_id", "sessions", ["user_id"]) + + op.add_column("workstreams", sa.Column("user_id", sa.Text)) + op.create_index("idx_workstreams_user_id", "workstreams", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("idx_workstreams_user_id", "workstreams") + op.drop_column("workstreams", "user_id") + op.drop_index("idx_sessions_user_id", "sessions") + op.drop_column("sessions", "user_id") + op.drop_table("channel_users") + op.drop_table("api_tokens") + op.drop_table("users") diff --git a/turnstone/sdk/console.py b/turnstone/sdk/console.py index 3f67115e..031588c0 100644 --- a/turnstone/sdk/console.py +++ b/turnstone/sdk/console.py @@ -21,7 +21,12 @@ from turnstone.api.console_schemas import ( ConsoleHealthResponse, NodeDetailResponse, ) -from turnstone.api.schemas import AuthLoginResponse, StatusResponse +from turnstone.api.schemas import ( + AuthLoginResponse, + AuthSetupResponse, + AuthStatusResponse, + StatusResponse, +) from turnstone.sdk._base import _BaseClient from turnstone.sdk._sync import _SyncRunner from turnstone.sdk.events import ClusterEvent @@ -125,14 +130,47 @@ class AsyncTurnstoneConsole(_BaseClient): # -- auth ---------------------------------------------------------------- - async def login(self, token: str) -> AuthLoginResponse: + async def login( + self, + token: str = "", + *, + username: str = "", + password: str = "", + ) -> AuthLoginResponse: + """Authenticate via API token or username:password.""" + if username and password: + body: dict[str, str] = {"username": username, "password": password} + else: + body = {"token": token} return await self._request( "POST", "/v1/api/auth/login", - json_body={"token": token}, + json_body=body, response_model=AuthLoginResponse, ) + async def auth_status(self) -> AuthStatusResponse: + """Get auth status (public -- no auth required).""" + return await self._request("GET", "/v1/api/auth/status", response_model=AuthStatusResponse) + + async def setup( + self, + username: str, + display_name: str, + password: str, + ) -> AuthSetupResponse: + """First-time setup: create initial admin user (public, one-time only).""" + return await self._request( + "POST", + "/v1/api/auth/setup", + json_body={ + "username": username, + "display_name": display_name, + "password": password, + }, + response_model=AuthSetupResponse, + ) + async def logout(self) -> StatusResponse: return await self._request("POST", "/v1/api/auth/logout", response_model=StatusResponse) @@ -217,8 +255,16 @@ class TurnstoneConsole: # -- auth ---------------------------------------------------------------- - def login(self, token: str) -> AuthLoginResponse: - return self._runner.run(self._async.login(token)) + def login( + self, token: str = "", *, username: str = "", password: str = "" + ) -> AuthLoginResponse: + return self._runner.run(self._async.login(token, username=username, password=password)) + + def auth_status(self) -> AuthStatusResponse: + return self._runner.run(self._async.auth_status()) + + def setup(self, username: str, display_name: str, password: str) -> AuthSetupResponse: + return self._runner.run(self._async.setup(username, display_name, password)) def logout(self) -> StatusResponse: return self._runner.run(self._async.logout()) diff --git a/turnstone/sdk/server.py b/turnstone/sdk/server.py index d0569a10..d717431e 100644 --- a/turnstone/sdk/server.py +++ b/turnstone/sdk/server.py @@ -16,7 +16,12 @@ import asyncio import contextlib from typing import TYPE_CHECKING, Any -from turnstone.api.schemas import AuthLoginResponse, StatusResponse +from turnstone.api.schemas import ( + AuthLoginResponse, + AuthSetupResponse, + AuthStatusResponse, + StatusResponse, +) from turnstone.api.server_schemas import ( CreateWorkstreamResponse, DashboardResponse, @@ -213,14 +218,47 @@ class AsyncTurnstoneServer(_BaseClient): # -- auth ---------------------------------------------------------------- - async def login(self, token: str) -> AuthLoginResponse: + async def login( + self, + token: str = "", + *, + username: str = "", + password: str = "", + ) -> AuthLoginResponse: + """Authenticate via API token or username:password.""" + if username and password: + body: dict[str, str] = {"username": username, "password": password} + else: + body = {"token": token} return await self._request( "POST", "/v1/api/auth/login", - json_body={"token": token}, + json_body=body, response_model=AuthLoginResponse, ) + async def auth_status(self) -> AuthStatusResponse: + """Get auth status (public -- no auth required).""" + return await self._request("GET", "/v1/api/auth/status", response_model=AuthStatusResponse) + + async def setup( + self, + username: str, + display_name: str, + password: str, + ) -> AuthSetupResponse: + """First-time setup: create initial admin user (public, one-time only).""" + return await self._request( + "POST", + "/v1/api/auth/setup", + json_body={ + "username": username, + "display_name": display_name, + "password": password, + }, + response_model=AuthSetupResponse, + ) + async def logout(self) -> StatusResponse: return await self._request("POST", "/v1/api/auth/logout", response_model=StatusResponse) @@ -326,8 +364,16 @@ class TurnstoneServer: # -- auth ---------------------------------------------------------------- - def login(self, token: str) -> AuthLoginResponse: - return self._runner.run(self._async.login(token)) + def login( + self, token: str = "", *, username: str = "", password: str = "" + ) -> AuthLoginResponse: + return self._runner.run(self._async.login(token, username=username, password=password)) + + def auth_status(self) -> AuthStatusResponse: + return self._runner.run(self._async.auth_status()) + + def setup(self, username: str, display_name: str, password: str) -> AuthSetupResponse: + return self._runner.run(self._async.setup(username, display_name, password)) def logout(self) -> StatusResponse: return self._runner.run(self._async.logout()) diff --git a/turnstone/server.py b/turnstone/server.py index 6c1ef521..fdfeb39b 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -355,15 +355,34 @@ class AuthMiddleware: from turnstone.core.auth import check_request auth_config = request.app.state.auth_config + jwt_secret = getattr(request.app.state, "jwt_secret", "") + storage = getattr(request.app.state, "auth_storage", None) method = request.method path = request.url.path auth_header = request.headers.get("Authorization") cookie_header = request.headers.get("Cookie") - allowed, status, msg = check_request(auth_config, method, path, auth_header, cookie_header) + allowed, status, msg, auth_result = check_request( + auth_config, + method, + path, + auth_header, + cookie_header, + jwt_secret=jwt_secret, + storage=storage, + ) if not allowed: response = JSONResponse({"error": msg}, status_code=status) await response(scope, receive, send) return + + # Set user_id in log context and stash auth result for handlers + if auth_result and auth_result.user_id: + from turnstone.core.log import ctx_user_id + + ctx_user_id.set(auth_result.user_id) + if "state" not in scope: + scope["state"] = {} + scope["state"]["auth_result"] = auth_result await self.app(scope, receive, send) @@ -914,18 +933,70 @@ async def close_workstream(request: Request) -> JSONResponse: async def auth_login(request: Request) -> Response: - """POST /v1/api/auth/login — authenticate with a token.""" - from turnstone.core.auth import make_set_cookie + """POST /v1/api/auth/login — authenticate and return JWT. + + Accepts either: + - ``{"username": "...", "password": "..."}`` — credential-based login + - ``{"token": "..."}`` — legacy token-based login + """ + from turnstone.core.auth import ( + AuthResult, + _authenticate_token, + create_jwt, + make_set_cookie, + verify_password, + ) body = await _read_json(request) - token = body.get("token", "") auth_config = request.app.state.auth_config - role = auth_config.check(token) - if role: - response = JSONResponse({"status": "ok", "role": role}) - response.headers["Set-Cookie"] = make_set_cookie(token) - return response - return JSONResponse({"error": "Invalid token"}, status_code=401) + jwt_secret = getattr(request.app.state, "jwt_secret", "") + storage = getattr(request.app.state, "auth_storage", None) + + result: AuthResult | None = None + username = body.get("username", "") + password = body.get("password", "") + + if username and password and storage is not None: + user = storage.get_user_by_username(username) + if user and verify_password(password, user["password_hash"]): + result = AuthResult( + user_id=user["user_id"], + scopes=frozenset({"read", "write", "approve"}), + token_source="password", + ) + elif body.get("token"): + result = _authenticate_token( + body["token"], + auth_config, + jwt_secret=jwt_secret, + storage=storage, + ) + + if result is None: + return JSONResponse({"error": "Invalid credentials"}, status_code=401) + + jwt_token = "" + if jwt_secret: + jwt_token = create_jwt( + user_id=result.user_id, + scopes=result.scopes, + source=result.token_source, + secret=jwt_secret, + ) + + role = "full" if result.has_scope("write") else "read" + scopes_str = ",".join(sorted(result.scopes)) + resp_body: dict[str, str] = {"status": "ok", "role": role, "scopes": scopes_str} + if jwt_token: + resp_body["jwt"] = jwt_token + if result.user_id: + resp_body["user_id"] = result.user_id + + response = JSONResponse(resp_body) + cookie_value = jwt_token if jwt_token else body.get("token", "") + if cookie_value: + response.headers["Set-Cookie"] = make_set_cookie(cookie_value) + return response async def auth_logout(request: Request) -> Response: @@ -937,6 +1008,94 @@ async def auth_logout(request: Request) -> Response: return response +async def auth_status(request: Request) -> JSONResponse: + """GET /v1/api/auth/status — public endpoint for login UI state detection.""" + auth_config = request.app.state.auth_config + storage = getattr(request.app.state, "auth_storage", None) + + has_users = False + if storage is not None: + try: + users = storage.list_users() + has_users = len(users) > 0 + except Exception: + pass + + return JSONResponse( + { + "auth_enabled": auth_config.enabled, + "has_users": has_users, + "setup_required": auth_config.enabled and not has_users, + } + ) + + +async def auth_setup(request: Request) -> JSONResponse: + """POST /v1/api/auth/setup — create first admin user (public, one-time only).""" + import uuid + + from turnstone.core.auth import create_jwt, hash_password, make_set_cookie + + storage = getattr(request.app.state, "auth_storage", None) + jwt_secret = getattr(request.app.state, "jwt_secret", "") + + if storage is None: + return JSONResponse({"error": "Storage not available"}, status_code=503) + + body = await _read_json(request) + username = body.get("username", "").strip() + display_name = body.get("display_name", "").strip() + password = body.get("password", "") + + from turnstone.core.auth import is_valid_username + + if not is_valid_username(username): + return JSONResponse( + {"error": "Invalid username (1-64 chars: letters, digits, . _ -)"}, + status_code=400, + ) + if not display_name: + return JSONResponse({"error": "display_name is required"}, status_code=400) + if len(password) < 8: + return JSONResponse({"error": "Password must be at least 8 characters"}, status_code=400) + + user_id = uuid.uuid4().hex + pw_hash = hash_password(password) + + # Atomic: insert only if no users exist (prevents TOCTOU race) + try: + created = storage.create_first_user(user_id, username, display_name, pw_hash) + except Exception: + return JSONResponse({"error": "Storage error"}, status_code=503) + if not created: + return JSONResponse({"error": "Setup already completed"}, status_code=409) + + scopes = frozenset({"read", "write", "approve"}) + jwt_token = "" + if jwt_secret: + jwt_token = create_jwt( + user_id=user_id, + scopes=scopes, + source="password", + secret=jwt_secret, + ) + + resp_body: dict[str, str] = { + "status": "ok", + "user_id": user_id, + "username": username, + "role": "full", + "scopes": ",".join(sorted(scopes)), + } + if jwt_token: + resp_body["jwt"] = jwt_token + + response = JSONResponse(resp_body) + if jwt_token: + response.headers["Set-Cookie"] = make_set_cookie(jwt_token) + return response + + # --------------------------------------------------------------------------- # Model auto-detection (shared with cli.py) # --------------------------------------------------------------------------- @@ -1045,6 +1204,8 @@ def create_app( global_listeners_lock: threading.Lock, skip_permissions: bool, auth_config: Any, + jwt_secret: str = "", + auth_storage: Any = None, health_monitor: Any = None, rate_limiter: Any = None, mcp_client: Any = None, @@ -1076,6 +1237,8 @@ def create_app( Route("/api/workstreams/close", close_workstream, methods=["POST"]), Route("/api/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), + Route("/api/auth/status", auth_status), + Route("/api/auth/setup", auth_setup, methods=["POST"]), ], ), Route("/health", health), @@ -1105,6 +1268,8 @@ def create_app( app.state.global_listeners_lock = global_listeners_lock app.state.skip_permissions = skip_permissions app.state.auth_config = auth_config + app.state.jwt_secret = jwt_secret + app.state.auth_storage = auth_storage app.state.health_monitor = health_monitor app.state.rate_limiter = rate_limiter app.state.mcp_client = mcp_client @@ -1500,11 +1665,13 @@ def main() -> None: _metrics.model = model # Auth config - from turnstone.core.auth import load_auth_config + from turnstone.core.auth import load_auth_config, load_jwt_secret + from turnstone.core.storage import get_storage auth_config = load_auth_config() + jwt_secret = load_jwt_secret() if auth_config.enabled else "" if auth_config.enabled: - log.info("Auth: enabled (%d token(s) configured)", len(auth_config.tokens)) + log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens)) # Build the ASGI app app = create_app( @@ -1514,6 +1681,8 @@ def main() -> None: global_listeners_lock=global_listeners_lock, skip_permissions=args.skip_permissions, auth_config=auth_config, + jwt_secret=jwt_secret, + auth_storage=get_storage(), health_monitor=health_monitor, rate_limiter=rate_limiter, mcp_client=mcp_client, diff --git a/turnstone/shared_static/auth.js b/turnstone/shared_static/auth.js index 82b6faaf..240481cb 100644 --- a/turnstone/shared_static/auth.js +++ b/turnstone/shared_static/auth.js @@ -1,10 +1,17 @@ /* Shared auth system — turnstone design system Configure: window.TURNSTONE_AUTH_TITLE (default "turnstone") - Hooks: window.onLoginSuccess() and window.onLogout() */ + Hooks: window.onLoginSuccess() and window.onLogout() + + Flows: + 1. Check /v1/api/auth/status — detect if setup is needed + 2. If setup_required → show first-time setup wizard (create admin user) + 3. If auth_enabled + has_users → show login (username:password) + 4. Legacy: token-based login still supported via toggle */ var _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone"; var _loginTrapHandler = null; var _loginBusy = false; +var _authMode = "login"; // "login", "setup", "token" async function authFetch(url, opts) { var maxRetries = 2; @@ -22,6 +29,10 @@ async function authFetch(url, opts) { }); continue; } + // Successful auth — ensure logout button and SSE connection + var _lb = document.getElementById("logout-btn"); + if (_lb) _lb.style.display = ""; + if (typeof _ensureSSE === "function") _ensureSSE(); return r; } } @@ -33,30 +44,131 @@ function initLogin() { overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-labelledby", "login-title"); - overlay.innerHTML = + overlay.innerHTML = _buildLoginHTML(); + document.body.appendChild(overlay); + _bindLoginEvents(); +} + +function _buildLoginHTML() { + return ( '
' + '

' + escapeHtml(_AUTH_TITLE) + "

" + + '' + '' + - '' + + // --- Setup mode fields --- + '" + + // --- Login mode fields --- + '
' + + '' + + '' + + '' + + '' + + "
" + + // --- Token mode fields --- + '" + '' + - "
"; - document.body.appendChild(overlay); - document.getElementById("login-submit").onclick = submitLogin; - document - .getElementById("login-token") - .addEventListener("keydown", function (e) { - if (e.key === "Enter") submitLogin(); - if (e.key === "Escape") { - var errEl = document.getElementById("login-error"); - if (errEl && errEl.style.display !== "none") { - errEl.style.display = "none"; - errEl.textContent = ""; - } - } + // --- Mode toggle --- + '" + + "" + ); +} + +function _bindLoginEvents() { + document.getElementById("login-submit").onclick = _handleSubmit; + + // Enter key on all inputs + var inputs = document.querySelectorAll("#login-box input"); + for (var i = 0; i < inputs.length; i++) { + inputs[i].addEventListener("keydown", function (e) { + if (e.key === "Enter") _handleSubmit(); + if (e.key === "Escape") _clearError(); }); + } + + // Mode toggle + document.getElementById("toggle-token").onclick = function () { + if (_authMode === "login") { + _switchMode("token"); + } else if (_authMode === "token") { + _switchMode("login"); + } + }; +} + +function _switchMode(mode) { + _authMode = mode; + var setupFields = document.getElementById("setup-fields"); + var loginFields = document.getElementById("login-fields"); + var tokenFields = document.getElementById("token-fields"); + var toggleDiv = document.getElementById("login-toggle"); + var toggleBtn = document.getElementById("toggle-token"); + var subtitle = document.getElementById("login-subtitle"); + var btn = document.getElementById("login-submit"); + + setupFields.style.display = "none"; + loginFields.style.display = "none"; + tokenFields.style.display = "none"; + _clearError(); + + if (mode === "setup") { + setupFields.style.display = ""; + toggleDiv.style.display = "none"; + subtitle.textContent = "Create the first admin account"; + btn.textContent = "Create account"; + setTimeout(function () { + document.getElementById("setup-username").focus(); + }, 50); + } else if (mode === "login") { + loginFields.style.display = ""; + toggleDiv.style.display = ""; + toggleBtn.textContent = "Use token instead"; + subtitle.textContent = ""; + btn.textContent = "Sign in"; + setTimeout(function () { + document.getElementById("login-username").focus(); + }, 50); + } else if (mode === "token") { + tokenFields.style.display = ""; + toggleDiv.style.display = ""; + toggleBtn.textContent = "Use password instead"; + subtitle.textContent = ""; + btn.textContent = "Sign in"; + setTimeout(function () { + document.getElementById("login-token").focus(); + }, 50); + } +} + +function _clearError() { + var errEl = document.getElementById("login-error"); + if (errEl && errEl.style.display !== "none") { + errEl.style.display = "none"; + errEl.textContent = ""; + } +} + +function _showError(msg) { + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.textContent = msg; + errEl.style.display = "block"; + } } function showLogin() { @@ -66,26 +178,42 @@ function showLogin() { document.body.style.overflow = "hidden"; var logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) logoutBtn.style.display = "none"; - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.style.display = "none"; - errEl.textContent = ""; - } - setTimeout(function () { - var inp = document.getElementById("login-token"); - if (inp) { - inp.value = ""; - inp.focus(); - } - }, 50); + _clearError(); + + // Check auth status to determine mode + fetch("/v1/api/auth/status") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data.setup_required) { + _switchMode("setup"); + } else { + _switchMode("login"); + } + }) + .catch(function () { + // Fallback to login mode + _switchMode("login"); + }); + + // Keyboard trap if (_loginTrapHandler) document.removeEventListener("keydown", _loginTrapHandler); _loginTrapHandler = function (e) { if (e.key === "Tab") { var box = document.getElementById("login-box"); - var focusable = box.querySelectorAll("input, button"); - var first = focusable[0]; - var last = focusable[focusable.length - 1]; + var focusable = box.querySelectorAll( + 'input:not([style*="display: none"]):not([style*="display:none"]), button:not([style*="display: none"]):not([style*="display:none"])', + ); + // Filter to visible elements + var visible = []; + for (var i = 0; i < focusable.length; i++) { + if (focusable[i].offsetParent !== null) visible.push(focusable[i]); + } + if (visible.length === 0) return; + var first = visible[0]; + var last = visible[visible.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); @@ -112,26 +240,59 @@ function hideLogin() { } } -function submitLogin() { +function _handleSubmit() { if (_loginBusy) return; - var token = (document.getElementById("login-token").value || "").trim(); - if (!token) { - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.textContent = "Token is required"; - errEl.style.display = "block"; - } - document.getElementById("login-token").focus(); + if (_authMode === "setup") return _submitSetup(); + if (_authMode === "token") return _submitToken(); + return _submitLogin(); +} + +function _submitLogin() { + var username = (document.getElementById("login-username").value || "").trim(); + var password = document.getElementById("login-password").value || ""; + + if (!username) { + _showError("Username is required"); + return; + } + if (!password) { + _showError("Password is required"); return; } - _loginBusy = true; - var btn = document.getElementById("login-submit"); - var inp = document.getElementById("login-token"); - btn.disabled = true; - btn.textContent = "Signing in\u2026"; - inp.disabled = true; + _setBusy(true); + fetch("/v1/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password }), + }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("invalid"); + if (!r.ok) throw new Error("server"); + return r.json(); + }) + .then(function () { + _setBusy(false); + _onSuccess(); + }) + .catch(function (err) { + _setBusy(false); + _showError( + err.message === "invalid" + ? "Invalid username or password" + : "Connection failed \u2014 try again", + ); + }); +} +function _submitToken() { + var token = (document.getElementById("login-token").value || "").trim(); + if (!token) { + _showError("Token is required"); + return; + } + + _setBusy(true); fetch("/v1/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -143,31 +304,100 @@ function submitLogin() { return r.json(); }) .then(function () { - _loginBusy = false; - btn.disabled = false; - btn.textContent = "Sign in"; - inp.disabled = false; - hideLogin(); - var logoutBtn = document.getElementById("logout-btn"); - if (logoutBtn) logoutBtn.style.display = ""; - if (typeof window.onLoginSuccess === "function") window.onLoginSuccess(); + _setBusy(false); + _onSuccess(); }) .catch(function (err) { - _loginBusy = false; - btn.disabled = false; - btn.textContent = "Sign in"; - inp.disabled = false; - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.textContent = - err.message === "invalid" - ? "Invalid token" - : "Connection failed \u2014 try again"; - errEl.style.display = "block"; - } + _setBusy(false); + _showError( + err.message === "invalid" + ? "Invalid token" + : "Connection failed \u2014 try again", + ); }); } +function _submitSetup() { + var username = (document.getElementById("setup-username").value || "").trim(); + var displayName = ( + document.getElementById("setup-displayname").value || "" + ).trim(); + var password = document.getElementById("setup-password").value || ""; + var confirm = document.getElementById("setup-confirm").value || ""; + + if (!username) { + _showError("Username is required"); + return; + } + if (!displayName) { + _showError("Display name is required"); + return; + } + if (!password) { + _showError("Password is required"); + return; + } + if (password.length < 8) { + _showError("Password must be at least 8 characters"); + return; + } + if (password !== confirm) { + _showError("Passwords do not match"); + return; + } + + _setBusy(true, "Creating account\u2026"); + + // Use the public setup endpoint (creates user + returns JWT in one step) + fetch("/v1/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: username, + display_name: displayName, + password: password, + }), + }) + .then(function (r) { + if (r.status === 409) throw new Error("Setup already completed"); + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed to create account"); + }); + return r.json(); + }) + .then(function () { + _setBusy(false); + _onSuccess(); + }) + .catch(function (err) { + _setBusy(false); + _showError(err.message || "Setup failed \u2014 try again"); + }); +} + +function _setBusy(busy, label) { + _loginBusy = busy; + var btn = document.getElementById("login-submit"); + var inputs = document.querySelectorAll("#login-box input"); + btn.disabled = busy; + if (busy) { + btn.textContent = label || "Signing in\u2026"; + } else { + btn.textContent = _authMode === "setup" ? "Create account" : "Sign in"; + } + for (var i = 0; i < inputs.length; i++) { + inputs[i].disabled = busy; + } +} + +function _onSuccess() { + hideLogin(); + var logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.style.display = ""; + if (typeof window.onLoginSuccess === "function") window.onLoginSuccess(); +} + function logout() { fetch("/v1/api/auth/logout", { method: "POST" }).then(function () { if (typeof window.onLogout === "function") window.onLogout(); diff --git a/turnstone/shared_static/base.css b/turnstone/shared_static/base.css index e8596a17..6c26b0fd 100644 --- a/turnstone/shared_static/base.css +++ b/turnstone/shared_static/base.css @@ -295,12 +295,11 @@ body { background: linear-gradient(90deg, transparent, var(--accent), transparent); border-radius: 1px; } -#login-box h2 { +#login-box > h2 { font-family: var(--font-display); color: var(--accent); font-size: 16px; font-weight: 700; - margin-bottom: 20px; letter-spacing: 0.02em; } #login-box input { @@ -317,7 +316,7 @@ body { } #login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-dim); } #login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; } -#login-box button { +#login-submit { width: 100%; padding: 11px; background: var(--accent); @@ -332,9 +331,44 @@ body { transition: filter 0.15s; letter-spacing: 0.02em; } -#login-box button:hover { filter: brightness(1.1); } -#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } -#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } +#login-submit:hover { filter: brightness(1.1); } +#login-submit:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } +#login-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } +.login-subtitle { + font-family: var(--font-display); + font-size: 11px; + color: var(--fg-dim); + margin-bottom: 18px; + letter-spacing: 0.02em; + min-height: 14px; +} +.login-label { + display: block; + font-family: var(--font-display); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); + margin-bottom: 5px; +} +.login-toggle { + text-align: center; + margin-top: 14px; +} +.login-link { + background: none; + border: none; + color: var(--fg-dim); + font-family: var(--font-display); + font-size: 11px; + cursor: pointer; + padding: 4px 8px; + letter-spacing: 0.02em; + transition: color 0.15s; +} +.login-link:hover { color: var(--accent); } +.login-link:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } #login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } @media (max-width: 380px) { #login-box { padding: 28px 20px; } } diff --git a/turnstone/shared_static/utils.js b/turnstone/shared_static/utils.js index 362709d7..4f839c98 100644 --- a/turnstone/shared_static/utils.js +++ b/turnstone/shared_static/utils.js @@ -3,7 +3,7 @@ function escapeHtml(text) { var el = document.createElement("span"); el.textContent = text; - return el.innerHTML; + return el.innerHTML.replace(/'/g, "'").replace(/"/g, """); } function formatTokens(n) { diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index 8d81c181..69900317 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -545,6 +545,8 @@ function connectContentSSE(wsId) { contentEvtSource.onerror = function () { contentEvtSource.close(); contentEvtSource = null; + var loginOverlay = document.getElementById("login-overlay"); + if (loginOverlay && loginOverlay.style.display !== "none") return; statusBar.textContent = "Reconnecting\u2026"; statusBar.classList.add("disconnected"); // Raw fetch (not authFetch) — need to inspect status before throwing