From 80530aba94a9e8dcdde29dc8de40dfe39cf4ea52 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 15 Jun 2026 02:27:49 -0700 Subject: [PATCH] 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. --- docs/api-reference.md | 14 ++- docs/architecture.md | 3 +- tests/test_auth.py | 203 +++++++++++++++++++++++++++++------- tests/test_auth_identity.py | 7 ++ tests/test_oidc_handlers.py | 13 ++- turnstone/console/server.py | 20 ++-- turnstone/core/auth.py | 63 +++++++---- turnstone/server.py | 20 ++-- 8 files changed, 262 insertions(+), 81 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 71a7dbdf..4bf16f17 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 ` -- **Cookie**: `turnstone_auth=` (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` diff --git a/docs/architecture.md b/docs/architecture.md index 569d2529..1531d6ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ` 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. diff --git a/tests/test_auth.py b/tests/test_auth.py index d1f17e43..7b934a1b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py index 20882f62..f768d5fc 100644 --- a/tests/test_auth_identity.py +++ b/tests/test_auth_identity.py @@ -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 diff --git a/tests/test_oidc_handlers.py b/tests/test_oidc_handlers.py index 9f9a83f9..cd0ee1b4 100644 --- a/tests/test_oidc_handlers.py +++ b/tests/test_oidc_handlers.py @@ -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. diff --git a/turnstone/console/server.py b/turnstone/console/server.py index f8d2fe75..c0c222b8 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -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 diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 1ebb318a..654dc1a5 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -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 `` 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 diff --git a/turnstone/server.py b/turnstone/server.py index 22756025..c739599b 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -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), ] )