mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
+8
-11
@@ -85,9 +85,8 @@ services:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
@@ -124,9 +123,8 @@ services:
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
@@ -161,8 +159,8 @@ services:
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
@@ -208,9 +206,8 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
|
||||
@@ -36,13 +36,13 @@ spec:
|
||||
- secretRef:
|
||||
name: {{ include "turnstone.llm.secretName" . }}
|
||||
optional: true
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
env:
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -41,12 +41,12 @@ spec:
|
||||
env:
|
||||
- name: TURNSTONE_DB_URL
|
||||
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 }}
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -15,7 +15,7 @@ data:
|
||||
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
|
||||
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
|
||||
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -59,10 +59,9 @@ llm:
|
||||
apiKey: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Authentication
|
||||
# -- Authentication (always enabled, JWT secret required)
|
||||
auth:
|
||||
enabled: false
|
||||
token: ""
|
||||
jwtSecret: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Ingress configuration
|
||||
|
||||
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
|
||||
[
|
||||
aws_secretsmanager_secret.openai_api_key.arn,
|
||||
aws_secretsmanager_secret.db_password.arn,
|
||||
aws_secretsmanager_secret.jwt_secret.arn,
|
||||
],
|
||||
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
@@ -41,20 +41,26 @@ locals {
|
||||
},
|
||||
]
|
||||
|
||||
auth_env = var.auth_token != "" ? [
|
||||
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
|
||||
] : []
|
||||
|
||||
auth_secrets = var.auth_token != "" ? [
|
||||
auth_secrets = [
|
||||
{
|
||||
name = "TURNSTONE_AUTH_TOKEN"
|
||||
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
|
||||
name = "TURNSTONE_JWT_SECRET"
|
||||
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
|
||||
},
|
||||
] : []
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- 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" {
|
||||
name = "${var.name_prefix}-${var.environment}-openai-api-key"
|
||||
tags = local.common_tags
|
||||
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "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" {
|
||||
name = "${var.name_prefix}-${var.environment}-db-password"
|
||||
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
|
||||
{ containerPort = 8080, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
|
||||
{ containerPort = 8090, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
|
||||
@@ -90,11 +90,10 @@ variable "name_prefix" {
|
||||
default = "turnstone"
|
||||
}
|
||||
|
||||
variable "auth_token" {
|
||||
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
|
||||
variable "jwt_secret" {
|
||||
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
|
||||
@@ -56,7 +56,7 @@ console.log(result.content);
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
|
||||
Auth is always enabled. All API endpoints except public paths require a valid token.
|
||||
|
||||
### Sending Credentials
|
||||
|
||||
@@ -65,15 +65,14 @@ Include a token in one of two ways:
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
|
||||
The server accepts three token types:
|
||||
The server accepts two token types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| JWT | Base64 segments separated by dots | `eyJhbG...` |
|
||||
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
|
||||
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
|
||||
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
|
||||
|
||||
### `POST /v1/api/auth/login`
|
||||
|
||||
|
||||
@@ -1016,13 +1016,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||||
Turnstone supports three authentication mechanisms, unified behind an
|
||||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||||
|
||||
1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]`
|
||||
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
|
||||
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
|
||||
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
1. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||||
`POST /v1/api/auth/login`.
|
||||
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||||
`src` (origin) in claims.
|
||||
|
||||
@@ -1046,9 +1043,8 @@ Three hierarchical scopes control endpoint access:
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token; otherwise config-file token.
|
||||
4. **Validation** — JWT signature check, API token hash lookup in storage, or
|
||||
config-token hmac comparison.
|
||||
indicates API token.
|
||||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||||
scope; the request is rejected with 403 if the token lacks it.
|
||||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||||
|
||||
+5
-6
@@ -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) |
|
||||
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
||||
| `--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-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
@@ -321,11 +320,11 @@ The `services` table schema:
|
||||
### Security
|
||||
|
||||
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
||||
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
|
||||
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
||||
or a static token via `--auth-token`. If neither is set, the
|
||||
gateway fails closed and rejects all requests with 401. Server JWTs
|
||||
(`aud: turnstone-server`) are rejected.
|
||||
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
||||
server can mint JWTs with `aud: turnstone-channel` automatically.
|
||||
If the secret is not set, the gateway fails closed and rejects all
|
||||
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
||||
rejected.
|
||||
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
||||
increments on successful delivery, so failures don't consume the
|
||||
budget.
|
||||
|
||||
+1
-2
@@ -628,7 +628,6 @@ CLI flags for `turnstone-console`:
|
||||
|------|---------|-------------|
|
||||
| `--host` | `0.0.0.0` | Bind host |
|
||||
| `--port` | `8090` | HTTP port |
|
||||
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
|
||||
| `--log-level` | `INFO` | Log level |
|
||||
|
||||
Config file (`~/.config/turnstone/config.toml`):
|
||||
@@ -649,7 +648,7 @@ url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
turnstone-server --port 8080
|
||||
|
||||
# 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.
|
||||
|
||||
+3
-3
@@ -72,11 +72,11 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|
||||
### Auth
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `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) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
### Database
|
||||
|
||||
|
||||
+5
-5
@@ -38,7 +38,7 @@ are set.
|
||||
| `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_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. |
|
||||
|
||||
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
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token)
|
||||
continues to work regardless of this setting. JWTs and API tokens are
|
||||
the supported authentication methods. OIDC-only mode affects
|
||||
password-based authentication only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -332,6 +332,6 @@ client.login(token="ts_abc123...")
|
||||
- `client.logout()` clears the stored JWT from the client.
|
||||
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
|
||||
|
||||
### Backward Compatibility
|
||||
### 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
@@ -8,23 +8,6 @@ credentials while individual server nodes validate JWTs locally.
|
||||
|
||||
## Token Types
|
||||
|
||||
### Config-file tokens
|
||||
|
||||
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
||||
environment variable. Validated in-memory using `hmac.compare_digest`
|
||||
(timing-safe). Each token maps to a role that determines its scopes.
|
||||
|
||||
```toml
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full" # full → {read, write, approve}
|
||||
```
|
||||
|
||||
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
||||
|
||||
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
||||
on every request. No JWT exchange is needed.
|
||||
|
||||
### API tokens
|
||||
|
||||
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
||||
@@ -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
|
||||
automated clients that need cookie-based sessions.
|
||||
|
||||
### Config-file tokens (direct)
|
||||
|
||||
Config tokens are validated per-request via `hmac.compare_digest`. No
|
||||
login exchange is needed — include the token as a `Bearer` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer tok_legacy
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
When no users exist in the database:
|
||||
@@ -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
|
||||
level. The setup wizard always works regardless of this setting — the
|
||||
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
|
||||
|
||||
@@ -297,8 +271,6 @@ and classifies the token:
|
||||
|
||||
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
||||
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
||||
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
||||
each configured token
|
||||
|
||||
If a session cookie is present and no `Authorization` header is sent,
|
||||
the cookie value is treated as a JWT (step 1).
|
||||
@@ -332,16 +304,10 @@ deployments.
|
||||
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
||||
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
||||
| 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
|
||||
secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
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.
|
||||
All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
|
||||
missing or shorter than 32 characters.
|
||||
|
||||
---
|
||||
|
||||
@@ -442,16 +408,15 @@ Console (cluster-wide) Server (per-node)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ User/Token CRUD (DB) │ │ JWT validation only │
|
||||
│ Login: creds → JWT │ │ (shared signing key) │
|
||||
│ Admin API endpoints │ │ Config tokens: hmac │
|
||||
│ Storage: users, │ │ No auth DB needed │
|
||||
│ Admin API endpoints │ │ No auth DB needed │
|
||||
│ Storage: users, │ │ │
|
||||
│ api_tokens tables │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The console owns the credential database and handles all user/token
|
||||
CRUD. Individual server nodes only need the JWT signing secret to
|
||||
validate session tokens. Config-file tokens are validated locally
|
||||
without any database.
|
||||
validate session tokens.
|
||||
|
||||
### 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),
|
||||
the proxy falls back to a `ServiceTokenManager` with service identity
|
||||
`console-proxy` and full scopes. If `--auth-token` is provided, that
|
||||
static token is used as a final fallback.
|
||||
`console-proxy` and full scopes.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
@@ -518,22 +482,17 @@ channel gateway endpoint, and vice versa.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
jwt_secret = "your-secret-key-here"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
|
||||
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
|
||||
|
||||
---
|
||||
@@ -571,8 +530,6 @@ and browsers enforce same-origin policy.
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **Timing-safe comparison** for config-file tokens via
|
||||
`hmac.compare_digest` — no timing side-channel.
|
||||
- **Hash-based lookup** for API tokens — the database stores only
|
||||
SHA-256 hashes, eliminating timing attacks on token comparison.
|
||||
- **Local JWT validation** — no network call or database query needed
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* const client = new TurnstoneServer({
|
||||
* baseUrl: "http://localhost:8080",
|
||||
* token: "tok_xxx",
|
||||
* token: "ts_your_api_token",
|
||||
* });
|
||||
*
|
||||
* const ws = await client.createWorkstream({ name: "demo" });
|
||||
|
||||
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
|
||||
|
||||
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:
|
||||
"""Test /v1/ routes and OpenAPI endpoints on the server."""
|
||||
@@ -14,7 +45,6 @@ class TestServerVersioning:
|
||||
def client(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.server import create_app
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
@@ -26,19 +56,19 @@ class TestServerVersioning:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
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 "workstreams" in resp.json()
|
||||
|
||||
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
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
|
||||
+263
-332
@@ -9,12 +9,11 @@ import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
WRITE_PATHS,
|
||||
AuthConfig,
|
||||
_extract_bearer,
|
||||
_extract_cookie,
|
||||
check_request,
|
||||
create_jwt,
|
||||
is_public_path,
|
||||
load_auth_config,
|
||||
make_clear_cookie,
|
||||
make_set_cookie,
|
||||
required_scope,
|
||||
@@ -199,37 +198,6 @@ class TestRequiredScope:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -353,167 +321,157 @@ class TestMakeClearCookie:
|
||||
class TestCheckRequest:
|
||||
"""Tests for the main check_request() entry point."""
|
||||
|
||||
@pytest.fixture()
|
||||
def disabled(self):
|
||||
return AuthConfig(enabled=False)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
def read_jwt(self):
|
||||
return f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', self._SECRET)}"
|
||||
|
||||
def test_disabled_allows_all(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None)
|
||||
@pytest.fixture()
|
||||
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 status == 200
|
||||
|
||||
def test_disabled_allows_no_header(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None)
|
||||
def test_public_root_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_path_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_public_root_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
|
||||
def test_public_static_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_static_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_api_no_token_401(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
|
||||
def test_api_no_token_401(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
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(
|
||||
enabled, "GET", "/api/workstreams", "Bearer wrong_token"
|
||||
"GET", "/api/workstreams", "Bearer wrong_token"
|
||||
)
|
||||
assert allowed is False
|
||||
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(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_read"
|
||||
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
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(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_full"
|
||||
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
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(
|
||||
enabled, "POST", "/api/send", "Bearer tok_read"
|
||||
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
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(
|
||||
enabled, "POST", "/api/send", "Bearer tok_full"
|
||||
"POST", "/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
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(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_read"
|
||||
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
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."""
|
||||
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 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."""
|
||||
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 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."""
|
||||
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 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."""
|
||||
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
|
||||
|
||||
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."""
|
||||
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 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."""
|
||||
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
|
||||
|
||||
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."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/node/node-a/v1/api/cluster/workstreams/new",
|
||||
"Bearer tok_read",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
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."""
|
||||
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
|
||||
|
||||
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."""
|
||||
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 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(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_full"
|
||||
"POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_auth_header_string(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "")
|
||||
def test_no_auth_header_string(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
@@ -526,70 +484,71 @@ class TestCheckRequest:
|
||||
class TestCheckRequestWithCookie:
|
||||
"""Tests for cookie-based auth fallback in check_request."""
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
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(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_bearer_takes_precedence_over_cookie(self, enabled):
|
||||
# Bearer is full, cookie is read — Bearer should win
|
||||
def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
"Bearer tok_full",
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
f"Bearer {full_jwt}",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_invalid_cookie_401(self, enabled):
|
||||
def test_invalid_cookie_401(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=wrong_token",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
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(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
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(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_full",
|
||||
cookie_header=f"turnstone_auth={full_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
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(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
@@ -598,18 +557,16 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_login_path_public(self, enabled):
|
||||
def test_login_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
None,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_logout_path_public(self, enabled):
|
||||
def test_logout_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
None,
|
||||
@@ -617,139 +574,6 @@ class TestCheckRequestWithCookie:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -785,16 +609,22 @@ class TestServerAuth:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
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(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
cors_origins=["*"],
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -809,7 +639,6 @@ class TestServerAuth:
|
||||
|
||||
def test_metrics_no_token_passes_auth(self):
|
||||
resp = self.client.get("/metrics")
|
||||
# Public path — should never be 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_root_no_token_200(self):
|
||||
@@ -826,23 +655,17 @@ class TestServerAuth:
|
||||
assert "Unauthorized" in resp.json().get("error", "")
|
||||
|
||||
def test_api_workstreams_read_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_workstreams_full_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_send_read_token_403(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
headers=self._read_hdr,
|
||||
json={"message": "hello", "ws_id": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -851,10 +674,9 @@ class TestServerAuth:
|
||||
def test_api_send_full_token_passes_auth(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
headers=self._full_hdr,
|
||||
json={"message": "hello", "ws_id": "nonexistent"},
|
||||
)
|
||||
# Should get 404 (unknown workstream), not 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_api_send_no_token_401(self):
|
||||
@@ -921,12 +743,18 @@ class TestConsoleAuth:
|
||||
"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(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -947,17 +775,11 @@ class TestConsoleAuth:
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_api_overview_read_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_overview_full_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_token_401(self):
|
||||
@@ -1003,16 +825,33 @@ class TestServerLogin:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
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(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1020,36 +859,36 @@ class TestServerLogin:
|
||||
def teardown_class(cls):
|
||||
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(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_full"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "full"
|
||||
cookie = resp.headers.get("set-cookie", "")
|
||||
assert "turnstone_auth=tok_full" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_invalid_token_401(self):
|
||||
def test_login_invalid_credentials_401(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "wrong"},
|
||||
json={"username": "testuser", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_no_auth_required(self):
|
||||
# /v1/api/auth/login is public — shouldn't require auth itself
|
||||
def test_login_password_ok(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_read"},
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "jwt" in data
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
# 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
|
||||
|
||||
# Use cookie to access API — TestClient forwards cookies
|
||||
@@ -1057,7 +896,10 @@ class TestServerLogin:
|
||||
assert resp.status_code == 200
|
||||
|
||||
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_resp = self.test_client.post("/v1/api/auth/logout")
|
||||
@@ -1081,6 +923,7 @@ class TestConsoleLogin:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import hash_password
|
||||
|
||||
_load_static()
|
||||
|
||||
@@ -1092,12 +935,26 @@ class TestConsoleLogin:
|
||||
"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(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1105,28 +962,34 @@ class TestConsoleLogin:
|
||||
def teardown_class(cls):
|
||||
cls.test_client.close()
|
||||
|
||||
def test_login_valid_token(self):
|
||||
def test_login_config_token_rejected(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
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 "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):
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
|
||||
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")
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 401
|
||||
@@ -1385,25 +1248,29 @@ class TestIsSecureRequest:
|
||||
|
||||
|
||||
class TestSecretStrength:
|
||||
def test_short_secret_warns(self, caplog):
|
||||
import logging
|
||||
def test_short_secret_exits(self):
|
||||
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"):
|
||||
import turnstone.core.auth as auth_mod
|
||||
def test_missing_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
secret = auth_mod.load_jwt_secret()
|
||||
assert secret == "short"
|
||||
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records)
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
auth_mod.load_jwt_secret()
|
||||
|
||||
|
||||
class TestCorsConfigurable:
|
||||
@@ -1424,7 +1291,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health", headers={"Origin": "http://evil.com"})
|
||||
@@ -1446,7 +1312,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
cors_origins=["http://example.com"],
|
||||
)
|
||||
client = TestClient(app)
|
||||
@@ -1502,3 +1367,69 @@ class TestOIDCPublicPaths:
|
||||
def test_oidc_callback_is_public(self):
|
||||
assert is_public_path("/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
@@ -7,7 +7,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AuthConfig,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
@@ -203,24 +202,10 @@ class TestRequiredScope:
|
||||
|
||||
|
||||
class TestAuthenticateToken:
|
||||
def test_config_token_read(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
result = _authenticate_token("tok_read", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read"})
|
||||
assert result.token_source == "config"
|
||||
|
||||
def test_config_token_full(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
result = _authenticate_token("tok_full", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_source == "db"
|
||||
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
|
||||
}
|
||||
return None
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.has_scope("write")
|
||||
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
|
||||
"expires": "2020-01-02T00:00:00",
|
||||
}
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is None
|
||||
|
||||
def test_unknown_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
||||
result = _authenticate_token("unknown", cfg)
|
||||
result = _authenticate_token("unknown")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
|
||||
|
||||
|
||||
class TestCheckRequestScopes:
|
||||
def test_config_read_on_write_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
||||
_SECRET = "test-secret-key-for-jwt-min-32b!"
|
||||
|
||||
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 status == 403
|
||||
assert "write" in msg
|
||||
|
||||
def test_config_read_on_approve_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
||||
def test_jwt_read_on_approve_403(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "approve" in msg
|
||||
|
||||
def test_config_full_on_approve_ok(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
||||
def test_jwt_full_on_approve_ok(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
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(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_read",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_backward_compat_role_full(self):
|
||||
"""Config tokens with role='full' get all scopes."""
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, _, _, result = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_full",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
+34
-23
@@ -9,6 +9,24 @@ import pytest
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -711,13 +729,11 @@ class TestConsoleHTTPEndpoints:
|
||||
|
||||
_load_static()
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
app = create_app(
|
||||
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
|
||||
client.close()
|
||||
|
||||
@@ -953,12 +969,11 @@ class TestConsoleWorkstreamCreation:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
|
||||
# Set up a mock proxy_client (lifespan doesn't run in TestClient)
|
||||
@@ -974,7 +989,7 @@ class TestConsoleWorkstreamCreation:
|
||||
mock_proxy.post = mock_post
|
||||
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
|
||||
client.close()
|
||||
|
||||
@@ -1151,14 +1166,13 @@ class TestConsoleProxy:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
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
|
||||
client.close()
|
||||
|
||||
@@ -1322,14 +1336,13 @@ class TestConsoleVersionEndpoints:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
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
|
||||
client.close()
|
||||
|
||||
@@ -1364,7 +1377,6 @@ class TestSharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1376,9 +1388,9 @@ class TestSharedStatic:
|
||||
}
|
||||
app = create_app(
|
||||
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
|
||||
client.close()
|
||||
|
||||
@@ -1481,7 +1493,6 @@ class TestProxySharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1494,9 +1505,9 @@ class TestProxySharedStatic:
|
||||
collector.get_node_detail.return_value = None
|
||||
app = create_app(
|
||||
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")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
@@ -1815,14 +1826,14 @@ class TestProxyAuthHeaders:
|
||||
# Should use ServiceTokenManager, not mint a user JWT
|
||||
assert headers["Authorization"] == f"Bearer {mgr.token}"
|
||||
|
||||
def test_fallback_static_token(self):
|
||||
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token."""
|
||||
def test_no_mgr_no_user_returns_empty(self):
|
||||
"""No auth_result, no ServiceTokenManager → empty 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)
|
||||
|
||||
assert headers == {"Authorization": "Bearer static-tok-123"}
|
||||
assert headers == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,24 @@ from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -42,12 +60,11 @@ def _make_app(
|
||||
router: Any = None,
|
||||
) -> Any:
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
return create_app(
|
||||
collector=collector or _make_mock_collector(),
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
router=router,
|
||||
)
|
||||
|
||||
@@ -100,6 +117,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -109,6 +127,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -126,6 +145,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"resume_ws": "old_ws_id"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -150,6 +170,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"target_node": "node-c"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -203,6 +224,7 @@ class TestRouteCreate503Retry:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -233,6 +255,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc123", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
|
||||
@@ -245,6 +268,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/approve",
|
||||
json={"ws_id": "abc123", "approved": True},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -252,6 +276,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/cancel",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -259,6 +284,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/command",
|
||||
json={"ws_id": "abc123", "command": "status"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -266,6 +292,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/close",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -288,14 +315,14 @@ class TestRouteLookup:
|
||||
client.close()
|
||||
|
||||
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
|
||||
data = resp.json()
|
||||
assert data["node_url"] == "http://a:8080"
|
||||
assert data["node_id"] == "node-a"
|
||||
|
||||
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 "ws_id" in resp.json()["error"]
|
||||
|
||||
@@ -329,6 +356,7 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -336,6 +364,7 @@ class TestRouteNotReady:
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -343,22 +372,24 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
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
|
||||
|
||||
def test_route_proxy_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -384,6 +415,7 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "No available node" in resp.json()["error"]
|
||||
@@ -392,9 +424,10 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
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
|
||||
|
||||
+38
-48
@@ -8,8 +8,26 @@ import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
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
|
||||
|
||||
_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
|
||||
def storage(tmp_path):
|
||||
@@ -33,22 +51,22 @@ def no_auth_client(storage, mock_adapter):
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage, mock_adapter):
|
||||
"""Default client with static auth token configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
|
||||
"""Default client with JWT auth configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(storage, mock_adapter):
|
||||
"""Alias — same as client, for auth-specific test clarity."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
|
||||
"""Alias -- same as client, for auth-specific test clarity."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_client(storage, mock_adapter):
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -58,9 +76,6 @@ class TestNotifyEndpoint:
|
||||
assert resp.status_code == 200
|
||||
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):
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
@@ -68,7 +83,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -85,7 +100,7 @@ class TestNotifyEndpoint:
|
||||
"message": "Hello!",
|
||||
"title": "Alert",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
|
||||
@@ -101,7 +116,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -116,7 +131,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "nobody"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
error = resp.json()["error"]
|
||||
@@ -132,10 +147,10 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-secret-token"},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
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"]
|
||||
assert "testuser" not in error
|
||||
assert "not found or has no linked channels" in error
|
||||
@@ -144,7 +159,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"target": {"username": "x"}},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -152,7 +167,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"message": "Hello!"},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -163,7 +178,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"invalid": "field"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -175,7 +190,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "email", "channel_id": "test@example.com"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -189,7 +204,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -201,7 +216,7 @@ class TestNotifyEndpoint:
|
||||
content=b"not json",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"Authorization": "Bearer test-secret-token",
|
||||
"Authorization": f"Bearer {_make_jwt()}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -214,7 +229,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123"},
|
||||
"message": " ",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -256,30 +271,9 @@ class TestNotifyAuth:
|
||||
)
|
||||
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):
|
||||
"""Requests with a valid JWT for the channel audience are accepted."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
audience=JWT_AUD_CHANNEL,
|
||||
)
|
||||
token = _make_jwt()
|
||||
resp = jwt_client.post(
|
||||
"/v1/api/notify",
|
||||
json={
|
||||
@@ -292,13 +286,11 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_audience(self, jwt_client):
|
||||
"""JWTs with wrong audience are rejected."""
|
||||
from turnstone.core.auth import create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
secret=_JWT_SECRET,
|
||||
audience="turnstone-server", # wrong audience
|
||||
)
|
||||
resp = jwt_client.post(
|
||||
@@ -313,8 +305,6 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_secret(self, jwt_client):
|
||||
"""JWTs signed with wrong secret are rejected."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
|
||||
+30
-11
@@ -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:
|
||||
"""Verify /health and /metrics endpoints using a Starlette TestClient.
|
||||
|
||||
@@ -596,7 +614,6 @@ class TestServerHealthMetrics:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
@@ -631,7 +648,7 @@ class TestServerHealthMetrics:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -727,8 +744,8 @@ class TestServerHealthMetrics:
|
||||
assert 'le="+Inf"' in body
|
||||
|
||||
def test_unknown_endpoint_returns_404(self):
|
||||
status, _, _ = self._get("/does-not-exist")
|
||||
assert status == 404
|
||||
resp = self.client.get("/does-not-exist", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_health_contains_backend_field(self):
|
||||
_, _, body = self._get("/health")
|
||||
@@ -772,7 +789,6 @@ class TestServerRateLimiting:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.ratelimit import RateLimiter
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
@@ -808,7 +824,7 @@ class TestServerRateLimiting:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3),
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -830,16 +846,19 @@ class TestServerRateLimiting:
|
||||
"""After exhausting burst on a non-exempt endpoint, get 429."""
|
||||
# Exhaust burst on a non-exempt endpoint
|
||||
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
|
||||
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
|
||||
|
||||
def test_429_includes_retry_after(self):
|
||||
"""429 response includes Retry-After header."""
|
||||
# Burn through burst
|
||||
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:
|
||||
assert "retry-after" in resp.headers
|
||||
data = resp.json()
|
||||
@@ -851,7 +870,7 @@ class TestServerRateLimiting:
|
||||
"""Health endpoint is always accessible regardless of rate limit."""
|
||||
# Burn through bucket on non-exempt path
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
# Health should still work
|
||||
resp = self._get("/health")
|
||||
assert resp.status_code == 200
|
||||
@@ -859,6 +878,6 @@ class TestServerRateLimiting:
|
||||
def test_metrics_exempt_from_ratelimit(self):
|
||||
"""Metrics endpoint is always accessible regardless of rate limit."""
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
resp = self._get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
+109
-2
@@ -55,8 +55,8 @@ def _make_app(tls_manager):
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
scopes=frozenset({"approve", "service"}),
|
||||
token_source="test",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -124,6 +124,113 @@ def test_delete_cert_not_found(tls_manager):
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
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)
|
||||
|
||||
@@ -190,7 +190,7 @@ async def test_tls_endpoints_disabled():
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
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)
|
||||
|
||||
|
||||
@@ -56,11 +56,9 @@
|
||||
# --- Auth (node, console) ---
|
||||
|
||||
[auth]
|
||||
# enabled = true # env: TURNSTONE_AUTH_ENABLED
|
||||
# Auth is always enabled. JWT secret is required.
|
||||
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
|
||||
# env: TURNSTONE_JWT_SECRET
|
||||
# token = "" # Static config token for full access
|
||||
# env: TURNSTONE_AUTH_TOKEN
|
||||
|
||||
# --- Logging (turnstone, node, console) ---
|
||||
|
||||
|
||||
+13
-18
@@ -265,9 +265,19 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
|
||||
url = f"{console_url}/v1/api/admin/tls/certs"
|
||||
headers = {}
|
||||
token = getattr(args, "auth_token", "") or _get_config_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
# Prefer JWT via ServiceTokenManager when JWT secret is available
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
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.raise_for_status()
|
||||
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}")
|
||||
|
||||
|
||||
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:
|
||||
"""Discover console URL from the services table."""
|
||||
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.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()
|
||||
if not args.command:
|
||||
|
||||
@@ -82,10 +82,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
|
||||
- `POSTGRES_USER` — PostgreSQL username (default: turnstone)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (required for production/cluster)
|
||||
|
||||
### Authentication
|
||||
- `TURNSTONE_AUTH_ENABLED` — Enable auth (`true`/empty)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled)
|
||||
- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth
|
||||
### Authentication (always enabled)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required). All services must share the same secret. \
|
||||
Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
|
||||
### OIDC SSO (optional)
|
||||
- `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.
|
||||
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
|
||||
PostgreSQL is required for cluster mode.
|
||||
5. **Security**: Recommend enabling auth for any non-local deployment. \
|
||||
Use `generate_secret` for JWT secret, auth token, and Postgres password. \
|
||||
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
|
||||
Use `generate_secret` for JWT secret and Postgres password. \
|
||||
Always set `TURNSTONE_JWT_SECRET` in the .env. \
|
||||
Ask for initial admin username and password. \
|
||||
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. \
|
||||
|
||||
@@ -37,10 +37,9 @@ async def _handle_health(request: Request) -> JSONResponse:
|
||||
|
||||
def _check_auth(request: Request) -> JSONResponse | 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", "")
|
||||
|
||||
if not auth_token and not jwt_secret:
|
||||
if not jwt_secret:
|
||||
log.warning("notify.auth_not_configured")
|
||||
return JSONResponse({"error": "authentication not configured"}, status_code=401)
|
||||
|
||||
@@ -50,15 +49,8 @@ def _check_auth(request: Request) -> JSONResponse | None:
|
||||
|
||||
token = header[7:]
|
||||
|
||||
# Static token check
|
||||
if auth_token:
|
||||
import hmac
|
||||
|
||||
if hmac.compare_digest(token, auth_token):
|
||||
return None
|
||||
|
||||
# JWT check
|
||||
if jwt_secret and "." in token:
|
||||
# JWT validation
|
||||
if "." in token:
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt
|
||||
|
||||
result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL)
|
||||
@@ -178,7 +170,6 @@ def create_channel_app(
|
||||
adapters: dict[str, ChannelAdapter],
|
||||
storage: StorageBackend,
|
||||
*,
|
||||
auth_token: str = "",
|
||||
jwt_secret: str = "",
|
||||
) -> Starlette:
|
||||
"""Create the channel gateway HTTP application."""
|
||||
@@ -195,7 +186,6 @@ def create_channel_app(
|
||||
)
|
||||
app.state.adapters = adapters
|
||||
app.state.storage = storage
|
||||
app.state.auth_token = auth_token
|
||||
app.state.jwt_secret = jwt_secret
|
||||
return app
|
||||
|
||||
|
||||
@@ -72,13 +72,6 @@ def main() -> None:
|
||||
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")
|
||||
|
||||
# -- 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 -------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
@@ -121,7 +114,6 @@ def main() -> None:
|
||||
)
|
||||
|
||||
# -- Auth config ---------------------------------------------------------
|
||||
auth_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "") or args.auth_token
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
|
||||
# Prefer auto-rotating service JWTs when jwt_secret is available.
|
||||
@@ -132,7 +124,7 @@ def main() -> None:
|
||||
if jwt_secret:
|
||||
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(
|
||||
user_id="channel-gateway",
|
||||
scopes=_scopes,
|
||||
@@ -151,7 +143,6 @@ def main() -> None:
|
||||
)
|
||||
_console_token_factory = lambda: _console_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
|
||||
console_url: str = args.console_url
|
||||
@@ -242,7 +233,6 @@ def main() -> None:
|
||||
config,
|
||||
server_url,
|
||||
storage,
|
||||
api_token=auth_token,
|
||||
console_url=console_url,
|
||||
console_token_factory=_console_token_factory,
|
||||
server_token_factory=_server_token_factory,
|
||||
@@ -253,7 +243,6 @@ def main() -> None:
|
||||
channel_app = create_channel_app(
|
||||
adapters, # type: ignore[arg-type]
|
||||
storage,
|
||||
auth_token=auth_token,
|
||||
jwt_secret=jwt_secret,
|
||||
)
|
||||
|
||||
|
||||
+14
-9
@@ -633,7 +633,7 @@ def _handle_ws_command(
|
||||
# ─── 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."""
|
||||
import httpx
|
||||
|
||||
@@ -642,8 +642,18 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
|
||||
return
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
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()
|
||||
sub = parts[1] if len(parts) > 1 else "status"
|
||||
@@ -968,11 +978,6 @@ def main() -> None:
|
||||
default=None,
|
||||
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(
|
||||
"--mcp-config",
|
||||
default=None,
|
||||
@@ -1247,7 +1252,7 @@ def main() -> None:
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
active = manager.get_active()
|
||||
|
||||
@@ -59,7 +59,6 @@ class ClusterCollector:
|
||||
storage: StorageBackend,
|
||||
discovery_interval: float = 60.0,
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
@@ -74,10 +73,6 @@ class ClusterCollector:
|
||||
self._console_metrics = console_metrics
|
||||
self._tls_verify = tls_verify
|
||||
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._nodes: dict[str, NodeSnapshot] = {}
|
||||
@@ -165,7 +160,7 @@ class ClusterCollector:
|
||||
"""Build auth headers for the current SSE connection."""
|
||||
if self._token_manager is not None:
|
||||
return {"Authorization": f"Bearer {self._token_manager.token}"}
|
||||
return dict(self._static_auth) if self._static_auth else {}
|
||||
return {}
|
||||
|
||||
# -- SSE manager ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class Rebalancer:
|
||||
lock_ttl: int = 120,
|
||||
eager_migrate: bool = False,
|
||||
api_token: str = "",
|
||||
token_manager: Any = None,
|
||||
) -> None:
|
||||
self._storage = storage
|
||||
self._router = router
|
||||
@@ -75,6 +76,7 @@ class Rebalancer:
|
||||
self._lock_ttl = lock_ttl
|
||||
self._eager_migrate = eager_migrate
|
||||
self._api_token = api_token
|
||||
self._token_manager = token_manager
|
||||
self._stop_event = threading.Event()
|
||||
self._trigger_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
@@ -530,7 +532,9 @@ class Rebalancer:
|
||||
return 0
|
||||
|
||||
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}"
|
||||
|
||||
migrated = 0
|
||||
|
||||
+26
-69
@@ -162,19 +162,11 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]:
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Fallback: service identity (no user context).
|
||||
# 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.
|
||||
# Fallback: service identity via ServiceTokenManager.
|
||||
mgr = getattr(request.app.state, "proxy_token_mgr", None)
|
||||
if mgr is not None:
|
||||
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 {}
|
||||
|
||||
|
||||
@@ -5924,10 +5916,8 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None:
|
||||
def create_app(
|
||||
*,
|
||||
collector: ClusterCollector,
|
||||
auth_config: Any,
|
||||
jwt_secret: str = "",
|
||||
auth_storage: Any = None,
|
||||
proxy_auth_token: str = "",
|
||||
proxy_token_mgr: Any = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
tls_manager: Any = None,
|
||||
@@ -6265,10 +6255,8 @@ def create_app(
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
app.state.collector = collector
|
||||
app.state.auth_config = auth_config
|
||||
app.state.jwt_secret = jwt_secret
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.proxy_auth_token = proxy_auth_token
|
||||
app.state.proxy_token_mgr = proxy_token_mgr
|
||||
app.state.console_url = console_url
|
||||
app.state.tls_manager = tls_manager
|
||||
@@ -6301,7 +6289,7 @@ def create_app(
|
||||
scheduler = TaskScheduler(
|
||||
collector=collector,
|
||||
storage=auth_storage,
|
||||
api_token=proxy_auth_token,
|
||||
api_token="",
|
||||
token_manager=proxy_token_mgr,
|
||||
)
|
||||
app.state.scheduler = scheduler
|
||||
@@ -6351,12 +6339,6 @@ def main() -> None:
|
||||
from turnstone.core.log import add_log_args
|
||||
|
||||
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
|
||||
|
||||
add_config_arg(parser)
|
||||
@@ -6367,10 +6349,9 @@ def main() -> None:
|
||||
|
||||
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() if auth_config.enabled else ""
|
||||
jwt_secret = load_jwt_secret()
|
||||
|
||||
# Initialize storage early — the collector needs it for service discovery.
|
||||
auth_storage = None
|
||||
@@ -6399,38 +6380,23 @@ def main() -> None:
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
# If no explicit auth token is provided, use a 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
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=_jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
|
||||
router = ConsoleRouter(storage=auth_storage)
|
||||
console_metrics = ConsoleMetrics()
|
||||
|
||||
collector = ClusterCollector(
|
||||
storage=auth_storage,
|
||||
auth_token=collector_token if collector_token_mgr is None else "",
|
||||
token_manager=collector_token_mgr,
|
||||
router=router,
|
||||
console_metrics=console_metrics,
|
||||
@@ -6439,22 +6405,15 @@ def main() -> None:
|
||||
|
||||
_load_static()
|
||||
|
||||
# If no explicit auth token is provided, use a ServiceTokenManager
|
||||
# so proxy JWTs auto-rotate.
|
||||
proxy_token = args.auth_token
|
||||
proxy_token_mgr = None
|
||||
if not proxy_token and jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.proxy_token_manager_created")
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
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
|
||||
|
||||
@@ -6541,7 +6500,8 @@ def main() -> None:
|
||||
threshold=_rcs.get("rebalancer.threshold", 0.10),
|
||||
vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150),
|
||||
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")
|
||||
except Exception:
|
||||
@@ -6549,10 +6509,8 @@ def main() -> None:
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=auth_storage,
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
tls_manager=tls_mgr,
|
||||
@@ -6563,8 +6521,7 @@ def main() -> None:
|
||||
)
|
||||
|
||||
log.info("Console starting on %s", console_url)
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
log.info("Auth: enabled (JWT)")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
import uvicorn
|
||||
|
||||
+31
-130
@@ -1,15 +1,12 @@
|
||||
"""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
|
||||
``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
|
||||
1. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256
|
||||
hashes. Exchanged for JWTs via ``/api/auth/login``.
|
||||
3. **JWTs** — short-lived session tokens issued after API token validation.
|
||||
Validated locally via shared HMAC-SHA256 secret. Contain user_id and
|
||||
scopes in claims.
|
||||
2. **JWTs** — short-lived session tokens issued after login or by
|
||||
:class:`ServiceTokenManager`. Validated locally via shared HMAC-SHA256
|
||||
secret. Contain user_id and scopes in claims.
|
||||
|
||||
Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``,
|
||||
``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are
|
||||
@@ -19,7 +16,6 @@ always accessible without authentication.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -28,7 +24,7 @@ import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -56,7 +52,7 @@ JWT_AUD_CONSOLE = "turnstone-console"
|
||||
JWT_AUD_CHANNEL = "turnstone-channel"
|
||||
_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_MAX_LEN = 64
|
||||
@@ -72,16 +68,12 @@ def is_valid_username(username: str) -> bool:
|
||||
|
||||
|
||||
# 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]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"write": frozenset({"read", "write"}),
|
||||
"approve": frozenset({"read", "write", "approve"}),
|
||||
}
|
||||
|
||||
# Map old role names to scope sets.
|
||||
_ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"full": frozenset({"read", "write", "approve"}),
|
||||
"service": frozenset({"read", "write", "approve", "service"}),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,7 +98,7 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
for perm in permissions:
|
||||
if perm in VALID_SCOPES:
|
||||
if perm in VALID_SCOPES and perm != "service":
|
||||
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
|
||||
# Any admin.* permission requires access to admin endpoints → approve scope
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
if auth_result is None:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
# Config-file tokens (no user_id) are treated as full-access
|
||||
if not auth_result.user_id:
|
||||
if auth_result.has_scope("service"):
|
||||
return None
|
||||
if auth_result.has_permission(permission):
|
||||
return None
|
||||
@@ -200,9 +191,9 @@ def _strip_version_prefix(path: str) -> str:
|
||||
class AuthResult:
|
||||
"""Result of successful authentication."""
|
||||
|
||||
user_id: str # empty string for config-file tokens
|
||||
user_id: 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()
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
@@ -214,28 +205,6 @@ class AuthResult:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -303,7 +272,11 @@ def parse_scopes(scopes_str: str) -> frozenset[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()
|
||||
if not secret:
|
||||
from turnstone.core.config import load_config
|
||||
@@ -312,18 +285,19 @@ def load_jwt_secret() -> str:
|
||||
secret = str(auth_cfg.get("jwt_secret", "")).strip()
|
||||
|
||||
if not secret:
|
||||
# Auto-generate an ephemeral secret
|
||||
secret = secrets.token_hex(32)
|
||||
log.warning(
|
||||
"No JWT secret configured — using ephemeral secret (tokens will not survive restart)"
|
||||
log.error(
|
||||
"TURNSTONE_JWT_SECRET is required but not set. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||
)
|
||||
return secret
|
||||
raise SystemExit(1)
|
||||
|
||||
if len(secret) < _MIN_SECRET_LENGTH:
|
||||
log.warning(
|
||||
"JWT secret is shorter than %d characters — consider using a stronger secret",
|
||||
log.error(
|
||||
"JWT secret must be at least %d characters. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"',
|
||||
_MIN_SECRET_LENGTH,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -529,7 +447,6 @@ def _extract_proxied_path(normalized: str) -> str | None:
|
||||
|
||||
|
||||
def check_request(
|
||||
auth_config: AuthConfig,
|
||||
method: str,
|
||||
path: str,
|
||||
auth_header: str | None,
|
||||
@@ -539,20 +456,16 @@ def check_request(
|
||||
jwt_audience: str = "",
|
||||
storage: Any = 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
|
||||
``turnstone_auth`` cookie. Token types are auto-detected:
|
||||
|
||||
- Contains ``.`` → JWT (validated with *jwt_secret*)
|
||||
- Starts with ``ts_`` → API token (looked up in *storage* by hash)
|
||||
- Otherwise → config-file token (hmac check)
|
||||
|
||||
Returns ``(allowed, status_code, message, auth_result)``.
|
||||
"""
|
||||
if not auth_config.enabled:
|
||||
return True, 200, "", None
|
||||
|
||||
if is_public_path(path):
|
||||
return True, 200, "", None
|
||||
|
||||
@@ -566,7 +479,7 @@ def check_request(
|
||||
|
||||
# Authenticate
|
||||
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:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
@@ -581,7 +494,6 @@ def check_request(
|
||||
|
||||
def _authenticate_token(
|
||||
token: str,
|
||||
auth_config: AuthConfig,
|
||||
*,
|
||||
jwt_secret: str = "",
|
||||
jwt_audience: str = "",
|
||||
@@ -601,12 +513,6 @@ def _authenticate_token(
|
||||
if token.startswith(TOKEN_PREFIX) and storage is not None:
|
||||
return _authenticate_api_token(token, storage)
|
||||
|
||||
# 3. Config-file token (hmac comparison)
|
||||
role = auth_config.check(token)
|
||||
if role is not None:
|
||||
scopes = _ROLE_TO_SCOPES.get(role, frozenset({"read"}))
|
||||
return AuthResult(user_id="", scopes=scopes, token_source="config")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -852,7 +758,6 @@ class AuthMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
method = request.method
|
||||
@@ -860,7 +765,6 @@ class AuthMiddleware:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
cookie_header = request.headers.get("Cookie")
|
||||
allowed, status, msg, auth_result = check_request(
|
||||
auth_config,
|
||||
method,
|
||||
path,
|
||||
auth_header,
|
||||
@@ -904,7 +808,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", 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"):
|
||||
result = _authenticate_token(
|
||||
body["token"],
|
||||
auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
jwt_audience=audience,
|
||||
storage=storage,
|
||||
@@ -1011,7 +913,6 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
"""Shared ``GET /api/auth/status`` handler — login UI state detection."""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
|
||||
has_users = False
|
||||
@@ -1027,9 +928,9 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
oidc_enabled = bool(oidc_config and oidc_config.enabled)
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"auth_enabled": auth_config.enabled,
|
||||
"auth_enabled": True,
|
||||
"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:
|
||||
resp["oidc_enabled"] = True
|
||||
|
||||
@@ -66,7 +66,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
|
||||
"ANTHROPIC_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"TURNSTONE_JWT_SECRET",
|
||||
"TURNSTONE_AUTH_TOKEN",
|
||||
"TURNSTONE_DISCORD_TOKEN",
|
||||
"TURNSTONE_GITHUB_TOKEN",
|
||||
"TURNSTONE_OIDC_CLIENT_SECRET",
|
||||
|
||||
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
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()
|
||||
print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}")
|
||||
"""
|
||||
@@ -73,7 +73,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
@@ -961,14 +961,14 @@ class TurnstoneConsole:
|
||||
|
||||
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()
|
||||
print(f"Nodes: {overview.nodes}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
|
||||
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
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")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
@@ -432,7 +432,7 @@ class TurnstoneServer:
|
||||
|
||||
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")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
|
||||
+3
-8
@@ -2449,7 +2449,6 @@ def create_app(
|
||||
global_listeners: list[queue.Queue[dict[str, Any]]],
|
||||
global_listeners_lock: threading.Lock,
|
||||
skip_permissions: bool,
|
||||
auth_config: Any,
|
||||
jwt_secret: str = "",
|
||||
auth_storage: Any = None,
|
||||
health_monitor: Any = None,
|
||||
@@ -2530,7 +2529,6 @@ def create_app(
|
||||
app.state.global_listeners = global_listeners
|
||||
app.state.global_listeners_lock = global_listeners_lock
|
||||
app.state.skip_permissions = skip_permissions
|
||||
app.state.auth_config = auth_config
|
||||
app.state.jwt_secret = jwt_secret
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.health_monitor = health_monitor
|
||||
@@ -3007,13 +3005,11 @@ def main() -> None:
|
||||
_metrics.set_judge_enabled(judge_config.enabled if judge_config else False)
|
||||
|
||||
# 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
|
||||
|
||||
auth_config = load_auth_config()
|
||||
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
jwt_secret = load_jwt_secret()
|
||||
log.info("Auth: enabled (JWT)")
|
||||
|
||||
# Build the ASGI app
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
@@ -3038,7 +3034,6 @@ def main() -> None:
|
||||
global_listeners=global_listeners,
|
||||
global_listeners_lock=global_listeners_lock,
|
||||
skip_permissions=_skip_perms,
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=get_storage(),
|
||||
health_monitor=health_monitor,
|
||||
|
||||
Reference in New Issue
Block a user