Files
computer-playbook/tests/unit/python/utils/roles/applications/services/test_sso.py
T
kevinveenbirkenbachandClaude Opus 5.5 5a202218c9 feat(web-app-keycloak): run one oauth2-proxy per network a gated application is served on
One oauth2-proxy applies one cookie-secure flag to every host it answers, so a secure clearnet instance drops the CSRF cookie of its plaintext onion sibling and the login loops. proxy_instances now yields one instance per network with its own compose service, sso or sso_onion host port and config file; each config takes cookie_secure, redirect_url and cookie_domains from its own network, and every vhost routes /oauth2/ to the instance of its network. The 29 gated roles gain a sso_onion port, and Bluesky heals the bind mount of every instance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:57:38 +02:00

294 lines
10 KiB
Python

from __future__ import annotations
import unittest
from jinja2.filters import do_urlencode
from utils.roles.applications.services.sso import (
get_sso_config,
is_potentially_enabled,
logout_url,
proxy_instances,
)
KEYCLOAK_LOGOUT = "https://auth.example.org/realms/r/protocol/openid-connect/logout"
ONION = "a" * 56 + ".onion"
CONFIG_FILE = "oauth2-proxy-keycloak.cfg"
def _apps(
*,
enabled=False,
shared=False,
flavor=None,
oauth2_origin_host=None,
oauth2_origin_port=None,
oauth2_acl=None,
oauth2_allowed_groups=None,
) -> dict:
sso: dict = {"enabled": enabled, "shared": shared}
if flavor is not None:
sso["flavor"] = flavor
oauth2_sub: dict = {}
if oauth2_origin_host is not None or oauth2_origin_port is not None:
oauth2_sub["origin"] = {
**({"host": oauth2_origin_host} if oauth2_origin_host is not None else {}),
**({"port": oauth2_origin_port} if oauth2_origin_port is not None else {}),
}
if oauth2_acl is not None:
oauth2_sub["acl"] = oauth2_acl
if oauth2_allowed_groups is not None:
oauth2_sub["allowed_groups"] = oauth2_allowed_groups
if oauth2_sub:
sso["oauth2"] = oauth2_sub
return {"web-app-x": {"services": {"sso": sso}}}
class GetSsoConfigTests(unittest.TestCase):
def test_defaults_when_app_missing(self):
res = get_sso_config({}, "web-app-x")
self.assertEqual(res["enabled"], False)
self.assertEqual(res["shared"], False)
self.assertEqual(res["flavor"], "oidc")
self.assertFalse(res["is_enabled"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
self.assertFalse(res["is_saml"])
def test_disabled_collapses_all_predicates(self):
res = get_sso_config(_apps(enabled=False, flavor="oauth2"), "web-app-x")
self.assertFalse(res["is_enabled"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
def test_enabled_oidc_default_flavor(self):
res = get_sso_config(_apps(enabled=True), "web-app-x")
self.assertTrue(res["is_enabled"])
self.assertTrue(res["is_oidc_native"])
self.assertFalse(res["is_proxy_gated"])
self.assertEqual(res["flavor"], "oidc")
def test_enabled_oauth2_flavor_is_proxy_gated(self):
res = get_sso_config(_apps(enabled=True, flavor="oauth2"), "web-app-x")
self.assertTrue(res["is_enabled"])
self.assertTrue(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
self.assertFalse(res["is_saml"])
def test_enabled_saml_flavor(self):
res = get_sso_config(_apps(enabled=True, flavor="saml"), "web-app-x")
self.assertTrue(res["is_saml"])
self.assertFalse(res["is_proxy_gated"])
self.assertFalse(res["is_oidc_native"])
def test_unknown_flavor_falls_back_to_oidc(self):
res = get_sso_config(_apps(enabled=True, flavor="bogus"), "web-app-x")
self.assertEqual(res["flavor"], "oidc")
self.assertTrue(res["is_oidc_native"])
def test_non_string_flavor_falls_back_to_oidc(self):
res = get_sso_config(_apps(enabled=True, flavor=42), "web-app-x")
self.assertEqual(res["flavor"], "oidc")
def test_shared_field_round_trips(self):
res = get_sso_config(_apps(enabled=True, shared=True), "web-app-x")
self.assertTrue(res["shared"])
def test_predicate_keys_present_when_app_has_partial_block(self):
apps = {"web-app-x": {"services": {"sso": {"enabled": True}}}}
res = get_sso_config(apps, "web-app-x")
for key in (
"enabled",
"shared",
"flavor",
"is_enabled",
"is_proxy_gated",
"is_oidc_native",
"is_saml",
):
self.assertIn(key, res, f"missing key {key}")
class Oauth2SubPropertiesTests(unittest.TestCase):
def test_defaults_when_oauth2_block_missing(self):
res = get_sso_config(_apps(enabled=True, flavor="oauth2"), "web-app-x")
self.assertEqual(res["oauth2_origin_host"], "")
self.assertEqual(res["oauth2_origin_port"], "")
self.assertEqual(res["oauth2_acl"], {})
self.assertEqual(res["oauth2_allowed_groups"], [])
def test_origin_host_and_port_pass_through(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oauth2",
oauth2_origin_host="application",
oauth2_origin_port=8000,
),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_host"], "application")
self.assertEqual(res["oauth2_origin_port"], "8000") # stringified
def test_origin_port_string_pass_through(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_origin_port="9090"),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_port"], "9090")
def test_acl_dict_pass_through(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oauth2",
oauth2_acl={"whitelist": ["/api/", "/static/"]},
),
"web-app-x",
)
self.assertEqual(res["oauth2_acl"], {"whitelist": ["/api/", "/static/"]})
def test_acl_non_dict_falls_back_to_empty(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_acl="bogus"),
"web-app-x",
)
self.assertEqual(res["oauth2_acl"], {})
def test_allowed_groups_list_pass_through(self):
groups = ["/roles/web-app-x/administrator", "/roles/web-app-x/biber"]
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_allowed_groups=groups),
"web-app-x",
)
self.assertEqual(res["oauth2_allowed_groups"], groups)
def test_allowed_groups_non_list_falls_back_to_empty(self):
res = get_sso_config(
_apps(enabled=True, flavor="oauth2", oauth2_allowed_groups="bogus"),
"web-app-x",
)
self.assertEqual(res["oauth2_allowed_groups"], [])
def test_oauth2_sub_keys_exposed_even_when_flavor_is_oidc(self):
res = get_sso_config(
_apps(
enabled=True,
flavor="oidc",
oauth2_origin_host="application",
oauth2_origin_port=8080,
),
"web-app-x",
)
self.assertEqual(res["oauth2_origin_host"], "application")
self.assertFalse(res["is_proxy_gated"]) # still oidc
class IsPotentiallyEnabledTests(unittest.TestCase):
def test_none_is_not_potentially_enabled(self):
self.assertFalse(is_potentially_enabled(None))
def test_literal_false_is_not_potentially_enabled(self):
self.assertFalse(is_potentially_enabled(False))
def test_literal_true_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled(True))
def test_string_false_case_insensitive_is_not_potentially_enabled(self):
for v in ("false", "False", "FALSE", " false "):
with self.subTest(value=v):
self.assertFalse(is_potentially_enabled(v))
def test_string_true_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled("true"))
def test_jinja_template_string_is_potentially_enabled(self):
self.assertTrue(
is_potentially_enabled("{{ 'web-app-keycloak' in group_names }}")
)
def test_empty_string_is_potentially_enabled(self):
self.assertTrue(is_potentially_enabled(""))
class TestLogoutUrl(unittest.TestCase):
def test_an_app_without_the_proxy_logs_out_at_the_provider(self):
self.assertEqual(logout_url(KEYCLOAK_LOGOUT, False), KEYCLOAK_LOGOUT)
def test_a_proxy_gated_app_signs_out_of_oauth2_proxy_first(self):
self.assertEqual(
logout_url(KEYCLOAK_LOGOUT, True),
"/oauth2/sign_out?rd=https%3A//auth.example.org/realms/r/protocol/openid-connect/logout",
)
def test_the_rd_encoding_matches_the_jinja_urlencode_filter(self):
self.assertEqual(
logout_url(KEYCLOAK_LOGOUT, True),
f"/oauth2/sign_out?rd={do_urlencode(KEYCLOAK_LOGOUT)}",
)
class TestProxyInstances(unittest.TestCase):
def test_a_clearnet_app_keeps_the_single_unsuffixed_instance(self):
self.assertEqual(
proxy_instances(["app.example.org"], CONFIG_FILE),
[
{
"network": "clearnet",
"service": "sso-proxy",
"port_key": "sso",
"config_file": CONFIG_FILE,
}
],
)
def test_an_onion_sibling_gets_its_own_labelled_instance(self):
instances = proxy_instances(["app.example.org", f"app.{ONION}"], CONFIG_FILE)
self.assertEqual([i["network"] for i in instances], ["clearnet", "tor"])
self.assertEqual(
instances[1],
{
"network": "tor",
"service": "sso-proxy-onion",
"port_key": "sso_onion",
"config_file": "oauth2-proxy-keycloak-onion.cfg",
},
)
def test_an_onion_only_app_runs_one_unsuffixed_instance_on_tor(self):
self.assertEqual(
proxy_instances([f"app.{ONION}"], CONFIG_FILE),
[
{
"network": "tor",
"service": "sso-proxy",
"port_key": "sso",
"config_file": CONFIG_FILE,
}
],
)
def test_named_canonical_domains_yield_one_instance_per_network(self):
instances = proxy_instances(
{
"filer": "filer.example.org",
"master": "master.example.org",
"filer_onion": f"filer.{ONION}",
"master_onion": f"master.{ONION}",
},
CONFIG_FILE,
)
self.assertEqual(
[i["service"] for i in instances], ["sso-proxy", "sso-proxy-onion"]
)
def test_a_config_file_without_extension_takes_the_label_as_suffix(self):
instances = proxy_instances(["app.example.org", f"app.{ONION}"], "proxy")
self.assertEqual(
[i["config_file"] for i in instances], ["proxy", "proxy-onion"]
)
if __name__ == "__main__":
unittest.main()