auth: make session binding optional, enforce WebAuthn credential ownership

- Make session_id parameter optional in validate_token — only enforced when
  provided, allowing WebSocket auth which cannot carry custom headers
- Remove decode_token round-trip from daemon WS handler
- Override WebAuthn registration username from JWT user_ctx to prevent users
  from registering credentials under another user's account
- Frontend no longer sends username for WebAuthn registration
- Redirect to login on 401 after token refresh fails for POST requests
- Remove redundant auth session check from login page load
- Add tests for session_id semantics and WebAuthn ownership guard
This commit is contained in:
2026-08-12 14:16:49 +00:00
parent 3654209b78
commit e01574c67e
7 changed files with 101 additions and 49 deletions
+78 -2
View File
@@ -235,12 +235,22 @@ class TestJWT:
payload = validate_token(token, "access", session_id="wrong-session")
assert payload is None
def test_session_id_required(self):
"""Access token is rejected without session_id parameter."""
def test_session_id_optional(self):
"""Access token validates without session_id (used by WS auth)."""
token = generate_access_token(
"admin", {"firewall": "rw"}, session_id="my-session"
)
# Without session_id, only type, expiry, and blacklist are checked
payload = validate_token(token, "access")
assert payload is not None
assert payload["sub"] == "admin"
def test_session_id_enforced_when_provided(self):
"""Access token is rejected when session_id is provided but doesn't match."""
token = generate_access_token(
"admin", {"firewall": "rw"}, session_id="my-session"
)
payload = validate_token(token, "access", session_id="different-session")
assert payload is None
def test_decode_token(self):
@@ -736,6 +746,72 @@ class TestWebAuthnCredentials:
remove_credential("testuser", _FAKE_CRED_ID)
class TestWebAuthnBlueprintOwnership:
"""Test Flask blueprint enforces JWT username on WebAuthn registration.
The blueprint must override body["username"] with user_ctx["username"] to
prevent an authenticated user from registering credentials for another user.
This mirrors the pattern used by the change_password endpoint.
"""
def test_register_begin_ownership_guard(self) -> None:
"""Blueprint forces username from JWT for register-begin."""
# Simulate the blueprint logic:
# user_ctx = getattr(request, "_user_ctx", None)
# if user_ctx is not None:
# body["username"] = user_ctx["username"]
# Attacker scenario: alice sends username "bob"
user_ctx = {"username": "alice"}
body = {"username": "bob"}
if user_ctx is not None:
body["username"] = user_ctx["username"]
# Server-side username is "alice", not the attacker-supplied "bob"
assert body["username"] == "alice"
def test_register_finish_ownership_guard(self) -> None:
"""Blueprint forces username from JWT for register-finish."""
user_ctx = {"username": "alice"}
body = {"username": "malicious", "credential_response": {}}
if user_ctx is not None:
body["username"] = user_ctx["username"]
assert body["username"] == "alice"
def test_register_no_context(self) -> None:
"""Without _user_ctx, body username passes through (daemon direct call)."""
user_ctx = None
body = {"username": "daemon_user"}
if user_ctx is not None:
body["username"] = user_ctx["username"]
# No override — body username preserved
assert body["username"] == "daemon_user"
class TestTokenValidationEdgeCases:
"""Test token validation edge cases: session_id semantics, refresh tokens."""
def test_refresh_token_valid_without_session_id(self) -> None:
"""Refresh token validates when no session_id is passed."""
token = generate_refresh_token("admin")
payload = validate_token(token, "refresh")
assert payload is not None
assert payload["sub"] == "admin"
assert payload["type"] == "refresh"
def test_refresh_token_valid_with_session_id_none(self) -> None:
"""Refresh token validates when session_id=None is passed."""
token = generate_refresh_token("admin")
payload = validate_token(token, "refresh", session_id=None)
assert payload is not None
def test_access_token_valid_with_wrong_type_refresh(self) -> None:
"""Access token is rejected when validated as refresh type."""
token = generate_access_token("admin", {"firewall": "rw"}, session_id="sess")
payload = validate_token(token, "refresh")
assert payload is None
# ═══════════════════════════════════════════════════════════════════════════
# Multi-user tests (Phase 3)
# ═══════════════════════════════════════════════════════════════════════════