mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 /`
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export type {
|
||||
HealthResponse,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
AuthStatusResponse,
|
||||
AuthSetupResponse,
|
||||
StatusResponse,
|
||||
ErrorResponse,
|
||||
ClusterOverviewResponse,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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">☾</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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
function escapeHtml(text) {
|
||||
var el = document.createElement("span");
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
return el.innerHTML.replace(/'/g, "'").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user