mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
108714a48d
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.
345 lines
11 KiB
Python
345 lines
11 KiB
Python
"""Tests for user identity, API tokens, JWT, and scoped auth."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.auth import (
|
|
AUTH_COOKIE_SERVER,
|
|
AuthResult,
|
|
_authenticate_token,
|
|
check_request,
|
|
create_jwt,
|
|
generate_token,
|
|
hash_password,
|
|
hash_token,
|
|
parse_scopes,
|
|
required_scope,
|
|
token_prefix,
|
|
validate_jwt,
|
|
verify_password,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AuthResult
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthResult:
|
|
def test_frozen(self):
|
|
r = AuthResult(user_id="u1", scopes=frozenset({"read"}), token_source="config")
|
|
with pytest.raises(AttributeError):
|
|
r.user_id = "u2" # type: ignore[misc]
|
|
|
|
def test_has_scope(self):
|
|
r = AuthResult(user_id="", scopes=frozenset({"read", "write"}), token_source="config")
|
|
assert r.has_scope("read")
|
|
assert r.has_scope("write")
|
|
assert not r.has_scope("approve")
|
|
|
|
def test_empty_scopes(self):
|
|
r = AuthResult(user_id="", scopes=frozenset(), token_source="config")
|
|
assert not r.has_scope("read")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token generation and hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTokenHelpers:
|
|
def test_generate_token_format(self):
|
|
tok = generate_token()
|
|
assert tok.startswith("ts_")
|
|
assert len(tok) == 3 + 64 # ts_ + 64 hex chars
|
|
|
|
def test_generate_token_unique(self):
|
|
tokens = {generate_token() for _ in range(10)}
|
|
assert len(tokens) == 10
|
|
|
|
def test_hash_token_deterministic(self):
|
|
assert hash_token("ts_abc") == hash_token("ts_abc")
|
|
|
|
def test_hash_token_hex(self):
|
|
h = hash_token("test")
|
|
assert len(h) == 64 # SHA-256 hex
|
|
int(h, 16) # valid hex
|
|
|
|
def test_token_prefix(self):
|
|
assert token_prefix("ts_abcdefgh1234") == "ts_abcde"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Password hashing (bcrypt)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPasswordHashing:
|
|
def test_hash_and_verify(self):
|
|
pw = "hunter2"
|
|
hashed = hash_password(pw)
|
|
assert verify_password(pw, hashed)
|
|
|
|
def test_wrong_password(self):
|
|
hashed = hash_password("correct")
|
|
assert not verify_password("wrong", hashed)
|
|
|
|
def test_hash_is_different_each_time(self):
|
|
h1 = hash_password("same")
|
|
h2 = hash_password("same")
|
|
assert h1 != h2 # different salts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseScopes:
|
|
def test_single_scope(self):
|
|
assert parse_scopes("read") == frozenset({"read"})
|
|
|
|
def test_hierarchy_write(self):
|
|
assert parse_scopes("write") == frozenset({"read", "write"})
|
|
|
|
def test_hierarchy_approve(self):
|
|
assert parse_scopes("approve") == frozenset({"read", "write", "approve"})
|
|
|
|
def test_comma_separated(self):
|
|
assert parse_scopes("read,write") == frozenset({"read", "write"})
|
|
|
|
def test_redundant_scopes(self):
|
|
# approve already includes read,write
|
|
assert parse_scopes("read,approve") == frozenset({"read", "write", "approve"})
|
|
|
|
def test_empty_string(self):
|
|
assert parse_scopes("") == frozenset()
|
|
|
|
def test_invalid_scope_filtered(self):
|
|
assert parse_scopes("bogus") == frozenset()
|
|
|
|
def test_mixed_valid_invalid(self):
|
|
assert parse_scopes("read,bogus,approve") == frozenset({"read", "write", "approve"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JWT create / validate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJWT:
|
|
SECRET = "test-secret-key-for-jwt-min-32b!"
|
|
|
|
def test_round_trip(self):
|
|
scopes = frozenset({"read", "write"})
|
|
token = create_jwt("user123", scopes, "database", self.SECRET, expiry_hours=1)
|
|
result = validate_jwt(token, self.SECRET)
|
|
assert result is not None
|
|
assert result.user_id == "user123"
|
|
assert result.scopes == frozenset({"read", "write"})
|
|
|
|
def test_expired_token(self):
|
|
import jwt
|
|
|
|
payload = {
|
|
"sub": "user1",
|
|
"scopes": "read",
|
|
"src": "database",
|
|
"iat": int(time.time()) - 7200,
|
|
"exp": int(time.time()) - 3600,
|
|
}
|
|
token = jwt.encode(payload, self.SECRET, algorithm="HS256")
|
|
assert validate_jwt(token, self.SECRET) is None
|
|
|
|
def test_invalid_signature(self):
|
|
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
|
|
assert validate_jwt(token, "wrong-secret-key-for-jwt-min-32b") is None
|
|
|
|
def test_malformed_token(self):
|
|
assert validate_jwt("not.a.jwt", self.SECRET) is None
|
|
|
|
def test_contains_dots(self):
|
|
"""JWTs contain dots, used for detection."""
|
|
token = create_jwt("u1", frozenset({"read"}), "db", self.SECRET)
|
|
assert "." in token
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# required_scope
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRequiredScope:
|
|
def test_get_read(self):
|
|
assert required_scope("GET", "/api/workstreams") == "read"
|
|
|
|
def test_post_write(self):
|
|
assert required_scope("POST", "/api/workstreams/abc/send") == "write"
|
|
|
|
def test_post_approve(self):
|
|
assert required_scope("POST", "/api/workstreams/abc/approve") == "approve"
|
|
|
|
def test_admin_prefix(self):
|
|
assert required_scope("GET", "/api/admin/users") == "approve"
|
|
assert required_scope("POST", "/api/admin/users") == "approve"
|
|
assert required_scope("DELETE", "/api/admin/users/abc") == "approve"
|
|
|
|
def test_versioned_path(self):
|
|
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
|
|
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
|
|
|
|
def test_proxy_write(self):
|
|
assert required_scope("POST", "/node/n1/api/workstreams/abc/send") == "write"
|
|
|
|
def test_proxy_approve(self):
|
|
assert required_scope("POST", "/node/n1/api/workstreams/abc/approve") == "approve"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _authenticate_token
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthenticateToken:
|
|
def test_jwt_token(self):
|
|
secret = "test-secret-key-for-jwt-min-32b!"
|
|
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
|
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
|
assert result is not None
|
|
assert result.user_id == "user1"
|
|
assert result.token_source == "db"
|
|
|
|
def test_api_token_with_storage(self):
|
|
"""API tokens are looked up by hash in storage."""
|
|
raw = generate_token()
|
|
|
|
class MockStorage:
|
|
def get_api_token_by_hash(self, token_hash):
|
|
expected = hash_token(raw)
|
|
if token_hash == expected:
|
|
return {
|
|
"token_id": "tid",
|
|
"token_prefix": "ts_abcde",
|
|
"user_id": "user1",
|
|
"name": "test",
|
|
"scopes": "read,write",
|
|
"created": "2026-01-01T00:00:00",
|
|
}
|
|
return None
|
|
|
|
result = _authenticate_token(raw, storage=MockStorage())
|
|
assert result is not None
|
|
assert result.user_id == "user1"
|
|
assert result.has_scope("write")
|
|
assert result.token_source == "database"
|
|
|
|
def test_api_token_expired(self):
|
|
"""Expired API tokens are rejected."""
|
|
raw = generate_token()
|
|
|
|
class MockStorage:
|
|
def get_api_token_by_hash(self, token_hash):
|
|
return {
|
|
"token_id": "tid",
|
|
"token_prefix": "ts_abcde",
|
|
"user_id": "user1",
|
|
"name": "test",
|
|
"scopes": "read",
|
|
"created": "2020-01-01T00:00:00",
|
|
"expires": "2020-01-02T00:00:00",
|
|
}
|
|
|
|
result = _authenticate_token(raw, storage=MockStorage())
|
|
assert result is None
|
|
|
|
def test_unknown_token(self):
|
|
result = _authenticate_token("unknown")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_request with scopes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckRequestScopes:
|
|
_SECRET = "test-secret-key-for-jwt-min-32b!"
|
|
|
|
def test_jwt_read_on_write_403(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/workstreams/abc/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "write" in msg
|
|
|
|
def test_jwt_read_on_approve_403(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/workstreams/abc/approve",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "approve" in msg
|
|
|
|
def test_jwt_full_on_approve_ok(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
|
allowed, status, msg, result = check_request(
|
|
"POST",
|
|
"/api/workstreams/abc/approve",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.has_scope("approve")
|
|
|
|
def test_jwt_with_scopes(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
|
allowed, status, msg, result = check_request(
|
|
"POST",
|
|
"/api/workstreams/abc/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.user_id == "u1"
|
|
|
|
def test_jwt_insufficient_scope(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"POST",
|
|
"/api/workstreams/abc/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
|
|
def test_admin_path_requires_approve(self):
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
|
allowed, status, msg, _ = check_request(
|
|
"GET",
|
|
"/v1/api/admin/users",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=self._SECRET,
|
|
cookie_name=AUTH_COOKIE_SERVER,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|