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:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-11 13:06:19 +02:00
1 parent a69f169600
commit 9d0f618e23
8 files changed
+105 -5

No files matched your search

+14 -1
View File
@@ -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()
+14
View File
@@ -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='/')}"