Files
hermes-webui/tests/test_trusted_header_auth.py
T
carlotestor 7bb12d0aab fix: keep per-profile models disk cache across /api/profile/switch
POST /api/profile/switch called invalidate_models_cache(), whose
test-isolation contract also unlinks models_cache.<profile>.json. On a
switch nothing about the sources changed, so the delete only forced a
full cold rebuild (live provider fetch_models HTTPS calls, ~490k deepcopy
calls) on the very next /api/models — several seconds on every switch.

The disk cache is already keyed per profile and rejected on read when
_models_cache_source_fingerprint() differs, so it is safe to keep. Add a
delete_disk keyword (default True — every existing caller keeps the old
behaviour) and have the switch route pass delete_disk=False.

Regression tests cover: memory-only drop preserves the disk file, default
call still unlinks it, the next get_available_models() serves the disk
snapshot without a live rebuild, and the switch route opts out. Two
existing zero-arg stubs of invalidate_models_cache accept **kwargs now.
2026-09-20 11:19:41 +00:00

896 lines
35 KiB
Python

from __future__ import annotations
import io
import json
import shutil
import subprocess
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
import api.auth as auth
import api.routes as routes
import api.profiles as profiles
from tests.js_source_extract import extract_function
PANELS_JS = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
class _Handler:
def __init__(self, *, headers=None, client_address=("127.0.0.1", 12345)):
self.headers = dict(headers or {})
self.client_address = client_address
self.command = "GET"
self.path = "/"
self.request = SimpleNamespace()
self.rfile = io.BytesIO(b"")
self.wfile = io.BytesIO()
self.status = None
self.sent_headers = []
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def body_bytes(self):
return self.wfile.getvalue()
def body_text(self):
return self.body_bytes().decode("utf-8")
def json_body(self):
return json.loads(self.body_text())
def header_values(self, name):
return [value for key, value in self.sent_headers if key == name]
@pytest.fixture(autouse=True)
def isolated_auth_state(monkeypatch, tmp_path):
monkeypatch.setattr(auth, "STATE_DIR", tmp_path)
monkeypatch.setattr(auth, "_SESSIONS_FILE", tmp_path / ".sessions.json")
monkeypatch.setattr(auth, "is_password_auth_enabled", lambda: False)
monkeypatch.setattr(auth, "are_passkeys_enabled", lambda: False)
monkeypatch.setattr(auth, "is_oidc_auth_enabled", lambda: False)
auth._sessions.clear()
auth._TRUSTED_AUTH_WARNINGS_EMITTED.clear()
profiles.clear_request_profile()
yield
auth._sessions.clear()
auth._TRUSTED_AUTH_WARNINGS_EMITTED.clear()
profiles.clear_request_profile()
def _trusted_env(
monkeypatch,
*,
header="Remote-User",
groups_header=None,
group_map=None,
proxy_cidrs=None,
logout_url=None,
):
for key in (
"HERMES_WEBUI_TRUSTED_AUTH_HEADER",
"HERMES_WEBUI_TRUSTED_GROUPS_HEADER",
"HERMES_WEBUI_GROUP_PROFILE_MAP",
"HERMES_WEBUI_TRUSTED_PROXY_CIDRS",
"HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL",
):
monkeypatch.delenv(key, raising=False)
if header is not None:
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_AUTH_HEADER", header)
if groups_header is not None:
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_GROUPS_HEADER", groups_header)
if group_map is not None:
monkeypatch.setenv("HERMES_WEBUI_GROUP_PROFILE_MAP", json.dumps(group_map))
if proxy_cidrs is not None:
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", proxy_cidrs)
if logout_url is not None:
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL", logout_url)
def test_trusted_header_only_enables_auth_gate(monkeypatch):
_trusted_env(monkeypatch)
assert auth.is_trusted_auth_enabled() is True
assert auth.is_auth_enabled() is True
def test_untrusted_peer_header_does_not_create_session(monkeypatch):
_trusted_env(monkeypatch)
handler = _Handler(
headers={"Remote-User": "alice"},
client_address=("10.0.0.5", 12345),
)
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert result is False
assert handler.status == 401
assert getattr(handler, "_pending_set_cookies", []) == []
def test_malformed_trusted_proxy_cidr_rejects_non_loopback_peer(monkeypatch):
_trusted_env(monkeypatch, proxy_cidrs="bad-cidr")
handler = _Handler(headers={"Remote-User": "alice"}, client_address=("10.0.0.5", 12345))
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert auth.is_trusted_auth_enabled() is True
assert auth.is_auth_enabled() is True
assert result is False
assert handler.status == 401
assert getattr(handler, "_pending_set_cookies", []) == []
def test_malformed_trusted_proxy_cidr_rejects_existing_trusted_session(monkeypatch):
_trusted_env(monkeypatch, proxy_cidrs="bad-cidr")
cookie = auth.create_session(auth_type="trusted", username="alice")
handler = _Handler(
headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice"},
client_address=("10.0.0.5", 12345),
)
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert auth.is_trusted_auth_enabled() is True
assert auth.is_auth_enabled() is True
assert result is False
assert handler.status == 401
assert handler.body_text() == '{"error":"Authentication required"}'
assert auth.verify_session(cookie) is False
def test_invalid_trusted_header_name_fails_closed(monkeypatch):
_trusted_env(monkeypatch, header="Bad Header")
handler = _Handler(headers={"Bad Header": "alice"})
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert auth.is_trusted_auth_enabled() is True
assert auth.is_auth_enabled() is True
assert result is False
assert handler.status == 401
def test_allowlisted_peer_header_creates_trusted_session(monkeypatch):
_trusted_env(monkeypatch)
handler = _Handler(headers={"Remote-User": "alice"})
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert result is True
assert handler.status is None
pending = getattr(handler, "_pending_set_cookies", [])
assert any(cookie.startswith("hermes_session=") for cookie in pending)
assert not any(cookie.startswith("hermes_profile=") for cookie in pending)
def test_group_map_binds_profile(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
)
handler = _Handler(
headers={
"Remote-User": "alice",
"Remote-Groups": "hermes_devops,ai_users",
}
)
info = auth.ensure_trusted_auth_session(handler)
assert info["auth_type"] == "trusted"
assert info["username"] == "alice"
assert info["bound_profile"] == "devops"
cookie_value = handler._trusted_auth_session_cookie_value
assert auth.session_bound_profile(cookie_value) == "devops"
assert any(cookie.startswith("hermes_profile=") for cookie in handler._pending_set_cookies)
def test_group_map_prefers_mapping_order_over_header_order(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"admins": "ops", "devs": "sandbox"},
)
first = _Handler(headers={"Remote-User": "alice", "Remote-Groups": "devs,admins"})
second = _Handler(headers={"Remote-User": "bob", "Remote-Groups": "admins,devs"})
assert auth.ensure_trusted_auth_session(first)["bound_profile"] == "ops"
assert auth.ensure_trusted_auth_session(second)["bound_profile"] == "ops"
@pytest.mark.parametrize(
"raw_header, expected",
[
# Single group, no separator to parse.
("admins", ["admins"]),
# Comma-separated (the format this parser originally supported).
("admins,developpeur", ["admins", "developpeur"]),
# Newline-separated (already supported before this fix).
("admins\ndeveloppeur", ["admins", "developpeur"]),
# Repeated/adjacent comma separators collapse instead of producing
# empty group names.
("admins,,developpeur", ["admins", "developpeur"]),
# Surrounding and interior whitespace is trimmed per group.
("admins, developpeur", ["admins", "developpeur"]),
],
)
def test_trusted_groups_header_value_accepts_separator_variants(
monkeypatch, raw_header, expected
):
_trusted_env(monkeypatch, groups_header="Remote-Groups")
monkeypatch.delenv("HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR", raising=False)
handler = _Handler(headers={"Remote-User": "alice", "Remote-Groups": raw_header})
assert auth._trusted_groups_header_value(handler) == expected
def test_trusted_groups_pipe_is_literal_by_default(monkeypatch):
"""Without the opt-in, a '|' is part of the group NAME, not a separator.
This is the backward-compatibility guarantee: an existing deployment whose
group name legitimately contains a '|' must not be silently re-split into
two groups (which could change its profile binding)."""
_trusted_env(monkeypatch, groups_header="Remote-Groups")
monkeypatch.delenv("HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR", raising=False)
handler = _Handler(
headers={"Remote-User": "alice", "Remote-Groups": "admins|developpeur"}
)
assert auth._trusted_groups_header_value(handler) == ["admins|developpeur"]
@pytest.mark.parametrize(
"raw_header, expected",
[
# Pipe-separated (this deployment's Authentik outpost format).
("admins|developpeur", ["admins", "developpeur"]),
# Mixed separators in the same value.
("admins,developpeur|it\nops", ["admins", "developpeur", "it", "ops"]),
# Repeated/adjacent pipe separators collapse instead of producing
# empty group names.
("admins||developpeur", ["admins", "developpeur"]),
# Surrounding and interior whitespace is trimmed per group.
(" admins | developpeur ", ["admins", "developpeur"]),
],
)
def test_trusted_groups_pipe_separator_opt_in(monkeypatch, raw_header, expected):
"""With HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR set, '|' also splits."""
_trusted_env(monkeypatch, groups_header="Remote-Groups")
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR", "1")
handler = _Handler(headers={"Remote-User": "alice", "Remote-Groups": raw_header})
assert auth._trusted_groups_header_value(handler) == expected
def test_group_map_accepts_pipe_separated_header_value(monkeypatch):
# Some Authentik proxy provider / property mapping configs join multiple
# group names with "|" instead of ",". Regression case for the identity
# falling back to the unbound "default" profile despite being a member
# of a mapped group: a lone-membership identity works fine (no
# separator to parse), but a second group membership starts producing a
# pipe-joined header value that a comma-only split can't see through.
# Pipe splitting is opt-in, so this exercises the env flag explicitly.
#
# NOTE: this only covers the concrete-mapped-group case. The
# wildcard-mapped-group case (a "*" mapping value dominating a concrete
# one) is intentionally not exercised here — that precedence rule isn't
# implemented on this branch yet; it ships separately in #6798. See the
# PR description for the full scope note.
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
)
monkeypatch.setenv("HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR", "1")
handler = _Handler(
headers={"Remote-User": "alice", "Remote-Groups": "hermes_devops|other_group"}
)
info = auth.ensure_trusted_auth_session(handler)
assert info["bound_profile"] == "devops"
assert auth.trusted_session_allows_active_profile(info) is True
@pytest.mark.parametrize(
"group_map",
[
{"ops": "ops_profile", "": "admin"},
{"": "admin", "ops": "ops_profile"},
],
)
def test_group_map_ignores_invalid_entry_without_discarding_valid_mappings(monkeypatch, group_map):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map=group_map,
)
handler = _Handler(headers={"Remote-User": "alice", "Remote-Groups": "ops"})
assert auth.ensure_trusted_auth_session(handler)["bound_profile"] == "ops_profile"
def test_group_map_without_match_binds_default(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
)
handler = _Handler(
headers={
"Remote-User": "alice",
"Remote-Groups": "ai_users,ops",
}
)
info = auth.ensure_trusted_auth_session(handler)
assert info["bound_profile"] == "default"
assert auth.session_bound_profile(handler._trusted_auth_session_cookie_value) == "default"
def test_bound_profile_mismatch_rejected(monkeypatch):
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "coworkers")
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert result is False
assert handler.status == 403
assert handler.body_text() == '{"error":"Profile access forbidden"}'
def test_bound_profile_match_allowed(monkeypatch):
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
result = auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query=""))
assert result is True
assert handler.status is None
def test_profile_switch_rejects_other_bound_profile(monkeypatch):
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
handler.command = "POST"
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "coworkers"})
monkeypatch.setattr("api.profiles.switch_profile", lambda *_, **__: (_ for _ in ()).throw(AssertionError("should not switch")))
routes.handle_post(handler, SimpleNamespace(path="/api/profile/switch", query=""))
assert handler.status == 403
assert handler.json_body()["error"] == "Profile is bound to the current session"
def test_first_trusted_profile_switch_rejection_keeps_session_cookies(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
)
handler = _Handler(
headers={
"Remote-User": "alice",
"Remote-Groups": "hermes_devops",
}
)
handler.command = "POST"
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "coworkers"})
monkeypatch.setattr("api.profiles.switch_profile", lambda *_, **__: (_ for _ in ()).throw(AssertionError("should not switch")))
assert auth.check_auth(handler, SimpleNamespace(path="/api/profile/switch", query="")) is True
routes.handle_post(handler, SimpleNamespace(path="/api/profile/switch", query=""))
set_cookies = handler.header_values("Set-Cookie")
assert handler.status == 403
assert handler.json_body()["error"] == "Profile is bound to the current session"
assert any(cookie.startswith("hermes_session=") for cookie in set_cookies)
assert any(cookie.startswith("hermes_profile=") for cookie in set_cookies)
assert len([cookie for cookie in set_cookies if cookie.startswith("hermes_session=")]) == 1
def test_profile_switch_accepts_bound_profile(monkeypatch):
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
handler.command = "POST"
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "devops"})
monkeypatch.setattr("api.profiles.switch_profile", lambda name, process_wide=False: {"ok": True, "profile": name})
monkeypatch.setattr("api.config.invalidate_models_cache", lambda *_a, **_kw: None)
monkeypatch.setattr("api.gateway_watcher.restart_watcher_for_profile", lambda _name: None)
routes.handle_post(handler, SimpleNamespace(path="/api/profile/switch", query=""))
assert handler.status == 200
assert handler.json_body()["profile"] == "devops"
assert any(value.startswith("hermes_profile=") for value in handler.header_values("Set-Cookie"))
def test_auth_status_reports_trusted_session_fields(monkeypatch):
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
monkeypatch.setattr("api.passkeys.registered_credentials", lambda: [])
routes.handle_get(handler, SimpleNamespace(path="/api/auth/status", query=""))
payload = handler.json_body()
assert payload["auth_enabled"] is True
assert payload["logged_in"] is True
assert payload["trusted_auth_enabled"] is True
assert payload["auth_type"] == "trusted"
assert payload["user"] == "alice"
assert payload["bound_profile"] == "devops"
def test_auth_status_rejects_trusted_cookie_when_proxy_cidr_is_malformed(monkeypatch):
_trusted_env(monkeypatch, proxy_cidrs="bad-cidr")
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(
headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice"},
client_address=("10.0.0.5", 12345),
)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
monkeypatch.setattr("api.passkeys.registered_credentials", lambda: [])
routes.handle_get(handler, SimpleNamespace(path="/api/auth/status", query=""))
payload = handler.json_body()
assert payload["auth_enabled"] is True
assert payload["logged_in"] is False
assert payload["trusted_auth_enabled"] is True
assert "auth_type" not in payload
assert "user" not in payload
assert "bound_profile" not in payload
assert auth.verify_session(cookie) is False
def test_malformed_trusted_proxy_cidr_keeps_loopback_trusted(monkeypatch):
_trusted_env(monkeypatch, proxy_cidrs="bad-cidr")
handler = _Handler(headers={"Remote-User": "alice"})
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
def test_mapped_ipv6_peer_matches_canonical_trusted_proxy_cidr(monkeypatch):
_trusted_env(monkeypatch, proxy_cidrs="10.0.0.0/8")
handler = _Handler(
headers={"Remote-User": "alice"},
client_address=("::ffff:10.0.0.5", 12345),
)
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
assert handler._trusted_auth_session_info["username"] == "alice"
def test_existing_trusted_session_rotates_for_current_identity(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"alice-group": "alice", "bob-group": "bob"},
)
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="alice",
)
handler = _Handler(
headers={
"Cookie": f"hermes_session={cookie}",
"Remote-User": "bob",
"Remote-Groups": "bob-group",
}
)
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
assert auth.verify_session(cookie) is False
assert handler._trusted_auth_session_info["username"] == "bob"
assert handler._trusted_auth_session_info["bound_profile"] == "bob"
assert handler._trusted_auth_session_cookie_value != cookie
assert profiles.get_active_profile_name() == "bob"
def test_trusted_reconciliation_cache_resets_between_requests(monkeypatch):
_trusted_env(monkeypatch)
handler = _Handler(headers={"Remote-User": "alice"})
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
alice_cookie = handler._trusted_auth_session_cookie_value
handler.headers = {"Cookie": f"hermes_session={alice_cookie}", "Remote-User": "bob"}
handler._pending_set_cookies = []
auth.reset_trusted_auth_request_state(handler)
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
assert auth.verify_session(alice_cookie) is False
assert handler._trusted_auth_session_info["username"] == "bob"
def test_server_resets_trusted_auth_request_state_per_request():
server_source = (Path(__file__).resolve().parents[1] / "server.py").read_text(encoding="utf-8")
assert server_source.count("reset_trusted_auth_request_state(self)") == 2
def test_reset_clears_pending_cookies_across_keepalive_requests(monkeypatch):
# A queued-but-unflushed Set-Cookie must NOT survive onto the next request on
# a reused HTTP/1.1 keep-alive handler (regression: a stale trusted-auth
# cookie leaking across the request boundary could overwrite a later valid
# login cookie and 401 the user). reset_trusted_auth_request_state() runs at
# the per-request entry (server.py do_GET/do_POST), so it must drop the queue.
_trusted_env(monkeypatch)
handler = _Handler()
# Request N queues an auth cookie but the response is never flushed.
auth._queue_pending_cookie(handler, "hermes_session=stale-value; Path=/")
assert handler._pending_set_cookies == ["hermes_session=stale-value; Path=/"]
# Request N+1 begins on the same reused handler.
auth.reset_trusted_auth_request_state(handler)
# The stale queued cookie must be gone — nothing to flush into N+1's response.
assert getattr(handler, "_pending_set_cookies", []) == []
from api.helpers import flush_pending_auth_cookies
flush_pending_auth_cookies(handler)
assert handler.header_values("Set-Cookie") == []
def test_auth_status_reports_reconciled_trusted_identity(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"alice-group": "alice", "bob-group": "bob"},
)
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="alice",
)
handler = _Handler(
headers={
"Cookie": f"hermes_session={cookie}",
"Remote-User": "bob",
"Remote-Groups": "bob-group",
}
)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
monkeypatch.setattr("api.passkeys.registered_credentials", lambda: [])
routes.handle_get(handler, SimpleNamespace(path="/api/auth/status", query=""))
payload = handler.json_body()
assert payload["logged_in"] is True
assert payload["user"] == "bob"
assert payload["bound_profile"] == "bob"
assert auth.verify_session(cookie) is False
def test_existing_trusted_session_without_header_is_invalidated(monkeypatch):
_trusted_env(monkeypatch)
cookie = auth.create_session(auth_type="trusted", username="alice")
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}"})
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is False
assert handler.status == 401
assert auth.verify_session(cookie) is False
def test_untrusted_existing_trusted_session_is_rejected_by_all_consumers(monkeypatch):
_trusted_env(monkeypatch)
headers = {"Remote-User": "alice"}
protected_cookie = auth.create_session(auth_type="trusted", username="alice")
protected = _Handler(
headers={**headers, "Cookie": f"hermes_session={protected_cookie}"},
client_address=("10.0.0.5", 12345),
)
assert auth.check_auth(protected, SimpleNamespace(path="/api/sessions", query="")) is False
assert protected.status == 401
assert auth.verify_session(protected_cookie) is False
status_cookie = auth.create_session(auth_type="trusted", username="alice")
status = _Handler(
headers={**headers, "Cookie": f"hermes_session={status_cookie}"},
client_address=("10.0.0.5", 12345),
)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
monkeypatch.setattr("api.passkeys.registered_credentials", lambda: [])
routes.handle_get(status, SimpleNamespace(path="/api/auth/status", query=""))
assert status.json_body()["logged_in"] is False
assert auth.verify_session(status_cookie) is False
switch_cookie = auth.create_session(auth_type="trusted", username="alice")
switch = _Handler(
headers={**headers, "Cookie": f"hermes_session={switch_cookie}"},
client_address=("10.0.0.5", 12345),
)
switch.command = "POST"
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "default"})
monkeypatch.setattr(
"api.profiles.switch_profile",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not switch")),
)
routes.handle_post(switch, SimpleNamespace(path="/api/profile/switch", query=""))
assert switch.status == 401
assert auth.verify_session(switch_cookie) is False
def test_trusted_session_rehydrates_bound_profile_cookie(monkeypatch):
_trusted_env(monkeypatch, groups_header="Remote-Groups", group_map={"hermes_devops": "devops"})
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
assert profiles.get_active_profile_name() == "devops"
profile_cookie = next(
cookie_header for cookie_header in handler._pending_set_cookies if cookie_header.startswith("hermes_profile=")
)
profile_value = profile_cookie.split("=", 1)[1].split(";", 1)[0]
assert auth.verify_profile_cookie_value(profile_value, cookie) == "devops"
def test_first_trusted_shell_response_includes_csrf_token(monkeypatch):
_trusted_env(monkeypatch)
handler = _Handler(headers={"Remote-User": "alice"})
monkeypatch.setattr(routes, "_render_index_shell_base", lambda: "csrfToken:__CSRF_TOKEN_JSON__")
monkeypatch.setattr("api.extensions.inject_extension_tags", lambda html: html)
assert auth.check_auth(handler, SimpleNamespace(path="/", query="")) is True
routes.handle_get(handler, SimpleNamespace(path="/", query=""))
cookie_value = handler._trusted_auth_session_cookie_value
assert any(cookie.startswith("hermes_session=") for cookie in handler.header_values("Set-Cookie"))
assert handler.body_text() == f"csrfToken:{json.dumps(auth.csrf_token_for_session(cookie_value))}"
def test_logout_clears_auth_and_profile_cookies(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
logout_url="https://auth.example.com/logout",
)
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}", "Remote-User": "alice", "Remote-Groups": "hermes_devops"})
handler.command = "POST"
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {})
routes.handle_post(handler, SimpleNamespace(path="/api/auth/logout", query=""))
payload = handler.json_body()
assert payload["ok"] is True
assert payload["trusted_logout_url"] == "https://auth.example.com/logout"
set_cookies = handler.header_values("Set-Cookie")
assert any(cookie.startswith("hermes_session=") and "Max-Age=0" in cookie for cookie in set_cookies)
assert any(cookie.startswith("hermes_profile=") and "Max-Age=0" in cookie and "SameSite=Lax" in cookie for cookie in set_cookies)
assert auth.verify_session(cookie) is False
def test_logout_identity_rotation_preserves_csrf_validation(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"alice-group": "alice", "bob-group": "bob"},
logout_url="https://auth.example.com/logout",
)
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="alice",
)
handler = _Handler(
headers={
"Cookie": f"hermes_session={cookie}",
"Remote-User": "bob",
"Remote-Groups": "bob-group",
auth.CSRF_HEADER_NAME: auth.csrf_token_for_session(cookie),
}
)
handler.command = "POST"
monkeypatch.setattr(routes, "read_body", lambda _handler: {})
assert auth.check_auth(handler, SimpleNamespace(path="/api/auth/logout", query="")) is True
routes.handle_post(handler, SimpleNamespace(path="/api/auth/logout", query=""))
payload = handler.json_body()
assert payload["ok"] is True
assert payload["trusted_logout_url"] == "https://auth.example.com/logout"
assert handler._trusted_auth_session_info["username"] == "bob"
assert handler._trusted_auth_session_cookie_value != cookie
assert auth.verify_session(cookie) is False
assert auth.verify_session(handler._trusted_auth_session_cookie_value) is False
set_cookies = handler.header_values("Set-Cookie")
assert any(cookie_header.startswith("hermes_session=") and "Max-Age=0" in cookie_header for cookie_header in set_cookies)
assert any(cookie_header.startswith("hermes_profile=") and "Max-Age=0" in cookie_header for cookie_header in set_cookies)
def test_unconfigured_remote_user_header_is_ordinary_header(monkeypatch):
_trusted_env(monkeypatch, header=None)
handler = _Handler(headers={"Remote-User": "alice"})
assert auth.is_trusted_auth_enabled() is False
assert auth.ensure_trusted_auth_session(handler) is None
assert auth.check_auth(handler, SimpleNamespace(path="/api/sessions", query="")) is True
def test_trusted_auth_owner_contract(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
)
handler = _Handler(
headers={
"Remote-User": "alice",
"Remote-Groups": "hermes_devops,ai_users",
}
)
info = auth.ensure_trusted_auth_session(handler)
assert info["auth_type"] == "trusted"
assert info["bound_profile"] == "devops"
assert auth.is_trusted_auth_enabled() is True
token = "deadbeef" * 8
auth._sessions[token] = time.time() + 3600
legacy_sig = auth.hmac.new(
auth._signing_key(),
token.encode(),
auth.hashlib.sha256,
).hexdigest()
legacy_cookie = f"{token}.{legacy_sig}"
legacy_info = auth.get_session_info(legacy_cookie)
assert legacy_info["auth_type"] is None
assert legacy_info["bound_profile"] is None
assert legacy_info["expiry"] > time.time()
def test_consumers_route_through_auth_owner(monkeypatch):
_trusted_env(
monkeypatch,
groups_header="Remote-Groups",
group_map={"hermes_devops": "devops"},
logout_url="https://auth.example.com/logout",
)
cookie = auth.create_session(
auth_type="trusted",
username="alice",
bound_profile="devops",
)
handler = _Handler(headers={"Cookie": f"hermes_session={cookie}"})
handler.command = "POST"
calls = []
def _get_session_info(cookie_value):
calls.append(("get_session_info", cookie_value))
return {
"token": cookie_value.split(".", 1)[0],
"expiry": time.time() + 3600,
"auth_type": "trusted",
"username": "alice",
"bound_profile": "devops",
}
monkeypatch.setattr(auth, "get_session_info", _get_session_info)
monkeypatch.setattr(auth, "ensure_trusted_auth_session", lambda _handler: calls.append(("ensure", None)) or {
"token": "token",
"expiry": time.time() + 3600,
"auth_type": "trusted",
"username": "alice",
"bound_profile": "devops",
})
monkeypatch.setattr(auth, "parse_cookie", lambda _handler: cookie)
monkeypatch.setattr(auth, "verify_session", lambda _cookie: True)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
monkeypatch.setattr("api.passkeys.registered_credentials", lambda: [])
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "devops")
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "devops"})
monkeypatch.setattr("api.profiles.switch_profile", lambda name, process_wide=False: {"ok": True, "profile": name})
monkeypatch.setattr("api.config.invalidate_models_cache", lambda *_a, **_kw: None)
monkeypatch.setattr("api.gateway_watcher.restart_watcher_for_profile", lambda _name: None)
routes.handle_get(handler, SimpleNamespace(path="/api/auth/status", query=""))
assert calls and calls[0][0] == "ensure"
assert handler.json_body()["bound_profile"] == "devops"
calls.clear()
routes.handle_post(handler, SimpleNamespace(path="/api/profile/switch", query=""))
assert calls and calls[0][0] == "ensure"
assert handler.status == 200
def test_sign_out_uses_trusted_logout_url_with_login_fallback():
sign_out = extract_function(PANELS_JS, "signOut", prefix="async function")
assert "const response=await api('/api/auth/logout',{method:'POST',body:'{}'});" in sign_out
assert "window.location.href=response.trusted_logout_url||'login';" in sign_out
assert NODE is not None
def run_sign_out(logout_url):
script = f"""
const signOut = (0, eval)("(" + {json.dumps(sign_out)} + ")");
globalThis.api = async () => ({{trusted_logout_url: {json.dumps(logout_url)}}});
globalThis.window = {{location: {{href: null}}}};
globalThis.showToast = () => {{}};
globalThis.t = (key) => key;
signOut().then(() => process.stdout.write(JSON.stringify(window.location.href)));
"""
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
[NODE, "-e", script],
capture_output=True,
text=True,
timeout=30,
creationflags=creationflags,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
assert run_sign_out("https://auth.example.com/logout") == "https://auth.example.com/logout"
assert run_sign_out(None) == "login"