diff --git a/roles/web-app-keycloak/files/javascript/logout-panel.js b/roles/web-app-keycloak/files/javascript/logout-panel.js index 86a1dc807d..7b5c9f7fcf 100644 --- a/roles/web-app-keycloak/files/javascript/logout-panel.js +++ b/roles/web-app-keycloak/files/javascript/logout-panel.js @@ -169,6 +169,14 @@ tally(); } + function webUrl(value) { + try { + const url = new URL(value); + if (url.protocol === "http:" || url.protocol === "https:") { return url.href; } + } catch {} + return ""; + } + function handle(report) { if (report.type === "start") { clearTimeout(startTimer); @@ -191,6 +199,12 @@ } else { say(s.all_done, s.may_close, OK); } + } else if (report.type === "handoff") { + const target = webUrl(report.url); + if (!target) { return; } + finished = true; + unhold(); + window.location.replace(target); } } diff --git a/roles/web-svc-logout/README.md b/roles/web-svc-logout/README.md index 697a04dee6..bfde22a647 100644 --- a/roles/web-svc-logout/README.md +++ b/roles/web-svc-logout/README.md @@ -23,6 +23,7 @@ It solves the common challenge of logging a user out from all connected apps wit - Status page with live feedback on logout progress for each domain. - Built-in support for Docker Compose deployment and integration with the Infinito.Nexus ecosystem. - Includes security-conscious headers (CORS, CSP) for smooth cross-domain logout operations. +- **Network hand-off:** A node serving several networks runs one conductor per network, and each conductor receives the other conductors' origins in `LOGOUT_SIBLINGS`, so a finished sweep hands the browser on to the next network. A single-network node renders it empty, which disables the hand-off. ## Further Resources diff --git a/roles/web-svc-logout/templates/compose.yml.j2 b/roles/web-svc-logout/templates/compose.yml.j2 index 850bf35d61..6037f26aa9 100644 --- a/roles/web-svc-logout/templates/compose.yml.j2 +++ b/roles/web-svc-logout/templates/compose.yml.j2 @@ -5,13 +5,18 @@ {% for logout_domain in lookup('applications') | logout_domains(group_names, lookup('domains'), logout_instance.network) | unique | sort %} {% set _ = logout_urls.append(lookup('tls', logout_domain, 'url.origin')) %} {% endfor %} + {% set logout_siblings = [] %} + {% for logout_sibling in LOGOUT_INSTANCES if logout_sibling.network != logout_instance.network %} + {% set _ = logout_siblings.append(lookup('tls', lookup('domains')[application_id] | domain_strings | in_network(logout_sibling.network) | first, 'url.origin')) %} + {% endfor %} {{ service_name }}: {% include 'roles/sys-svc-container/templates/base.yml.j2' %} {{ lookup('container_image', application_id, 'logout') }} {{ lookup('compose_only', 'container_name', LOGOUT_CONTAINER | labelled(logout_instance.label, '-')) }} {{ lookup('container_ports', ['logout', 'http' | labelled(logout_instance.label, '_'), DOCKER_BIND_HOST]) | indent(4) }} - environment: # nocheck: compose-environment-from-env-file one conductor per served network and LOGOUT_DOMAINS is derived rather than configured; a per-service env file is possible but costs a render loop and a second name binding for this one computed line + environment: # nocheck: compose-environment-from-env-file one conductor per served network and LOGOUT_DOMAINS and LOGOUT_SIBLINGS are derived per conductor rather than configured; a per-service env file is possible but costs a render loop and a second name binding for these computed lines LOGOUT_DOMAINS: "{{ logout_urls | join(',') }}" + LOGOUT_SIBLINGS: "{{ logout_siblings | join(',') }}" {{ lookup('container_networks') | indent(4) }} {{ lookup('container_healthcheck', 'logout') | indent(4) }} diff --git a/tests/unit/javascript/roles/web-app-keycloak/files/logout-panel.test.js b/tests/unit/javascript/roles/web-app-keycloak/files/logout-panel.test.js index 81d0f579fe..aa04bdc701 100644 --- a/tests/unit/javascript/roles/web-app-keycloak/files/logout-panel.test.js +++ b/tests/unit/javascript/roles/web-app-keycloak/files/logout-panel.test.js @@ -16,6 +16,7 @@ const LOCALE = path.join(PROJECT_ROOT, "locale"); const LANGUAGES = path.join(PROJECT_ROOT, "meta/languages.yml"); const ORIGIN = "https://logout.example.test"; const DOMAINS = ["https://shop.example.test", "https://cloud.example.test"]; +const SIBLING = `http://logout.${"a".repeat(56)}.onion/?handoff=`; function readEnglish() { const out = {}; @@ -126,11 +127,12 @@ function mount({ lang = "en", hostname = "auth.example.test", path: pathname = " const body = element(); const listeners = {}; const timers = {}; + const visited = []; let next = 0; global.window = { __INFINITO_LOGOUT__: { origin: ORIGIN, i18n: readCatalogue() }, - location: { pathname, hostname }, + location: { pathname, hostname, replace: (url) => visited.push(url) }, addEventListener(type, fn) { listeners[type] = listeners[type] || []; if (!listeners[type].includes(fn)) listeners[type].push(fn); @@ -169,9 +171,13 @@ function mount({ lang = "en", hostname = "auth.example.test", path: pathname = " (listeners.message || []).forEach((fn) => fn({ origin: ORIGIN, data: { source: "universal-logout", ...payload } }), ); + const send = (origin, data) => + (listeners.message || []).forEach((fn) => fn({ origin, data })); const panel = () => body.children[0]; return { post, + send, + visited, panel, status: () => panel().children[0].textContent, hint: () => panel().children[1].textContent, @@ -261,3 +267,44 @@ test("the panel stays off every other Keycloak page", () => { const page = mount({ path: "/realms/x/account" }); assert.equal(page.armed(), 0); }); + +test("a hand-off moves the whole page to the next network's conductor", () => { + const page = mount(); + page.post({ type: "start", domains: DOMAINS }); + page.post({ type: "host", host: DOMAINS[0], ok: true }); + page.post({ type: "host", host: DOMAINS[1], ok: true }); + page.post({ type: "done", total: 2, failed: 0 }); + page.post({ type: "handoff", url: SIBLING }); + + assert.deepEqual(page.visited, [SIBLING]); +}); + +test("a hand-off is not held back by the unload guard", () => { + const page = mount(); + page.post({ type: "start", domains: DOMAINS }); + assert.equal(page.armed(), 1); + + page.post({ type: "handoff", url: SIBLING }); + + assert.deepEqual(page.visited, [SIBLING]); + assert.equal(page.armed(), 0, "a guard left armed would stop the navigation with a leave-page prompt"); +}); + +test("only the conductor can hand the page off", () => { + const page = mount(); + page.send("https://evil.test", { source: "universal-logout", type: "handoff", url: SIBLING }); + page.send(ORIGIN, { type: "handoff", url: SIBLING }); + + assert.deepEqual(page.visited, []); +}); + +test("a hand-off to anything but an absolute http(s) URL is ignored", () => { + const page = mount(); + page.post({ type: "start", domains: DOMAINS }); + for (const url of ["javascript:alert(1)", "data:text/html,x", "/realms/x", "", undefined]) { + page.post({ type: "handoff", url }); + } + + assert.deepEqual(page.visited, []); + assert.equal(page.armed(), 1, "a refused hand-off leaves the sweep guarded"); +}); diff --git a/tests/unit/python/roles/web-svc-logout/templates/__init__.py b/tests/unit/python/roles/web-svc-logout/templates/__init__.py new file mode 100644 index 0000000000..087c384b57 --- /dev/null +++ b/tests/unit/python/roles/web-svc-logout/templates/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[6] + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/unit/python/roles/web-svc-logout/templates/test_compose_siblings.py b/tests/unit/python/roles/web-svc-logout/templates/test_compose_siblings.py new file mode 100644 index 0000000000..492cf4883b --- /dev/null +++ b/tests/unit/python/roles/web-svc-logout/templates/test_compose_siblings.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import importlib.util +import unittest +from unittest import mock + +import yaml +from ansible.parsing.dataloader import DataLoader +from ansible.plugins.loader import filter_loader, lookup_loader +from ansible.plugins.lookup import LookupBase +from ansible.template import Templar +from jinja2 import DictLoader, Environment, StrictUndefined, select_autoescape + +from plugins.filter.cookie_scope import domain_strings +from plugins.filter.network_of import in_network +from plugins.lookup.tls import LookupModule as TlsLookup +from utils.cache.files import read_text +from utils.networks.instances import labelled, network_instances +from utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE, ROLE_FILE_VARS_MAIN + +from . import PROJECT_ROOT + +filter_loader.add_directory(str(PROJECT_ROOT / "plugins" / "filter")) + +APP = "web-svc-logout" +ROLE = PROJECT_ROOT / "roles" / APP +TEMPLATE = ROLE / ROLE_FILE_TEMPL_COMPOSE +VARS = ROLE / ROLE_FILE_VARS_MAIN + +_spec = importlib.util.spec_from_file_location( + "logout_domain_filters", ROLE / "filter_plugins" / "domain_filters.py" +) +_domain_filters = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_domain_filters) +logout_domains = _domain_filters.FilterModule().filters()["logout_domains"] + +NODE = "a" * 56 + ".onion" +CLEAR = "logout.example.test" +ONION = f"logout.{NODE}" +SHOP = "shop.example.test" +SHOP_ONION = f"shop.{NODE}" + +TLS_VARIABLES = {"TLS_ENABLED": True, "TLS_MODE": "letsencrypt"} +INCLUDES = { + "roles/sys-svc-compose/templates/base.yml.j2": "services:\n\n", + "roles/sys-svc-container/templates/base.yml.j2": "", +} + + +class _Stub(LookupBase): + def __init__(self, answer): + super().__init__() + self._answer = answer + + def run(self, terms, variables=None, **kwargs): + return [self._answer] + + +def render(conductor: list[str], shop: list[str]) -> dict: + """Render the logout compose file for one node. + + Args: + conductor: the domains the node serves web-svc-logout on. + shop: the domains of one logout-enabled application. + + Returns: + The ``services`` mapping of the rendered compose file. + """ + domains = {APP: conductor, "web-app-shop": shop} + applications = {app: {"services": {"logout": {"enabled": True}}} for app in domains} + real_get = lookup_loader.get + + def get(name, *args, **kwargs): + if name == "domains": + return _Stub(domains) + if name == "applications": + return _Stub(applications) + return real_get(name, *args, **kwargs) + + def lookup(name, *terms, **_kwargs): + if name == "domains": + return domains + if name == "applications": + return applications + if name == "tls": + tls = TlsLookup() + tls._loader = None + tls._templar = None + return tls.run(list(terms), variables=TLS_VARIABLES)[0] + return "" + + declared = DataLoader().load_from_file(str(VARS), trusted_as_template=True) + templar = Templar( + loader=DataLoader(), variables={**declared, "application_id": APP} + ) + env = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + loader=DictLoader(INCLUDES), + ) + env.filters.update( + labelled=labelled, + network_instances=network_instances, + domain_strings=domain_strings, + in_network=in_network, + logout_domains=logout_domains, + ) + env.globals["lookup"] = lookup + with mock.patch.object(lookup_loader, "get", side_effect=get): + instances = templar.template(declared["LOGOUT_INSTANCES"]) + rendered = env.from_string(read_text(str(TEMPLATE))).render( + application_id=APP, + group_names=sorted(domains), + LOGOUT_INSTANCES=instances, + LOGOUT_CONTAINER="logout", + DOCKER_BIND_HOST="127.0.0.1", + ) + return yaml.safe_load(rendered)["services"] + + +def siblings(services: dict) -> dict[str, str]: + return { + name: service["environment"]["LOGOUT_SIBLINGS"] + for name, service in services.items() + } + + +class TestSingleNetwork(unittest.TestCase): + def test_a_clearnet_conductor_hands_off_nowhere(self) -> None: + self.assertEqual(siblings(render([CLEAR], [SHOP])), {"logout": ""}) + + def test_a_tor_only_conductor_hands_off_nowhere(self) -> None: + self.assertEqual(siblings(render([ONION], [SHOP_ONION])), {"logout": ""}) + + +class TestClearnetAndTor(unittest.TestCase): + def setUp(self) -> None: + self.services = render([CLEAR, ONION], [SHOP, SHOP_ONION]) + + def test_each_conductor_lists_exactly_the_other(self) -> None: + self.assertEqual( + siblings(self.services), + {"logout": f"http://{ONION}", "logout-onion": f"https://{CLEAR}"}, + ) + + def test_each_conductor_still_sweeps_only_its_own_network(self) -> None: + self.assertEqual( + { + name: service["environment"]["LOGOUT_DOMAINS"] + for name, service in self.services.items() + }, + { + "logout": f"https://{CLEAR},https://{SHOP}", + "logout-onion": f"http://{ONION},http://{SHOP_ONION}", + }, + ) + + +if __name__ == "__main__": + unittest.main()