Add user identity, JWT auth, and admin console UI (#23)

* Add user identity, JWT auth, and admin console UI (#23)

JWT-based authentication with three token types: config-file (hmac,
backward-compat), API tokens (ts_ prefix, SHA-256 hashed), and JWTs
(HS256, 24h expiry). Username:password login via bcrypt. Hierarchical
scopes: read < write < approve.

New tables: users (username, password_hash), api_tokens (token_hash,
scopes, expires), channel_users (future channel integrations). user_id
column added to sessions and workstreams for attribution.

Console owns admin CRUD (6 endpoints under /api/admin/). Server
validates JWTs locally with shared signing secret. Public /api/auth/setup
endpoint for first-time admin creation (atomic, only works with zero
users). turnstone-admin CLI for user/token management.

Admin console UI: Users and Tokens tabs with full CRUD modals, scope
badges, token show-once with clipboard copy, keyboard accessibility
(focus traps, Escape, arrow key tabs, ARIA roles).

Login UI redesigned: username:password primary, token toggle for legacy,
setup wizard auto-detected via /api/auth/status. Python + TypeScript
SDKs updated with login(username, password), authStatus(), setup().

New docs/security.md + diagram 15-auth-architecture.puml. All existing
docs updated. OpenAPI specs include all new endpoints. 64 new tests
(1023 total). Dependencies: PyJWT, bcrypt.

* Fix auth bugs, XSS vector, and doc inaccuracies from PR #23 review

Address Copilot review feedback: escape double quotes in escapeHtml()
to prevent XSS in HTML attributes, add JWT validation fallback so
config tokens containing dots still work, add user_id to
AuthLoginResponse schema, return created field from admin_create_user,
and correct five documentation files to match actual API behavior.
This commit is contained in:
Patrick Buckley
2026-03-04 09:12:18 -08:00
committed by GitHub
parent 0fd0ad3b2d
commit 047680d669
42 changed files with 5024 additions and 253 deletions
+1
View File
@@ -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
+5
View File
@@ -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:
+164
View File
@@ -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 <token>`
- **Cookie**: `turnstone_auth=<token>` (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 /`
+92
View File
@@ -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 <token>` 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
+123 -4
View File
@@ -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.
---
+11 -2
View File
@@ -37,6 +37,11 @@ interface "StorageBackend" as SB <<protocol>> {
+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 <<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 <<migration>> {
class "migrations/" as Versions <<migration>> {
001_initial_schema.py
002_user_identity.py
}
' -- Registry --
+179
View File
@@ -0,0 +1,179 @@
@startuml
!theme plain
title Turnstone — Authentication Architecture
skinparam class {
BackgroundColor<<core>> #E8EAF6
BackgroundColor<<jwt>> #C8E6C9
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<endpoint>> #FFE0B2
BackgroundColor<<scope>> #F3E5F5
}
' -- Core Auth --
class "AuthConfig" as AC <<core>> {
+enabled: bool
+tokens: dict[str, str]
+check(token) → role | None
--
Static config-file tokens
hmac.compare_digest
}
class "AuthResult" as AR <<core>> {
+user_id: str
+scopes: frozenset[str]
+token_source: str
+has_scope(scope) → bool
}
class "check_request()" as CR <<core>> {
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 <<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 <<jwt>> {
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 <<core>> {
Raw value in memory
Role: "read" | "full"
--
Detected by: fallback
hmac.compare_digest
No DB needed
}
' -- Scopes --
class "Scope Hierarchy" as SH <<scope>> {
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 <<storage>> {
user_id (PK)
username (unique)
display_name
password_hash (bcrypt)
created
}
class "api_tokens" as TT <<storage>> {
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 <<endpoint>> {
{username, password}
OR {token: "ts_xxx"}
→ {jwt, role, scopes, user_id}
--
Sets HttpOnly cookie
}
class "GET /api/auth/status" as Status <<endpoint>> {
→ {auth_enabled, has_users,
setup_required}
--
Public (no auth)
Drives UI setup wizard
}
class "POST /api/auth/setup" as Setup <<endpoint>> {
{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 <<endpoint>> {
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
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd776a34b50d3fe194e2a34d3b4d04ec71af31e1154b5fc98223636b1844e6b8
size 226686
oid sha256:733aa17cbfdab60a601cac6adf439c657dd3535e3d6c33c69c2ef93ba8ec5989
size 251042
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4b2a2010335f986511c8dabaf49ec046ac02e577f9bc9924897e045f860bb13
size 248808
+20 -2
View File
@@ -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
+64 -15
View File
@@ -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.
+376
View File
@@ -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 <token>` header
and classifies the token:
1. **Contains `.`** → JWT → validate HS256 signature and expiry
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
3. **Otherwise** → config-file token → `hmac.compare_digest` against
each configured token
If a session cookie is present and no `Authorization` header is sent,
the cookie value is treated as a JWT (step 1).
---
## Password Storage
Passwords are hashed with **bcrypt** using a random salt per password.
Plaintext passwords are only accepted over HTTPS in production
deployments.
---
## Cookie Security
| Attribute | Value | Purpose |
|-----------|-------|---------|
| `HttpOnly` | `true` | Prevents JavaScript access |
| `SameSite` | `Lax` | CSRF protection |
| `Path` | `/` | Available to all routes |
| `Max-Age` | 30 days | Session lifetime |
| `Secure` | conditional | Set when served over HTTPS |
---
## JWT Configuration
| Setting | Config key | Env var | Default |
|---------|-----------|---------|---------|
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
| Algorithm | — | — | HS256 (not configurable) |
All service nodes that need to validate JWTs must share the same signing
secret. If no secret is configured, an ephemeral key is generated at
startup and a warning is logged — JWTs will not survive restarts or work
across nodes.
---
## Admin API Endpoints
All admin endpoints require `approve` scope.
### Users
| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/api/admin/users` | Create user (username, display_name, password) |
| GET | `/v1/api/admin/users` | List all users |
| DELETE | `/v1/api/admin/users/{user_id}` | Delete user and cascade tokens |
### API tokens
| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/api/admin/users/{user_id}/tokens` | Create API token (returns raw value once) |
| GET | `/v1/api/admin/users/{user_id}/tokens` | List tokens (prefix only, no hashes) |
| DELETE | `/v1/api/admin/tokens/{token_id}` | Revoke token |
---
## CLI Administration
The `turnstone-admin` command provides offline user and token management:
```
turnstone-admin create-user --username admin --name "Admin" [--password] [--token]
turnstone-admin create-token --user <user_id> --scopes read,write --name "CI bot"
turnstone-admin list-users
turnstone-admin list-tokens
turnstone-admin revoke-token <token_id>
```
When `--password` is omitted, the CLI prompts interactively. When
`--token` is passed to `create-user`, an API token is created alongside
the user and printed to stdout.
---
## Database Schema
```sql
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created TEXT NOT NULL
);
CREATE TABLE api_tokens (
token_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL, -- SHA-256 of raw token
token_prefix TEXT NOT NULL, -- first 8 chars for display
user_id TEXT NOT NULL REFERENCES users(user_id),
name TEXT NOT NULL,
scopes TEXT NOT NULL, -- comma-separated
created TEXT NOT NULL,
expires TEXT -- nullable, ISO 8601
);
CREATE UNIQUE INDEX ix_api_tokens_hash ON api_tokens(token_hash);
CREATE TABLE channel_users (
channel_type TEXT NOT NULL,
channel_user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(user_id),
created TEXT NOT NULL,
PRIMARY KEY (channel_type, channel_user_id)
);
```
The `sessions` and `workstreams` tables have a nullable `user_id`
column for attribution when auth is enabled.
---
## Revocation
- **API tokens**: Deleting a token via the admin API or CLI prevents new
JWTs from being issued with that token. Existing JWTs derived from the
token remain valid until they expire (at most 24 hours).
- **Config-file tokens**: Remove the token from `config.toml` and
restart the service. No JWTs are involved, so revocation is immediate.
- **JWTs**: Cannot be individually revoked. Rely on short expiry (24h)
and revoke the underlying credential to prevent renewal.
---
## Architecture
```
Console (cluster-wide) Server (per-node)
┌──────────────────────┐ ┌──────────────────────┐
│ User/Token CRUD (DB) │ │ JWT validation only │
│ Login: creds → JWT │ │ (shared signing key) │
│ Admin API endpoints │ │ Config tokens: hmac │
│ Storage: users, │ │ No auth DB needed │
│ api_tokens tables │ │ │
└──────────────────────┘ └──────────────────────┘
```
The console owns the credential database and handles all user/token
CRUD. Individual server nodes only need the JWT signing secret to
validate session tokens. Config-file tokens are validated locally
without any database.
### Proxy auth forwarding
When the console proxies requests to server nodes (via `/node/{id}/...`
routes), it extracts the user's JWT from the incoming request's cookie
or `Authorization` header and forwards it as `Authorization: Bearer`
to the upstream server. This means a single login on the console
grants access to all server UIs without re-authentication — the shared
`TURNSTONE_JWT_SECRET` ensures tokens are valid on every node.
---
## Configuration Reference
### config.toml
```toml
[auth]
enabled = true
jwt_secret = "your-secret-key-here"
jwt_expiry_hours = 24
[[auth.tokens]]
value = "tok_legacy"
role = "full"
```
### Environment variables
| Variable | Description |
|----------|-------------|
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
---
## Security Properties
- **Timing-safe comparison** for config-file tokens via
`hmac.compare_digest` — no timing side-channel.
- **Hash-based lookup** for API tokens — the database stores only
SHA-256 hashes, eliminating timing attacks on token comparison.
- **Local JWT validation** — no network call or database query needed
per request on server nodes.
- **One-time display** of raw API tokens at creation. The plaintext is
never stored; `token_hash` never appears in API responses or logs.
- **Structured logging audit trail** — `ctx_user_id` is set on every
authenticated request and injected into all log events.
- **Scope enforcement** at the middleware layer before any handler
executes. Path-to-scope mapping is defined statically.
+7
View File
@@ -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
+29 -3
View File
@@ -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<AuthLoginResponse> {
return this.request("POST", "/v1/api/auth/login", {
json: { token },
async login(opts: {
token?: string;
username?: string;
password?: string;
}): Promise<AuthLoginResponse> {
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<AuthStatusResponse> {
return this.request("GET", "/v1/api/auth/status");
}
async setup(opts: {
username: string;
displayName: string;
password: string;
}): Promise<AuthSetupResponse> {
return this.request("POST", "/v1/api/auth/setup", {
json: {
username: opts.username,
display_name: opts.displayName,
password: opts.password,
},
});
}
+2
View File
@@ -90,6 +90,8 @@ export type {
HealthResponse,
AuthLoginRequest,
AuthLoginResponse,
AuthStatusResponse,
AuthSetupResponse,
StatusResponse,
ErrorResponse,
ClusterOverviewResponse,
+29 -3
View File
@@ -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<AuthLoginResponse> {
return this.request("POST", "/v1/api/auth/login", {
json: { token },
async login(opts: {
token?: string;
username?: string;
password?: string;
}): Promise<AuthLoginResponse> {
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<AuthStatusResponse> {
return this.request("GET", "/v1/api/auth/status");
}
async setup(opts: {
username: string;
displayName: string;
password: string;
}): Promise<AuthSetupResponse> {
return this.request("POST", "/v1/api/auth/setup", {
json: {
username: opts.username,
display_name: opts.displayName,
password: opts.password,
},
});
}
+18
View File
@@ -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;
}
// ---------------------------------------------------------------------------
+63 -33
View File
@@ -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,
+357
View File
@@ -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")
+169
View File
@@ -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
+186
View File
@@ -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)
+81
View File
@@ -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,
+113 -3
View File
@@ -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
+22
View File
@@ -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,
+403 -21
View File
@@ -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 = "<style>.dashboard-overlay{top:32px!important}</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
+524
View File
@@ -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 =
'<div class="dashboard-empty">Failed to load users</div>';
});
}
function _renderUsers(users) {
var container = document.getElementById("admin-users-table");
if (!users.length) {
container.innerHTML =
'<div class="dashboard-empty">No users yet. Create one to get started.</div>';
return;
}
var html = "";
for (var i = 0; i < users.length; i++) {
var u = users[i];
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-username">' +
escapeHtml(u.username) +
"</span>" +
'<span class="admin-col admin-col-name">' +
escapeHtml(u.display_name) +
"</span>" +
'<span class="admin-col admin-col-created">' +
escapeHtml(u.created || "").slice(0, 10) +
"</span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-danger" data-delete-user="' +
escapeHtml(u.user_id) +
'" data-username="' +
escapeHtml(u.username) +
'" title="Delete user">delete</button>' +
"</span>" +
"</div>";
}
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 = '<option value="">Select user...</option>';
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 =
'<div class="dashboard-empty">Select a user to view tokens</div>';
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 =
'<div class="dashboard-empty">Failed to load tokens</div>';
});
}
function _renderTokens(tokens) {
var container = document.getElementById("admin-tokens-table");
if (!tokens.length) {
container.innerHTML =
'<div class="dashboard-empty">No tokens for this user</div>';
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 +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-prefix"><code>' +
escapeHtml(t.token_prefix) +
"\u2026</code></span>" +
'<span class="admin-col admin-col-tname">' +
escapeHtml(t.name || "\u2014") +
"</span>" +
'<span class="admin-col admin-col-scopes">' +
_renderScopeBadges(t.scopes) +
"</span>" +
'<span class="admin-col admin-col-created">' +
escapeHtml(t.created || "").slice(0, 10) +
"</span>" +
'<span class="admin-col admin-col-expires">' +
expires +
"</span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-danger" data-revoke-token="' +
escapeHtml(t.token_id) +
'" title="Revoke token">revoke</button>' +
"</span>" +
"</div>";
}
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 += '<span class="' + cls + '">' + escapeHtml(s) + "</span>";
}
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";
}
+22 -2
View File
@@ -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
+110
View File
@@ -16,6 +16,7 @@
<span id="cluster-summary" aria-live="polite"></span>
<span id="status-bar" role="status" aria-live="polite"></span>
<button id="new-ws-btn" class="header-btn header-btn-accent" onclick="showNewWsModal()" title="Create workstream">+ new</button>
<button id="admin-btn" class="header-btn" onclick="showAdmin()" title="User &amp; token administration">admin</button>
<button id="logout-btn" class="header-btn" onclick="logout()" style="display:none">logout</button>
<button id="theme-toggle" class="header-btn" onclick="toggleTheme()" aria-label="Toggle light/dark theme">&#9790;</button>
</div>
@@ -72,6 +73,54 @@
<div id="filtered-ws-table" class="dash-table" role="group" aria-label="Workstreams" aria-live="polite"></div>
<div id="filtered-pagination" class="pagination"></div>
</div>
<!-- ADMIN PANEL -->
<div id="view-admin" style="display:none">
<div class="admin-tabs" role="tablist">
<button class="admin-tab active" data-tab="users" role="tab" aria-selected="true" aria-controls="admin-users" tabindex="0" onclick="switchAdminTab('users')">Users</button>
<button class="admin-tab" data-tab="tokens" role="tab" aria-selected="false" aria-controls="admin-tokens" tabindex="-1" onclick="switchAdminTab('tokens')">Tokens</button>
</div>
<!-- Users Tab -->
<div id="admin-users" class="admin-panel" role="tabpanel">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">USERS</span>
<button class="admin-action-btn" onclick="showCreateUserModal()">+ Create user</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-username">USERNAME</span>
<span class="admin-col admin-col-name">DISPLAY NAME</span>
<span class="admin-col admin-col-created">CREATED</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-users-table" role="list" aria-label="Users" aria-live="polite">
<div class="dashboard-empty">Loading users...</div>
</div>
</div>
<!-- Tokens Tab -->
<div id="admin-tokens" class="admin-panel" role="tabpanel" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">TOKENS</span>
<label for="admin-token-user" class="sr-only">Filter tokens by user</label>
<select id="admin-token-user" onchange="loadAdminTokens()">
<option value="">Select user...</option>
</select>
<button class="admin-action-btn" onclick="showCreateTokenModal()">+ Create token</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-prefix">PREFIX</span>
<span class="admin-col admin-col-tname">NAME</span>
<span class="admin-col admin-col-scopes">SCOPES</span>
<span class="admin-col admin-col-created">CREATED</span>
<span class="admin-col admin-col-expires">EXPIRES</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-tokens-table" role="list" aria-label="Tokens" aria-live="polite">
<div class="dashboard-empty">Select a user to view tokens</div>
</div>
</div>
</div>
</div>
<div id="cluster-status-bar" role="region" aria-label="Cluster status">
@@ -123,6 +172,67 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Create User Modal -->
<div id="create-user-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-user-title">
<div id="create-user-box" class="admin-modal">
<h2 id="create-user-title">Create User</h2>
<div id="create-user-error" role="alert" aria-live="assertive"></div>
<label for="cu-username">Username</label>
<input id="cu-username" type="text" placeholder="login username" autocomplete="off" spellcheck="false">
<label for="cu-displayname">Display name</label>
<input id="cu-displayname" type="text" placeholder="Full name" autocomplete="off">
<label for="cu-password">Password</label>
<input id="cu-password" type="password" placeholder="Minimum 8 characters" autocomplete="new-password">
<label for="cu-confirm">Confirm password</label>
<input id="cu-confirm" type="password" placeholder="Confirm password" autocomplete="new-password">
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateUserModal()">Cancel</button>
<button id="cu-submit" class="modal-submit" onclick="submitCreateUser()">Create</button>
</div>
</div>
</div>
<!-- Create Token Modal -->
<div id="create-token-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-token-title">
<div id="create-token-box" class="admin-modal">
<h2 id="create-token-title">Create API Token</h2>
<div id="create-token-error" role="alert" aria-live="assertive"></div>
<label for="ct-name">Token name <span class="label-hint">optional</span></label>
<input id="ct-name" type="text" placeholder="e.g. CI pipeline, bridge-prod" autocomplete="off">
<label for="ct-scopes">Scopes</label>
<select id="ct-scopes">
<option value="read,write,approve">Full access (read, write, approve)</option>
<option value="read,write">Read + write</option>
<option value="read">Read only</option>
</select>
<label for="ct-expires">Expiry <span class="label-hint">optional</span></label>
<select id="ct-expires">
<option value="">Never</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateTokenModal()">Cancel</button>
<button id="ct-submit" class="modal-submit" onclick="submitCreateToken()">Create</button>
</div>
</div>
</div>
<!-- Token Created (show-once) Modal -->
<div id="token-created-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="token-created-title">
<div id="token-created-box" class="admin-modal">
<h2 id="token-created-title">Token Created</h2>
<p class="token-created-warning">Copy this token now. It will not be shown again.</p>
<div id="token-created-value" class="token-display"></div>
<div class="modal-buttons">
<button class="modal-submit" onclick="copyCreatedToken()">Copy to clipboard</button>
<button class="modal-cancel" onclick="hideTokenCreatedModal()">Done</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+284
View File
@@ -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; }
}
+353 -67
View File
@@ -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 <token>`` 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=<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 <token>`` 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"
+209
View File
@@ -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:
+57
View File
@@ -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:
+48
View File
@@ -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)
+205
View File
@@ -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:
@@ -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")
+51 -5
View File
@@ -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())
+51 -5
View File
@@ -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())
+181 -12
View File
@@ -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,
+297 -67
View File
@@ -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 (
'<div id="login-box">' +
'<h2 id="login-title">' +
escapeHtml(_AUTH_TITLE) +
"</h2>" +
'<div id="login-subtitle" class="login-subtitle"></div>' +
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
'<label for="login-token" class="sr-only">Auth token</label>' +
// --- Setup mode fields ---
'<div id="setup-fields" style="display:none">' +
'<label for="setup-username" class="login-label">Username</label>' +
'<input id="setup-username" type="text" placeholder="admin" autocomplete="username" spellcheck="false">' +
'<label for="setup-displayname" class="login-label">Display name</label>' +
'<input id="setup-displayname" type="text" placeholder="Administrator" autocomplete="name">' +
'<label for="setup-password" class="login-label">Password</label>' +
'<input id="setup-password" type="password" placeholder="Choose a strong password" autocomplete="new-password">' +
'<label for="setup-confirm" class="login-label">Confirm password</label>' +
'<input id="setup-confirm" type="password" placeholder="Confirm password" autocomplete="new-password">' +
"</div>" +
// --- Login mode fields ---
'<div id="login-fields">' +
'<label for="login-username" class="login-label">Username</label>' +
'<input id="login-username" type="text" placeholder="Username" autocomplete="username" spellcheck="false">' +
'<label for="login-password" class="login-label">Password</label>' +
'<input id="login-password" type="password" placeholder="Password" autocomplete="current-password">' +
"</div>" +
// --- Token mode fields ---
'<div id="token-fields" style="display:none">' +
'<label for="login-token" class="login-label">Auth token</label>' +
'<input id="login-token" type="password" placeholder="Enter auth token" autocomplete="off">' +
"</div>" +
'<button id="login-submit">Sign in</button>' +
"</div>";
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 ---
'<div id="login-toggle" class="login-toggle">' +
'<button id="toggle-token" class="login-link" type="button">Use token instead</button>' +
"</div>" +
"</div>"
);
}
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();
+40 -6
View File
@@ -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; } }
+1 -1
View File
@@ -3,7 +3,7 @@
function escapeHtml(text) {
var el = document.createElement("span");
el.textContent = text;
return el.innerHTML;
return el.innerHTML.replace(/'/g, "&#39;").replace(/"/g, "&quot;");
}
function formatTokens(n) {
+2
View File
@@ -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