From 62d2a0fe6a1a97e22c63df026dc33766428debea Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Wed, 1 Apr 2026 19:38:24 -0700 Subject: [PATCH] fix: remove non-auth support from bootstrap wizard (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- compose.yaml | 19 +- .../templates/deployment-console.yaml | 8 +- .../templates/deployment-server.yaml | 8 +- deploy/helm/turnstone/templates/secret.yaml | 4 +- deploy/helm/turnstone/values.yaml | 5 +- deploy/terraform/modules/aws-ecs/iam.tf | 2 +- deploy/terraform/modules/aws-ecs/main.tf | 36 +- deploy/terraform/modules/aws-ecs/variables.tf | 5 +- docs/api-reference.md | 7 +- docs/architecture.md | 12 +- docs/channels.md | 11 +- docs/console.md | 3 +- docs/docker.md | 6 +- docs/oidc.md | 10 +- docs/sdk.md | 4 +- docs/security.md | 65 +- docs/tls.md | 2 +- sdk/typescript/src/index.ts | 2 +- tests/test_api_versioning.py | 45 +- tests/test_auth.py | 595 ++++++++---------- tests/test_auth_identity.py | 94 ++- tests/test_console.py | 57 +- tests/test_console_routing_proxy.py | 47 +- tests/test_notify_http.py | 86 ++- tests/test_server_live.py | 41 +- tests/test_tls_admin.py | 111 +++- tests/test_tls_manager.py | 4 +- turnstone.example.toml | 4 +- turnstone/admin.py | 31 +- turnstone/bootstrap.py | 12 +- turnstone/channels/_http.py | 16 +- turnstone/channels/cli.py | 13 +- turnstone/cli.py | 23 +- turnstone/console/collector.py | 7 +- turnstone/console/rebalancer.py | 6 +- turnstone/console/server.py | 95 +-- turnstone/core/auth.py | 161 +---- turnstone/core/env.py | 1 - turnstone/sdk/console.py | 8 +- turnstone/sdk/server.py | 4 +- turnstone/server.py | 11 +- 41 files changed, 775 insertions(+), 906 deletions(-) diff --git a/compose.yaml b/compose.yaml index 805f089e..308ad593 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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} diff --git a/deploy/helm/turnstone/templates/deployment-console.yaml b/deploy/helm/turnstone/templates/deployment-console.yaml index 9ee645a3..9a955688 100644 --- a/deploy/helm/turnstone/templates/deployment-console.yaml +++ b/deploy/helm/turnstone/templates/deployment-console.yaml @@ -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: diff --git a/deploy/helm/turnstone/templates/deployment-server.yaml b/deploy/helm/turnstone/templates/deployment-server.yaml index e1f1a39c..97895fdd 100644 --- a/deploy/helm/turnstone/templates/deployment-server.yaml +++ b/deploy/helm/turnstone/templates/deployment-server.yaml @@ -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: diff --git a/deploy/helm/turnstone/templates/secret.yaml b/deploy/helm/turnstone/templates/secret.yaml index 56de2117..e13aeb59 100644 --- a/deploy/helm/turnstone/templates/secret.yaml +++ b/deploy/helm/turnstone/templates/secret.yaml @@ -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 }} diff --git a/deploy/helm/turnstone/values.yaml b/deploy/helm/turnstone/values.yaml index 76509d6b..7b8bdf88 100644 --- a/deploy/helm/turnstone/values.yaml +++ b/deploy/helm/turnstone/values.yaml @@ -59,10 +59,9 @@ llm: apiKey: "" existingSecret: "" -# -- Authentication +# -- Authentication (always enabled, JWT secret required) auth: - enabled: false - token: "" + jwtSecret: "" existingSecret: "" # -- Ingress configuration diff --git a/deploy/terraform/modules/aws-ecs/iam.tf b/deploy/terraform/modules/aws-ecs/iam.tf index c65a608c..1e4f41b6 100644 --- a/deploy/terraform/modules/aws-ecs/iam.tf +++ b/deploy/terraform/modules/aws-ecs/iam.tf @@ -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] : [], ) }, ] diff --git a/deploy/terraform/modules/aws-ecs/main.tf b/deploy/terraform/modules/aws-ecs/main.tf index c94faeb0..b0d29122 100644 --- a/deploy/terraform/modules/aws-ecs/main.tf +++ b/deploy/terraform/modules/aws-ecs/main.tf @@ -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 = { diff --git a/deploy/terraform/modules/aws-ecs/variables.tf b/deploy/terraform/modules/aws-ecs/variables.tf index 393de6d4..7913b062 100644 --- a/deploy/terraform/modules/aws-ecs/variables.tf +++ b/deploy/terraform/modules/aws-ecs/variables.tf @@ -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" { diff --git a/docs/api-reference.md b/docs/api-reference.md index a67d9807..034f216a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 ` - **Cookie**: `turnstone_auth=` (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` diff --git a/docs/architecture.md b/docs/architecture.md index 66990f65..cf831359 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ` 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 diff --git a/docs/channels.md b/docs/channels.md index 85246a6c..876f85f0 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -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. diff --git a/docs/console.md b/docs/console.md index ea4207d7..0f347237 100644 --- a/docs/console.md +++ b/docs/console.md @@ -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. diff --git a/docs/docker.md b/docs/docker.md index 5fab818b..846f1106 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/docs/oidc.md b/docs/oidc.md index 1c9b08bf..f902ac17 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -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. --- diff --git a/docs/sdk.md b/docs/sdk.md index 4387f30b..29c53784 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -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. diff --git a/docs/security.md b/docs/security.md index 588b7a99..b39c4403 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/docs/tls.md b/docs/tls.md index 8aae3842..98305993 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -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 diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index ccf510db..0c3746c5 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -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" }); diff --git a/tests/test_api_versioning.py b/tests/test_api_versioning.py index 6d3e452c..b559640a 100644 --- a/tests/test_api_versioning.py +++ b/tests/test_api_versioning.py @@ -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): diff --git a/tests/test_auth.py b/tests/test_auth.py index f159f6f3..2ac10bb2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py index 2af5c0de..df27d874 100644 --- a/tests/test_auth_identity.py +++ b/tests/test_auth_identity.py @@ -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") diff --git a/tests/test_console.py b/tests/test_console.py index 1d1effbc..37986924 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -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 == {} # --------------------------------------------------------------------------- diff --git a/tests/test_console_routing_proxy.py b/tests/test_console_routing_proxy.py index 88775b19..4249b881 100644 --- a/tests/test_console_routing_proxy.py +++ b/tests/test_console_routing_proxy.py @@ -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 diff --git a/tests/test_notify_http.py b/tests/test_notify_http.py index c50934d3..9428dce8 100644 --- a/tests/test_notify_http.py +++ b/tests/test_notify_http.py @@ -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"}), diff --git a/tests/test_server_live.py b/tests/test_server_live.py index 95809361..afa4ebb1 100644 --- a/tests/test_server_live.py +++ b/tests/test_server_live.py @@ -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 diff --git a/tests/test_tls_admin.py b/tests/test_tls_admin.py index 4576d0f5..c2043c4c 100644 --- a/tests/test_tls_admin.py +++ b/tests/test_tls_admin.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/tests/test_tls_manager.py b/tests/test_tls_manager.py index 29251e11..f6610b36 100644 --- a/tests/test_tls_manager.py +++ b/tests/test_tls_manager.py @@ -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) diff --git a/turnstone.example.toml b/turnstone.example.toml index 5a114dfb..b66927b7 100644 --- a/turnstone.example.toml +++ b/turnstone.example.toml @@ -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) --- diff --git a/turnstone/admin.py b/turnstone/admin.py index 09493e0f..57a575a7 100644 --- a/turnstone/admin.py +++ b/turnstone/admin.py @@ -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: diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py index cf0e7627..3b60e4ff 100644 --- a/turnstone/bootstrap.py +++ b/turnstone/bootstrap.py @@ -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. \ diff --git a/turnstone/channels/_http.py b/turnstone/channels/_http.py index ca876aef..9cb7b134 100644 --- a/turnstone/channels/_http.py +++ b/turnstone/channels/_http.py @@ -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 diff --git a/turnstone/channels/cli.py b/turnstone/channels/cli.py index fb1ae19a..31175f26 100644 --- a/turnstone/channels/cli.py +++ b/turnstone/channels/cli.py @@ -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, ) diff --git a/turnstone/cli.py b/turnstone/cli.py index d02a8fdd..b33b3c47 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -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() diff --git a/turnstone/console/collector.py b/turnstone/console/collector.py index dd3cf928..23fbc598 100644 --- a/turnstone/console/collector.py +++ b/turnstone/console/collector.py @@ -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 --------------------------------------------------------- diff --git a/turnstone/console/rebalancer.py b/turnstone/console/rebalancer.py index 9c374852..49c7e3dd 100644 --- a/turnstone/console/rebalancer.py +++ b/turnstone/console/rebalancer.py @@ -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 diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 9d2d1283..ab006d74 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -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 diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 7b09f75b..0ca6fead 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -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=`` — 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 `` 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 diff --git a/turnstone/core/env.py b/turnstone/core/env.py index 780b4170..151f275b 100644 --- a/turnstone/core/env.py +++ b/turnstone/core/env.py @@ -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", diff --git a/turnstone/sdk/console.py b/turnstone/sdk/console.py index 567e8836..5871bc64 100644 --- a/turnstone/sdk/console.py +++ b/turnstone/sdk/console.py @@ -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, diff --git a/turnstone/sdk/server.py b/turnstone/sdk/server.py index 90f4d268..418e3ddf 100644 --- a/turnstone/sdk/server.py +++ b/turnstone/sdk/server.py @@ -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) diff --git a/turnstone/server.py b/turnstone/server.py index b2d9e84e..820a90c7 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -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,