fix: remove non-auth support from bootstrap wizard (#274)

* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
This commit is contained in:
Patrick Buckley
2026-04-01 19:38:24 -07:00
committed by GitHub
parent 5df37f83a7
commit 62d2a0fe6a
41 changed files with 775 additions and 906 deletions
+8 -11
View File
@@ -85,9 +85,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy} - OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
- TAVILY_API_KEY=${TAVILY_API_KEY:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-} - SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
- MODEL=${MODEL:-} - MODEL=${MODEL:-}
- MCP_CONFIG=${MCP_CONFIG:-} - MCP_CONFIG=${MCP_CONFIG:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite} - TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
@@ -124,9 +123,8 @@ services:
ports: ports:
- "${CONSOLE_PORT:-8090}:8090" - "${CONSOLE_PORT:-8090}:8090"
environment: environment:
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite} - TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-} - TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090 - TURNSTONE_CONSOLE_URL=http://console:8090
@@ -161,8 +159,8 @@ services:
environment: environment:
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-} - TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0} - TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-} - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql} - TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone} - TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091 - TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
@@ -208,9 +206,8 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
TAVILY_API_KEY: ${TAVILY_API_KEY:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-}
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-} # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-} TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
MODEL: ${MODEL:-} MODEL: ${MODEL:-}
MCP_CONFIG: ${MCP_CONFIG:-} MCP_CONFIG: ${MCP_CONFIG:-}
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql} TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
@@ -36,13 +36,13 @@ spec:
- secretRef: - secretRef:
name: {{ include "turnstone.llm.secretName" . }} name: {{ include "turnstone.llm.secretName" . }}
optional: true optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }} {{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
env: env:
- name: TURNSTONE_AUTH_TOKEN - name: TURNSTONE_JWT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ .Values.auth.existingSecret }} name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_AUTH_TOKEN key: TURNSTONE_JWT_SECRET
{{- end }} {{- end }}
readinessProbe: readinessProbe:
httpGet: httpGet:
@@ -41,12 +41,12 @@ spec:
env: env:
- name: TURNSTONE_DB_URL - name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)" value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- if and .Values.auth.enabled .Values.auth.existingSecret }} {{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
- name: TURNSTONE_AUTH_TOKEN - name: TURNSTONE_JWT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ .Values.auth.existingSecret }} name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_AUTH_TOKEN key: TURNSTONE_JWT_SECRET
{{- end }} {{- end }}
readinessProbe: readinessProbe:
httpGet: httpGet:
+2 -2
View File
@@ -15,7 +15,7 @@ data:
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }} {{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }} POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
{{- end }} {{- end }}
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }} {{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }} TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
{{- end }} {{- end }}
{{- end }} {{- end }}
+2 -3
View File
@@ -59,10 +59,9 @@ llm:
apiKey: "" apiKey: ""
existingSecret: "" existingSecret: ""
# -- Authentication # -- Authentication (always enabled, JWT secret required)
auth: auth:
enabled: false jwtSecret: ""
token: ""
existingSecret: "" existingSecret: ""
# -- Ingress configuration # -- Ingress configuration
+1 -1
View File
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
[ [
aws_secretsmanager_secret.openai_api_key.arn, aws_secretsmanager_secret.openai_api_key.arn,
aws_secretsmanager_secret.db_password.arn, aws_secretsmanager_secret.db_password.arn,
aws_secretsmanager_secret.jwt_secret.arn,
], ],
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
) )
}, },
] ]
+16 -20
View File
@@ -41,20 +41,26 @@ locals {
}, },
] ]
auth_env = var.auth_token != "" ? [ auth_secrets = [
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
] : []
auth_secrets = var.auth_token != "" ? [
{ {
name = "TURNSTONE_AUTH_TOKEN" name = "TURNSTONE_JWT_SECRET"
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
}, },
] : [] ]
} }
# ---------- Secrets Manager ---------- # ---------- Secrets Manager ----------
resource "aws_secretsmanager_secret" "jwt_secret" {
name = "${var.name_prefix}-${var.environment}-jwt-secret"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "jwt_secret" {
secret_id = aws_secretsmanager_secret.jwt_secret.id
secret_string = var.jwt_secret
}
resource "aws_secretsmanager_secret" "openai_api_key" { resource "aws_secretsmanager_secret" "openai_api_key" {
name = "${var.name_prefix}-${var.environment}-openai-api-key" name = "${var.name_prefix}-${var.environment}-openai-api-key"
tags = local.common_tags tags = local.common_tags
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
secret_string = var.openai_api_key secret_string = var.openai_api_key
} }
resource "aws_secretsmanager_secret" "auth_token" {
count = var.auth_token != "" ? 1 : 0
name = "${var.name_prefix}-${var.environment}-auth-token"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "auth_token" {
count = var.auth_token != "" ? 1 : 0
secret_id = aws_secretsmanager_secret.auth_token[0].id
secret_string = var.auth_token
}
resource "aws_secretsmanager_secret" "db_password" { resource "aws_secretsmanager_secret" "db_password" {
name = "${var.name_prefix}-${var.environment}-db-password" name = "${var.name_prefix}-${var.environment}-db-password"
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
{ containerPort = 8080, protocol = "tcp" }, { containerPort = 8080, protocol = "tcp" },
] ]
environment = concat(local.common_env, local.auth_env) environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets) secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = { logConfiguration = {
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
{ containerPort = 8090, protocol = "tcp" }, { containerPort = 8090, protocol = "tcp" },
] ]
environment = concat(local.common_env, local.auth_env) environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets) secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = { logConfiguration = {
@@ -90,11 +90,10 @@ variable "name_prefix" {
default = "turnstone" default = "turnstone"
} }
variable "auth_token" { variable "jwt_secret" {
description = "Optional authentication token for the Turnstone API. Empty string disables auth." description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
type = string type = string
sensitive = true sensitive = true
default = ""
} }
variable "certificate_arn" { variable "certificate_arn" {
+3 -4
View File
@@ -56,7 +56,7 @@ console.log(result.content);
## Authentication ## Authentication
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token. Auth is always enabled. All API endpoints except public paths require a valid token.
### Sending Credentials ### Sending Credentials
@@ -65,15 +65,14 @@ Include a token in one of two ways:
- **Bearer header**: `Authorization: Bearer <token>` - **Bearer header**: `Authorization: Bearer <token>`
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint) - **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
The server accepts three token types: The server accepts two token types:
| Type | Format | Example | | Type | Format | Example |
|------|--------|---------| |------|--------|---------|
| JWT | Base64 segments separated by dots | `eyJhbG...` | | JWT | Base64 segments separated by dots | `eyJhbG...` |
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` | | 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. JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
### `POST /v1/api/auth/login` ### `POST /v1/api/auth/login`
+4 -8
View File
@@ -1016,13 +1016,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
Turnstone supports three authentication mechanisms, unified behind an Turnstone supports three authentication mechanisms, unified behind an
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`: `AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
1. **Config-file tokens**static secrets in `config.toml` `[[auth.tokens]]` 1. **API tokens**database-backed, prefixed `ts_`, stored as SHA-256 hashes
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 in the `api_tokens` table. Can be exchanged for JWTs via
`POST /v1/api/auth/login`. `POST /v1/api/auth/login`.
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after 2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
successful credential validation. Contain `sub` (user_id), `scopes`, and successful credential validation. Contain `sub` (user_id), `scopes`, and
`src` (origin) in claims. `src` (origin) in claims.
@@ -1046,9 +1043,8 @@ Three hierarchical scopes control endpoint access:
2. **Token extraction**`Authorization: Bearer <token>` header first, then 2. **Token extraction**`Authorization: Bearer <token>` header first, then
`turnstone_auth` cookie as fallback. `turnstone_auth` cookie as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix 3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token; otherwise config-file token. indicates API token.
4. **Validation** — JWT signature check, API token hash lookup in storage, or 4. **Validation** — JWT signature check or API token hash lookup in storage.
config-token hmac comparison.
5. **Scope check**`required_scope(method, path)` determines the minimum 5. **Scope check**`required_scope(method, path)` determines the minimum
scope; the request is rejected with 403 if the token lacks it. scope; the request is rejected with 403 if the token lacks it.
6. **Context propagation** — on success, `ctx_user_id` is set so structured 6. **Context propagation** — on success, `ctx_user_id` is set so structured
+5 -6
View File
@@ -193,7 +193,6 @@ Plan review requests are displayed as a blue embed with:
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) | | `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint | | `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port | | `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level | | `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) | | `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
@@ -321,11 +320,11 @@ The `services` table schema:
### Security ### Security
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint - **Authentication** — the gateway's `POST /v1/api/notify` endpoint
requires authentication. Configure either `TURNSTONE_JWT_SECRET` requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
(the server mints JWTs with `aud: turnstone-channel` automatically) server can mint JWTs with `aud: turnstone-channel` automatically.
or a static token via `--auth-token`. If neither is set, the If the secret is not set, the gateway fails closed and rejects all
gateway fails closed and rejects all requests with 401. Server JWTs requests with 401. Server JWTs (`aud: turnstone-server`) are
(`aud: turnstone-server`) are rejected. rejected.
- **Rate limit** — maximum 5 notifications per turn. The counter only - **Rate limit** — maximum 5 notifications per turn. The counter only
increments on successful delivery, so failures don't consume the increments on successful delivery, so failures don't consume the
budget. budget.
+1 -2
View File
@@ -628,7 +628,6 @@ CLI flags for `turnstone-console`:
|------|---------|-------------| |------|---------|-------------|
| `--host` | `0.0.0.0` | Bind host | | `--host` | `0.0.0.0` | Bind host |
| `--port` | `8090` | HTTP port | | `--port` | `8090` | HTTP port |
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
| `--log-level` | `INFO` | Log level | | `--log-level` | `INFO` | Log level |
Config file (`~/.config/turnstone/config.toml`): Config file (`~/.config/turnstone/config.toml`):
@@ -649,7 +648,7 @@ url = "http://localhost:8090" # used by CLI /cluster commands
turnstone-server --port 8080 turnstone-server --port 8080
# Start cluster console (one instance) # Start cluster console (one instance)
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN" turnstone-console --port 8090
``` ```
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required. Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
+3 -3
View File
@@ -72,11 +72,11 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
### Auth ### Auth
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication | | `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
### Database ### Database
+5 -5
View File
@@ -38,7 +38,7 @@ are set.
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") | | `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) | | `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) | | `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. | | `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. | | `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
OIDC is enabled when all three required fields (issuer, client ID, client OIDC is enabled when all three required fields (issuer, client ID, client
@@ -246,10 +246,10 @@ password) before OIDC is enabled. The setup wizard always works
regardless of this setting because it is only available when zero users regardless of this setting because it is only available when zero users
exist in the database. exist in the database.
API token login (`POST /v1/api/auth/login` with a `ts_` token) and API token login (`POST /v1/api/auth/login` with a `ts_` token)
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work continues to work regardless of this setting. JWTs and API tokens are
regardless of this setting. OIDC-only mode affects password-based the supported authentication methods. OIDC-only mode affects
authentication only. password-based authentication only.
--- ---
+2 -2
View File
@@ -332,6 +332,6 @@ client.login(token="ts_abc123...")
- `client.logout()` clears the stored JWT from the client. - `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. - If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
### Backward Compatibility ### Token Types
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. The SDK accepts any Bearer token — JWTs (from `ServiceTokenManager` or login) and API tokens (`ts_` prefix) are both supported. Use `token_factory` for auto-rotating JWTs or a static `token` for API tokens.
+11 -54
View File
@@ -8,23 +8,6 @@ credentials while individual server nodes validate JWTs locally.
## Token Types ## 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 ### API tokens
Database-backed tokens prefixed with `ts_`. Created via the admin CLI Database-backed tokens prefixed with `ts_`. Created via the admin CLI
@@ -149,15 +132,6 @@ 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 JWT with the token's scopes. This is the recommended flow for SDKs and
automated clients that need cookie-based sessions. 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 ### First-time setup
When no users exist in the database: When no users exist in the database:
@@ -276,7 +250,7 @@ Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
form on the login page and blocks password-based login at the API form on the login page and blocks password-based login at the API
level. The setup wizard always works regardless of this setting — the level. The setup wizard always works regardless of this setting — the
first admin user is created with a password before OIDC is relevant. first admin user is created with a password before OIDC is relevant.
API tokens and config-file tokens are unaffected by this setting. API tokens are unaffected by this setting.
#### Known limitations #### Known limitations
@@ -297,8 +271,6 @@ and classifies the token:
1. **Contains `.`** → JWT → validate HS256 signature and expiry 1. **Contains `.`** → JWT → validate HS256 signature and expiry
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup 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, If a session cookie is present and no `Authorization` header is sent,
the cookie value is treated as a JWT (step 1). the cookie value is treated as a JWT (step 1).
@@ -332,16 +304,10 @@ deployments.
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) | | Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours | | Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
| Algorithm | — | — | HS256 (not configurable) | | Algorithm | — | — | HS256 (not configurable) |
| Minimum secret length | — | — | 32 characters (warning if shorter) | | Minimum secret length | — | — | 32 characters (exits if shorter) |
All service nodes that need to validate JWTs must share the same signing All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
secret. If no secret is configured, an ephemeral key is generated at missing or shorter than 32 characters.
startup and a warning is logged — JWTs will not survive restarts or work
across nodes.
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
is provided. It exits with an error if the secret is missing, since
ephemeral secrets would silently break inter-service communication.
--- ---
@@ -442,16 +408,15 @@ Console (cluster-wide) Server (per-node)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ User/Token CRUD (DB) │ │ JWT validation only │ │ User/Token CRUD (DB) │ │ JWT validation only │
│ Login: creds → JWT │ │ (shared signing key) │ │ Login: creds → JWT │ │ (shared signing key) │
│ Admin API endpoints │ │ Config tokens: hmac │ Admin API endpoints │ │ No auth DB needed
│ Storage: users, │ │ No auth DB needed │ Storage: users, │ │
│ api_tokens tables │ │ │ │ api_tokens tables │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
``` ```
The console owns the credential database and handles all user/token The console owns the credential database and handles all user/token
CRUD. Individual server nodes only need the JWT signing secret to CRUD. Individual server nodes only need the JWT signing secret to
validate session tokens. Config-file tokens are validated locally validate session tokens.
without any database.
### Proxy auth forwarding ### Proxy auth forwarding
@@ -478,8 +443,7 @@ distinguish proxied requests from direct logins in audit logs.
When no user context is available (auth disabled, or internal requests), When no user context is available (auth disabled, or internal requests),
the proxy falls back to a `ServiceTokenManager` with service identity the proxy falls back to a `ServiceTokenManager` with service identity
`console-proxy` and full scopes. If `--auth-token` is provided, that `console-proxy` and full scopes.
static token is used as a final fallback.
### Service-to-service authentication ### Service-to-service authentication
@@ -518,22 +482,17 @@ channel gateway endpoint, and vice versa.
```toml ```toml
[auth] [auth]
enabled = true
jwt_secret = "your-secret-key-here" jwt_secret = "your-secret-key-here"
jwt_expiry_hours = 24 jwt_expiry_hours = 24
[[auth.tokens]]
value = "tok_legacy"
role = "full"
``` ```
### Environment variables ### Environment variables
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication | | `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) | | `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
--- ---
@@ -571,8 +530,6 @@ and browsers enforce same-origin policy.
## Security Properties ## 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 - **Hash-based lookup** for API tokens — the database stores only
SHA-256 hashes, eliminating timing attacks on token comparison. SHA-256 hashes, eliminating timing attacks on token comparison.
- **Local JWT validation** — no network call or database query needed - **Local JWT validation** — no network call or database query needed
+1 -1
View File
@@ -105,7 +105,7 @@ turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080 turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
# List issued certs # List issued certs
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN turnstone-admin tls-list --console-url http://console:8080
``` ```
### Console URL Discovery ### Console URL Discovery
+1 -1
View File
@@ -7,7 +7,7 @@
* *
* const client = new TurnstoneServer({ * const client = new TurnstoneServer({
* baseUrl: "http://localhost:8080", * baseUrl: "http://localhost:8080",
* token: "tok_xxx", * token: "ts_your_api_token",
* }); * });
* *
* const ws = await client.createWorkstream({ name: "demo" }); * const ws = await client.createWorkstream({ name: "demo" });
+37 -8
View File
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
import pytest import pytest
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _server_jwt() -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt(
user_id="test-versioning",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
def _console_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-versioning",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
_CONSOLE_AUTH_HEADERS = {"Authorization": f"Bearer {_console_jwt()}"}
class TestServerVersioning: class TestServerVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the server.""" """Test /v1/ routes and OpenAPI endpoints on the server."""
@@ -14,7 +45,6 @@ class TestServerVersioning:
def client(self): def client(self):
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.core.auth import AuthConfig
from turnstone.server import create_app from turnstone.server import create_app
mock_mgr = MagicMock() mock_mgr = MagicMock()
@@ -26,19 +56,19 @@ class TestServerVersioning:
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False)
yield client yield client
client.close() client.close()
def test_v1_workstreams(self, client): def test_v1_workstreams(self, client):
resp = client.get("/v1/api/workstreams") resp = client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
assert resp.status_code == 200 assert resp.status_code == 200
assert "workstreams" in resp.json() assert "workstreams" in resp.json()
def test_unversioned_api_404(self, client): def test_unversioned_api_404(self, client):
resp = client.get("/api/workstreams") resp = client.get("/api/workstreams", headers=_SERVER_AUTH_HEADERS)
assert resp.status_code == 404 assert resp.status_code == 404
def test_openapi_json(self, client): def test_openapi_json(self, client):
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
from turnstone.console.collector import ClusterCollector from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
collector = MagicMock(spec=ClusterCollector) collector = MagicMock(spec=ClusterCollector)
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
} }
app = create_app( app = create_app(
collector=collector, collector=collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False)
yield client yield client
client.close() client.close()
def test_v1_cluster_overview(self, client): def test_v1_cluster_overview(self, client):
resp = client.get("/v1/api/cluster/overview") resp = client.get("/v1/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
assert resp.status_code == 200 assert resp.status_code == 200
def test_unversioned_api_404(self, client): def test_unversioned_api_404(self, client):
resp = client.get("/api/cluster/overview") resp = client.get("/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
assert resp.status_code == 404 assert resp.status_code == 404
def test_openapi_json(self, client): def test_openapi_json(self, client):
+263 -332
View File
@@ -9,12 +9,11 @@ import pytest
from turnstone.core.auth import ( from turnstone.core.auth import (
WRITE_PATHS, WRITE_PATHS,
AuthConfig,
_extract_bearer, _extract_bearer,
_extract_cookie, _extract_cookie,
check_request, check_request,
create_jwt,
is_public_path, is_public_path,
load_auth_config,
make_clear_cookie, make_clear_cookie,
make_set_cookie, make_set_cookie,
required_scope, required_scope,
@@ -199,37 +198,6 @@ class TestRequiredScope:
assert required_scope("GET", "/api/_internal/mcp-reload") == "read" assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
# ---------------------------------------------------------------------------
# TestAuthConfig
# ---------------------------------------------------------------------------
class TestAuthConfig:
def test_check_valid_full_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
assert cfg.check("tok_full") == "full"
def test_check_valid_read_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
assert cfg.check("tok_read") == "read"
def test_check_invalid_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check("wrong") is None
def test_check_none_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check(None) is None
def test_check_empty_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check("") is None
def test_check_no_tokens(self):
cfg = AuthConfig(enabled=True, tokens={})
assert cfg.check("anything") is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestExtractBearer # TestExtractBearer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -353,167 +321,157 @@ class TestMakeClearCookie:
class TestCheckRequest: class TestCheckRequest:
"""Tests for the main check_request() entry point.""" """Tests for the main check_request() entry point."""
@pytest.fixture() _SECRET = "test-jwt-secret-minimum-32-chars!"
def disabled(self):
return AuthConfig(enabled=False)
@pytest.fixture() @pytest.fixture()
def enabled(self): def read_jwt(self):
return AuthConfig( return f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', self._SECRET)}"
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
)
def test_disabled_allows_all(self, disabled): @pytest.fixture()
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None) def full_jwt(self):
return f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', self._SECRET)}"
def test_public_path_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/health", None)
assert allowed is True assert allowed is True
assert status == 200 assert status == 200
def test_disabled_allows_no_header(self, disabled): def test_public_root_no_token_ok(self):
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None) allowed, status, msg, _result = check_request("GET", "/", None)
assert allowed is True assert allowed is True
def test_public_path_no_token_ok(self, enabled): def test_public_static_no_token_ok(self):
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None) allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
assert allowed is True
assert status == 200
def test_public_root_no_token_ok(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
assert allowed is True assert allowed is True
def test_public_static_no_token_ok(self, enabled): def test_api_no_token_401(self):
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None) allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
assert allowed is True
def test_api_no_token_401(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
assert allowed is False assert allowed is False
assert status == 401 assert status == 401
assert "Unauthorized" in msg assert "Unauthorized" in msg
def test_api_invalid_token_401(self, enabled): def test_api_invalid_token_401(self):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer wrong_token" "GET", "/api/workstreams", "Bearer wrong_token"
) )
assert allowed is False assert allowed is False
assert status == 401 assert status == 401
def test_api_read_token_ok(self, enabled): def test_api_read_token_ok(self, read_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer tok_read" "GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
assert status == 200 assert status == 200
def test_api_full_token_ok(self, enabled): def test_api_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer tok_full" "GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
def test_write_read_token_403(self, enabled): def test_write_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send", "Bearer tok_read" "POST", "/api/send", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
assert "Forbidden" in msg assert "Forbidden" in msg
def test_write_full_token_ok(self, enabled): def test_write_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send", "Bearer tok_full" "POST", "/api/send", full_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
assert status == 200 assert status == 200
def test_approve_read_token_403(self, enabled): def test_approve_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/approve", "Bearer tok_read" "POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_proxy_write_read_token_403(self, enabled): def test_proxy_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via proxy routes.""" """Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read" "POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_proxy_write_trailing_slash_read_token_403(self, enabled): def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on proxy routes.""" """Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read" "POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_direct_write_trailing_slash_read_token_403(self, enabled): def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on direct routes.""" """Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send/", "Bearer tok_read" "POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_proxy_write_full_token_ok(self, enabled): def test_proxy_write_full_token_ok(self, full_jwt):
"""Full tokens pass through proxy write routes.""" """Full tokens pass through proxy write routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full" "POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
def test_proxy_v1_write_read_token_403(self, enabled): def test_proxy_v1_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via v1 proxy routes.""" """Read tokens cannot escalate to write ops via v1 proxy routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read" "POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_proxy_v1_write_full_token_ok(self, enabled): def test_proxy_v1_write_full_token_ok(self, full_jwt):
"""Full tokens pass through v1 proxy write routes.""" """Full tokens pass through v1 proxy write routes."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full" "POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
def test_proxy_v1_cluster_ws_new_read_403(self, enabled): def test_proxy_v1_cluster_ws_new_read_403(self, read_jwt):
"""Read tokens cannot create workstreams via v1 proxy.""" """Read tokens cannot create workstreams via v1 proxy."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled,
"POST", "POST",
"/node/node-a/v1/api/cluster/workstreams/new", "/node/node-a/v1/api/cluster/workstreams/new",
"Bearer tok_read", read_jwt,
jwt_secret=self._SECRET,
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_proxy_read_endpoint_read_token_ok(self, enabled): def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
"""Read tokens can access proxy read endpoints.""" """Read tokens can access proxy read endpoints."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read" "GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
def test_console_create_ws_read_token_403(self, enabled): def test_console_create_ws_read_token_403(self, read_jwt):
"""Read tokens cannot create workstreams.""" """Read tokens cannot create workstreams."""
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read" "POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_approve_full_token_ok(self, enabled): def test_approve_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request( allowed, status, msg, _result = check_request(
enabled, "POST", "/api/approve", "Bearer tok_full" "POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
) )
assert allowed is True assert allowed is True
def test_no_auth_header_string(self, enabled): def test_no_auth_header_string(self):
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "") allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
assert allowed is False assert allowed is False
assert status == 401 assert status == 401
@@ -526,70 +484,71 @@ class TestCheckRequest:
class TestCheckRequestWithCookie: class TestCheckRequestWithCookie:
"""Tests for cookie-based auth fallback in check_request.""" """Tests for cookie-based auth fallback in check_request."""
@pytest.fixture() _SECRET = "test-jwt-secret-minimum-32-chars!"
def enabled(self):
return AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
)
def test_cookie_fallback_when_no_bearer(self, enabled): @pytest.fixture()
def read_jwt(self):
return create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
@pytest.fixture()
def full_jwt(self):
return create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
def test_cookie_fallback_when_no_bearer(self, read_jwt):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"GET", "GET",
"/api/workstreams", "/api/workstreams",
None, None,
cookie_header="turnstone_auth=tok_read", cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
) )
assert allowed is True assert allowed is True
assert status == 200 assert status == 200
def test_bearer_takes_precedence_over_cookie(self, enabled): def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
# Bearer is full, cookie is read — Bearer should win
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"POST", "POST",
"/api/send", "/api/send",
"Bearer tok_full", f"Bearer {full_jwt}",
cookie_header="turnstone_auth=tok_read", cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
) )
assert allowed is True assert allowed is True
def test_invalid_cookie_401(self, enabled): def test_invalid_cookie_401(self):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"GET", "GET",
"/api/workstreams", "/api/workstreams",
None, None,
cookie_header="turnstone_auth=wrong_token", cookie_header="turnstone_auth=wrong_token",
jwt_secret=self._SECRET,
) )
assert allowed is False assert allowed is False
assert status == 401 assert status == 401
def test_cookie_read_on_write_403(self, enabled): def test_cookie_read_on_write_403(self, read_jwt):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"POST", "POST",
"/api/send", "/api/send",
None, None,
cookie_header="turnstone_auth=tok_read", cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
) )
assert allowed is False assert allowed is False
assert status == 403 assert status == 403
def test_cookie_full_on_write_ok(self, enabled): def test_cookie_full_on_write_ok(self, full_jwt):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"POST", "POST",
"/api/send", "/api/send",
None, None,
cookie_header="turnstone_auth=tok_full", cookie_header=f"turnstone_auth={full_jwt}",
jwt_secret=self._SECRET,
) )
assert allowed is True assert allowed is True
def test_no_cookie_no_bearer_401(self, enabled): def test_no_cookie_no_bearer_401(self):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"GET", "GET",
"/api/workstreams", "/api/workstreams",
None, None,
@@ -598,18 +557,16 @@ class TestCheckRequestWithCookie:
assert allowed is False assert allowed is False
assert status == 401 assert status == 401
def test_login_path_public(self, enabled): def test_login_path_public(self):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"POST", "POST",
"/api/auth/login", "/api/auth/login",
None, None,
) )
assert allowed is True assert allowed is True
def test_logout_path_public(self, enabled): def test_logout_path_public(self):
allowed, status, _, _r = check_request( allowed, status, _, _r = check_request(
enabled,
"POST", "POST",
"/api/auth/logout", "/api/auth/logout",
None, None,
@@ -617,139 +574,6 @@ class TestCheckRequestWithCookie:
assert allowed is True assert allowed is True
# ---------------------------------------------------------------------------
# TestLoadAuthConfig
# ---------------------------------------------------------------------------
class TestLoadAuthConfig:
"""Tests for load_auth_config with mocked config + env vars."""
def test_default_enabled(self):
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_auth_config()
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,
"tokens": [
{"value": "tok_a", "role": "full"},
{"value": "tok_b", "role": "read"},
],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert cfg.enabled is True
assert cfg.tokens == {"tok_a": "full", "tok_b": "read"}
def test_env_var_enabled(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "1"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
def test_env_var_token(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
):
cfg = load_auth_config()
assert "tok_env" in cfg.tokens
assert cfg.tokens["tok_env"] == "full"
def test_config_plus_env_merge(self):
mock_cfg = {
"enabled": True,
"tokens": [{"value": "tok_cfg", "role": "read"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
):
cfg = load_auth_config()
assert cfg.tokens["tok_cfg"] == "read"
assert cfg.tokens["tok_env"] == "full"
def test_invalid_role_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": [
{"value": "tok_ok", "role": "full"},
{"value": "tok_bad", "role": "admin"},
],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert "tok_ok" in cfg.tokens
assert "tok_bad" not in cfg.tokens
def test_empty_value_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": [{"value": "", "role": "full"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert len(cfg.tokens) == 0
def test_non_dict_token_entry_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": ["not_a_dict", {"value": "tok_ok", "role": "full"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert cfg.tokens == {"tok_ok": "full"}
def test_env_enabled_true(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "true"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
def test_env_enabled_yes(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "yes"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Integration tests — actual HTTP server with auth enabled # Integration tests — actual HTTP server with auth enabled
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -785,16 +609,22 @@ class TestServerAuth:
mock_mgr.list_all.return_value = [mock_ws] mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10 mock_mgr.max_workstreams = 10
from turnstone.core.auth import JWT_AUD_SERVER
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
cls._read_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
}
cls._full_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
}
app = srv_mod.create_app( app = srv_mod.create_app(
workstreams=mock_mgr, workstreams=mock_mgr,
global_queue=queue.Queue(), global_queue=queue.Queue(),
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig( jwt_secret=cls._jwt_secret,
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
cors_origins=["*"], cors_origins=["*"],
) )
cls.client = TestClient(app, raise_server_exceptions=False) cls.client = TestClient(app, raise_server_exceptions=False)
@@ -809,7 +639,6 @@ class TestServerAuth:
def test_metrics_no_token_passes_auth(self): def test_metrics_no_token_passes_auth(self):
resp = self.client.get("/metrics") resp = self.client.get("/metrics")
# Public path — should never be 401/403
assert resp.status_code not in (401, 403) assert resp.status_code not in (401, 403)
def test_root_no_token_200(self): def test_root_no_token_200(self):
@@ -826,23 +655,17 @@ class TestServerAuth:
assert "Unauthorized" in resp.json().get("error", "") assert "Unauthorized" in resp.json().get("error", "")
def test_api_workstreams_read_token_200(self): def test_api_workstreams_read_token_200(self):
resp = self.client.get( resp = self.client.get("/v1/api/workstreams", headers=self._read_hdr)
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_read"},
)
assert resp.status_code == 200 assert resp.status_code == 200
def test_api_workstreams_full_token_200(self): def test_api_workstreams_full_token_200(self):
resp = self.client.get( resp = self.client.get("/v1/api/workstreams", headers=self._full_hdr)
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_full"},
)
assert resp.status_code == 200 assert resp.status_code == 200
def test_api_send_read_token_403(self): def test_api_send_read_token_403(self):
resp = self.client.post( resp = self.client.post(
"/v1/api/send", "/v1/api/send",
headers={"Authorization": "Bearer tok_read"}, headers=self._read_hdr,
json={"message": "hello", "ws_id": "x"}, json={"message": "hello", "ws_id": "x"},
) )
assert resp.status_code == 403 assert resp.status_code == 403
@@ -851,10 +674,9 @@ class TestServerAuth:
def test_api_send_full_token_passes_auth(self): def test_api_send_full_token_passes_auth(self):
resp = self.client.post( resp = self.client.post(
"/v1/api/send", "/v1/api/send",
headers={"Authorization": "Bearer tok_full"}, headers=self._full_hdr,
json={"message": "hello", "ws_id": "nonexistent"}, json={"message": "hello", "ws_id": "nonexistent"},
) )
# Should get 404 (unknown workstream), not 401/403
assert resp.status_code not in (401, 403) assert resp.status_code not in (401, 403)
def test_api_send_no_token_401(self): def test_api_send_no_token_401(self):
@@ -921,12 +743,18 @@ class TestConsoleAuth:
"aggregate": {"total_tokens": 100}, "aggregate": {"total_tokens": 100},
} }
from turnstone.core.auth import JWT_AUD_CONSOLE
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
cls._read_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
}
cls._full_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
}
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig( jwt_secret=cls._jwt_secret,
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
) )
cls.test_client = TestClient(app, raise_server_exceptions=False) cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -947,17 +775,11 @@ class TestConsoleAuth:
assert resp.status_code == 401 assert resp.status_code == 401
def test_api_overview_read_token_200(self): def test_api_overview_read_token_200(self):
resp = self.test_client.get( resp = self.test_client.get("/v1/api/cluster/overview", headers=self._read_hdr)
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_read"},
)
assert resp.status_code == 200 assert resp.status_code == 200
def test_api_overview_full_token_200(self): def test_api_overview_full_token_200(self):
resp = self.test_client.get( resp = self.test_client.get("/v1/api/cluster/overview", headers=self._full_hdr)
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_full"},
)
assert resp.status_code == 200 assert resp.status_code == 200
def test_invalid_token_401(self): def test_invalid_token_401(self):
@@ -1003,16 +825,33 @@ class TestServerLogin:
mock_mgr.list_all.return_value = [mock_ws] mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10 mock_mgr.max_workstreams = 10
# Mock storage with a test user for password login
from turnstone.core.auth import hash_password
mock_storage = MagicMock()
mock_storage.get_user_by_username.side_effect = lambda u: (
{
"user_id": "uid_test",
"username": "testuser",
"password_hash": hash_password("testpass"),
"display_name": "Test",
}
if u == "testuser"
else None
)
mock_storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
]
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
app = srv_mod.create_app( app = srv_mod.create_app(
workstreams=mock_mgr, workstreams=mock_mgr,
global_queue=queue.Queue(), global_queue=queue.Queue(),
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig( jwt_secret=cls._jwt_secret,
enabled=True, auth_storage=mock_storage,
tokens={"tok_full": "full", "tok_read": "read"},
),
) )
cls.test_client = TestClient(app, raise_server_exceptions=False) cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -1020,36 +859,36 @@ class TestServerLogin:
def teardown_class(cls): def teardown_class(cls):
cls.test_client.close() cls.test_client.close()
def test_login_valid_token_sets_cookie(self): def test_login_config_token_rejected(self):
"""Config token exchange is no longer allowed."""
resp = self.test_client.post( resp = self.test_client.post(
"/v1/api/auth/login", "/v1/api/auth/login",
json={"token": "tok_full"}, json={"token": "tok_full"},
) )
assert resp.status_code == 200 assert resp.status_code == 401
data = resp.json()
assert data["role"] == "full"
cookie = resp.headers.get("set-cookie", "")
assert "turnstone_auth=tok_full" in cookie
assert "HttpOnly" in cookie
def test_login_invalid_token_401(self): def test_login_invalid_credentials_401(self):
resp = self.test_client.post( resp = self.test_client.post(
"/v1/api/auth/login", "/v1/api/auth/login",
json={"token": "wrong"}, json={"username": "testuser", "password": "wrong"},
) )
assert resp.status_code == 401 assert resp.status_code == 401
def test_login_no_auth_required(self): def test_login_password_ok(self):
# /v1/api/auth/login is public — shouldn't require auth itself
resp = self.test_client.post( resp = self.test_client.post(
"/v1/api/auth/login", "/v1/api/auth/login",
json={"token": "tok_read"}, json={"username": "testuser", "password": "testpass"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json()
assert "jwt" in data
def test_cookie_auth_on_api(self): def test_cookie_auth_on_api(self):
# Login to get cookie (TestClient tracks cookies automatically) # Login to get cookie (TestClient tracks cookies automatically)
login_resp = self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"}) login_resp = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login_resp.status_code == 200 assert login_resp.status_code == 200
# Use cookie to access API — TestClient forwards cookies # Use cookie to access API — TestClient forwards cookies
@@ -1057,7 +896,10 @@ class TestServerLogin:
assert resp.status_code == 200 assert resp.status_code == 200
def test_logout_clears_cookie(self): def test_logout_clears_cookie(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"}) self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
# Logout # Logout
logout_resp = self.test_client.post("/v1/api/auth/logout") logout_resp = self.test_client.post("/v1/api/auth/logout")
@@ -1081,6 +923,7 @@ class TestConsoleLogin:
from turnstone.console.collector import ClusterCollector from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import hash_password
_load_static() _load_static()
@@ -1092,12 +935,26 @@ class TestConsoleLogin:
"aggregate": {"total_tokens": 100}, "aggregate": {"total_tokens": 100},
} }
mock_storage = MagicMock()
mock_storage.get_user_by_username.side_effect = lambda u: (
{
"user_id": "uid_test",
"username": "testuser",
"password_hash": hash_password("testpass"),
"display_name": "Test",
}
if u == "testuser"
else None
)
mock_storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
]
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig( jwt_secret=cls._jwt_secret,
enabled=True, auth_storage=mock_storage,
tokens={"tok_full": "full", "tok_read": "read"},
),
) )
cls.test_client = TestClient(app, raise_server_exceptions=False) cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -1105,28 +962,34 @@ class TestConsoleLogin:
def teardown_class(cls): def teardown_class(cls):
cls.test_client.close() cls.test_client.close()
def test_login_valid_token(self): def test_login_config_token_rejected(self):
resp = self.test_client.post( resp = self.test_client.post(
"/v1/api/auth/login", "/v1/api/auth/login",
json={"token": "tok_read"}, json={"token": "tok_read"},
) )
assert resp.status_code == 401
def test_login_password_ok(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert resp.status_code == 200 assert resp.status_code == 200
assert "turnstone_auth" in resp.headers.get("set-cookie", "") assert "turnstone_auth" in resp.headers.get("set-cookie", "")
def test_login_invalid_token(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "wrong"},
)
assert resp.status_code == 401
def test_cookie_auth_on_api(self): def test_cookie_auth_on_api(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"}) self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
resp = self.test_client.get("/v1/api/cluster/overview") resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 200 assert resp.status_code == 200
def test_logout_then_api_fails(self): def test_logout_then_api_fails(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"}) self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
self.test_client.post("/v1/api/auth/logout") self.test_client.post("/v1/api/auth/logout")
resp = self.test_client.get("/v1/api/cluster/overview") resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 401 assert resp.status_code == 401
@@ -1385,25 +1248,29 @@ class TestIsSecureRequest:
class TestSecretStrength: class TestSecretStrength:
def test_short_secret_warns(self, caplog): def test_short_secret_exits(self):
import logging import turnstone.core.auth as auth_mod
from turnstone.core.auth import _MIN_SECRET_LENGTH old = os.environ.get("TURNSTONE_JWT_SECRET", "")
os.environ["TURNSTONE_JWT_SECRET"] = "short"
try:
with pytest.raises(SystemExit):
auth_mod.load_jwt_secret()
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
else:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
with caplog.at_level(logging.WARNING, logger="turnstone.core.auth"): def test_missing_secret_exits(self):
import turnstone.core.auth as auth_mod import turnstone.core.auth as auth_mod
old = os.environ.get("TURNSTONE_JWT_SECRET", "") with (
os.environ["TURNSTONE_JWT_SECRET"] = "short" patch("turnstone.core.config.load_config", return_value={}),
try: patch.dict(os.environ, {}, clear=True),
secret = auth_mod.load_jwt_secret() pytest.raises(SystemExit),
assert secret == "short" ):
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records) auth_mod.load_jwt_secret()
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
else:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
class TestCorsConfigurable: class TestCorsConfigurable:
@@ -1424,7 +1291,6 @@ class TestCorsConfigurable:
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig(enabled=False),
) )
client = TestClient(app) client = TestClient(app)
resp = client.get("/health", headers={"Origin": "http://evil.com"}) resp = client.get("/health", headers={"Origin": "http://evil.com"})
@@ -1446,7 +1312,6 @@ class TestCorsConfigurable:
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig(enabled=False),
cors_origins=["http://example.com"], cors_origins=["http://example.com"],
) )
client = TestClient(app) client = TestClient(app)
@@ -1502,3 +1367,69 @@ class TestOIDCPublicPaths:
def test_oidc_callback_is_public(self): def test_oidc_callback_is_public(self):
assert is_public_path("/api/auth/oidc/callback") is True assert is_public_path("/api/auth/oidc/callback") is True
assert is_public_path("/v1/api/auth/oidc/callback") is True assert is_public_path("/v1/api/auth/oidc/callback") is True
# ---------------------------------------------------------------------------
# TestRequirePermissionServiceScope — service scope bypasses permission checks
# ---------------------------------------------------------------------------
class TestRequirePermissionServiceScope:
"""Verify require_permission() behaviour with the service scope."""
def _make_request(self, auth_result):
"""Build a mock Starlette request with the given AuthResult on state."""
request = MagicMock()
request.state.auth_result = auth_result
return request
def test_service_scope_bypasses_permission(self):
"""Service-scoped tokens bypass all permission checks (returns None)."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="svc-agent",
scopes=frozenset({"service"}),
token_source="jwt",
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is None # bypass — no 403
def test_without_service_scope_and_without_permission_returns_403(self):
"""Non-service tokens without the required permission get 403."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="regular-user",
scopes=frozenset({"read", "write"}),
token_source="jwt",
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is not None
assert result.status_code == 403
def test_without_service_scope_with_permission_returns_none(self):
"""Non-service tokens with the required permission pass."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="admin-user",
scopes=frozenset({"read", "write", "approve"}),
token_source="jwt",
permissions=frozenset({"admin.users"}),
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is None # granted — no 403
def test_no_auth_result_returns_401(self):
"""Missing auth_result on request state returns 401."""
from turnstone.core.auth import require_permission
request = MagicMock()
del request.state.auth_result # ensure attribute is absent
result = require_permission(request, "admin.users")
assert result is not None
assert result.status_code == 401
+37 -57
View File
@@ -7,7 +7,6 @@ import time
import pytest import pytest
from turnstone.core.auth import ( from turnstone.core.auth import (
AuthConfig,
AuthResult, AuthResult,
_authenticate_token, _authenticate_token,
check_request, check_request,
@@ -203,24 +202,10 @@ class TestRequiredScope:
class TestAuthenticateToken: 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): def test_jwt_token(self):
secret = "test-secret-key-for-jwt-min-32b!" secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret) jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
cfg = AuthConfig(enabled=True) result = _authenticate_token(jwt_tok, jwt_secret=secret)
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
assert result is not None assert result is not None
assert result.user_id == "user1" assert result.user_id == "user1"
assert result.token_source == "db" assert result.token_source == "db"
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
} }
return None return None
cfg = AuthConfig(enabled=True) result = _authenticate_token(raw, storage=MockStorage())
result = _authenticate_token(raw, cfg, storage=MockStorage())
assert result is not None assert result is not None
assert result.user_id == "user1" assert result.user_id == "user1"
assert result.has_scope("write") assert result.has_scope("write")
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
"expires": "2020-01-02T00:00:00", "expires": "2020-01-02T00:00:00",
} }
cfg = AuthConfig(enabled=True) result = _authenticate_token(raw, storage=MockStorage())
result = _authenticate_token(raw, cfg, storage=MockStorage())
assert result is None assert result is None
def test_unknown_token(self): def test_unknown_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok": "full"}) result = _authenticate_token("unknown")
result = _authenticate_token("unknown", cfg)
assert result is None assert result is None
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
class TestCheckRequestScopes: class TestCheckRequestScopes:
def test_config_read_on_write_403(self): _SECRET = "test-secret-key-for-jwt-min-32b!"
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read") def test_jwt_read_on_write_403(self):
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert not allowed assert not allowed
assert status == 403 assert status == 403
assert "write" in msg assert "write" in msg
def test_config_read_on_approve_403(self): def test_jwt_read_on_approve_403(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read") allowed, status, msg, _ = check_request(
"POST",
"/api/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert not allowed assert not allowed
assert status == 403 assert status == 403
assert "approve" in msg assert "approve" in msg
def test_config_full_on_approve_ok(self): def test_jwt_full_on_approve_ok(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full") allowed, status, msg, result = check_request(
"POST",
"/api/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert allowed assert allowed
assert result is not None assert result is not None
assert result.has_scope("approve") assert result.has_scope("approve")
def test_jwt_with_scopes(self): def test_jwt_with_scopes(self):
secret = "test-secret-key-for-jwt-min-32b!" jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
cfg = AuthConfig(enabled=True)
allowed, status, msg, result = check_request( allowed, status, msg, result = check_request(
cfg,
"POST", "POST",
"/api/send", "/api/send",
f"Bearer {jwt_tok}", f"Bearer {jwt_tok}",
jwt_secret=secret, jwt_secret=self._SECRET,
) )
assert allowed assert allowed
assert result is not None assert result is not None
assert result.user_id == "u1" assert result.user_id == "u1"
def test_jwt_insufficient_scope(self): def test_jwt_insufficient_scope(self):
secret = "test-secret-key-for-jwt-min-32b!" jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
cfg = AuthConfig(enabled=True)
allowed, status, msg, _ = check_request( allowed, status, msg, _ = check_request(
cfg,
"POST", "POST",
"/api/send", "/api/send",
f"Bearer {jwt_tok}", f"Bearer {jwt_tok}",
jwt_secret=secret, jwt_secret=self._SECRET,
) )
assert not allowed assert not allowed
assert status == 403 assert status == 403
def test_admin_path_requires_approve(self): def test_admin_path_requires_approve(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"}) jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request( allowed, status, msg, _ = check_request(
cfg,
"GET", "GET",
"/v1/api/admin/users", "/v1/api/admin/users",
"Bearer tok_read", f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
) )
assert not allowed assert not allowed
assert status == 403 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")
+34 -23
View File
@@ -9,6 +9,24 @@ import pytest
from turnstone.console.collector import ClusterCollector, NodeSnapshot from turnstone.console.collector import ClusterCollector, NodeSnapshot
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-console",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mock storage for collector tests # Mock storage for collector tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -711,13 +729,11 @@ class TestConsoleHTTPEndpoints:
_load_static() _load_static()
from turnstone.core.auth import AuthConfig
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client yield client
client.close() client.close()
@@ -953,12 +969,11 @@ class TestConsoleWorkstreamCreation:
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
# Set up a mock proxy_client (lifespan doesn't run in TestClient) # Set up a mock proxy_client (lifespan doesn't run in TestClient)
@@ -974,7 +989,7 @@ class TestConsoleWorkstreamCreation:
mock_proxy.post = mock_post mock_proxy.post = mock_post
app.state.proxy_client = mock_proxy app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client, mock_post yield client, mock_post
client.close() client.close()
@@ -1151,14 +1166,13 @@ class TestConsoleProxy:
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client yield client
client.close() client.close()
@@ -1322,14 +1336,13 @@ class TestConsoleVersionEndpoints:
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
app = create_app( app = create_app(
collector=mock_collector, collector=mock_collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client yield client
client.close() client.close()
@@ -1364,7 +1377,6 @@ class TestSharedStatic:
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
collector = MagicMock(spec=ClusterCollector) collector = MagicMock(spec=ClusterCollector)
@@ -1376,9 +1388,9 @@ class TestSharedStatic:
} }
app = create_app( app = create_app(
collector=collector, collector=collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client yield client
client.close() client.close()
@@ -1481,7 +1493,6 @@ class TestProxySharedStatic:
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
collector = MagicMock(spec=ClusterCollector) collector = MagicMock(spec=ClusterCollector)
@@ -1494,9 +1505,9 @@ class TestProxySharedStatic:
collector.get_node_detail.return_value = None collector.get_node_detail.return_value = None
app = create_app( app = create_app(
collector=collector, collector=collector,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
client = TestClient(app, raise_server_exceptions=False) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
resp = client.get("/node/unknown/shared/base.css") resp = client.get("/node/unknown/shared/base.css")
assert resp.status_code == 404 assert resp.status_code == 404
client.close() client.close()
@@ -1815,14 +1826,14 @@ class TestProxyAuthHeaders:
# Should use ServiceTokenManager, not mint a user JWT # Should use ServiceTokenManager, not mint a user JWT
assert headers["Authorization"] == f"Bearer {mgr.token}" assert headers["Authorization"] == f"Bearer {mgr.token}"
def test_fallback_static_token(self): def test_no_mgr_no_user_returns_empty(self):
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token.""" """No auth_result, no ServiceTokenManager → empty headers."""
from turnstone.console.server import _proxy_auth_headers from turnstone.console.server import _proxy_auth_headers
req = self._make_request(proxy_auth_token="static-tok-123") req = self._make_request()
headers = _proxy_auth_headers(req) headers = _proxy_auth_headers(req)
assert headers == {"Authorization": "Bearer static-tok-123"} assert headers == {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+40 -7
View File
@@ -13,6 +13,24 @@ from turnstone.console.collector import ClusterCollector
from turnstone.console.router import ConsoleRouter, NodeRef from turnstone.console.router import ConsoleRouter, NodeRef
from turnstone.core.hash_ring import NoAvailableNodeError from turnstone.core.hash_ring import NoAvailableNodeError
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-routing",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -42,12 +60,11 @@ def _make_app(
router: Any = None, router: Any = None,
) -> Any: ) -> Any:
from turnstone.console.server import _load_static, create_app from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static() _load_static()
return create_app( return create_app(
collector=collector or _make_mock_collector(), collector=collector or _make_mock_collector(),
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
router=router, router=router,
) )
@@ -100,6 +117,7 @@ class TestRouteCreate:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test-ws"}, json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@@ -109,6 +127,7 @@ class TestRouteCreate:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test-ws"}, json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@@ -126,6 +145,7 @@ class TestRouteCreate:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"resume_ws": "old_ws_id"}, json={"resume_ws": "old_ws_id"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@@ -150,6 +170,7 @@ class TestRouteCreate:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"target_node": "node-c"}, json={"target_node": "node-c"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@@ -203,6 +224,7 @@ class TestRouteCreate503Retry:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test-ws"}, json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@@ -233,6 +255,7 @@ class TestRouteProxy:
resp = client.post( resp = client.post(
"/v1/api/route/send", "/v1/api/route/send",
json={"ws_id": "abc123", "message": "hello"}, json={"ws_id": "abc123", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
# Verify upstream URL was /v1/api/send (not /v1/api/route/send) # Verify upstream URL was /v1/api/send (not /v1/api/route/send)
@@ -245,6 +268,7 @@ class TestRouteProxy:
resp = client.post( resp = client.post(
"/v1/api/route/approve", "/v1/api/route/approve",
json={"ws_id": "abc123", "approved": True}, json={"ws_id": "abc123", "approved": True},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -252,6 +276,7 @@ class TestRouteProxy:
resp = client.post( resp = client.post(
"/v1/api/route/cancel", "/v1/api/route/cancel",
json={"ws_id": "abc123"}, json={"ws_id": "abc123"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -259,6 +284,7 @@ class TestRouteProxy:
resp = client.post( resp = client.post(
"/v1/api/route/command", "/v1/api/route/command",
json={"ws_id": "abc123", "command": "status"}, json={"ws_id": "abc123", "command": "status"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -266,6 +292,7 @@ class TestRouteProxy:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/close", "/v1/api/route/workstreams/close",
json={"ws_id": "abc123"}, json={"ws_id": "abc123"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -288,14 +315,14 @@ class TestRouteLookup:
client.close() client.close()
def test_route_lookup(self, client): def test_route_lookup(self, client):
resp = client.get("/v1/api/route?ws_id=abc123") resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["node_url"] == "http://a:8080" assert data["node_url"] == "http://a:8080"
assert data["node_id"] == "node-a" assert data["node_id"] == "node-a"
def test_route_lookup_missing_ws_id(self, client): def test_route_lookup_missing_ws_id(self, client):
resp = client.get("/v1/api/route") resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 400 assert resp.status_code == 400
assert "ws_id" in resp.json()["error"] assert "ws_id" in resp.json()["error"]
@@ -329,6 +356,7 @@ class TestRouteNotReady:
resp = client_no_router.post( resp = client_no_router.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test"}, json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
@@ -336,6 +364,7 @@ class TestRouteNotReady:
resp = client_empty_cache.post( resp = client_empty_cache.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test"}, json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
@@ -343,22 +372,24 @@ class TestRouteNotReady:
resp = client_no_router.post( resp = client_no_router.post(
"/v1/api/route/send", "/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"}, json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
def test_route_lookup_no_router_503(self, client_no_router): def test_route_lookup_no_router_503(self, client_no_router):
resp = client_no_router.get("/v1/api/route?ws_id=abc") resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503 assert resp.status_code == 503
def test_route_proxy_empty_cache_503(self, client_empty_cache): def test_route_proxy_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post( resp = client_empty_cache.post(
"/v1/api/route/send", "/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"}, json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
def test_route_lookup_empty_cache_503(self, client_empty_cache): def test_route_lookup_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.get("/v1/api/route?ws_id=abc") resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503 assert resp.status_code == 503
@@ -384,6 +415,7 @@ class TestRouteNoNode:
resp = client.post( resp = client.post(
"/v1/api/route/workstreams/new", "/v1/api/route/workstreams/new",
json={"name": "test"}, json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
assert "No available node" in resp.json()["error"] assert "No available node" in resp.json()["error"]
@@ -392,9 +424,10 @@ class TestRouteNoNode:
resp = client.post( resp = client.post(
"/v1/api/route/send", "/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"}, json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
) )
assert resp.status_code == 503 assert resp.status_code == 503
def test_route_lookup_no_node_503(self, client): def test_route_lookup_no_node_503(self, client):
resp = client.get("/v1/api/route?ws_id=abc") resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503 assert resp.status_code == 503
+38 -48
View File
@@ -8,8 +8,26 @@ import pytest
from starlette.testclient import TestClient from starlette.testclient import TestClient
from turnstone.channels._http import create_channel_app from turnstone.channels._http import create_channel_app
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
from turnstone.core.storage._sqlite import SQLiteBackend from turnstone.core.storage._sqlite import SQLiteBackend
_JWT_SECRET = "a" * 32
def _make_jwt() -> str:
"""Create a valid JWT for channel auth."""
return create_jwt(
user_id="system",
scopes=frozenset({"write"}),
source="service",
secret=_JWT_SECRET,
audience=JWT_AUD_CHANNEL,
)
def _auth_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {_make_jwt()}"}
@pytest.fixture @pytest.fixture
def storage(tmp_path): def storage(tmp_path):
@@ -33,22 +51,22 @@ def no_auth_client(storage, mock_adapter):
@pytest.fixture @pytest.fixture
def client(storage, mock_adapter): def client(storage, mock_adapter):
"""Default client with static auth token configured.""" """Default client with JWT auth configured."""
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token") app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app) return TestClient(app)
@pytest.fixture @pytest.fixture
def authed_client(storage, mock_adapter): def authed_client(storage, mock_adapter):
"""Alias same as client, for auth-specific test clarity.""" """Alias -- same as client, for auth-specific test clarity."""
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token") app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app) return TestClient(app)
@pytest.fixture @pytest.fixture
def jwt_client(storage, mock_adapter): def jwt_client(storage, mock_adapter):
"""Client with JWT auth configured.""" """Client with JWT auth configured."""
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret="a" * 32) app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app) return TestClient(app)
@@ -58,9 +76,6 @@ class TestNotifyEndpoint:
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "ok" assert resp.json()["status"] == "ok"
def _headers(self) -> dict[str, str]:
return {"Authorization": "Bearer test-secret-token"}
def test_direct_discord_target(self, client, mock_adapter): def test_direct_discord_target(self, client, mock_adapter):
resp = client.post( resp = client.post(
"/v1/api/notify", "/v1/api/notify",
@@ -68,7 +83,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123456"}, "target": {"channel_type": "discord", "channel_id": "123456"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 200 assert resp.status_code == 200
results = resp.json()["results"] results = resp.json()["results"]
@@ -85,7 +100,7 @@ class TestNotifyEndpoint:
"message": "Hello!", "message": "Hello!",
"title": "Alert", "title": "Alert",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 200 assert resp.status_code == 200
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!") mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
@@ -101,7 +116,7 @@ class TestNotifyEndpoint:
"target": {"username": "testuser"}, "target": {"username": "testuser"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 200 assert resp.status_code == 200
results = resp.json()["results"] results = resp.json()["results"]
@@ -116,7 +131,7 @@ class TestNotifyEndpoint:
"target": {"username": "nobody"}, "target": {"username": "nobody"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 404 assert resp.status_code == 404
error = resp.json()["error"] error = resp.json()["error"]
@@ -132,10 +147,10 @@ class TestNotifyEndpoint:
"target": {"username": "testuser"}, "target": {"username": "testuser"},
"message": "Hello!", "message": "Hello!",
}, },
headers={"Authorization": "Bearer test-secret-token"}, headers=_auth_headers(),
) )
assert resp.status_code == 404 assert resp.status_code == 404
# Generic message must not differentiate "not found" vs "no channels" # Generic message -- must not differentiate "not found" vs "no channels"
error = resp.json()["error"] error = resp.json()["error"]
assert "testuser" not in error assert "testuser" not in error
assert "not found or has no linked channels" in error assert "not found or has no linked channels" in error
@@ -144,7 +159,7 @@ class TestNotifyEndpoint:
resp = client.post( resp = client.post(
"/v1/api/notify", "/v1/api/notify",
json={"target": {"username": "x"}}, json={"target": {"username": "x"}},
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -152,7 +167,7 @@ class TestNotifyEndpoint:
resp = client.post( resp = client.post(
"/v1/api/notify", "/v1/api/notify",
json={"message": "Hello!"}, json={"message": "Hello!"},
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -163,7 +178,7 @@ class TestNotifyEndpoint:
"target": {"invalid": "field"}, "target": {"invalid": "field"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -175,7 +190,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "email", "channel_id": "test@example.com"}, "target": {"channel_type": "email", "channel_id": "test@example.com"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 200 assert resp.status_code == 200
results = resp.json()["results"] results = resp.json()["results"]
@@ -189,7 +204,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123456"}, "target": {"channel_type": "discord", "channel_id": "123456"},
"message": "Hello!", "message": "Hello!",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 200 assert resp.status_code == 200
results = resp.json()["results"] results = resp.json()["results"]
@@ -201,7 +216,7 @@ class TestNotifyEndpoint:
content=b"not json", content=b"not json",
headers={ headers={
"content-type": "application/json", "content-type": "application/json",
"Authorization": "Bearer test-secret-token", "Authorization": f"Bearer {_make_jwt()}",
}, },
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -214,7 +229,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123"}, "target": {"channel_type": "discord", "channel_id": "123"},
"message": " ", "message": " ",
}, },
headers=self._headers(), headers=_auth_headers(),
) )
assert resp.status_code == 400 assert resp.status_code == 400
@@ -256,30 +271,9 @@ class TestNotifyAuth:
) )
assert resp.status_code == 401 assert resp.status_code == 401
def test_accept_valid_static_token(self, authed_client, mock_adapter):
"""Requests with correct static token are accepted."""
resp = authed_client.post(
"/v1/api/notify",
json={
"target": {"channel_type": "discord", "channel_id": "123"},
"message": "Hello!",
},
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
assert resp.json()["results"][0]["status"] == "sent"
def test_accept_valid_jwt(self, jwt_client, mock_adapter): def test_accept_valid_jwt(self, jwt_client, mock_adapter):
"""Requests with a valid JWT for the channel audience are accepted.""" """Requests with a valid JWT for the channel audience are accepted."""
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt token = _make_jwt()
token = create_jwt(
user_id="system",
scopes=frozenset({"write"}),
source="service",
secret="a" * 32,
audience=JWT_AUD_CHANNEL,
)
resp = jwt_client.post( resp = jwt_client.post(
"/v1/api/notify", "/v1/api/notify",
json={ json={
@@ -292,13 +286,11 @@ class TestNotifyAuth:
def test_reject_jwt_wrong_audience(self, jwt_client): def test_reject_jwt_wrong_audience(self, jwt_client):
"""JWTs with wrong audience are rejected.""" """JWTs with wrong audience are rejected."""
from turnstone.core.auth import create_jwt
token = create_jwt( token = create_jwt(
user_id="system", user_id="system",
scopes=frozenset({"write"}), scopes=frozenset({"write"}),
source="service", source="service",
secret="a" * 32, secret=_JWT_SECRET,
audience="turnstone-server", # wrong audience audience="turnstone-server", # wrong audience
) )
resp = jwt_client.post( resp = jwt_client.post(
@@ -313,8 +305,6 @@ class TestNotifyAuth:
def test_reject_jwt_wrong_secret(self, jwt_client): def test_reject_jwt_wrong_secret(self, jwt_client):
"""JWTs signed with wrong secret are rejected.""" """JWTs signed with wrong secret are rejected."""
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
token = create_jwt( token = create_jwt(
user_id="system", user_id="system",
scopes=frozenset({"write"}), scopes=frozenset({"write"}),
+30 -11
View File
@@ -580,6 +580,24 @@ class TestSessionConfig:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _server_jwt() -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt(
user_id="test-server-live",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
class TestServerHealthMetrics: class TestServerHealthMetrics:
"""Verify /health and /metrics endpoints using a Starlette TestClient. """Verify /health and /metrics endpoints using a Starlette TestClient.
@@ -596,7 +614,6 @@ class TestServerHealthMetrics:
from starlette.testclient import TestClient from starlette.testclient import TestClient
import turnstone.server as srv_mod import turnstone.server as srv_mod
from turnstone.core.auth import AuthConfig
from turnstone.core.metrics import MetricsCollector from turnstone.core.metrics import MetricsCollector
from turnstone.core.workstream import WorkstreamState from turnstone.core.workstream import WorkstreamState
@@ -631,7 +648,7 @@ class TestServerHealthMetrics:
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
) )
cls.client = TestClient(app, raise_server_exceptions=False) cls.client = TestClient(app, raise_server_exceptions=False)
@@ -727,8 +744,8 @@ class TestServerHealthMetrics:
assert 'le="+Inf"' in body assert 'le="+Inf"' in body
def test_unknown_endpoint_returns_404(self): def test_unknown_endpoint_returns_404(self):
status, _, _ = self._get("/does-not-exist") resp = self.client.get("/does-not-exist", headers=_SERVER_AUTH_HEADERS)
assert status == 404 assert resp.status_code == 404
def test_health_contains_backend_field(self): def test_health_contains_backend_field(self):
_, _, body = self._get("/health") _, _, body = self._get("/health")
@@ -772,7 +789,6 @@ class TestServerRateLimiting:
from starlette.testclient import TestClient from starlette.testclient import TestClient
import turnstone.server as srv_mod import turnstone.server as srv_mod
from turnstone.core.auth import AuthConfig
from turnstone.core.metrics import MetricsCollector from turnstone.core.metrics import MetricsCollector
from turnstone.core.ratelimit import RateLimiter from turnstone.core.ratelimit import RateLimiter
from turnstone.core.workstream import WorkstreamState from turnstone.core.workstream import WorkstreamState
@@ -808,7 +824,7 @@ class TestServerRateLimiting:
global_listeners=[], global_listeners=[],
global_listeners_lock=threading.Lock(), global_listeners_lock=threading.Lock(),
skip_permissions=False, skip_permissions=False,
auth_config=AuthConfig(), jwt_secret=_TEST_JWT_SECRET,
rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3), rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3),
) )
cls.client = TestClient(app, raise_server_exceptions=False) cls.client = TestClient(app, raise_server_exceptions=False)
@@ -830,16 +846,19 @@ class TestServerRateLimiting:
"""After exhausting burst on a non-exempt endpoint, get 429.""" """After exhausting burst on a non-exempt endpoint, get 429."""
# Exhaust burst on a non-exempt endpoint # Exhaust burst on a non-exempt endpoint
for _ in range(5): for _ in range(5):
self._get("/v1/api/workstreams") self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
# At least one should be 429 # At least one should be 429
statuses = [self._get("/v1/api/workstreams").status_code for _ in range(3)] statuses = [
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS).status_code
for _ in range(3)
]
assert 429 in statuses assert 429 in statuses
def test_429_includes_retry_after(self): def test_429_includes_retry_after(self):
"""429 response includes Retry-After header.""" """429 response includes Retry-After header."""
# Burn through burst # Burn through burst
for _ in range(10): for _ in range(10):
resp = self._get("/v1/api/workstreams") resp = self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
if resp.status_code == 429: if resp.status_code == 429:
assert "retry-after" in resp.headers assert "retry-after" in resp.headers
data = resp.json() data = resp.json()
@@ -851,7 +870,7 @@ class TestServerRateLimiting:
"""Health endpoint is always accessible regardless of rate limit.""" """Health endpoint is always accessible regardless of rate limit."""
# Burn through bucket on non-exempt path # Burn through bucket on non-exempt path
for _ in range(10): for _ in range(10):
self._get("/v1/api/workstreams") self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
# Health should still work # Health should still work
resp = self._get("/health") resp = self._get("/health")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -859,6 +878,6 @@ class TestServerRateLimiting:
def test_metrics_exempt_from_ratelimit(self): def test_metrics_exempt_from_ratelimit(self):
"""Metrics endpoint is always accessible regardless of rate limit.""" """Metrics endpoint is always accessible regardless of rate limit."""
for _ in range(10): for _ in range(10):
self._get("/v1/api/workstreams") self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
resp = self._get("/metrics") resp = self._get("/metrics")
assert resp.status_code == 200 assert resp.status_code == 200
+109 -2
View File
@@ -55,8 +55,8 @@ def _make_app(tls_manager):
async def _grant_access(request, call_next): # type: ignore[no-untyped-def] async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult( request.state.auth_result = AuthResult(
user_id="", user_id="",
scopes=frozenset({"approve"}), scopes=frozenset({"approve", "service"}),
token_source="config", token_source="test",
) )
return await call_next(request) return await call_next(request)
@@ -124,6 +124,113 @@ def test_delete_cert_not_found(tls_manager):
assert resp.status_code == 404 assert resp.status_code == 404
# ── Auth enforcement ──────────────────────────────────────────────────────────
def _make_app_no_auth(tls_manager):
"""Create app without auth middleware — simulates unauthenticated requests."""
from starlette.applications import Starlette
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
)
app.state.tls_manager = tls_manager
return app
def _make_app_read_only(tls_manager):
"""Create app with read-only auth — should be rejected by admin endpoints."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
from turnstone.core.auth import AuthResult
async def _grant_read(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="viewer",
scopes=frozenset({"read"}),
token_source="jwt",
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_read)],
)
app.state.tls_manager = tls_manager
return app
def test_unauthenticated_list_certs_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.get("/certs")
assert resp.status_code == 401
def test_unauthenticated_renew_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 401
def test_unauthenticated_delete_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 401
def test_read_only_renew_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 403
def test_read_only_delete_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 403
# ── CLI bootstrap ───────────────────────────────────────────────────────────── # ── CLI bootstrap ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -145,7 +145,7 @@ async def test_tls_ca_cert_endpoint(tls_manager):
async def _grant_access(request, call_next): # type: ignore[no-untyped-def] async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult( request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config" user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
) )
return await call_next(request) return await call_next(request)
@@ -190,7 +190,7 @@ async def test_tls_endpoints_disabled():
async def _grant_access(request, call_next): # type: ignore[no-untyped-def] async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult( request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config" user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
) )
return await call_next(request) return await call_next(request)
+1 -3
View File
@@ -56,11 +56,9 @@
# --- Auth (node, console) --- # --- Auth (node, console) ---
[auth] [auth]
# enabled = true # env: TURNSTONE_AUTH_ENABLED # Auth is always enabled. JWT secret is required.
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended) # jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
# env: TURNSTONE_JWT_SECRET # env: TURNSTONE_JWT_SECRET
# token = "" # Static config token for full access
# env: TURNSTONE_AUTH_TOKEN
# --- Logging (turnstone, node, console) --- # --- Logging (turnstone, node, console) ---
+13 -18
View File
@@ -265,9 +265,19 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
url = f"{console_url}/v1/api/admin/tls/certs" url = f"{console_url}/v1/api/admin/tls/certs"
headers = {} headers = {}
token = getattr(args, "auth_token", "") or _get_config_token() # Prefer JWT via ServiceTokenManager when JWT secret is available
if token: jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
headers["Authorization"] = f"Bearer {token}" if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
mgr = ServiceTokenManager(
user_id="admin-cli",
scopes=frozenset({"read", "write", "approve", "service"}),
source="cli",
secret=jwt_secret,
audience=JWT_AUD_CONSOLE,
)
headers["Authorization"] = f"Bearer {mgr.token}"
resp = httpx.get(url, headers=headers) resp = httpx.get(url, headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -283,20 +293,6 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}") print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
def _get_config_token() -> str:
"""Try to load auth token from config.toml or environment."""
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
if token:
return token
try:
from turnstone.core.config import load_config
cfg = load_config("auth")
return str(cfg.get("token", ""))
except Exception:
return ""
def _discover_console_url() -> str: def _discover_console_url() -> str:
"""Discover console URL from the services table.""" """Discover console URL from the services table."""
from turnstone.core.storage import get_storage from turnstone.core.storage import get_storage
@@ -381,7 +377,6 @@ def main() -> None:
p_tlslist = sub.add_parser("tls-list", help="List issued certificates") p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
p_tlslist.add_argument("--console-url", default="", help="Console URL") p_tlslist.add_argument("--console-url", default="", help="Console URL")
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
+6 -6
View File
@@ -82,10 +82,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
- `POSTGRES_USER` PostgreSQL username (default: turnstone) - `POSTGRES_USER` PostgreSQL username (default: turnstone)
- `POSTGRES_PASSWORD` PostgreSQL password (required for production/cluster) - `POSTGRES_PASSWORD` PostgreSQL password (required for production/cluster)
### Authentication ### Authentication (always enabled)
- `TURNSTONE_AUTH_ENABLED` Enable auth (`true`/empty) - `TURNSTONE_JWT_SECRET` JWT signing secret (required). All services must share the same secret. \
- `TURNSTONE_JWT_SECRET` JWT signing secret (required if auth enabled) Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
- `TURNSTONE_AUTH_TOKEN` Static bearer token for inter-service auth
### OIDC SSO (optional) ### OIDC SSO (optional)
- `TURNSTONE_OIDC_ISSUER` OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO. - `TURNSTONE_OIDC_ISSUER` OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
@@ -162,8 +161,9 @@ Walk the user through setting up their deployment step by step:
(may differ from this wizard's model). Ask for base URL, API key, model name. (may differ from this wizard's model). Ask for base URL, API key, model name.
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \ 4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
PostgreSQL is required for cluster mode. PostgreSQL is required for cluster mode.
5. **Security**: Recommend enabling auth for any non-local deployment. \ 5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
Use `generate_secret` for JWT secret, auth token, and Postgres password. \ Use `generate_secret` for JWT secret and Postgres password. \
Always set `TURNSTONE_JWT_SECRET` in the .env. \
Ask for initial admin username and password. \ Ask for initial admin username and password. \
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \ If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \ offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
+3 -13
View File
@@ -37,10 +37,9 @@ async def _handle_health(request: Request) -> JSONResponse:
def _check_auth(request: Request) -> JSONResponse | None: def _check_auth(request: Request) -> JSONResponse | None:
"""Validate the request's Authorization header. Returns an error response or None.""" """Validate the request's Authorization header. Returns an error response or None."""
auth_token: str = getattr(request.app.state, "auth_token", "")
jwt_secret: str = getattr(request.app.state, "jwt_secret", "") jwt_secret: str = getattr(request.app.state, "jwt_secret", "")
if not auth_token and not jwt_secret: if not jwt_secret:
log.warning("notify.auth_not_configured") log.warning("notify.auth_not_configured")
return JSONResponse({"error": "authentication not configured"}, status_code=401) return JSONResponse({"error": "authentication not configured"}, status_code=401)
@@ -50,15 +49,8 @@ def _check_auth(request: Request) -> JSONResponse | None:
token = header[7:] token = header[7:]
# Static token check # JWT validation
if auth_token: if "." in token:
import hmac
if hmac.compare_digest(token, auth_token):
return None
# JWT check
if jwt_secret and "." in token:
from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt
result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL) result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL)
@@ -178,7 +170,6 @@ def create_channel_app(
adapters: dict[str, ChannelAdapter], adapters: dict[str, ChannelAdapter],
storage: StorageBackend, storage: StorageBackend,
*, *,
auth_token: str = "",
jwt_secret: str = "", jwt_secret: str = "",
) -> Starlette: ) -> Starlette:
"""Create the channel gateway HTTP application.""" """Create the channel gateway HTTP application."""
@@ -195,7 +186,6 @@ def create_channel_app(
) )
app.state.adapters = adapters app.state.adapters = adapters
app.state.storage = storage app.state.storage = storage
app.state.auth_token = auth_token
app.state.jwt_secret = jwt_secret app.state.jwt_secret = jwt_secret
return app return app
+1 -12
View File
@@ -72,13 +72,6 @@ def main() -> None:
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file") parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification") parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
# -- Auth ----------------------------------------------------------------
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_CHANNEL_AUTH_TOKEN", ""),
help="Static auth token for /v1/api/notify (default: $TURNSTONE_CHANNEL_AUTH_TOKEN)",
)
# -- Workstream defaults ------------------------------------------------- # -- Workstream defaults -------------------------------------------------
parser.add_argument( parser.add_argument(
"--model", "--model",
@@ -121,7 +114,6 @@ def main() -> None:
) )
# -- Auth config --------------------------------------------------------- # -- Auth config ---------------------------------------------------------
auth_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "") or args.auth_token
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip() jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
# Prefer auto-rotating service JWTs when jwt_secret is available. # Prefer auto-rotating service JWTs when jwt_secret is available.
@@ -132,7 +124,7 @@ def main() -> None:
if jwt_secret: if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, ServiceTokenManager from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, ServiceTokenManager
_scopes = frozenset({"read", "write", "approve"}) _scopes = frozenset({"read", "write", "approve", "service"})
_console_mgr = ServiceTokenManager( _console_mgr = ServiceTokenManager(
user_id="channel-gateway", user_id="channel-gateway",
scopes=_scopes, scopes=_scopes,
@@ -151,7 +143,6 @@ def main() -> None:
) )
_console_token_factory = lambda: _console_mgr.token # noqa: E731 _console_token_factory = lambda: _console_mgr.token # noqa: E731
_server_token_factory = lambda: _server_mgr.token # noqa: E731 _server_token_factory = lambda: _server_mgr.token # noqa: E731
auth_token = "" # don't also pass static token
server_url: str = args.server_url server_url: str = args.server_url
console_url: str = args.console_url console_url: str = args.console_url
@@ -242,7 +233,6 @@ def main() -> None:
config, config,
server_url, server_url,
storage, storage,
api_token=auth_token,
console_url=console_url, console_url=console_url,
console_token_factory=_console_token_factory, console_token_factory=_console_token_factory,
server_token_factory=_server_token_factory, server_token_factory=_server_token_factory,
@@ -253,7 +243,6 @@ def main() -> None:
channel_app = create_channel_app( channel_app = create_channel_app(
adapters, # type: ignore[arg-type] adapters, # type: ignore[arg-type]
storage, storage,
auth_token=auth_token,
jwt_secret=jwt_secret, jwt_secret=jwt_secret,
) )
+14 -9
View File
@@ -633,7 +633,7 @@ def _handle_ws_command(
# ─── Cluster commands ───────────────────────────────────────────────────── # ─── Cluster commands ─────────────────────────────────────────────────────
def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token: str = "") -> None: def _handle_cluster_command(cmd_line: str, console_url: str | None) -> None:
"""Handle /cluster subcommands querying the turnstone-console API.""" """Handle /cluster subcommands querying the turnstone-console API."""
import httpx import httpx
@@ -642,8 +642,18 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
return return
headers: dict[str, str] = {} headers: dict[str, str] = {}
if auth_token: jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
headers["Authorization"] = f"Bearer {auth_token}" if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
_cluster_token_mgr = ServiceTokenManager(
user_id="cli",
scopes=frozenset({"read", "write", "approve", "service"}),
source="cli",
secret=jwt_secret,
audience=JWT_AUD_CONSOLE,
)
headers["Authorization"] = f"Bearer {_cluster_token_mgr.token}"
parts = cmd_line.strip().split() parts = cmd_line.strip().split()
sub = parts[1] if len(parts) > 1 else "status" sub = parts[1] if len(parts) > 1 else "status"
@@ -968,11 +978,6 @@ def main() -> None:
default=None, default=None,
help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)", help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)",
) )
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
help="Bearer token for authenticating to turnstone services (default: $TURNSTONE_AUTH_TOKEN)",
)
parser.add_argument( parser.add_argument(
"--mcp-config", "--mcp-config",
default=None, default=None,
@@ -1247,7 +1252,7 @@ def main() -> None:
continue continue
if user_input.startswith("/cluster"): if user_input.startswith("/cluster"):
_handle_cluster_command(user_input, args.console_url, args.auth_token) _handle_cluster_command(user_input, args.console_url)
continue continue
active = manager.get_active() active = manager.get_active()
+1 -6
View File
@@ -59,7 +59,6 @@ class ClusterCollector:
storage: StorageBackend, storage: StorageBackend,
discovery_interval: float = 60.0, discovery_interval: float = 60.0,
http_timeout: float = 30.0, http_timeout: float = 30.0,
auth_token: str = "",
token_manager: ServiceTokenManager | None = None, token_manager: ServiceTokenManager | None = None,
tls_verify: Any = True, tls_verify: Any = True,
tls_cert: tuple[str, str] | None = None, tls_cert: tuple[str, str] | None = None,
@@ -74,10 +73,6 @@ class ClusterCollector:
self._console_metrics = console_metrics self._console_metrics = console_metrics
self._tls_verify = tls_verify self._tls_verify = tls_verify
self._tls_cert = tls_cert self._tls_cert = tls_cert
# Static auth header — only used when no token_manager is present.
self._static_auth: dict[str, str] | None = None
if auth_token and token_manager is None:
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
self._lock = threading.Lock() self._lock = threading.Lock()
self._nodes: dict[str, NodeSnapshot] = {} self._nodes: dict[str, NodeSnapshot] = {}
@@ -165,7 +160,7 @@ class ClusterCollector:
"""Build auth headers for the current SSE connection.""" """Build auth headers for the current SSE connection."""
if self._token_manager is not None: if self._token_manager is not None:
return {"Authorization": f"Bearer {self._token_manager.token}"} return {"Authorization": f"Bearer {self._token_manager.token}"}
return dict(self._static_auth) if self._static_auth else {} return {}
# -- SSE manager --------------------------------------------------------- # -- SSE manager ---------------------------------------------------------
+5 -1
View File
@@ -64,6 +64,7 @@ class Rebalancer:
lock_ttl: int = 120, lock_ttl: int = 120,
eager_migrate: bool = False, eager_migrate: bool = False,
api_token: str = "", api_token: str = "",
token_manager: Any = None,
) -> None: ) -> None:
self._storage = storage self._storage = storage
self._router = router self._router = router
@@ -75,6 +76,7 @@ class Rebalancer:
self._lock_ttl = lock_ttl self._lock_ttl = lock_ttl
self._eager_migrate = eager_migrate self._eager_migrate = eager_migrate
self._api_token = api_token self._api_token = api_token
self._token_manager = token_manager
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._trigger_event = threading.Event() self._trigger_event = threading.Event()
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@@ -530,7 +532,9 @@ class Rebalancer:
return 0 return 0
headers: dict[str, str] = {} headers: dict[str, str] = {}
if self._api_token: if self._token_manager is not None:
headers["Authorization"] = f"Bearer {self._token_manager.token}"
elif self._api_token:
headers["Authorization"] = f"Bearer {self._api_token}" headers["Authorization"] = f"Bearer {self._api_token}"
migrated = 0 migrated = 0
+26 -69
View File
@@ -162,19 +162,11 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]:
) )
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
# Fallback: service identity (no user context). # Fallback: service identity via ServiceTokenManager.
# When auth is disabled on the console, auth_result is None, so all proxied
# requests use the full-privilege service identity. This is safe only when
# the upstream server also has auth disabled.
mgr = getattr(request.app.state, "proxy_token_mgr", None) mgr = getattr(request.app.state, "proxy_token_mgr", None)
if mgr is not None: if mgr is not None:
return dict(mgr.bearer_header) return dict(mgr.bearer_header)
# Fall back to static proxy_auth_token (e.g. from --auth-token)
static_token = getattr(request.app.state, "proxy_auth_token", "")
if static_token:
return {"Authorization": f"Bearer {static_token}"}
return {} return {}
@@ -5924,10 +5916,8 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None:
def create_app( def create_app(
*, *,
collector: ClusterCollector, collector: ClusterCollector,
auth_config: Any,
jwt_secret: str = "", jwt_secret: str = "",
auth_storage: Any = None, auth_storage: Any = None,
proxy_auth_token: str = "",
proxy_token_mgr: Any = None, proxy_token_mgr: Any = None,
cors_origins: list[str] | None = None, cors_origins: list[str] | None = None,
tls_manager: Any = None, tls_manager: Any = None,
@@ -6265,10 +6255,8 @@ def create_app(
lifespan=_lifespan, lifespan=_lifespan,
) )
app.state.collector = collector app.state.collector = collector
app.state.auth_config = auth_config
app.state.jwt_secret = jwt_secret app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage app.state.auth_storage = auth_storage
app.state.proxy_auth_token = proxy_auth_token
app.state.proxy_token_mgr = proxy_token_mgr app.state.proxy_token_mgr = proxy_token_mgr
app.state.console_url = console_url app.state.console_url = console_url
app.state.tls_manager = tls_manager app.state.tls_manager = tls_manager
@@ -6301,7 +6289,7 @@ def create_app(
scheduler = TaskScheduler( scheduler = TaskScheduler(
collector=collector, collector=collector,
storage=auth_storage, storage=auth_storage,
api_token=proxy_auth_token, api_token="",
token_manager=proxy_token_mgr, token_manager=proxy_token_mgr,
) )
app.state.scheduler = scheduler app.state.scheduler = scheduler
@@ -6351,12 +6339,6 @@ def main() -> None:
from turnstone.core.log import add_log_args from turnstone.core.log import add_log_args
add_log_args(parser) add_log_args(parser)
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)",
)
from turnstone.core.config import add_config_arg, apply_config from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser) add_config_arg(parser)
@@ -6367,10 +6349,9 @@ def main() -> None:
configure_logging_from_args(args, "console") configure_logging_from_args(args, "console")
from turnstone.core.auth import load_auth_config, load_jwt_secret from turnstone.core.auth import load_jwt_secret
auth_config = load_auth_config() jwt_secret = load_jwt_secret()
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
# Initialize storage early — the collector needs it for service discovery. # Initialize storage early — the collector needs it for service discovery.
auth_storage = None auth_storage = None
@@ -6399,38 +6380,23 @@ def main() -> None:
) )
raise SystemExit(1) raise SystemExit(1)
# If no explicit auth token is provided, use a ServiceTokenManager from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
# so collector JWTs auto-rotate. A shared JWT secret is required for
# multi-service deployments — ephemeral secrets differ per process.
collector_token = args.auth_token
collector_token_mgr = None
if not collector_token:
_jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "")
if not _jwt_secret:
log.error(
"TURNSTONE_JWT_SECRET is not set and no --auth-token provided. "
"The console cannot authenticate to server nodes. Set TURNSTONE_JWT_SECRET "
"to a shared secret (at least 32 characters) or pass --auth-token."
)
raise SystemExit(1)
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
collector_token_mgr = ServiceTokenManager( collector_token_mgr = ServiceTokenManager(
user_id="console-collector", user_id="console-collector",
scopes=frozenset({"read"}), scopes=frozenset({"read"}),
source="console", source="console",
secret=_jwt_secret, secret=jwt_secret,
audience=JWT_AUD_SERVER, audience=JWT_AUD_SERVER,
expiry_hours=1, expiry_hours=1,
) )
log.info("console.collector_token_manager_created") log.info("console.collector_token_manager_created")
router = ConsoleRouter(storage=auth_storage) router = ConsoleRouter(storage=auth_storage)
console_metrics = ConsoleMetrics() console_metrics = ConsoleMetrics()
collector = ClusterCollector( collector = ClusterCollector(
storage=auth_storage, storage=auth_storage,
auth_token=collector_token if collector_token_mgr is None else "",
token_manager=collector_token_mgr, token_manager=collector_token_mgr,
router=router, router=router,
console_metrics=console_metrics, console_metrics=console_metrics,
@@ -6439,22 +6405,15 @@ def main() -> None:
_load_static() _load_static()
# If no explicit auth token is provided, use a ServiceTokenManager proxy_token_mgr = ServiceTokenManager(
# so proxy JWTs auto-rotate. user_id="console-proxy",
proxy_token = args.auth_token scopes=frozenset({"read", "write", "approve", "service"}),
proxy_token_mgr = None source="console",
if not proxy_token and jwt_secret: secret=jwt_secret,
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager audience=JWT_AUD_SERVER,
expiry_hours=1,
proxy_token_mgr = ServiceTokenManager( )
user_id="console-proxy", log.info("console.proxy_token_manager_created")
scopes=frozenset({"read", "write", "approve"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.proxy_token_manager_created")
from turnstone.core.web_helpers import parse_cors_origins from turnstone.core.web_helpers import parse_cors_origins
@@ -6541,7 +6500,8 @@ def main() -> None:
threshold=_rcs.get("rebalancer.threshold", 0.10), threshold=_rcs.get("rebalancer.threshold", 0.10),
vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150), vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150),
eager_migrate=_rcs.get("rebalancer.eager_migrate", False), eager_migrate=_rcs.get("rebalancer.eager_migrate", False),
api_token=proxy_token if proxy_token_mgr is None else "", api_token="",
token_manager=proxy_token_mgr,
) )
log.info("rebalancer.configured") log.info("rebalancer.configured")
except Exception: except Exception:
@@ -6549,10 +6509,8 @@ def main() -> None:
app = create_app( app = create_app(
collector=collector, collector=collector,
auth_config=auth_config,
jwt_secret=jwt_secret, jwt_secret=jwt_secret,
auth_storage=auth_storage, auth_storage=auth_storage,
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
proxy_token_mgr=proxy_token_mgr, proxy_token_mgr=proxy_token_mgr,
cors_origins=cors_origins, cors_origins=cors_origins,
tls_manager=tls_mgr, tls_manager=tls_mgr,
@@ -6563,8 +6521,7 @@ def main() -> None:
) )
log.info("Console starting on %s", console_url) log.info("Console starting on %s", console_url)
if auth_config.enabled: log.info("Auth: enabled (JWT)")
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
print("Press Ctrl+C to stop.") print("Press Ctrl+C to stop.")
import uvicorn import uvicorn
+31 -130
View File
@@ -1,15 +1,12 @@
"""Bearer token authentication and authorization for turnstone HTTP servers. """Bearer token authentication and authorization for turnstone HTTP servers.
Supports three token types: Supports two token types:
1. **Config-file tokens** static tokens in ``config.toml`` or the 1. **API tokens** database-backed, prefixed ``ts_``, stored as SHA-256
``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``. hashes. Exchanged for JWTs via ``/api/auth/login``.
3. **JWTs** short-lived session tokens issued after API token validation. 2. **JWTs** short-lived session tokens issued after login or by
Validated locally via shared HMAC-SHA256 secret. Contain user_id and :class:`ServiceTokenManager`. Validated locally via shared HMAC-SHA256
scopes in claims. secret. Contain user_id and scopes in claims.
Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``, Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``,
``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are ``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are
@@ -19,7 +16,6 @@ always accessible without authentication.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import hmac
import json import json
import os import os
import re import re
@@ -28,7 +24,7 @@ import threading
import time import time
import urllib.parse import urllib.parse
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -56,7 +52,7 @@ JWT_AUD_CONSOLE = "turnstone-console"
JWT_AUD_CHANNEL = "turnstone-channel" JWT_AUD_CHANNEL = "turnstone-channel"
_MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256 _MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve"}) VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$") _USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
USERNAME_MAX_LEN = 64 USERNAME_MAX_LEN = 64
@@ -72,16 +68,12 @@ def is_valid_username(username: str) -> bool:
# Hierarchical: each scope implies all lower scopes. # Hierarchical: each scope implies all lower scopes.
# "service" is a superset that grants full access + bypasses RBAC permission checks.
SCOPE_HIERARCHY: dict[str, frozenset[str]] = { SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
"read": frozenset({"read"}), "read": frozenset({"read"}),
"write": frozenset({"read", "write"}), "write": frozenset({"read", "write"}),
"approve": frozenset({"read", "write", "approve"}), "approve": frozenset({"read", "write", "approve"}),
} "service": frozenset({"read", "write", "approve", "service"}),
# Map old role names to scope sets.
_ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
"read": frozenset({"read"}),
"full": frozenset({"read", "write", "approve"}),
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -106,7 +98,7 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
scopes.add("read") scopes.add("read")
return frozenset(scopes) return frozenset(scopes)
for perm in permissions: for perm in permissions:
if perm in VALID_SCOPES: if perm in VALID_SCOPES and perm != "service":
scopes.update(SCOPE_HIERARCHY.get(perm, {perm})) scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
# Any admin.* permission requires access to admin endpoints → approve scope # Any admin.* permission requires access to admin endpoints → approve scope
if any(p.startswith("admin.") for p in permissions): if any(p.startswith("admin.") for p in permissions):
@@ -120,15 +112,14 @@ def require_permission(request: Request, permission: str) -> JSONResponse | None
"""Return a 403 JSONResponse if the user lacks *permission*, else None. """Return a 403 JSONResponse if the user lacks *permission*, else None.
Call from admin handlers after the middleware scope check passes. Call from admin handlers after the middleware scope check passes.
Config-file tokens (no user_id) are treated as full-access. Service tokens (scope ``service``) bypass permission checks.
""" """
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None) auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
if auth_result is None: if auth_result is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401) return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Config-file tokens (no user_id) are treated as full-access if auth_result.has_scope("service"):
if not auth_result.user_id:
return None return None
if auth_result.has_permission(permission): if auth_result.has_permission(permission):
return None return None
@@ -200,9 +191,9 @@ def _strip_version_prefix(path: str) -> str:
class AuthResult: class AuthResult:
"""Result of successful authentication.""" """Result of successful authentication."""
user_id: str # empty string for config-file tokens user_id: str
scopes: frozenset[str] scopes: frozenset[str]
token_source: str # "config", "jwt", "database" token_source: str # "jwt", "database", "password", or service origin (e.g. "console", "cli")
permissions: frozenset[str] = frozenset() permissions: frozenset[str] = frozenset()
def has_scope(self, scope: str) -> bool: def has_scope(self, scope: str) -> bool:
@@ -214,28 +205,6 @@ class AuthResult:
return permission in self.permissions return permission in self.permissions
# ---------------------------------------------------------------------------
# AuthConfig (unchanged from before — static config-file tokens)
# ---------------------------------------------------------------------------
@dataclass
class AuthConfig:
"""Auth configuration loaded once at startup (not modified after creation)."""
enabled: bool = False
tokens: dict[str, str] = field(default_factory=dict) # token_value → role
def check(self, token: str | None) -> str | None:
"""Return the role for a valid config token, or *None*."""
if not token:
return None
for known_token, role in self.tokens.items():
if hmac.compare_digest(token, known_token):
return role
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Token generation and hashing # Token generation and hashing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -303,7 +272,11 @@ def parse_scopes(scopes_str: str) -> frozenset[str]:
def load_jwt_secret() -> str: def load_jwt_secret() -> str:
"""Load JWT signing secret from env or config, or auto-generate.""" """Load JWT signing secret from env or config.
Raises :class:`SystemExit` if no secret is configured. A JWT secret
is required for auth, inter-service communication, and session tokens.
"""
secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip() secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
if not secret: if not secret:
from turnstone.core.config import load_config from turnstone.core.config import load_config
@@ -312,18 +285,19 @@ def load_jwt_secret() -> str:
secret = str(auth_cfg.get("jwt_secret", "")).strip() secret = str(auth_cfg.get("jwt_secret", "")).strip()
if not secret: if not secret:
# Auto-generate an ephemeral secret log.error(
secret = secrets.token_hex(32) "TURNSTONE_JWT_SECRET is required but not set. "
log.warning( 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
"No JWT secret configured — using ephemeral secret (tokens will not survive restart)"
) )
return secret raise SystemExit(1)
if len(secret) < _MIN_SECRET_LENGTH: if len(secret) < _MIN_SECRET_LENGTH:
log.warning( log.error(
"JWT secret is shorter than %d characters — consider using a stronger secret", "JWT secret must be at least %d characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"',
_MIN_SECRET_LENGTH, _MIN_SECRET_LENGTH,
) )
raise SystemExit(1)
return secret return secret
@@ -397,62 +371,6 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
) )
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
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 = false # opt out
[[auth.tokens]]
value = "tok_abc123"
role = "full"
Environment variables:
- ``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", True))
tokens: dict[str, str] = {}
# Tokens from config file (TOML array-of-tables)
for entry in auth_cfg.get("tokens", []):
value = entry.get("value", "") if isinstance(entry, dict) else ""
role = entry.get("role", "read") if isinstance(entry, dict) else ""
if value and role in ("read", "full"):
tokens[value] = role
# 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.info("Auth enabled (no config tokens — use /api/auth/setup or turnstone-admin)")
return AuthConfig(enabled=enabled, tokens=tokens)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Path helpers # Path helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -529,7 +447,6 @@ def _extract_proxied_path(normalized: str) -> str | None:
def check_request( def check_request(
auth_config: AuthConfig,
method: str, method: str,
path: str, path: str,
auth_header: str | None, auth_header: str | None,
@@ -539,20 +456,16 @@ def check_request(
jwt_audience: str = "", jwt_audience: str = "",
storage: Any = None, storage: Any = None,
) -> tuple[bool, int, str, AuthResult | None]: ) -> tuple[bool, int, str, AuthResult | None]:
"""Validate a request against the auth config. """Validate a request.
Checks ``Authorization: Bearer <token>`` first, then falls back to the Checks ``Authorization: Bearer <token>`` first, then falls back to the
``turnstone_auth`` cookie. Token types are auto-detected: ``turnstone_auth`` cookie. Token types are auto-detected:
- Contains ``.`` JWT (validated with *jwt_secret*) - Contains ``.`` JWT (validated with *jwt_secret*)
- Starts with ``ts_`` API token (looked up in *storage* by hash) - Starts with ``ts_`` API token (looked up in *storage* by hash)
- Otherwise config-file token (hmac check)
Returns ``(allowed, status_code, message, auth_result)``. Returns ``(allowed, status_code, message, auth_result)``.
""" """
if not auth_config.enabled:
return True, 200, "", None
if is_public_path(path): if is_public_path(path):
return True, 200, "", None return True, 200, "", None
@@ -566,7 +479,7 @@ def check_request(
# Authenticate # Authenticate
result = _authenticate_token( result = _authenticate_token(
raw_token, auth_config, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage raw_token, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
) )
if result is None: if result is None:
return False, 401, "Unauthorized: missing or invalid token", None return False, 401, "Unauthorized: missing or invalid token", None
@@ -581,7 +494,6 @@ def check_request(
def _authenticate_token( def _authenticate_token(
token: str, token: str,
auth_config: AuthConfig,
*, *,
jwt_secret: str = "", jwt_secret: str = "",
jwt_audience: str = "", jwt_audience: str = "",
@@ -601,12 +513,6 @@ def _authenticate_token(
if token.startswith(TOKEN_PREFIX) and storage is not None: if token.startswith(TOKEN_PREFIX) and storage is not None:
return _authenticate_api_token(token, storage) 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")
return None return None
@@ -852,7 +758,6 @@ class AuthMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
auth_config = request.app.state.auth_config
jwt_secret = getattr(request.app.state, "jwt_secret", "") jwt_secret = getattr(request.app.state, "jwt_secret", "")
storage = getattr(request.app.state, "auth_storage", None) storage = getattr(request.app.state, "auth_storage", None)
method = request.method method = request.method
@@ -860,7 +765,6 @@ class AuthMiddleware:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
cookie_header = request.headers.get("Cookie") cookie_header = request.headers.get("Cookie")
allowed, status, msg, auth_result = check_request( allowed, status, msg, auth_result = check_request(
auth_config,
method, method,
path, path,
auth_header, auth_header,
@@ -904,7 +808,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
except (ValueError, json.JSONDecodeError): except (ValueError, json.JSONDecodeError):
return JSONResponse({"error": "Invalid JSON body"}, status_code=400) return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
auth_config = request.app.state.auth_config
jwt_secret = getattr(request.app.state, "jwt_secret", "") jwt_secret = getattr(request.app.state, "jwt_secret", "")
storage = getattr(request.app.state, "auth_storage", None) storage = getattr(request.app.state, "auth_storage", None)
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None) login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
@@ -955,7 +858,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
elif body.get("token"): elif body.get("token"):
result = _authenticate_token( result = _authenticate_token(
body["token"], body["token"],
auth_config,
jwt_secret=jwt_secret, jwt_secret=jwt_secret,
jwt_audience=audience, jwt_audience=audience,
storage=storage, storage=storage,
@@ -1011,7 +913,6 @@ async def handle_auth_status(request: Request) -> Response:
"""Shared ``GET /api/auth/status`` handler — login UI state detection.""" """Shared ``GET /api/auth/status`` handler — login UI state detection."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
auth_config = request.app.state.auth_config
storage = getattr(request.app.state, "auth_storage", None) storage = getattr(request.app.state, "auth_storage", None)
has_users = False has_users = False
@@ -1027,9 +928,9 @@ async def handle_auth_status(request: Request) -> Response:
oidc_enabled = bool(oidc_config and oidc_config.enabled) oidc_enabled = bool(oidc_config and oidc_config.enabled)
resp: dict[str, Any] = { resp: dict[str, Any] = {
"auth_enabled": auth_config.enabled, "auth_enabled": True,
"has_users": has_users, "has_users": has_users,
"setup_required": auth_config.enabled and not has_users, "setup_required": not has_users,
} }
if oidc_enabled and oidc_config is not None: if oidc_enabled and oidc_config is not None:
resp["oidc_enabled"] = True resp["oidc_enabled"] = True
-1
View File
@@ -66,7 +66,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"TAVILY_API_KEY", "TAVILY_API_KEY",
"TURNSTONE_JWT_SECRET", "TURNSTONE_JWT_SECRET",
"TURNSTONE_AUTH_TOKEN",
"TURNSTONE_DISCORD_TOKEN", "TURNSTONE_DISCORD_TOKEN",
"TURNSTONE_GITHUB_TOKEN", "TURNSTONE_GITHUB_TOKEN",
"TURNSTONE_OIDC_CLIENT_SECRET", "TURNSTONE_OIDC_CLIENT_SECRET",
+4 -4
View File
@@ -4,7 +4,7 @@ Usage::
from turnstone.sdk import TurnstoneConsole from turnstone.sdk import TurnstoneConsole
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client: with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
overview = client.overview() overview = client.overview()
print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}") print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}")
""" """
@@ -73,7 +73,7 @@ class AsyncTurnstoneConsole(_BaseClient):
def __init__( def __init__(
self, self,
base_url: str = "http://localhost:8081", base_url: str = "http://localhost:8090",
token: str = "", token: str = "",
timeout: float = 30.0, timeout: float = 30.0,
httpx_client: httpx.AsyncClient | None = None, httpx_client: httpx.AsyncClient | None = None,
@@ -961,14 +961,14 @@ class TurnstoneConsole:
Usage:: Usage::
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client: with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
overview = client.overview() overview = client.overview()
print(f"Nodes: {overview.nodes}") print(f"Nodes: {overview.nodes}")
""" """
def __init__( def __init__(
self, self,
base_url: str = "http://localhost:8081", base_url: str = "http://localhost:8090",
token: str = "", token: str = "",
timeout: float = 30.0, timeout: float = 30.0,
ca_cert: str | None = None, ca_cert: str | None = None,
+2 -2
View File
@@ -4,7 +4,7 @@ Usage::
from turnstone.sdk import TurnstoneServer from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client: with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
ws = client.create_workstream(name="Analysis") ws = client.create_workstream(name="Analysis")
result = client.send_and_wait("Hello", ws.ws_id) result = client.send_and_wait("Hello", ws.ws_id)
print(result.content) print(result.content)
@@ -432,7 +432,7 @@ class TurnstoneServer:
Usage:: Usage::
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client: with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
ws = client.create_workstream(name="Analysis") ws = client.create_workstream(name="Analysis")
result = client.send_and_wait("Hello", ws.ws_id) result = client.send_and_wait("Hello", ws.ws_id)
print(result.content) print(result.content)
+3 -8
View File
@@ -2449,7 +2449,6 @@ def create_app(
global_listeners: list[queue.Queue[dict[str, Any]]], global_listeners: list[queue.Queue[dict[str, Any]]],
global_listeners_lock: threading.Lock, global_listeners_lock: threading.Lock,
skip_permissions: bool, skip_permissions: bool,
auth_config: Any,
jwt_secret: str = "", jwt_secret: str = "",
auth_storage: Any = None, auth_storage: Any = None,
health_monitor: Any = None, health_monitor: Any = None,
@@ -2530,7 +2529,6 @@ def create_app(
app.state.global_listeners = global_listeners app.state.global_listeners = global_listeners
app.state.global_listeners_lock = global_listeners_lock app.state.global_listeners_lock = global_listeners_lock
app.state.skip_permissions = skip_permissions app.state.skip_permissions = skip_permissions
app.state.auth_config = auth_config
app.state.jwt_secret = jwt_secret app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage app.state.auth_storage = auth_storage
app.state.health_monitor = health_monitor app.state.health_monitor = health_monitor
@@ -3007,13 +3005,11 @@ def main() -> None:
_metrics.set_judge_enabled(judge_config.enabled if judge_config else False) _metrics.set_judge_enabled(judge_config.enabled if judge_config else False)
# Auth config # Auth config
from turnstone.core.auth import load_auth_config, load_jwt_secret from turnstone.core.auth import load_jwt_secret
from turnstone.core.storage import get_storage from turnstone.core.storage import get_storage
auth_config = load_auth_config() jwt_secret = load_jwt_secret()
jwt_secret = load_jwt_secret() if auth_config.enabled else "" log.info("Auth: enabled (JWT)")
if auth_config.enabled:
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
# Build the ASGI app # Build the ASGI app
from turnstone.core.web_helpers import parse_cors_origins from turnstone.core.web_helpers import parse_cors_origins
@@ -3038,7 +3034,6 @@ def main() -> None:
global_listeners=global_listeners, global_listeners=global_listeners,
global_listeners_lock=global_listeners_lock, global_listeners_lock=global_listeners_lock,
skip_permissions=_skip_perms, skip_permissions=_skip_perms,
auth_config=auth_config,
jwt_secret=jwt_secret, jwt_secret=jwt_secret,
auth_storage=get_storage(), auth_storage=get_storage(),
health_monitor=health_monitor, health_monitor=health_monitor,