This commit is contained in:
2026-01-30 23:31:00 -06:00
commit a39095b3de
2665 changed files with 263970 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
"""OpenID / OAuth2 login component for Home Assistant."""
from __future__ import annotations
import asyncio
from http import HTTPStatus
import logging
import os
from pathlib import Path
import hass_frontend
import voluptuous as vol
from homeassistant.components.http import StaticPathConfig
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_AUTHORIZE_URL,
CONF_BLOCK_LOGIN,
CONF_CONFIGURE_URL,
CONF_CREATE_USER,
CONF_OPENID_TEXT,
CONF_SCOPE,
CONF_TOKEN_URL,
CONF_USER_INFO_URL,
CONF_USERNAME_FIELD,
DOMAIN,
)
from .http_helper import override_authorize_login_flow, override_authorize_route
from .views import OpenIDAuthorizeView, OpenIDCallbackView
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_AUTHORIZE_URL): cv.url,
vol.Optional(CONF_TOKEN_URL): cv.url,
vol.Optional(CONF_USER_INFO_URL): cv.url,
vol.Optional(CONF_CONFIGURE_URL): cv.url,
vol.Optional(CONF_SCOPE, default="openid profile email"): cv.string,
vol.Optional(
CONF_USERNAME_FIELD, default="preferred_username"
): cv.string,
vol.Optional(CONF_CREATE_USER, default=False): cv.boolean,
vol.Optional(CONF_BLOCK_LOGIN, default=False): cv.boolean,
vol.Optional(
CONF_OPENID_TEXT, default="OpenID / OAuth2 Authentication"
): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the OpenID component."""
if DOMAIN not in config:
_LOGGER.error("Missing '%s' section in configuration.yaml", DOMAIN)
return False
hass.data[DOMAIN] = config[DOMAIN]
hass.data.setdefault("_openid_state", {})
if CONF_CONFIGURE_URL in hass.data[DOMAIN]:
try:
await fetch_urls(hass, config[DOMAIN][CONF_CONFIGURE_URL])
except Exception as e: # noqa: BLE001
_LOGGER.error("Failed to fetch OpenID configuration: %s", e)
return False
# Preload HTML templates
authorize_path = hass_frontend.where() / "authorize.html"
authorize_template = await asyncio.to_thread(
authorize_path.read_text, encoding="utf-8"
)
token_path = Path(__file__).parent / "token.html"
token_template = await asyncio.to_thread(token_path.read_text, encoding="utf-8")
hass.data[DOMAIN]["authorize_template"] = authorize_template
hass.data[DOMAIN]["token_template"] = token_template
# Serve the custom frontend JS that hooks into the login dialog
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/openid/authorize.js",
os.path.join(os.path.dirname(__file__), "authorize.js"),
cache_headers=True,
)
]
)
# Register routes
hass.http.register_view(OpenIDAuthorizeView(hass))
hass.http.register_view(OpenIDCallbackView(hass))
# Patch /auth/authorize to inject our JS file.
override_authorize_route(hass)
# Patch the login flow to include additional OpenID data.
override_authorize_login_flow(hass)
return True
async def fetch_urls(hass: HomeAssistant, configure_url: str) -> None:
"""Fetch the OpenID URLs from the IdP's configuration endpoint."""
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
try:
_LOGGER.debug("Fetching OpenID configuration from %s", configure_url)
async with session.get(configure_url) as resp:
if resp.status != HTTPStatus.OK:
raise RuntimeError(f"Configuration endpoint returned {resp.status}") # noqa: TRY301
config_data = await resp.json()
# Update the configuration with fetched URLs
hass.data[DOMAIN][CONF_AUTHORIZE_URL] = config_data.get(
"authorization_endpoint"
)
hass.data[DOMAIN][CONF_TOKEN_URL] = config_data.get("token_endpoint")
hass.data[DOMAIN][CONF_USER_INFO_URL] = config_data.get("userinfo_endpoint")
_LOGGER.info("OpenID configuration loaded successfully")
except Exception as e: # noqa: BLE001
_LOGGER.error("Failed to fetch OpenID configuration: %s", e)

View File

@@ -0,0 +1,58 @@
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const response = await originalFetch(...args);
if (!args[0].includes('/auth/login_flow')) {
return response;
}
// Got the first response from /auth/login_flow
// Restore the original fetch function
window.fetch = originalFetch;
const responseBody = await response.clone().json();
if (responseBody.block_login) {
console.info('Home Assistant login methods are blocked by hass-openid. Redirecting to OpenID login.');
redirect_openid_login();
return response;
}
const openIdText = responseBody.openid_text;
const authFlow = document.getElementsByClassName('card-content')[0];
const listNode = document.createElement('ha-list');
const listItemNode = document.createElement('ha-list-item');
listItemNode.setAttribute('hasmeta', '');
listItemNode.setAttribute('mwc-list-item', '');
listItemNode.innerHTML = `${openIdText} <ha-icon-next slot="meta"></ha-icon-next>`;
listItemNode.onclick = redirect_openid_login;
listNode.appendChild(listItemNode);
authFlow.append(listNode);
const alertType = localStorage.getItem('alertType');
const alertMessage = localStorage.getItem('alertMessage') || 'No error message provided';
if (alertType) {
const alertNode = document.createElement('ha-alert');
alertNode.setAttribute('alert-type', alertType);
alertNode.textContent = alertMessage.replace(/&quot;/g, '"').replace(/&#39;/g, "'");
authFlow.prepend(alertNode);
localStorage.removeItem('alertType');
localStorage.removeItem('alertMessage');
}
return response;
};
function redirect_openid_login() {
const urlParams = new URLSearchParams(window.location.search);
const clientId = encodeURIComponent(urlParams.get('client_id'));
const redirectUri = encodeURIComponent(urlParams.get('redirect_uri'));
const baseUrl = encodeURIComponent(window.location.origin);
window.location.href = `/auth/openid/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&base_url=${baseUrl}`;
}

View File

@@ -0,0 +1,18 @@
"""Constants for OpenID integration."""
DOMAIN = "openid"
# Either provide these URLs in the config or use the configure url to discover them
CONF_AUTHORIZE_URL = "authorize_url"
CONF_TOKEN_URL = "token_url"
CONF_USER_INFO_URL = "user_info_url"
CONF_CONFIGURE_URL = "configure_url"
CONF_USERNAME_FIELD = "username_field"
CONF_SCOPE = "scope"
CONF_CREATE_USER = "create_user"
CONF_BLOCK_LOGIN = "block_login"
CONF_USE_HEADER_AUTH = "use_header_auth"
CONF_OPENID_TEXT = "openid_text"

View File

@@ -0,0 +1,84 @@
"""Patch the built-in /auth/authorize and /auth/login_flow pages to load our JS helper."""
from http import HTTPStatus
import json
import logging
from aiohttp.web import Request, Response
from homeassistant.core import HomeAssistant
from .const import CONF_BLOCK_LOGIN, CONF_OPENID_TEXT, DOMAIN
_LOGGER = logging.getLogger(__name__)
def override_authorize_login_flow(hass: HomeAssistant) -> None:
"""Patch the build-in /auth/login_flow page to not return any actual login data."""
_original_post_function = None
async def post(request: Request) -> Response:
if not hass.data[DOMAIN].get(CONF_BLOCK_LOGIN, False):
content = json.loads((await _original_post_function(request)).text)
else:
content = {
"type": "form",
"flow_id": None,
"handler": [None],
"data_schema": [],
"errors": {},
"description_placeholders": None,
"last_step": None,
"preview": None,
"step_id": "init",
}
content[CONF_BLOCK_LOGIN] = hass.data[DOMAIN].get(CONF_BLOCK_LOGIN, False)
content[CONF_OPENID_TEXT] = hass.data[DOMAIN].get(
CONF_OPENID_TEXT, "OpenID / OAuth2 Authentication"
)
return Response(
status=HTTPStatus.OK,
body=json.dumps(content),
content_type="application/json",
)
# Swap out the existing GET handler on /auth/authorize
for resource in hass.http.app.router._resources: # noqa: SLF001
if getattr(resource, "canonical", None) == "/auth/login_flow":
post_handler = resource._routes.get("POST") # noqa: SLF001
# Replace the underlying coroutine fn.
_original_post_function = post_handler._handler # noqa: SLF001
post_handler._handler = post # noqa: SLF001
# Reset the routes map to ensure only our GET exists.
resource._routes = {"POST": post_handler} # noqa: SLF001
_LOGGER.debug("Overrode /auth/login_flow route")
break
def override_authorize_route(hass: HomeAssistant) -> None:
"""Patch the built-in /auth/authorize page to load our JS helper."""
async def get(request: Request) -> Response:
content = hass.data[DOMAIN]["authorize_template"]
# Inject script before </head>
content = content.replace(
"</head>",
'<script src="/openid/authorize.js"></script></head>',
)
return Response(status=HTTPStatus.OK, body=content, content_type="text/html")
# Swap out the existing GET handler on /auth/authorize
for resource in hass.http.app.router._resources: # noqa: SLF001
if getattr(resource, "canonical", None) == "/auth/authorize":
get_handler = resource._routes.get("GET") # noqa: SLF001
# Replace the underlying coroutine fn.
get_handler._handler = get # noqa: SLF001
# Reset the routes map to ensure only our GET exists.
resource._routes = {"GET": get_handler} # noqa: SLF001
_LOGGER.debug("Overrode /auth/authorize route custom JS injected")
break

View File

@@ -0,0 +1,15 @@
{
"domain": "openid",
"name": "OpenID / OAuth2 authentication",
"codeowners": [
"@cavefire"
],
"dependencies": [
"http"
],
"documentation": "https://github.com/cavefire/hass-openid",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/cavefire/hass-openid/issues",
"requirements": [],
"version": "1.1.6"
}

View File

@@ -0,0 +1,68 @@
"""OpenID Connect OAuth helpers for Home Assistant."""
from base64 import b64encode
from http import HTTPStatus
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
_LOGGER = logging.getLogger(__name__)
async def exchange_code_for_token(
hass: HomeAssistant,
*,
token_url: str,
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
use_header_auth: bool = True,
) -> dict[str, Any]:
"""Exchange the *authorisation code* for tokens at the IdP."""
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
}
if use_header_auth:
credentials = f"{client_id}:{client_secret}"
encoded_credentials = b64encode(credentials.encode("utf-8")).decode("utf-8")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {encoded_credentials}",
}
else:
_LOGGER.warning(
"Using client id and secret in request body might expose them, when your IdP logging is wrongly configured. Use with caution"
)
data["client_id"] = client_id
data["client_secret"] = client_secret
_LOGGER.debug("Exchanging code for token at %s", token_url)
async with session.post(token_url, data=data, headers=headers) as resp:
if resp.status != HTTPStatus.OK:
text = await resp.text()
raise RuntimeError(f"Token endpoint returned {resp.status}: {text}")
return await resp.json()
async def fetch_user_info(
hass: HomeAssistant, user_info_url: str, access_token: str
) -> dict[str, Any]:
"""Fetch user information from the user info endpoint."""
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
headers = {"Authorization": f"Bearer {access_token}"}
_LOGGER.debug("Fetching user info from %s", user_info_url)
async with session.get(user_info_url, headers=headers) as resp:
if resp.status != HTTPStatus.OK:
text = await resp.text()
raise RuntimeError(f"User info endpoint returned {resp.status}: {text}")
return await resp.json()

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<noscript>
<h1>JavaScript is disabled</h1>
<p>Please enable JavaScript to use this application.</p>
</noscript>
<script>
localStorage.setItem('hassTokens', '<<hassTokens>>');
window.location.href = '<<redirect>>';
</script>
<a href="<<redirect>>">Click here if you are not redirected automatically</a>
</body>
</html>

View File

@@ -0,0 +1,251 @@
"""OpenID Connect views for Home Assistant."""
import base64
from http import HTTPStatus
import json
import logging
import secrets
from typing import Any
import urllib.parse
from aiohttp.web import HTTPFound, Request, Response
from yarl import URL
from homeassistant.auth.models import User
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from .const import (
CONF_AUTHORIZE_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
CONF_USE_HEADER_AUTH,
CONF_USER_INFO_URL,
CONF_USERNAME_FIELD,
DOMAIN,
)
from .oauth_helper import exchange_code_for_token, fetch_user_info
_LOGGER = logging.getLogger(__name__)
class OpenIDAuthorizeView(HomeAssistantView):
"""Redirect to the IdPs authorisation endpoint."""
name = "api:openid:authorize"
url = "/auth/openid/authorize"
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the authorisation view."""
self.hass = hass
async def get(self, request: Request) -> Response:
"""Redirect the browser to the IdPs authorisation endpoint."""
conf: dict[str, str] = self.hass.data[DOMAIN]
state = secrets.token_urlsafe(24)
params = request.rel_url.query
base_url = params.get("base_url", "")
redirect_uri = str(URL(base_url).with_path("/auth/openid/callback"))
self.hass.data["_openid_state"][state] = params
query = {
"response_type": "code",
"client_id": conf[CONF_CLIENT_ID],
"redirect_uri": redirect_uri,
"scope": conf.get(CONF_SCOPE, ""),
"state": state,
}
encoded_query = urllib.parse.urlencode(query)
url = conf[CONF_AUTHORIZE_URL] + "?" + encoded_query
_LOGGER.debug("Redirecting to IdP authorize endpoint: %s", url)
return Response(status=302, headers={"Location": url})
class OpenIDCallbackView(HomeAssistantView):
"""Handle the callback from the IdP after authorisation."""
name = "api:openid:callback"
url = "/auth/openid/callback"
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the callback view."""
self.hass = hass
async def get(self, request: Request) -> Response:
"""Handle redirect from IdP, exchange code for tokens."""
params = request.rel_url.query
code = params.get("code")
state = params.get("state")
if not code or not state:
_LOGGER.warning("Missing code/state query parameters params: %s", params)
return _show_error(
params,
alert_type="error",
alert_message="OpenID login failed! Missing code or state parameter.",
)
# Validate state
pending = self.hass.data.get("_openid_state", {}).pop(state, None)
params = {**params, **pending}
if not pending:
_LOGGER.warning("Invalid state parameter received: %s", state)
return _show_error(
params,
alert_type="error",
alert_message="OpenID login failed! Invalid state parameter.",
)
conf: dict[str, str] = self.hass.data[DOMAIN]
base_url = params.get("base_url", "")
redirect_uri = str(URL(base_url).with_path("/auth/openid/callback"))
token_data: dict[str, Any] | None = None
user_info: dict[str, Any] | None = None
try:
token_data = await exchange_code_for_token(
hass=self.hass,
token_url=conf[CONF_TOKEN_URL],
code=code,
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
redirect_uri=redirect_uri,
use_header_auth=conf.get(CONF_USE_HEADER_AUTH, True),
)
user_info = await fetch_user_info(
hass=self.hass,
user_info_url=conf[CONF_USER_INFO_URL],
access_token=token_data.get("access_token"),
)
except Exception:
_LOGGER.exception("Token exchange or user info fetch failed")
return _show_error(
params,
alert_type="error",
alert_message="OpenID login failed! Could not exchange code for tokens or fetch user info.",
)
username = user_info.get(conf[CONF_USERNAME_FIELD]) if user_info else None
if not username:
_LOGGER.warning("No username found in user info")
return _show_error(
params,
alert_type="error",
alert_message="OpenID login failed! No username found in user info.",
)
users: list[User] = await self.hass.auth.async_get_users()
user: User = None
for u in users:
for cred in u.credentials:
if cred.data.get("username") == username:
user = u
break
if user:
refresh_token = await self.hass.auth.async_create_refresh_token(
user, client_id=DOMAIN
)
access_token = self.hass.auth.async_create_access_token(refresh_token)
_LOGGER.debug("User %s logged in successfully", username)
content = self.hass.data[DOMAIN]["token_template"]
hassTokens = {
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": refresh_token.token,
"ha_auth_provider": DOMAIN,
"hassUrl": base_url,
"client_id": params.get("client_id"),
"expires": int(refresh_token.access_token_expiration.total_seconds()),
}
url = params.get("redirect_uri", "/")
result = self.hass.data["auth"](
params.get("client_id"), user.credentials[0]
)
resultState = {
"hassUrl": hassTokens["hassUrl"],
"clientId": hassTokens["client_id"],
}
resultStateB64 = base64.b64encode(
json.dumps(resultState).encode("utf-8")
).decode("utf-8")
url = str(
URL(url).with_query(
{
"auth_callback": 1,
"code": result,
"state": resultStateB64,
"storeToken": "true",
}
)
)
# Mobile app uses homeassistant:// URL scheme
if str(url).startswith("homeassistant://"):
return Response(
status=HTTPStatus.FOUND,
headers={"Location": url},
)
# Web app uses the standard redirect_uri
# and injects the tokens into the page
content = content.replace("<<hassTokens>>", json.dumps(hassTokens)).replace(
"<<redirect>>",
url,
)
return Response(
status=HTTPStatus.OK,
body=content,
content_type="text/html",
)
_LOGGER.warning("User %s not found in Home Assistant", username)
return _show_error(
params,
alert_type="error",
alert_message=(
f"OpenID login succeeded, but user not found in Home Assistant! "
f"Please ensure the user '{username}' exists and is enabled for login."
),
)
def _show_error(params, alert_type, alert_message):
# make sure the alert_type and alert_message can be safely displayed
alert_type = alert_type.replace("'", "&#39;").replace('"', "&quot;")
alert_message = alert_message.replace("'", "&#39;").replace('"', "&quot;")
redirect_url = params.get("redirect_uri", "/").replace("auth_callback=1", "")
return Response(
status=HTTPStatus.OK,
content_type="text/html",
text=(
"<html><body><script>"
f"localStorage.setItem('alertType', '{alert_type}');"
f"localStorage.setItem('alertMessage', '{alert_message}');"
f"window.location.href = '{redirect_url}';"
"</script>"
f"<h1>{alert_type}</h1>"
f"<p>{alert_message}</p>"
f"<p>Redirecting to {redirect_url}...</p>"
f"<p><a href='{redirect_url}'>Click here if not redirected</a></p>"
"</body></html>"
),
)