mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
refactor(sso): compute the app logout target once in the sso lookup
The previous commit wrote the same proxy-aware logout expression into
four javascript.js.j2 templates. The rule it encodes - sign out of
oauth2-proxy first when the app is proxy-gated, else go straight to the
provider - belongs where proxy-gating is already decided.
utils/roles/applications/services/sso.py gains logout_url(url,
is_proxy_gated) next to get_sso_config, with OAUTH2_SIGN_OUT_PATH as the
path constant; the rd value is quoted exactly as Jinja's urlencode
filter does it. The sso lookup exposes it as the logout_url term: it
renders OIDC through the templar, takes is_proxy_gated from the same
resolution, and refuses a context without OIDC. Prometheus, Checkmk,
Baserow and Jitsi now render lookup('sso', application_id, 'logout_url').
Behaviour is unchanged: the resolver tests pin the rd encoding against
jinja2's do_urlencode, and the lookup tests cover a proxy-gated app, an
OIDC-native app, a disabled SSO and a missing OIDC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a69f169600
commit
9d0f618e23
8 files changed
+105
-5
No files matched your search
+14
-1
@@ -8,6 +8,8 @@ API (STRICT):
|
||||
- {{ lookup('sso', application_id, 'flavor') }} → str
|
||||
- {{ lookup('sso', application_id, 'enabled') }} → bool
|
||||
- {{ lookup('sso', application_id, 'shared') }} → bool
|
||||
- {{ lookup('sso', application_id, 'logout_url') }} → str, oauth2-proxy
|
||||
sign-out wrapping OIDC.CLIENT.LOGOUT_URL when the app is proxy-gated
|
||||
|
||||
Wraps ``utils.roles.applications.services.sso.get_sso_config`` so
|
||||
templates and tasks share one source of truth with Python callers
|
||||
@@ -22,7 +24,7 @@ from ansible.errors import AnsibleError
|
||||
from ansible.plugins.loader import lookup_loader
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
|
||||
from utils.roles.applications.services.sso import get_sso_config
|
||||
from utils.roles.applications.services.sso import get_sso_config, logout_url
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
@@ -51,6 +53,17 @@ class LookupModule(LookupBase):
|
||||
|
||||
resolved = get_sso_config(applications, application_id)
|
||||
|
||||
if want == "logout_url":
|
||||
if "OIDC" not in vars_:
|
||||
raise AnsibleError(
|
||||
"sso: 'logout_url' needs OIDC in the templating context"
|
||||
)
|
||||
oidc = self._templar.template(vars_["OIDC"])
|
||||
return [
|
||||
logout_url(
|
||||
str(oidc["CLIENT"]["LOGOUT_URL"]), bool(resolved["is_proxy_gated"])
|
||||
)
|
||||
]
|
||||
if want == "all":
|
||||
return [resolved]
|
||||
if want not in resolved:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
const LOGOUT_URL = {{ (('/oauth2/sign_out?rd=' ~ (OIDC.CLIENT.LOGOUT_URL | urlencode)) if (lookup('sso', application_id, 'is_proxy_gated') | bool) else OIDC.CLIENT.LOGOUT_URL) | to_json }};
|
||||
const LOGOUT_URL = {{ lookup('sso', application_id, 'logout_url') | to_json }};
|
||||
const INJECTED_ATTR = "data-injected-logout";
|
||||
const LOGOUT_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
const LOGOUT_URL = {{ (('/oauth2/sign_out?rd=' ~ (OIDC.CLIENT.LOGOUT_URL | urlencode)) if (lookup('sso', application_id, 'is_proxy_gated') | bool) else OIDC.CLIENT.LOGOUT_URL) | to_json }};
|
||||
const LOGOUT_URL = {{ lookup('sso', application_id, 'logout_url') | to_json }};
|
||||
const INJECTED_ATTR = "data-injected-logout";
|
||||
const LOGOUT_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
const LOGOUT_URL = {{ (('/oauth2/sign_out?rd=' ~ (OIDC.CLIENT.LOGOUT_URL | urlencode)) if (lookup('sso', application_id, 'is_proxy_gated') | bool) else OIDC.CLIENT.LOGOUT_URL) | to_json }};
|
||||
const LOGOUT_URL = {{ lookup('sso', application_id, 'logout_url') | to_json }};
|
||||
const INJECTED_ATTR = "data-injected-logout";
|
||||
const LOGOUT_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
const LOGOUT_URL = {{ (('/oauth2/sign_out?rd=' ~ (OIDC.CLIENT.LOGOUT_URL | urlencode)) if (lookup('sso', application_id, 'is_proxy_gated') | bool) else OIDC.CLIENT.LOGOUT_URL) | to_json }};
|
||||
const LOGOUT_URL = {{ lookup('sso', application_id, 'logout_url') | to_json }};
|
||||
const INJECTED_ATTR = "data-injected-logout";
|
||||
const LOGOUT_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
|
||||
from .test_sso import _apps, _load_module
|
||||
|
||||
KEYCLOAK_LOGOUT = "https://auth.example.org/realms/r/protocol/openid-connect/logout"
|
||||
OIDC = {"CLIENT": {"LOGOUT_URL": KEYCLOAK_LOGOUT}}
|
||||
|
||||
|
||||
class SsoLogoutUrlLookupTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.mod = _load_module("plugins/lookup/sso.py", "sso")
|
||||
|
||||
def _run(self, applications, variables):
|
||||
lk = self.mod.LookupModule()
|
||||
lk._loader = mock.MagicMock()
|
||||
lk._templar = mock.MagicMock(template=lambda value: value)
|
||||
with mock.patch.object(self.mod, "lookup_loader") as loader_mock:
|
||||
loader_mock.get.return_value = mock.MagicMock(
|
||||
run=lambda *_a, **_k: [applications]
|
||||
)
|
||||
return lk.run(["web-app-x", "logout_url"], variables=variables)
|
||||
|
||||
def test_a_proxy_gated_app_signs_out_of_oauth2_proxy_first(self):
|
||||
result = self._run(_apps(enabled=True, flavor="oauth2"), {"OIDC": OIDC})
|
||||
self.assertEqual(
|
||||
result,
|
||||
[
|
||||
"/oauth2/sign_out?rd=https%3A//auth.example.org/realms/r/protocol/openid-connect/logout"
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_oidc_native_app_logs_out_at_the_provider(self):
|
||||
result = self._run(_apps(enabled=True, flavor="oidc"), {"OIDC": OIDC})
|
||||
self.assertEqual(result, [KEYCLOAK_LOGOUT])
|
||||
|
||||
def test_a_disabled_sso_keeps_the_provider_logout(self):
|
||||
result = self._run(_apps(enabled=False, flavor="oauth2"), {"OIDC": OIDC})
|
||||
self.assertEqual(result, [KEYCLOAK_LOGOUT])
|
||||
|
||||
def test_a_context_without_oidc_is_refused(self):
|
||||
with self.assertRaises(AnsibleError) as ctx:
|
||||
self._run(_apps(enabled=True, flavor="oauth2"), {"applications": {}})
|
||||
self.assertIn("OIDC", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,11 +2,16 @@ 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,
|
||||
)
|
||||
|
||||
KEYCLOAK_LOGOUT = "https://auth.example.org/realms/r/protocol/openid-connect/logout"
|
||||
|
||||
|
||||
def _apps(
|
||||
*,
|
||||
@@ -203,5 +208,22 @@ class IsPotentiallyEnabledTests(unittest.TestCase):
|
||||
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)}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,6 +19,7 @@ literal-protocol-lookup and lookup-config-path static-scan guards).
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from utils.roles.applications.config import get
|
||||
|
||||
@@ -28,6 +29,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_DEFAULT_FLAVOR = "oidc"
|
||||
_VALID_FLAVORS = frozenset({"oidc", "oauth2", "saml"})
|
||||
OAUTH2_SIGN_OUT_PATH = "/oauth2/sign_out"
|
||||
|
||||
|
||||
def is_potentially_enabled(value: Any) -> bool:
|
||||
@@ -145,3 +147,15 @@ def get_sso_config(
|
||||
),
|
||||
"oauth2_pass_access_token": _as_bool(raw_pass_access_token),
|
||||
}
|
||||
|
||||
|
||||
def logout_url(oidc_logout_url: str, is_proxy_gated: bool) -> str:
|
||||
"""Return the logout target of an application's own logout control.
|
||||
|
||||
Args:
|
||||
oidc_logout_url: the identity provider's end-session endpoint.
|
||||
is_proxy_gated: whether oauth2-proxy fronts the application.
|
||||
"""
|
||||
if not is_proxy_gated:
|
||||
return oidc_logout_url
|
||||
return f"{OAUTH2_SIGN_OUT_PATH}?rd={quote(oidc_logout_url, safe='/')}"
|
||||
Reference in new issue
Block a user