diff --git a/README.md b/README.md index 00e68d4..43aa6ab 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ checked against usernames AND full names. Users have to be created in Home Assis auth_header: # Optionally set this if you're not using authentik proxy or oauth2_proxy # username_header: X-Forwarded-Preferred-Username + # Optionally set this if you want to enable Single Logout + # single_logout_url: https://your.homeassistant.instance/oauth2/sign_out?rd=https%3A%2F%2Fyour.idp.com%2Foauth2%2Flogout # Optionally set this if you don't want to bypass the login prompt # allow_bypass_login: false # Optionally enable debug mode to see the headers Home-Assistant gets @@ -54,6 +56,22 @@ On boot, two main things are done when the integration is enabled: This ensures that Header auth is tried first, and if it fails the user can still use username/password. +3. If the single logout URL is available, we patch `_handleLogout` as soon as the home-assistant element is available + + This redirects the user to the configured SLO url after the stock logic to revoke the access token is executed + +## Single Logout Support + +The component can patch the stock log out handler to clear the authentication proxy's session and log you out of your IDP if you specify a `single_logout_url`. This is usually a call to your authentication proxy (such as oauth2-proxy) with a rd parameter to redirect the user to your IDP's logout endpoint after. The IDP's logout endpoint needs to be URL-encoded in the configuration. + +``` +https://your.homeassistant.instance/oauth2/sign_out?rd=https%3A%2F%2Fyour.idp.com%2Foauth2%2Flogout + +https://your.homeassistant.instance/oauth2/sign_out?rd= > your oauth proxy's SLO endpoint +https%3A%2F%2Fyour.idp.com%2Foauth2%2Flogout > your IDP's SLO endpoint URL-encoded +``` + +You can also configure a front-channel logout URL in your IDP to call the same endpoint to clear the proxy's session however, this does not revoke your currently logged in access token. ## Help! Everything is broken! diff --git a/custom_components/auth_header/__init__.py b/custom_components/auth_header/__init__.py index e90d5b5..8035b56 100644 --- a/custom_components/auth_header/__init__.py +++ b/custom_components/auth_header/__init__.py @@ -11,7 +11,7 @@ from homeassistant.components.auth import DOMAIN as AUTH_DOMAIN from homeassistant.components.auth import indieauth from homeassistant.components.auth.login_flow import LoginFlowIndexView -from homeassistant.components.http import StaticPathConfig +from homeassistant.components.http import StaticPathConfig, HomeAssistantView from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant @@ -24,6 +24,7 @@ from aiohttp.web_urldispatcher import UrlDispatcher, AbstractResource DOMAIN = "auth_header" +CONF_SINGLE_LOGOUT_URL = "single_logout_url" _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -32,6 +33,7 @@ vol.Optional("username_header", default="X-Forwarded-Preferred-Username"): cv.string, vol.Optional("allow_bypass_login", default=True): cv.boolean, vol.Optional("debug", default=False): cv.boolean, + vol.Optional(CONF_SINGLE_LOGOUT_URL): cv.url, } ) }, @@ -41,6 +43,9 @@ async def async_setup(hass: HomeAssistant, config): """Register custom view which includes request in context""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + hass.data[DOMAIN][CONF_SINGLE_LOGOUT_URL] = config[DOMAIN].get(CONF_SINGLE_LOGOUT_URL) # Because we start after auth, we have access to store_result store_result = hass.data[AUTH_DOMAIN] router: "FastUrlDispatcher" | "UrlDispatcher" = hass.http.app.router @@ -68,15 +73,22 @@ async def async_setup(hass: HomeAssistant, config): # Load script to store tokens in local storage, else we'll re-auth on every browser refresh. await hass.http.async_register_static_paths( - [StaticPathConfig( - "/auth_header/store-token.js", - os.path.join(os.path.dirname(__file__), 'store-token.js'), - True + [ + StaticPathConfig( + "/auth_header/store-token.js", + os.path.join(os.path.dirname(__file__), "store-token.js"), + True, + ), + StaticPathConfig( + "/auth_header/logout-slo.js", + os.path.join(os.path.dirname(__file__), "logout-slo.js"), + True, ) ] - ) + ) add_extra_js_url(hass, '/auth_header/store-token.js') + add_extra_js_url(hass, '/auth_header/logout-slo.js') # Inject Auth-Header provider. providers = OrderedDict() @@ -89,6 +101,7 @@ async def async_setup(hass: HomeAssistant, config): providers.update(hass.auth._providers) hass.auth._providers = providers _LOGGER.debug("Injected auth_header provider") + hass.http.register_view(SLOUrlView()) return True @@ -156,3 +169,12 @@ async def post(self, request: Request, data: dict[str, Any]) -> Response: ) return await self._async_flow_result_to_response(request, client_id, result) + +class SLOUrlView(HomeAssistantView): + url = "/api/auth_header/slo_url" + name = "auth_header:slo_url" + requires_auth = False + + async def get(self, request: Request) -> Response: + slo = request.app["hass"].data[DOMAIN].get(CONF_SINGLE_LOGOUT_URL, "") + return Response(text=slo, content_type="text/plain") diff --git a/custom_components/auth_header/logout-slo.js b/custom_components/auth_header/logout-slo.js new file mode 100644 index 0000000..324ffe6 --- /dev/null +++ b/custom_components/auth_header/logout-slo.js @@ -0,0 +1,25 @@ +(() => { + // Replace the frontend's _handleLogout() handler to redirect to the IdP single‑logout URL if configured. + const SLO_ENDPOINT = "/api/auth_header/slo_url"; + const fetchSlo = async () => { + try { + const resp = await fetch(SLO_ENDPOINT, { cache: "no-cache" }); + return resp.ok ? resp.text() : ""; + } catch { + return ""; + } + }; + const patchLogout = async () => { + const sloUrl = await fetchSlo(); + if (!sloUrl) return; // nothing to do + await customElements.whenDefined("home-assistant"); + const proto = customElements.get("home-assistant").prototype; + if (!proto._handleLogout) return; + const orig = proto._handleLogout; + proto._handleLogout = async function () { + await orig.call(this); + window.location.href = sloUrl; + }; + }; + patchLogout(); +})(); diff --git a/custom_components/auth_header/manifest.json b/custom_components/auth_header/manifest.json index bd01d77..164e610 100644 --- a/custom_components/auth_header/manifest.json +++ b/custom_components/auth_header/manifest.json @@ -10,5 +10,5 @@ "auth" ], "codeowners": ["@BeryJu"], - "version": "1.4" + "version": "1.5" }