mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-12 05:36:40 +00:00
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>
294 lines
10 KiB
Python
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()
|