fix(auth): isolate server/console session cookies by name

The server (:8080) and console (:8090) both set a cookie named
`turnstone_auth`. Cookies ignore port (RFC 6265), so on a shared host
(localhost dev, the Electron build, single-box installs) logging into one
surface overwrote the other's cookie and 401'd the first session.

Give each surface its own cookie name -- `turnstone_auth_server` /
`turnstone_auth_console` -- threaded as a required `cookie_name` argument
through the cookie builders, `check_request`, `AuthMiddleware`, and the six
shared auth handlers (login/logout/setup/whoami/refresh/oidc_callback). Each
app passes its own constant; the parameter is required (no default) so a
forgotten caller fails loudly instead of silently reverting to the legacy name.

Names key on role, not node: the cluster shares one JWT identity and the
console->node proxy re-mints a bearer token (dropping Set-Cookie), so
per-instance names would break identity portability and aren't used.

Hard cutover: the legacy `turnstone_auth` cookie is no longer read and
self-expires within its 24h TTL (one forced re-login). JWT audience was
already enforced, so the shared cookie was a session clobber, not an auth
bypass.
This commit is contained in:
Patrick Buckley
2026-06-15 02:27:49 -07:00
parent fb77fcd805
commit 80530aba94
8 changed files with 262 additions and 81 deletions
+10 -4
View File
@@ -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`
+2 -1
View File
@@ -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.
+166 -37
View File
@@ -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
View File
@@ -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
+9 -4
View File
@@ -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.
+13 -7
View File
@@ -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
View File
@@ -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
+13 -7
View File
@@ -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),
]
)