mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
047680d669
* Add user identity, JWT auth, and admin console UI (#23) JWT-based authentication with three token types: config-file (hmac, backward-compat), API tokens (ts_ prefix, SHA-256 hashed), and JWTs (HS256, 24h expiry). Username:password login via bcrypt. Hierarchical scopes: read < write < approve. New tables: users (username, password_hash), api_tokens (token_hash, scopes, expires), channel_users (future channel integrations). user_id column added to sessions and workstreams for attribution. Console owns admin CRUD (6 endpoints under /api/admin/). Server validates JWTs locally with shared signing secret. Public /api/auth/setup endpoint for first-time admin creation (atomic, only works with zero users). turnstone-admin CLI for user/token management. Admin console UI: Users and Tokens tabs with full CRUD modals, scope badges, token show-once with clipboard copy, keyboard accessibility (focus traps, Escape, arrow key tabs, ARIA roles). Login UI redesigned: username:password primary, token toggle for legacy, setup wizard auto-detected via /api/auth/status. Python + TypeScript SDKs updated with login(username, password), authStatus(), setup(). New docs/security.md + diagram 15-auth-architecture.puml. All existing docs updated. OpenAPI specs include all new endpoints. 64 new tests (1023 total). Dependencies: PyJWT, bcrypt. * Fix auth bugs, XSS vector, and doc inaccuracies from PR #23 review Address Copilot review feedback: escape double quotes in escapeHtml() to prevent XSS in HTML attributes, add JWT validation fallback so config tokens containing dots still work, add user_id to AuthLoginResponse schema, return created field from admin_create_user, and correct five documentation files to match actual API behavior.
358 lines
12 KiB
Python
358 lines
12 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 (
|
|
AuthConfig,
|
|
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"
|
|
|
|
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") 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/send") == "write"
|
|
|
|
def test_post_approve(self):
|
|
assert required_scope("POST", "/api/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/send") == "write"
|
|
assert required_scope("POST", "/v1/api/approve") == "approve"
|
|
|
|
def test_proxy_write(self):
|
|
assert required_scope("POST", "/node/n1/api/send") == "write"
|
|
|
|
def test_proxy_approve(self):
|
|
assert required_scope("POST", "/node/n1/api/approve") == "approve"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _authenticate_token
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthenticateToken:
|
|
def test_config_token_read(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
|
result = _authenticate_token("tok_read", cfg)
|
|
assert result is not None
|
|
assert result.scopes == frozenset({"read"})
|
|
assert result.token_source == "config"
|
|
|
|
def test_config_token_full(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
|
result = _authenticate_token("tok_full", cfg)
|
|
assert result is not None
|
|
assert result.scopes == frozenset({"read", "write", "approve"})
|
|
|
|
def test_jwt_token(self):
|
|
secret = "test-secret"
|
|
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
|
cfg = AuthConfig(enabled=True)
|
|
result = _authenticate_token(jwt_tok, cfg, 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
|
|
|
|
cfg = AuthConfig(enabled=True)
|
|
result = _authenticate_token(raw, cfg, 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",
|
|
}
|
|
|
|
cfg = AuthConfig(enabled=True)
|
|
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
|
assert result is None
|
|
|
|
def test_unknown_token(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
|
result = _authenticate_token("unknown", cfg)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_request with scopes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckRequestScopes:
|
|
def test_config_read_on_write_403(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
|
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "write" in msg
|
|
|
|
def test_config_read_on_approve_403(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
|
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
|
assert not allowed
|
|
assert status == 403
|
|
assert "approve" in msg
|
|
|
|
def test_config_full_on_approve_ok(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
|
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.has_scope("approve")
|
|
|
|
def test_jwt_with_scopes(self):
|
|
secret = "test"
|
|
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
|
cfg = AuthConfig(enabled=True)
|
|
allowed, status, msg, result = check_request(
|
|
cfg,
|
|
"POST",
|
|
"/api/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=secret,
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.user_id == "u1"
|
|
|
|
def test_jwt_insufficient_scope(self):
|
|
secret = "test"
|
|
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
|
cfg = AuthConfig(enabled=True)
|
|
allowed, status, msg, _ = check_request(
|
|
cfg,
|
|
"POST",
|
|
"/api/send",
|
|
f"Bearer {jwt_tok}",
|
|
jwt_secret=secret,
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
|
|
def test_admin_path_requires_approve(self):
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
|
allowed, status, msg, _ = check_request(
|
|
cfg,
|
|
"GET",
|
|
"/v1/api/admin/users",
|
|
"Bearer tok_read",
|
|
)
|
|
assert not allowed
|
|
assert status == 403
|
|
|
|
def test_backward_compat_role_full(self):
|
|
"""Config tokens with role='full' get all scopes."""
|
|
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
|
allowed, _, _, result = check_request(
|
|
cfg,
|
|
"GET",
|
|
"/v1/api/admin/users",
|
|
"Bearer tok_full",
|
|
)
|
|
assert allowed
|
|
assert result is not None
|
|
assert result.has_scope("approve")
|