Skip to content

Commit e1aba01

Browse files
authored
feat: implement logout & music assistant auth (#42)
1 parent eadd429 commit e1aba01

File tree

8 files changed

+828
-54
lines changed

8 files changed

+828
-54
lines changed

custom_components/openid/__init__.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from http import HTTPStatus
77
from ipaddress import ip_network
88
import logging
9-
import os
9+
from pathlib import Path
1010

1111
import hass_frontend
1212
import voluptuous as vol
1313

14+
from homeassistant.auth.models import Credentials
15+
from homeassistant.components.frontend import add_extra_js_url
1416
from homeassistant.components.http import StaticPathConfig
1517
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
1618
from homeassistant.core import HomeAssistant
@@ -23,16 +25,25 @@
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
)
3440
from .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

@@ -58,6 +69,7 @@
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

custom_components/openid/authorize.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,25 @@ function redirect_openid_login() {
4444
const urlParams = new URLSearchParams(window.location.search);
4545
const clientId = encodeURIComponent(urlParams.get('client_id'));
4646
const redirectUri = encodeURIComponent(urlParams.get('redirect_uri'));
47+
const referrerState = (() => {
48+
try {
49+
const ref = document.referrer ? new URL(document.referrer) : null;
50+
return ref ? ref.searchParams.get('state') : null;
51+
} catch (e) {
52+
return null;
53+
}
54+
})();
55+
56+
const state = urlParams.get('state') || referrerState || localStorage.getItem('openid_original_state');
57+
58+
if (state) {
59+
localStorage.setItem('openid_original_state', state);
60+
}
4761
const baseUrl = encodeURIComponent(window.location.origin);
62+
const stateParam = state ? `&state=${encodeURIComponent(state)}` : '';
63+
const clientStateParam = state ? `&client_state=${encodeURIComponent(state)}` : '';
4864

49-
window.location.href = `/auth/openid/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&base_url=${baseUrl}`;
65+
window.location.href = `/auth/openid/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&base_url=${baseUrl}${stateParam}${clientStateParam}`;
5066
}
5167

5268
function ensure_openid_button(openIdText) {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Authorize Application - Home Assistant</title>
7+
<style>
8+
body {
9+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
10+
background-color: #f5f5f5;
11+
display: flex;
12+
justify-content: center;
13+
align-items: center;
14+
min-height: 100vh;
15+
margin: 0;
16+
padding: 20px;
17+
}
18+
.consent-card {
19+
background: white;
20+
border-radius: 8px;
21+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
22+
max-width: 450px;
23+
width: 100%;
24+
padding: 32px;
25+
}
26+
h1 {
27+
color: #333;
28+
font-size: 24px;
29+
margin: 0 0 8px 0;
30+
font-weight: 500;
31+
}
32+
.subtitle {
33+
color: #666;
34+
font-size: 14px;
35+
margin: 0 0 24px 0;
36+
}
37+
.client-info {
38+
background: #f8f9fa;
39+
border-left: 4px solid #03a9f4;
40+
padding: 16px;
41+
margin: 24px 0;
42+
border-radius: 4px;
43+
}
44+
.client-info strong {
45+
color: #333;
46+
display: block;
47+
margin-bottom: 4px;
48+
}
49+
.client-info code {
50+
background: white;
51+
padding: 2px 6px;
52+
border-radius: 3px;
53+
font-size: 13px;
54+
color: #d32f2f;
55+
word-break: break-all;
56+
}
57+
.permissions {
58+
margin: 24px 0;
59+
}
60+
.permissions h2 {
61+
font-size: 16px;
62+
color: #333;
63+
margin: 0 0 12px 0;
64+
font-weight: 500;
65+
}
66+
.permissions ul {
67+
list-style: none;
68+
padding: 0;
69+
margin: 0;
70+
}
71+
.permissions li {
72+
padding: 8px 0 8px 28px;
73+
position: relative;
74+
color: #555;
75+
font-size: 14px;
76+
}
77+
.permissions li:before {
78+
content: '✓';
79+
position: absolute;
80+
left: 8px;
81+
color: #4caf50;
82+
font-weight: bold;
83+
}
84+
.actions {
85+
display: flex;
86+
gap: 12px;
87+
margin-top: 32px;
88+
}
89+
button {
90+
flex: 1;
91+
padding: 12px 24px;
92+
border: none;
93+
border-radius: 4px;
94+
font-size: 14px;
95+
font-weight: 500;
96+
cursor: pointer;
97+
transition: all 0.2s;
98+
}
99+
.btn-authorize {
100+
background: #03a9f4;
101+
color: white;
102+
}
103+
.btn-authorize:hover {
104+
background: #0288d1;
105+
}
106+
.btn-cancel {
107+
background: #e0e0e0;
108+
color: #333;
109+
}
110+
.btn-cancel:hover {
111+
background: #bdbdbd;
112+
}
113+
.warning {
114+
margin-top: 24px;
115+
padding: 12px;
116+
background: #fff3cd;
117+
border-left: 4px solid #ffc107;
118+
border-radius: 4px;
119+
font-size: 13px;
120+
color: #856404;
121+
}
122+
</style>
123+
</head>
124+
<body>
125+
<div class="consent-card">
126+
<h1>Authorize Application</h1>
127+
<p class="subtitle">An application is requesting access to your Home Assistant instance</p>
128+
129+
<div class="client-info">
130+
<strong>Application:</strong>
131+
<code>$client_id</code>
132+
</div>
133+
134+
<div class="permissions">
135+
<h2>This application will be able to:</h2>
136+
<ul>
137+
<li>Access your Home Assistant account</li>
138+
<li>Read your user profile information</li>
139+
<li>Perform actions on your behalf</li>
140+
</ul>
141+
</div>
142+
143+
<div class="warning">Only authorize applications you trust. Make sure you recognize the application URL above.</div>
144+
145+
<form method="post" action="/auth/openid/consent">
146+
<input type="hidden" name="state" value="$state" />
147+
<input type="hidden" name="client_id" value="$client_id" />
148+
<input type="hidden" name="redirect_uri" value="$redirect_uri" />
149+
<input type="hidden" name="base_url" value="$base_url" />
150+
$client_state_input
151+
152+
<div class="actions">
153+
<button type="button" class="btn-cancel" onclick="window.location.href='$cancel_url'">Cancel</button>
154+
<button type="submit" class="btn-authorize">Authorize</button>
155+
</div>
156+
</form>
157+
</div>
158+
</body>
159+
</html>

custom_components/openid/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@
1717
CONF_USE_HEADER_AUTH = "use_header_auth"
1818
CONF_OPENID_TEXT = "openid_text"
1919
CONF_TRUSTED_IPS = "trusted_ips"
20+
CONF_LOGOUT_URL = "logout_url"
21+
22+
CRED_ID_TOKEN = "openid_id_token"
23+
CRED_SESSION_STATE = "openid_session_state"
24+
CRED_LOGOUT_REDIRECT_URI = "openid_logout_redirect_uri"

0 commit comments

Comments
 (0)