init
This commit is contained in:
135
custom_components/openid/__init__.py
Normal file
135
custom_components/openid/__init__.py
Normal 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)
|
||||
58
custom_components/openid/authorize.js
Normal file
58
custom_components/openid/authorize.js
Normal 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(/"/g, '"').replace(/'/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}`;
|
||||
}
|
||||
18
custom_components/openid/const.py
Normal file
18
custom_components/openid/const.py
Normal 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"
|
||||
84
custom_components/openid/http_helper.py
Normal file
84
custom_components/openid/http_helper.py
Normal 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
|
||||
15
custom_components/openid/manifest.json
Normal file
15
custom_components/openid/manifest.json
Normal 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"
|
||||
}
|
||||
68
custom_components/openid/oauth_helper.py
Normal file
68
custom_components/openid/oauth_helper.py
Normal 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()
|
||||
19
custom_components/openid/token.html
Normal file
19
custom_components/openid/token.html
Normal 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>
|
||||
251
custom_components/openid/views.py
Normal file
251
custom_components/openid/views.py
Normal 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 IdP’s 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 IdP’s 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("'", "'").replace('"', """)
|
||||
alert_message = alert_message.replace("'", "'").replace('"', """)
|
||||
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>"
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user