mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5c1baf29d | |||
| fd8ec8ad18 | |||
| 04b3e8e36f | |||
| 80530aba94 |
+44
-16
@@ -29,10 +29,11 @@
|
||||
# Fewer nodes (lighter machines):
|
||||
# docker compose up postgres console caddy channel node-1 node-2 node-3
|
||||
#
|
||||
# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a
|
||||
# turnstone-server running directly on this machine (e.g. to use a local GPU)
|
||||
# can join the same cluster. Keep the secret + connection settings in
|
||||
# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise):
|
||||
# Join a bare-metal host: a turnstone-server running OUTSIDE compose (e.g. to use
|
||||
# a local GPU) can join this cluster. Postgres, the console's ACME endpoint, and
|
||||
# SearxNG are published on 127.0.0.1 so a node on THIS machine reaches them via
|
||||
# localhost. Keep secrets in ~/.config/turnstone/config.toml (chmod 0600 — the
|
||||
# loader warns otherwise):
|
||||
# [auth]
|
||||
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
|
||||
# [database]
|
||||
@@ -41,10 +42,19 @@
|
||||
# [api]
|
||||
# base_url = "http://localhost:8000/v1"
|
||||
# api_key = "dummy"
|
||||
# [tls] # only if the cluster runs mTLS
|
||||
# enabled = true
|
||||
# then run (node identity isn't a secret, so it stays on the command line):
|
||||
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
# TURNSTONE_NODE_ID=host-1 \
|
||||
# TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
# TURNSTONE_CONSOLE_URL=http://localhost:8090 \
|
||||
# TURNSTONE_SEARXNG_URL=http://localhost:8081 \
|
||||
# turnstone-server --host 0.0.0.0 --port 8080
|
||||
# It registers in Postgres and the console reaches it back via host.docker.internal.
|
||||
# The node registers in Postgres, auto-enrolls its mTLS cert from the console's
|
||||
# ACME endpoint (when the cluster runs mTLS), and the console collector reaches
|
||||
# it back via host.docker.internal. To join from ANOTHER machine, set
|
||||
# TURNSTONE_HOST_IP to this host's LAN IP and use it in the URLs above (and the
|
||||
# node's TURNSTONE_ADVERTISE_URL = the NODE host's IP) — see docs/docker.md.
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -93,13 +103,14 @@ services:
|
||||
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
# Published on localhost so a bare-metal turnstone-server running on THIS
|
||||
# host can join the cluster (see "Join a bare-metal host" in the header).
|
||||
# Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another
|
||||
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose
|
||||
# a database with the insecure default password to your network.
|
||||
# Published so a bare-metal turnstone-server can join the cluster (see "Join
|
||||
# a bare-metal host" in the header). Bound to 127.0.0.1 by default (same-host
|
||||
# nodes only); set TURNSTONE_HOST_IP to this host's LAN IP to let another
|
||||
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose a
|
||||
# database with the insecure default password to your network. (The legacy
|
||||
# POSTGRES_BIND is still honored as a fallback when TURNSTONE_HOST_IP is unset.)
|
||||
ports:
|
||||
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||
- "${TURNSTONE_HOST_IP:-${POSTGRES_BIND:-127.0.0.1}}:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -120,10 +131,12 @@ services:
|
||||
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
|
||||
# https://localhost:8443 (see the caddy service below).
|
||||
#
|
||||
# The console port (8090) is deliberately NOT published to the host: a plain
|
||||
# HTTP/1.1 origin caps the browser at 6 connections, which starves the
|
||||
# dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
|
||||
# (multiplexed) and proxies to console:8090 internally, so the cap is gone.
|
||||
# Browsers must reach the dashboard through Caddy (https://localhost:8443): a
|
||||
# plain HTTP/1.1 origin caps the browser at 6 connections, which starves the
|
||||
# dashboard's per-pane SSE streams, whereas Caddy serves HTTP/2 (multiplexed)
|
||||
# and proxies to console:8090 internally. The console's :8090 is published
|
||||
# below ONLY so bare-metal nodes can reach the plain-HTTP ACME enrollment
|
||||
# endpoint — don't point a browser at it.
|
||||
#
|
||||
# The single `build:` here produces the turnstone:local image every other
|
||||
# service reuses. extra_hosts lets the console reach a bare-metal server
|
||||
@@ -138,6 +151,14 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
# Publishes the console's plain-HTTP listener so a bare-metal node can reach
|
||||
# the ACME endpoint, fetch the CA, and enroll its cert (the console serves
|
||||
# HTTP here even under mTLS). Bound to 127.0.0.1 by default; setting
|
||||
# TURNSTONE_HOST_IP exposes the WHOLE console HTTP API — including the
|
||||
# cert-issuing ACME endpoint — on that interface, so the JWT secret's
|
||||
# strength is the only gate. Browsers use Caddy :8443, never this port.
|
||||
ports:
|
||||
- "${TURNSTONE_HOST_IP:-127.0.0.1}:8090:8090"
|
||||
environment:
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
@@ -219,6 +240,13 @@ services:
|
||||
# -------------------------------------------------------------------
|
||||
searxng:
|
||||
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
|
||||
# Published so a bare-metal node's web_search can reach it. SearxNG has NO
|
||||
# auth, so it is bound to 127.0.0.1 by default; setting TURNSTONE_HOST_IP
|
||||
# exposes it on that interface — an open search proxy on your LAN, which also
|
||||
# triggers the SearxNG AGPL-3.0 §13 source-offer obligation (see docs/docker.md).
|
||||
# In-compose nodes always use the internal http://searxng:8080 and ignore this.
|
||||
ports:
|
||||
- "${TURNSTONE_HOST_IP:-127.0.0.1}:${SEARXNG_API_PORT:-8081}:8080"
|
||||
volumes:
|
||||
- ./turnstone/deploy/searxng:/etc/searxng:ro
|
||||
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Running a bare-metal turnstone-server under systemd
|
||||
|
||||
These units run a `turnstone-server` **outside** Docker (e.g. on a box with a
|
||||
local GPU) so it joins an existing cluster — typically the docker-compose stack
|
||||
in [`compose.yaml`](../../compose.yaml). They are the hardened, production-shaped
|
||||
counterpart to the quick `turnstone-server …` invocation in
|
||||
[`docs/docker.md`](../../docs/docker.md) ("Join a bare-metal host").
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `turnstone-server.service` | The hardened server unit (sandboxed; secrets via `config.toml`). |
|
||||
| `turnstone.slice` | Shared memory/process budget for colocated Turnstone units. |
|
||||
| `turnstone-server.service.d/node.conf.example` | Per-host identity + cluster URLs drop-in (no secrets). |
|
||||
|
||||
## Cluster-side prerequisite
|
||||
|
||||
The compose stack must publish Postgres, the console's ACME endpoint, and SearxNG
|
||||
on an address the bare-metal host can reach. Start it with `TURNSTONE_HOST_IP`
|
||||
set to the compose host's LAN IP (default `127.0.0.1` keeps everything host-local):
|
||||
|
||||
```bash
|
||||
TURNSTONE_HOST_IP=<compose-host-ip> docker compose up -d
|
||||
```
|
||||
|
||||
## Install (run as root on the bare-metal host)
|
||||
|
||||
```bash
|
||||
# 1. A dedicated, unprivileged user.
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin turnstone
|
||||
|
||||
# 2. Install turnstone into a venv at /opt/turnstone-venv (lacme/mTLS is a core dep).
|
||||
uv venv /opt/turnstone-venv --python 3.12
|
||||
uv pip install --python /opt/turnstone-venv 'turnstone @ git+https://github.com/turnstonelabs/turnstone'
|
||||
# …or from a local checkout: uv pip install --python /opt/turnstone-venv /path/to/turnstone
|
||||
|
||||
# 3. Secrets — match the cluster's JWT secret + DB credentials (kept out of env).
|
||||
install -d -m 750 -o turnstone -g turnstone /etc/turnstone
|
||||
cat > /etc/turnstone/config.toml <<'TOML'
|
||||
[auth]
|
||||
jwt_secret = "<same secret as the cluster>"
|
||||
[database]
|
||||
backend = "postgresql"
|
||||
url = "postgresql+psycopg://turnstone:<password>@<compose-host-ip>:5432/turnstone"
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1" # a real model backend is configured in the console UI
|
||||
api_key = "dummy"
|
||||
TOML
|
||||
chown turnstone:turnstone /etc/turnstone/config.toml
|
||||
chmod 600 /etc/turnstone/config.toml
|
||||
|
||||
# 4. Units + per-host drop-in.
|
||||
cp turnstone-server.service turnstone.slice /etc/systemd/system/
|
||||
install -d /etc/systemd/system/turnstone-server.service.d
|
||||
cp turnstone-server.service.d/node.conf.example \
|
||||
/etc/systemd/system/turnstone-server.service.d/node.conf
|
||||
$EDITOR /etc/systemd/system/turnstone-server.service.d/node.conf # set the addresses
|
||||
|
||||
# 5. Go.
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now turnstone-server.service
|
||||
journalctl -u turnstone-server -f # watch it register + (if the cluster runs mTLS) enroll
|
||||
```
|
||||
|
||||
`tls.enabled` is **not** set here — a joining node inherits it from the cluster's
|
||||
shared settings (the database). If the cluster runs mTLS, the node auto-enrolls a
|
||||
cert from the console's ACME endpoint and re-advertises itself over `https://`.
|
||||
|
||||
> **mTLS + cross-host caveat:** a node on a *different* host than the console
|
||||
> currently can't complete ACME enrollment — the console advertises an
|
||||
> unroutable in-container address in its ACME directory
|
||||
> ([turnstonelabs/lacme#22](https://github.com/turnstonelabs/lacme/issues/22)).
|
||||
> Same-host bare-metal nodes, and any node in a non-mTLS cluster, are unaffected.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Run a bare-metal turnstone-server as a systemd service so it joins a cluster
|
||||
# (e.g. the docker-compose stack) from outside Docker — typically to use a local
|
||||
# GPU. Install steps + the cluster-side prerequisites are in deploy/systemd/README.md
|
||||
# and docs/docker.md ("Join a bare-metal host"). Per-host identity + the cluster
|
||||
# URLs go in a drop-in (see node.conf.example); secrets go in config.toml.
|
||||
[Unit]
|
||||
Description=Turnstone server (chat workstreams + LLM gateway)
|
||||
Documentation=https://github.com/turnstonelabs/turnstone
|
||||
# Postgres is required. After= orders against a colocated postgresql.service
|
||||
# when present and silently no-ops otherwise (the cluster DB is usually remote).
|
||||
After=network.target postgresql.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=turnstone
|
||||
Group=turnstone
|
||||
|
||||
# Secrets live in config.toml — JWT secret, Postgres URL+password, LLM API key —
|
||||
# kept out of os.environ so a prompt-injected tool can't dump them via `env`.
|
||||
Environment=TURNSTONE_CONFIG=/etc/turnstone/config.toml
|
||||
Environment=TURNSTONE_LOG_LEVEL=info
|
||||
|
||||
Slice=turnstone.slice
|
||||
|
||||
# Per-host node identity + cluster wiring (TURNSTONE_NODE_ID / _ADVERTISE_URL /
|
||||
# _CONSOLE_URL / _SEARXNG_URL) go in a drop-in, not here — see node.conf.example.
|
||||
|
||||
StateDirectory=turnstone
|
||||
StateDirectoryMode=0750
|
||||
LogsDirectory=turnstone
|
||||
LogsDirectoryMode=0750
|
||||
WorkingDirectory=/var/lib/turnstone
|
||||
|
||||
# --host 0.0.0.0 so the console collector + peer nodes can dial this node back
|
||||
# at its advertised address. (A single-node, Caddy-fronted install can use
|
||||
# 127.0.0.1 instead.) Rewrite --port if :8080 is already taken on the host.
|
||||
ExecStart=/opt/turnstone-venv/bin/turnstone-server --host 0.0.0.0 --port 8080
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=120
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
KillMode=mixed
|
||||
|
||||
# --- Resource limits ---
|
||||
# SSE keeps an fd per active workstream + outbound LLM stream + MCP stdio pipe.
|
||||
LimitNOFILE=65535
|
||||
LimitNPROC=8192
|
||||
TasksMax=8192
|
||||
LimitCORE=0
|
||||
|
||||
# --- Hardening ---
|
||||
NoNewPrivileges=true
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
UMask=0027
|
||||
PrivateTmp=true
|
||||
# PrivateDevices=true — disabled: GPU access via /sys/class/drm
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @mount
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=turnstone-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,25 @@
|
||||
# Per-host node identity + cluster wiring for a bare-metal turnstone-server.
|
||||
# Copy to /etc/systemd/system/turnstone-server.service.d/node.conf and edit the
|
||||
# addresses, then `systemctl daemon-reload`. Identity + URLs are NOT secrets, so
|
||||
# they live here; the JWT secret + DB URL live in /etc/turnstone/config.toml.
|
||||
#
|
||||
# Addresses below use RFC 5737 documentation IPs — replace them:
|
||||
# <this-host> = the bare-metal host's own LAN IP (what the console dials back)
|
||||
# <compose-host> = the host running the cluster / docker-compose stack, started
|
||||
# with TURNSTONE_HOST_IP=<compose-host> so :8090 and :8081 are
|
||||
# published on its LAN interface (see docs/docker.md).
|
||||
[Service]
|
||||
# Unique node id (defaults to the hostname if unset).
|
||||
Environment=TURNSTONE_NODE_ID=host-1
|
||||
|
||||
# The address peers + the console collector dial back. Auto-upgrades to https://
|
||||
# once the node enrolls its mTLS cert.
|
||||
Environment=TURNSTONE_ADVERTISE_URL=http://192.0.2.10:8080
|
||||
|
||||
# The cluster console's reachable plain-HTTP ACME/API endpoint. A bare-metal node
|
||||
# can't resolve the in-cluster name (console:8090), so point it at the published
|
||||
# port; turnstone-server honors this for cert enrollment.
|
||||
Environment=TURNSTONE_CONSOLE_URL=http://192.0.2.1:8090
|
||||
|
||||
# The cluster's published SearxNG, for the web_search tool.
|
||||
Environment=TURNSTONE_SEARXNG_URL=http://192.0.2.1:8081
|
||||
@@ -0,0 +1,15 @@
|
||||
# Shared resource budget for the colocated Turnstone units. Without a slice each
|
||||
# unit's MemoryMax= is enforced independently — three units at 85% each can sum
|
||||
# to 255% of host RAM before any throttles. Under a shared slice the cap is
|
||||
# hierarchical: the slice ceiling is the real limit. (A bare-metal node that runs
|
||||
# only turnstone-server still benefits — and keeps the unit's Slice= reference
|
||||
# valid.) Adjust if the host runs other meaningful workloads alongside Turnstone.
|
||||
[Unit]
|
||||
Description=Turnstone services slice (server + console + channel)
|
||||
Documentation=https://github.com/turnstonelabs/turnstone
|
||||
Before=slices.target
|
||||
|
||||
[Slice]
|
||||
MemoryHigh=70%
|
||||
MemoryMax=85%
|
||||
TasksMax=16384
|
||||
+10
-4
@@ -63,7 +63,10 @@ Auth is always enabled. All API endpoints except public paths require a valid to
|
||||
Include a token in one of two ways:
|
||||
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
- **Cookie**: the surface-scoped auth cookie — `turnstone_auth_server` on
|
||||
turnstone-server, `turnstone_auth_console` on turnstone-console (set
|
||||
automatically by the login endpoint). The names differ so the two surfaces,
|
||||
when co-hosted on one origin, don't overwrite each other's session.
|
||||
|
||||
The server accepts two token types:
|
||||
|
||||
@@ -102,7 +105,8 @@ Authenticate with credentials and receive a JWT. Accepts two credential formats:
|
||||
}
|
||||
```
|
||||
|
||||
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
|
||||
The response also sets a surface-scoped HttpOnly cookie containing the JWT
|
||||
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
|
||||
|
||||
**Response (failure):** `401`
|
||||
|
||||
@@ -114,7 +118,8 @@ The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
|
||||
|
||||
### `POST /v1/api/auth/logout`
|
||||
|
||||
Clears the `turnstone_auth` cookie. No request body required.
|
||||
Clears the surface-scoped auth cookie (`turnstone_auth_server` /
|
||||
`turnstone_auth_console`). No request body required.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
@@ -199,7 +204,8 @@ this endpoint.
|
||||
}
|
||||
```
|
||||
|
||||
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
|
||||
The response also sets a surface-scoped HttpOnly cookie containing the JWT
|
||||
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
|
||||
|
||||
**Response (already set up):** `409`
|
||||
|
||||
|
||||
@@ -1191,7 +1191,8 @@ Three hierarchical scopes control endpoint access:
|
||||
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
|
||||
are always allowed.
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
surface-scoped auth cookie (`turnstone_auth_server` on the node server,
|
||||
`turnstone_auth_console` on the console) as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token.
|
||||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||||
|
||||
+32
-14
@@ -59,9 +59,11 @@ is gone. Everything goes through `https://localhost:8443`.
|
||||
|
||||
## Join a bare-metal host
|
||||
|
||||
PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running
|
||||
directly on the same machine — for example to use a local GPU — can join the
|
||||
same cluster and show up in the console alongside the containerized nodes.
|
||||
PostgreSQL, the console's ACME endpoint (`:8090`), and SearxNG (`:8081`) are
|
||||
published on `127.0.0.1`, so a `turnstone-server` running directly on the same
|
||||
machine — for example to use a local GPU — can join the same cluster (enrolling
|
||||
its mTLS cert and running `web_search`) and show up in the console alongside the
|
||||
containerized nodes.
|
||||
|
||||
Put the secret and connection settings in `~/.config/turnstone/config.toml`
|
||||
(secrets belong in this file, not the process environment — keep it `0600`,
|
||||
@@ -85,18 +87,31 @@ command line:
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.config/turnstone/config.toml
|
||||
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
TURNSTONE_NODE_ID=host-1 \
|
||||
TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
TURNSTONE_CONSOLE_URL=http://localhost:8090 \
|
||||
TURNSTONE_SEARXNG_URL=http://localhost:8081 \
|
||||
turnstone-server --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
The host server registers itself in PostgreSQL; the console reaches it back via
|
||||
`host.docker.internal`. The `jwt_secret` and DB credentials above are the
|
||||
dev-stack defaults — match whatever you set in `.env` if you changed them. To
|
||||
let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0`
|
||||
and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL` —
|
||||
but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with
|
||||
the insecure default password (and every user account + API-token hash in it) to
|
||||
your network.
|
||||
`host.docker.internal`. `TURNSTONE_CONSOLE_URL` points the node at the console's
|
||||
published ACME endpoint so it can enroll its mTLS certificate (needed only when
|
||||
the cluster runs mTLS; harmless otherwise), and `TURNSTONE_SEARXNG_URL` points
|
||||
`web_search` at the published SearxNG. The `jwt_secret` and DB credentials above
|
||||
are the dev-stack defaults — match whatever you set in `.env` if you changed them.
|
||||
|
||||
To let a server on a **different** machine join, start the stack with
|
||||
`TURNSTONE_HOST_IP=<this host's LAN IP>` — that binds PostgreSQL, the console
|
||||
ACME endpoint, and SearxNG to that interface. Then on the remote box set the
|
||||
three URLs above to that IP, and set `TURNSTONE_ADVERTISE_URL` to the **remote**
|
||||
box's own IP (the address the console dials back). **Set a strong
|
||||
`POSTGRES_PASSWORD` first** — `TURNSTONE_HOST_IP` exposes the database (and every
|
||||
user account + API-token hash in it), the console API, and the unauthenticated
|
||||
SearxNG to your network.
|
||||
|
||||
To run the bare-metal node as a hardened, persistent service instead of by hand,
|
||||
use the systemd units in [`deploy/systemd/`](../deploy/systemd/).
|
||||
|
||||
## Production stack
|
||||
|
||||
@@ -174,15 +189,18 @@ overrides.
|
||||
### Ports
|
||||
|
||||
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
|
||||
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
|
||||
proxied by the console:
|
||||
publishes the console's ACME endpoint and SearxNG on localhost so a bare-metal
|
||||
node can enroll its cert and run `web_search`. Everything else is reached through
|
||||
Caddy or proxied by the console:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) |
|
||||
| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) |
|
||||
| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) |
|
||||
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
|
||||
| `SEARXNG_API_PORT` | `8081` | Host port for the SearxNG API a bare-metal node's `web_search` dials (dev stack) |
|
||||
| `TURNSTONE_HOST_IP` | `127.0.0.1` | Interface PostgreSQL, the console ACME endpoint, and SearxNG bind on (dev stack). Set to this host's LAN IP so a bare-metal node on **another machine** can reach them — set a strong `POSTGRES_PASSWORD` first (it also exposes the DB and the unauthenticated SearxNG to your network). |
|
||||
| `POSTGRES_BIND` | `127.0.0.1` | Production stack (`turnstone/deploy/compose.yaml`) only: interface PostgreSQL binds on; set to the host's LAN IP for remote joins. |
|
||||
|
||||
### Channel gateway
|
||||
|
||||
|
||||
+6
-2
@@ -244,7 +244,10 @@ const client = new TurnstoneServer({
|
||||
### Node Bootstrap Flow
|
||||
|
||||
1. Node starts, connects to shared database (plain connection)
|
||||
2. Discovers console URL from `services` table
|
||||
2. Discovers the console URL from the `services` table — or honors an explicit
|
||||
`TURNSTONE_CONSOLE_URL` (a bare-metal node outside the compose network can't
|
||||
resolve the in-cluster `console` name, so it points this at the console's
|
||||
published ACME endpoint)
|
||||
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
|
||||
4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's
|
||||
primary domain / SAN is the node's **advertised host** (the host of
|
||||
@@ -291,7 +294,8 @@ cert's SANs don't include the dialed name.
|
||||
|
||||
The console registers itself in the `services` table on startup. If the console
|
||||
hasn't started or the registration expired (1 hour TTL), nodes can't discover
|
||||
it. Use `--console-url` explicitly.
|
||||
it. Set `TURNSTONE_CONSOLE_URL` to a reachable console address (this is also how
|
||||
a bare-metal node that can't resolve the in-cluster `console` name enrolls).
|
||||
|
||||
### Browser HTTPS to the console
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.4"
|
||||
version = "1.6.5"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -333,7 +333,9 @@ prepare_env() {
|
||||
TURNSTONE_JWT_SECRET=$jwt
|
||||
POSTGRES_USER=turnstone
|
||||
POSTGRES_PASSWORD=$pgpw
|
||||
POSTGRES_BIND=127.0.0.1
|
||||
# Bind published bare-metal ports (Postgres, console ACME, SearxNG) to this
|
||||
# interface. 127.0.0.1 = same-host only; set your LAN IP to join from another box.
|
||||
TURNSTONE_HOST_IP=127.0.0.1
|
||||
POSTGRES_PORT=$PG_PORT
|
||||
CONSOLE_HTTPS_PORT=$CADDY_PORT
|
||||
EOF
|
||||
|
||||
+166
-37
@@ -8,6 +8,9 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AUTH_COOKIE,
|
||||
AUTH_COOKIE_CONSOLE,
|
||||
AUTH_COOKIE_SERVER,
|
||||
WRITE_PATHS,
|
||||
_extract_bearer,
|
||||
_extract_cookie,
|
||||
@@ -374,48 +377,59 @@ class TestExtractCookie:
|
||||
|
||||
class TestMakeSetCookie:
|
||||
def test_contains_token(self):
|
||||
val = make_set_cookie("tok_abc")
|
||||
assert "turnstone_auth=tok_abc" in val
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
assert "turnstone_auth_server=tok_abc" in val
|
||||
|
||||
def test_httponly(self):
|
||||
assert "HttpOnly" in make_set_cookie("tok_abc")
|
||||
assert "HttpOnly" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_samesite_lax(self):
|
||||
assert "SameSite=Lax" in make_set_cookie("tok_abc")
|
||||
assert "SameSite=Lax" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_path(self):
|
||||
assert "Path=/" in make_set_cookie("tok_abc")
|
||||
assert "Path=/" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_max_age_default(self):
|
||||
val = make_set_cookie("tok_abc")
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
assert "Max-Age=86400" in val # 24 hours (matches JWT expiry)
|
||||
|
||||
def test_max_age_custom(self):
|
||||
val = make_set_cookie("tok_abc", max_age=3600)
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, max_age=3600)
|
||||
assert "Max-Age=3600" in val
|
||||
|
||||
def test_secure_default(self):
|
||||
val = make_set_cookie("tok_abc")
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
|
||||
assert "; Secure" in val
|
||||
|
||||
def test_secure_false(self):
|
||||
val = make_set_cookie("tok_abc", secure=False)
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, secure=False)
|
||||
assert "; Secure" not in val
|
||||
|
||||
def test_secure_true(self):
|
||||
val = make_set_cookie("tok_abc", secure=True)
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, secure=True)
|
||||
assert "; Secure" in val
|
||||
|
||||
def test_uses_provided_name(self):
|
||||
# Name is honored verbatim; one surface's name never leaks into the other.
|
||||
val = make_set_cookie("tok_abc", AUTH_COOKIE_CONSOLE)
|
||||
assert "turnstone_auth_console=tok_abc" in val
|
||||
assert "turnstone_auth_server" not in val
|
||||
|
||||
|
||||
class TestMakeClearCookie:
|
||||
def test_max_age_zero(self):
|
||||
assert "Max-Age=0" in make_clear_cookie()
|
||||
assert "Max-Age=0" in make_clear_cookie(AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_empty_value(self):
|
||||
assert "turnstone_auth=;" in make_clear_cookie()
|
||||
assert "turnstone_auth_server=;" in make_clear_cookie(AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_httponly(self):
|
||||
assert "HttpOnly" in make_clear_cookie()
|
||||
assert "HttpOnly" in make_clear_cookie(AUTH_COOKIE_SERVER)
|
||||
|
||||
def test_uses_provided_name(self):
|
||||
val = make_clear_cookie(AUTH_COOKIE_CONSOLE)
|
||||
assert "turnstone_auth_console=;" in val
|
||||
assert "Max-Age=0" in val
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -437,47 +451,67 @@ class TestCheckRequest:
|
||||
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)
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/health", None, cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_public_root_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/", None)
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/", None, cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_static_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/static/style.css", None, cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_api_no_token_401(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/api/workstreams", None, cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
assert "Unauthorized" in msg
|
||||
|
||||
def test_api_invalid_token_401(self):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/api/workstreams", "Bearer wrong_token"
|
||||
"GET", "/api/workstreams", "Bearer wrong_token", cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_api_read_token_ok(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_api_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
full_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_write_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"POST", "/api/workstreams/abc/send", read_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/workstreams/abc/send",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -485,14 +519,22 @@ class TestCheckRequest:
|
||||
|
||||
def test_write_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"POST", "/api/workstreams/abc/send", full_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/workstreams/abc/send",
|
||||
full_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_approve_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"POST", "/api/workstreams/abc/approve", read_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/workstreams/abc/approve",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -504,6 +546,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/api/workstreams/abc/send",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -515,6 +558,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/api/workstreams/abc/send/",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -522,7 +566,11 @@ class TestCheckRequest:
|
||||
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(
|
||||
"POST", "/api/workstreams/abc/send/", read_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/workstreams/abc/send/",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -534,6 +582,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/api/workstreams/abc/send",
|
||||
full_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
@@ -544,6 +593,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/v1/api/workstreams/abc/send",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -555,6 +605,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/v1/api/workstreams/abc/send",
|
||||
full_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
@@ -565,6 +616,7 @@ class TestCheckRequest:
|
||||
"/node/node-a/v1/api/cluster/workstreams/new",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -572,26 +624,40 @@ class TestCheckRequest:
|
||||
def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
|
||||
"""Read tokens can access proxy read endpoints."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
"GET",
|
||||
"/node/node-a/api/workstreams",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_console_create_ws_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot create workstreams."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
"POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/cluster/workstreams/new",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_approve_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
"POST", "/api/workstreams/abc/approve", full_jwt, jwt_secret=self._SECRET
|
||||
"POST",
|
||||
"/api/workstreams/abc/approve",
|
||||
full_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_auth_header_string(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
|
||||
allowed, status, msg, _result = check_request(
|
||||
"GET", "/api/dashboard", "", cookie_name=AUTH_COOKIE_SERVER
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
@@ -619,8 +685,9 @@ class TestCheckRequestWithCookie:
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
@@ -630,8 +697,9 @@ class TestCheckRequestWithCookie:
|
||||
"POST",
|
||||
"/api/workstreams/abc/send",
|
||||
f"Bearer {full_jwt}",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
@@ -640,8 +708,9 @@ class TestCheckRequestWithCookie:
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=wrong_token",
|
||||
cookie_header=f"{AUTH_COOKIE_SERVER}=wrong_token",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
@@ -651,8 +720,9 @@ class TestCheckRequestWithCookie:
|
||||
"POST",
|
||||
"/api/workstreams/abc/send",
|
||||
None,
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
@@ -662,8 +732,9 @@ class TestCheckRequestWithCookie:
|
||||
"POST",
|
||||
"/api/workstreams/abc/send",
|
||||
None,
|
||||
cookie_header=f"turnstone_auth={full_jwt}",
|
||||
cookie_header=f"{AUTH_COOKIE_SERVER}={full_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
@@ -673,6 +744,7 @@ class TestCheckRequestWithCookie:
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header=None,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
@@ -682,6 +754,7 @@ class TestCheckRequestWithCookie:
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
None,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
@@ -690,9 +763,56 @@ class TestCheckRequestWithCookie:
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
None,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
# --- Cookie-name isolation (the server/console fix) -----------------------
|
||||
|
||||
def test_legacy_cookie_name_rejected(self, read_jwt):
|
||||
"""A pre-isolation ``turnstone_auth`` cookie no longer authenticates once
|
||||
the surface is configured for a scoped name: existing sessions must
|
||||
re-login after the rename (intentional hard cutover, no read-fallback)."""
|
||||
allowed, status, _, _r = check_request(
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header=f"{AUTH_COOKIE}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_console_cookie_name_not_read_by_server(self, read_jwt):
|
||||
"""The console's cookie name is invisible to a server-configured surface,
|
||||
so a console session can't satisfy a server request (no cross-read)."""
|
||||
allowed, status, _, _r = check_request(
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header=f"{AUTH_COOKIE_CONSOLE}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_both_cookies_present_no_clobber(self, read_jwt):
|
||||
"""With distinct names both surfaces' cookies coexist in one jar; the
|
||||
server reads only its own and authenticates even with a console cookie
|
||||
also present — the core property the rename buys."""
|
||||
allowed, status, _, _r = check_request(
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header=f"{AUTH_COOKIE_CONSOLE}=other; {AUTH_COOKIE_SERVER}={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — actual HTTP server with auth enabled
|
||||
@@ -1020,6 +1140,10 @@ class TestServerLogin:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "jwt" in data
|
||||
# Server surface sets ITS OWN scoped cookie — not the console's, not the legacy name.
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
assert AUTH_COOKIE_SERVER in set_cookie
|
||||
assert AUTH_COOKIE_CONSOLE not in set_cookie
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
# Login to get cookie (TestClient tracks cookies automatically)
|
||||
@@ -1044,6 +1168,7 @@ class TestServerLogin:
|
||||
assert logout_resp.status_code == 200
|
||||
cookie = logout_resp.headers.get("set-cookie", "")
|
||||
assert "Max-Age=0" in cookie
|
||||
assert AUTH_COOKIE_SERVER in cookie
|
||||
|
||||
# API should now fail (cookie cleared)
|
||||
resp = self.test_client.get("/v1/api/workstreams")
|
||||
@@ -1070,8 +1195,6 @@ class TestServerLogin:
|
||||
|
||||
def test_refresh_returns_new_jwt_and_cookie(self):
|
||||
"""POST /api/auth/refresh re-mints the cookie with a fresh exp."""
|
||||
from turnstone.core.auth import AUTH_COOKIE
|
||||
|
||||
# Storage needs get_user_permissions for the refresh re-resolve path.
|
||||
# Mock is shared across tests in the class — re-arm here in case a
|
||||
# prior test left it default.
|
||||
@@ -1098,7 +1221,7 @@ class TestServerLogin:
|
||||
# and refresh produce identical iat/exp claims and therefore an
|
||||
# identical token, which is fine: the cookie still gets re-set.
|
||||
cookie_hdr = refresh.headers.get("set-cookie", "")
|
||||
assert AUTH_COOKIE in cookie_hdr
|
||||
assert AUTH_COOKIE_SERVER in cookie_hdr
|
||||
assert "HttpOnly" in cookie_hdr
|
||||
|
||||
# The refreshed cookie must keep working.
|
||||
@@ -1279,7 +1402,10 @@ class TestConsoleLogin:
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
|
||||
# Exact scoped name (not the legacy prefix) and no server-name bleed.
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
assert AUTH_COOKIE_CONSOLE in set_cookie
|
||||
assert AUTH_COOKIE_SERVER not in set_cookie
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
self.test_client.post(
|
||||
@@ -1559,6 +1685,7 @@ class TestJWTVersionClaim:
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
@@ -1581,6 +1708,7 @@ class TestJWTVersionClaim:
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed
|
||||
|
||||
@@ -1602,6 +1730,7 @@ class TestJWTVersionClaim:
|
||||
jwt_secret=self.SECRET,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version="1.2",
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 401
|
||||
|
||||
@@ -7,6 +7,7 @@ import time
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AUTH_COOKIE_SERVER,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
@@ -273,6 +274,7 @@ class TestCheckRequestScopes:
|
||||
"/api/workstreams/abc/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
@@ -285,6 +287,7 @@ class TestCheckRequestScopes:
|
||||
"/api/workstreams/abc/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
@@ -297,6 +300,7 @@ class TestCheckRequestScopes:
|
||||
"/api/workstreams/abc/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
@@ -309,6 +313,7 @@ class TestCheckRequestScopes:
|
||||
"/api/workstreams/abc/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
@@ -321,6 +326,7 @@ class TestCheckRequestScopes:
|
||||
"/api/workstreams/abc/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
@@ -332,6 +338,7 @@ class TestCheckRequestScopes:
|
||||
"/v1/api/admin/users",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
@@ -24,6 +24,8 @@ from turnstone.console.server import (
|
||||
admin_list_oidc_identities,
|
||||
)
|
||||
from turnstone.core.auth import (
|
||||
AUTH_COOKIE_CONSOLE,
|
||||
AUTH_COOKIE_SERVER,
|
||||
AuthResult,
|
||||
LoginRateLimiter,
|
||||
handle_oidc_authorize,
|
||||
@@ -46,7 +48,7 @@ async def _oidc_authorize(request: Request) -> Response:
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request) -> Response:
|
||||
return await handle_oidc_callback(request, "test-audience")
|
||||
return await handle_oidc_callback(request, "test-audience", cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -292,7 +294,8 @@ class TestOIDCCallback:
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
assert "set-cookie" in resp.headers
|
||||
assert "turnstone_auth=" in resp.headers["set-cookie"]
|
||||
set_cookie = resp.headers["set-cookie"]
|
||||
assert set_cookie.split(";", 1)[0].partition("=")[0] == AUTH_COOKIE_SERVER
|
||||
|
||||
def test_oidc_not_configured_returns_404(self, storage: SQLiteBackend) -> None:
|
||||
app = Starlette(
|
||||
@@ -645,7 +648,9 @@ class TestOIDCCallback:
|
||||
# Wire a callback bound to the CONSOLE audience. After bug-3 the
|
||||
# stored audience must take precedence.
|
||||
async def _console_callback(request: Request) -> Response:
|
||||
return await handle_oidc_callback(request, "turnstone-console")
|
||||
return await handle_oidc_callback(
|
||||
request, "turnstone-console", cookie_name=AUTH_COOKIE_CONSOLE
|
||||
)
|
||||
|
||||
jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app = Starlette(
|
||||
@@ -670,7 +675,7 @@ class TestOIDCCallback:
|
||||
set_cookie = resp.headers["set-cookie"]
|
||||
cookie_kv = set_cookie.split(";", 1)[0]
|
||||
name, _, token = cookie_kv.partition("=")
|
||||
assert name == "turnstone_auth"
|
||||
assert name == AUTH_COOKIE_CONSOLE
|
||||
assert token
|
||||
|
||||
# Decoding without audience verification first to inspect the claim.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.4"
|
||||
__version__ = "1.6.5"
|
||||
|
||||
@@ -106,7 +106,10 @@ Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
### Ports / networking
|
||||
- `CONSOLE_HTTPS_PORT` — Caddy HTTPS port for the dashboard (default: 8443)
|
||||
- `POSTGRES_PORT` — PostgreSQL host port, for joining a bare-metal server (default: 5432)
|
||||
- `POSTGRES_BIND` — interface PostgreSQL binds on (default: 127.0.0.1; set 0.0.0.0 for LAN)
|
||||
- `TURNSTONE_HOST_IP` — interface the published bare-metal ports (Postgres, console
|
||||
ACME, SearxNG) bind on (default: 127.0.0.1; set your host's LAN IP to join from
|
||||
another machine). The legacy `POSTGRES_BIND` is still honored for Postgres.
|
||||
- `SEARXNG_API_PORT` — host port a bare-metal node's web_search dials SearxNG on (default: 8081)
|
||||
|
||||
### Channel Gateway (optional)
|
||||
- `TURNSTONE_DISCORD_TOKEN` — Discord bot token
|
||||
|
||||
@@ -47,6 +47,7 @@ from turnstone.console.metrics import ConsoleMetrics
|
||||
from turnstone.console.router import ConsoleRouter
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import (
|
||||
AUTH_COOKIE_CONSOLE,
|
||||
JWT_AUD_CONSOLE,
|
||||
JWT_AUD_SERVER,
|
||||
AuthMiddleware,
|
||||
@@ -1574,14 +1575,14 @@ async def auth_login(request: Request) -> Response:
|
||||
"""Authenticate via username:password or legacy token, return JWT."""
|
||||
from turnstone.core.auth import handle_auth_login
|
||||
|
||||
return await handle_auth_login(request, JWT_AUD_CONSOLE)
|
||||
return await handle_auth_login(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def auth_logout(request: Request) -> Response:
|
||||
"""POST /v1/api/auth/logout — clear auth cookie."""
|
||||
from turnstone.core.auth import handle_auth_logout
|
||||
|
||||
return await handle_auth_logout(request)
|
||||
return await handle_auth_logout(request, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def auth_status(request: Request) -> Response:
|
||||
@@ -1595,14 +1596,14 @@ async def auth_setup(request: Request) -> Response:
|
||||
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
|
||||
from turnstone.core.auth import handle_auth_setup
|
||||
|
||||
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
|
||||
return await handle_auth_setup(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def auth_whoami(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/whoami — return authenticated user info."""
|
||||
from turnstone.core.auth import handle_auth_whoami
|
||||
|
||||
return await handle_auth_whoami(request)
|
||||
return await handle_auth_whoami(request, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def auth_refresh(request: Request) -> Response:
|
||||
@@ -1613,7 +1614,7 @@ async def auth_refresh(request: Request) -> Response:
|
||||
"""
|
||||
from turnstone.core.auth import handle_auth_refresh
|
||||
|
||||
return await handle_auth_refresh(request, JWT_AUD_CONSOLE)
|
||||
return await handle_auth_refresh(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def oidc_authorize(request: Request) -> Response:
|
||||
@@ -1627,7 +1628,7 @@ async def oidc_callback(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
|
||||
from turnstone.core.auth import handle_oidc_callback
|
||||
|
||||
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
|
||||
return await handle_oidc_callback(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
|
||||
|
||||
|
||||
async def mcp_oauth_authorize(request: Request) -> Response:
|
||||
@@ -13564,7 +13565,12 @@ def _build_console_middleware(cors_origins: list[str] | None = None) -> list[Mid
|
||||
|
||||
stack.append(cors_middleware(cors_origins))
|
||||
stack.append(
|
||||
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_CONSOLE, jwt_version=jwt_version_slot())
|
||||
Middleware(
|
||||
AuthMiddleware,
|
||||
jwt_audience=JWT_AUD_CONSOLE,
|
||||
jwt_version=jwt_version_slot(),
|
||||
cookie_name=AUTH_COOKIE_CONSOLE,
|
||||
)
|
||||
)
|
||||
return stack
|
||||
|
||||
|
||||
+42
-21
@@ -54,7 +54,14 @@ log = get_logger(__name__)
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTH_COOKIE = "turnstone_auth"
|
||||
AUTH_COOKIE = "turnstone_auth" # legacy unscoped name (pre-isolation); see per-surface names below
|
||||
# Per-surface cookie names. The server (:8080) and console (:8090) are co-hostable
|
||||
# on a single origin; cookies ignore port, so distinct *names* keep one surface's
|
||||
# session from clobbering the other's. Keyed by role/audience, NOT by node — the
|
||||
# cluster shares one JWT identity (same secret + audience), so a token stays
|
||||
# portable across nodes and per-instance names would break proxy identity re-mint.
|
||||
AUTH_COOKIE_SERVER = "turnstone_auth_server"
|
||||
AUTH_COOKIE_CONSOLE = "turnstone_auth_console"
|
||||
TOKEN_PREFIX = "ts_"
|
||||
TOKEN_BYTES = 32 # 64 hex chars after prefix
|
||||
|
||||
@@ -801,11 +808,12 @@ def check_request(
|
||||
jwt_audience: str = "",
|
||||
jwt_version: str = "",
|
||||
storage: Any = None,
|
||||
cookie_name: str,
|
||||
) -> tuple[bool, int, str, AuthResult | None]:
|
||||
"""Validate a request.
|
||||
|
||||
Checks ``Authorization: Bearer <token>`` first, then falls back to the
|
||||
``turnstone_auth`` cookie. Token types are auto-detected:
|
||||
*cookie_name* cookie. Token types are auto-detected:
|
||||
|
||||
- Contains ``.`` → JWT (validated with *jwt_secret*)
|
||||
- Starts with ``ts_`` → API token (looked up in *storage* by hash)
|
||||
@@ -818,7 +826,7 @@ def check_request(
|
||||
# Extract token from header or cookie
|
||||
raw_token = _extract_bearer(auth_header)
|
||||
if raw_token is None:
|
||||
raw_token = _extract_cookie(cookie_header, AUTH_COOKIE)
|
||||
raw_token = _extract_cookie(cookie_header, cookie_name)
|
||||
|
||||
if not raw_token:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
@@ -932,22 +940,26 @@ def _extract_cookie(cookie_header: str | None, name: str) -> str | None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_set_cookie(token: str, max_age: int = 86400, *, secure: bool | None = None) -> str:
|
||||
def make_set_cookie(
|
||||
token: str, cookie_name: str, max_age: int = 86400, *, secure: bool | None = None
|
||||
) -> str:
|
||||
"""Return a ``Set-Cookie`` header value that stores the auth token.
|
||||
|
||||
*cookie_name* selects the per-surface cookie (``AUTH_COOKIE_SERVER`` /
|
||||
``AUTH_COOKIE_CONSOLE``) so co-hosted surfaces don't share a jar slot.
|
||||
When *secure* is ``None`` (default) the ``Secure`` flag is set
|
||||
unconditionally. Pass ``secure=False`` only for plaintext development.
|
||||
*max_age* defaults to 24 hours to match the default JWT expiry.
|
||||
"""
|
||||
val = f"{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
|
||||
val = f"{cookie_name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
|
||||
if secure is None or secure:
|
||||
val += "; Secure"
|
||||
return val
|
||||
|
||||
|
||||
def make_clear_cookie() -> str:
|
||||
"""Return a ``Set-Cookie`` header value that expires the auth cookie."""
|
||||
return f"{AUTH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
def make_clear_cookie(cookie_name: str) -> str:
|
||||
"""Return a ``Set-Cookie`` header value that expires the named auth cookie."""
|
||||
return f"{cookie_name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
|
||||
|
||||
def is_secure_request(headers: dict[str, str], scheme: str = "") -> bool:
|
||||
@@ -1094,10 +1106,18 @@ class AuthMiddleware:
|
||||
server (``JWT_AUD_SERVER``) and the console (``JWT_AUD_CONSOLE``).
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, jwt_audience: str = "", jwt_version: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
jwt_audience: str = "",
|
||||
jwt_version: str = "",
|
||||
*,
|
||||
cookie_name: str,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self._jwt_audience = jwt_audience
|
||||
self._jwt_version = jwt_version
|
||||
self._cookie_name = cookie_name
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
@@ -1128,6 +1148,7 @@ class AuthMiddleware:
|
||||
jwt_audience=self._jwt_audience,
|
||||
jwt_version=self._jwt_version,
|
||||
storage=storage,
|
||||
cookie_name=self._cookie_name,
|
||||
)
|
||||
if not allowed:
|
||||
body: dict[str, Any] = {"error": msg}
|
||||
@@ -1154,7 +1175,7 @@ class AuthMiddleware:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
async def handle_auth_login(request: Request, audience: str, cookie_name: str) -> Response:
|
||||
"""Shared ``POST /api/auth/login`` handler.
|
||||
|
||||
Authenticates via username:password or legacy token exchange, returning
|
||||
@@ -1257,16 +1278,16 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
response = JSONResponse(resp_body)
|
||||
cookie_value = jwt_token if jwt_token else body.get("token", "")
|
||||
if cookie_value:
|
||||
response.headers["Set-Cookie"] = make_set_cookie(cookie_value, secure=secure)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(cookie_value, cookie_name, secure=secure)
|
||||
return response
|
||||
|
||||
|
||||
async def handle_auth_logout(request: Request) -> Response:
|
||||
async def handle_auth_logout(request: Request, cookie_name: str) -> Response:
|
||||
"""Shared ``POST /api/auth/logout`` handler — clear auth cookie."""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse({"status": "ok"})
|
||||
response.headers["Set-Cookie"] = make_clear_cookie()
|
||||
response.headers["Set-Cookie"] = make_clear_cookie(cookie_name)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1300,7 +1321,7 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
async def handle_auth_setup(request: Request, audience: str, cookie_name: str) -> Response:
|
||||
"""Shared ``POST /api/auth/setup`` handler — create first admin user.
|
||||
|
||||
Only works when zero users exist. Returns JWT on success.
|
||||
@@ -1401,11 +1422,11 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
secure = is_secure_request(dict(request.headers), request.url.scheme)
|
||||
response = JSONResponse(resp_body)
|
||||
if jwt_token:
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, cookie_name, secure=secure)
|
||||
return response
|
||||
|
||||
|
||||
async def handle_auth_whoami(request: Request) -> Response:
|
||||
async def handle_auth_whoami(request: Request, cookie_name: str) -> Response:
|
||||
"""Shared ``GET /api/auth/whoami`` handler — return authenticated user info.
|
||||
|
||||
Includes the JWT ``exp`` claim (epoch seconds) so the frontend can
|
||||
@@ -1440,7 +1461,7 @@ async def handle_auth_whoami(request: Request) -> Response:
|
||||
resp["permissions"] = ",".join(sorted(auth_result.permissions))
|
||||
# Surface the cookie/JWT expiry so the client can schedule refresh.
|
||||
# Decoded without re-validating (auth middleware already validated).
|
||||
cookie_token = request.cookies.get(AUTH_COOKIE, "")
|
||||
cookie_token = request.cookies.get(cookie_name, "")
|
||||
if cookie_token:
|
||||
try:
|
||||
import jwt as _jwt
|
||||
@@ -1455,7 +1476,7 @@ async def handle_auth_whoami(request: Request) -> Response:
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
async def handle_auth_refresh(request: Request, audience: str) -> Response:
|
||||
async def handle_auth_refresh(request: Request, audience: str, cookie_name: str) -> Response:
|
||||
"""Shared ``POST /api/auth/refresh`` handler — re-mint the auth cookie.
|
||||
|
||||
Requires a currently-valid auth cookie (auth middleware enforces).
|
||||
@@ -1551,7 +1572,7 @@ async def handle_auth_refresh(request: Request, audience: str) -> Response:
|
||||
|
||||
response = JSONResponse(resp_body)
|
||||
secure = is_secure_request(dict(request.headers), request.url.scheme)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(new_token, secure=secure)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(new_token, cookie_name, secure=secure)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1657,7 +1678,7 @@ async def _refetch_jwks_locked(
|
||||
return fresh
|
||||
|
||||
|
||||
async def handle_oidc_callback(request: Request, audience: str) -> Response:
|
||||
async def handle_oidc_callback(request: Request, audience: str, cookie_name: str) -> Response:
|
||||
"""Shared ``GET /api/auth/oidc/callback`` handler — exchange code, provision user, issue JWT."""
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
@@ -1808,5 +1829,5 @@ async def handle_oidc_callback(request: Request, audience: str) -> Response:
|
||||
response = RedirectResponse("/?oidc_success=1", status_code=302)
|
||||
if jwt_token:
|
||||
secure = is_secure_request(dict(request.headers), request.url.scheme)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
|
||||
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, cookie_name, secure=secure)
|
||||
return response
|
||||
|
||||
+18
-7
@@ -48,6 +48,7 @@ from turnstone.api.docs import make_docs_handler, make_openapi_handler
|
||||
from turnstone.api.server_spec import build_server_spec
|
||||
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
|
||||
from turnstone.core.auth import (
|
||||
AUTH_COOKIE_SERVER,
|
||||
DENY_EMPTY_SUB,
|
||||
JWT_AUD_SERVER,
|
||||
AuthMiddleware,
|
||||
@@ -2574,14 +2575,14 @@ async def auth_login(request: Request) -> Response:
|
||||
"""POST /v1/api/auth/login — authenticate and return JWT."""
|
||||
from turnstone.core.auth import handle_auth_login
|
||||
|
||||
return await handle_auth_login(request, JWT_AUD_SERVER)
|
||||
return await handle_auth_login(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def auth_logout(request: Request) -> Response:
|
||||
"""POST /v1/api/auth/logout — clear auth cookie."""
|
||||
from turnstone.core.auth import handle_auth_logout
|
||||
|
||||
return await handle_auth_logout(request)
|
||||
return await handle_auth_logout(request, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def auth_status(request: Request) -> Response:
|
||||
@@ -2595,14 +2596,14 @@ async def auth_setup(request: Request) -> Response:
|
||||
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
|
||||
from turnstone.core.auth import handle_auth_setup
|
||||
|
||||
return await handle_auth_setup(request, JWT_AUD_SERVER)
|
||||
return await handle_auth_setup(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def auth_whoami(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/whoami — return authenticated user info."""
|
||||
from turnstone.core.auth import handle_auth_whoami
|
||||
|
||||
return await handle_auth_whoami(request)
|
||||
return await handle_auth_whoami(request, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def auth_refresh(request: Request) -> Response:
|
||||
@@ -2613,7 +2614,7 @@ async def auth_refresh(request: Request) -> Response:
|
||||
"""
|
||||
from turnstone.core.auth import handle_auth_refresh
|
||||
|
||||
return await handle_auth_refresh(request, JWT_AUD_SERVER)
|
||||
return await handle_auth_refresh(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def oidc_authorize(request: Request) -> Response:
|
||||
@@ -2627,7 +2628,7 @@ async def oidc_callback(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
|
||||
from turnstone.core.auth import handle_oidc_callback
|
||||
|
||||
return await handle_oidc_callback(request, JWT_AUD_SERVER)
|
||||
return await handle_oidc_callback(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
|
||||
|
||||
|
||||
async def mcp_oauth_authorize(request: Request) -> Response:
|
||||
@@ -3666,7 +3667,12 @@ def _build_middleware(cors_origins: list[str] | None = None) -> list[Middleware]
|
||||
stack.append(cors_middleware(cors_origins))
|
||||
stack.extend(
|
||||
[
|
||||
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_SERVER, jwt_version=jwt_version_slot()),
|
||||
Middleware(
|
||||
AuthMiddleware,
|
||||
jwt_audience=JWT_AUD_SERVER,
|
||||
jwt_version=jwt_version_slot(),
|
||||
cookie_name=AUTH_COOKIE_SERVER,
|
||||
),
|
||||
Middleware(RateLimitMiddleware),
|
||||
]
|
||||
)
|
||||
@@ -4661,6 +4667,11 @@ def main() -> None:
|
||||
tls_client = TLSClient(
|
||||
storage=get_storage(),
|
||||
hostnames=hostnames,
|
||||
# A bare-metal node can't resolve the in-network console URL the
|
||||
# services table advertises (http://console:8090), so honor an
|
||||
# explicit override pointing at the published ACME endpoint.
|
||||
# Empty (the in-cluster default) falls back to service discovery.
|
||||
console_url=os.environ.get("TURNSTONE_CONSOLE_URL", ""),
|
||||
)
|
||||
asyncio.run(tls_client.init(attempts=TLS_INIT_RETRY_ATTEMPTS))
|
||||
bundle = tls_client.bundle
|
||||
|
||||
Reference in New Issue
Block a user