66from http import HTTPStatus
77from ipaddress import ip_network
88import logging
9- import os
9+ from pathlib import Path
1010
1111import hass_frontend
1212import voluptuous as vol
1313
14+ from homeassistant .auth .models import Credentials
15+ from homeassistant .components .frontend import add_extra_js_url
1416from homeassistant .components .http import StaticPathConfig
1517from homeassistant .const import CONF_CLIENT_ID , CONF_CLIENT_SECRET
1618from homeassistant .core import HomeAssistant
2325 CONF_BLOCK_LOGIN ,
2426 CONF_CONFIGURE_URL ,
2527 CONF_CREATE_USER ,
28+ CONF_LOGOUT_URL ,
2629 CONF_OPENID_TEXT ,
2730 CONF_SCOPE ,
2831 CONF_TOKEN_URL ,
32+ CONF_TRUSTED_IPS ,
2933 CONF_USER_INFO_URL ,
3034 CONF_USERNAME_FIELD ,
31- CONF_TRUSTED_IPS ,
35+ CRED_ID_TOKEN ,
36+ CRED_LOGOUT_REDIRECT_URI ,
37+ CRED_SESSION_STATE ,
3238 DOMAIN ,
3339)
3440from .http_helper import override_authorize_login_flow , override_authorize_route
35- from .views import OpenIDAuthorizeView , OpenIDCallbackView
41+ from .views import (
42+ OpenIDAuthorizeView ,
43+ OpenIDCallbackView ,
44+ OpenIDConsentView ,
45+ OpenIDSessionView ,
46+ )
3647
3748_LOGGER = logging .getLogger (__name__ )
3849
5869 vol .Optional (CONF_TRUSTED_IPS , default = []): vol .All (
5970 cv .ensure_list , [cv .string ]
6071 ),
72+ vol .Optional (CONF_LOGOUT_URL ): cv .url ,
6173 }
6274 )
6375 },
@@ -86,6 +98,64 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
8698
8799 hass .data [DOMAIN ][CONF_TRUSTED_IPS ] = trusted_networks
88100
101+ async def _async_notify_idp_logout (credential : Credentials ) -> None :
102+ """Clear logout-related metadata from credentials.
103+
104+ The actual IdP logout is handled by the frontend (logout.js),
105+ which redirects the user to the IdP logout URL so their browser
106+ session can be properly cleared.
107+ """
108+ logout_url : str | None = hass .data [DOMAIN ].get (CONF_LOGOUT_URL )
109+ if not logout_url :
110+ _LOGGER .debug ("No logout URL configured; skipping logout metadata cleanup" )
111+ return
112+
113+ cleared = False
114+ if credential .data .pop (CRED_ID_TOKEN , None ) is not None :
115+ cleared = True
116+ if credential .data .pop (CRED_SESSION_STATE , None ) is not None :
117+ cleared = True
118+
119+ if cleared :
120+ hass .auth .async_update_user_credentials_data (
121+ credential , dict (credential .data )
122+ )
123+ _LOGGER .debug ("Cleared logout metadata from credentials" )
124+
125+ if not hass .data [DOMAIN ].get ("_remove_refresh_token_patched" ):
126+ original_remove_refresh_token = hass .auth .async_remove_refresh_token
127+
128+ def _patched_remove_refresh_token (refresh_token ):
129+ credential = getattr (refresh_token , "credential" , None )
130+
131+ if (
132+ credential is not None
133+ and getattr (credential , "auth_provider_type" , None ) == DOMAIN
134+ ):
135+ logout_url = hass .data [DOMAIN ].get (CONF_LOGOUT_URL )
136+ has_logout_metadata = any (
137+ credential .data .get (key )
138+ for key in (
139+ CRED_ID_TOKEN ,
140+ CRED_SESSION_STATE ,
141+ CRED_LOGOUT_REDIRECT_URI ,
142+ )
143+ )
144+
145+ if logout_url and has_logout_metadata :
146+ hass .async_create_background_task (
147+ _async_notify_idp_logout (credential ),
148+ name = "openid_notify_idp_logout" ,
149+ )
150+
151+ original_remove_refresh_token (refresh_token )
152+
153+ hass .auth .async_remove_refresh_token = _patched_remove_refresh_token
154+ hass .data [DOMAIN ]["_remove_refresh_token_patched" ] = True
155+ hass .data [DOMAIN ]["_remove_refresh_token_original" ] = (
156+ original_remove_refresh_token
157+ )
158+
89159 if CONF_CONFIGURE_URL in hass .data [DOMAIN ]:
90160 try :
91161 await fetch_urls (hass , config [DOMAIN ][CONF_CONFIGURE_URL ])
@@ -100,20 +170,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
100170 )
101171 hass .data [DOMAIN ]["authorize_template" ] = authorize_template
102172
173+ # Preload consent screen template
174+ consent_path = Path (__file__ ).parent / "consent_template.html"
175+ consent_template = await asyncio .to_thread (consent_path .read_text , encoding = "utf-8" )
176+ hass .data [DOMAIN ]["consent_template" ] = consent_template
177+
103178 # Serve the custom frontend JS that hooks into the login dialog
104179 await hass .http .async_register_static_paths (
105180 [
106181 StaticPathConfig (
107182 "/openid/authorize.js" ,
108- os . path . join ( os . path . dirname (__file__ ), "authorize.js" ),
183+ str ( Path (__file__ ). parent / "authorize.js" ),
109184 cache_headers = True ,
110- )
185+ ),
186+ StaticPathConfig (
187+ "/openid/logout.js" ,
188+ str (Path (__file__ ).parent / "logout.js" ),
189+ cache_headers = True ,
190+ ),
111191 ]
112192 )
113193
194+ add_extra_js_url (hass , "/openid/logout.js" )
195+
114196 # Register routes
115197 hass .http .register_view (OpenIDAuthorizeView (hass ))
116198 hass .http .register_view (OpenIDCallbackView (hass ))
199+ hass .http .register_view (OpenIDConsentView (hass ))
200+ hass .http .register_view (OpenIDSessionView (hass ))
117201
118202 # Patch /auth/authorize to inject our JS file.
119203 override_authorize_route (hass )
@@ -145,6 +229,10 @@ async def fetch_urls(hass: HomeAssistant, configure_url: str) -> None:
145229 )
146230 hass .data [DOMAIN ][CONF_TOKEN_URL ] = config_data .get ("token_endpoint" )
147231 hass .data [DOMAIN ][CONF_USER_INFO_URL ] = config_data .get ("userinfo_endpoint" )
232+ if (
233+ logout_endpoint := config_data .get ("end_session_endpoint" )
234+ ) and not hass .data [DOMAIN ].get (CONF_LOGOUT_URL ):
235+ hass .data [DOMAIN ][CONF_LOGOUT_URL ] = logout_endpoint
148236
149237 _LOGGER .info ("OpenID configuration loaded successfully" )
150238 except Exception as e : # noqa: BLE001
0 commit comments