Inject javascript into the main authorize page for better UX (#81)
This commit is contained in:
committed by
GitHub
parent
b4d5d7f2bf
commit
86c663700c
@@ -1,6 +1,7 @@
|
|||||||
"""OIDC Integration for Home Assistant."""
|
"""OIDC Integration for Home Assistant."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import OrderedDict
|
from typing import OrderedDict
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -31,6 +32,7 @@ from .endpoints.welcome import OIDCWelcomeView
|
|||||||
from .endpoints.redirect import OIDCRedirectView
|
from .endpoints.redirect import OIDCRedirectView
|
||||||
from .endpoints.finish import OIDCFinishView
|
from .endpoints.finish import OIDCFinishView
|
||||||
from .endpoints.callback import OIDCCallbackView
|
from .endpoints.callback import OIDCCallbackView
|
||||||
|
from .endpoints.injected_auth_page import OIDCInjectedAuthPage
|
||||||
|
|
||||||
from .oidc_client import OIDCClient
|
from .oidc_client import OIDCClient
|
||||||
from .provider import OpenIDAuthProvider
|
from .provider import OpenIDAuthProvider
|
||||||
@@ -91,6 +93,7 @@ async def async_setup(hass: HomeAssistant, config):
|
|||||||
|
|
||||||
# Register the views
|
# Register the views
|
||||||
name = config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
name = config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
||||||
|
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
|
||||||
|
|
||||||
hass.http.register_view(OIDCWelcomeView(name))
|
hass.http.register_view(OIDCWelcomeView(name))
|
||||||
hass.http.register_view(OIDCRedirectView(oidc_client))
|
hass.http.register_view(OIDCRedirectView(oidc_client))
|
||||||
@@ -99,4 +102,11 @@ async def async_setup(hass: HomeAssistant, config):
|
|||||||
|
|
||||||
_LOGGER.info("Registered OIDC views")
|
_LOGGER.info("Registered OIDC views")
|
||||||
|
|
||||||
|
# Inject OIDC code into the frontend for /auth/authorize if the user has the
|
||||||
|
# frontend injection feature enabled
|
||||||
|
if features_config.get("disable_frontend_changes", False) is False:
|
||||||
|
await OIDCInjectedAuthPage.inject(hass, name)
|
||||||
|
else:
|
||||||
|
_LOGGER.info("OIDC frontend changes are disabled, skipping injection")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ FEATURES_AUTOMATIC_USER_LINKING = "automatic_user_linking"
|
|||||||
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
|
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
|
||||||
FEATURES_DISABLE_PKCE = "disable_rfc7636"
|
FEATURES_DISABLE_PKCE = "disable_rfc7636"
|
||||||
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
|
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
|
||||||
|
FEATURE_DISABLE_FRONTEND_INJECTION = "disable_frontend_changes"
|
||||||
CLAIMS = "claims"
|
CLAIMS = "claims"
|
||||||
CLAIMS_DISPLAY_NAME = "display_name"
|
CLAIMS_DISPLAY_NAME = "display_name"
|
||||||
CLAIMS_USERNAME = "username"
|
CLAIMS_USERNAME = "username"
|
||||||
@@ -69,6 +70,10 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional(
|
vol.Optional(
|
||||||
FEATURES_INCLUDE_GROUPS_SCOPE, default=True
|
FEATURES_INCLUDE_GROUPS_SCOPE, default=True
|
||||||
): vol.Coerce(bool),
|
): vol.Coerce(bool),
|
||||||
|
# Disable frontend injection of OIDC login button
|
||||||
|
vol.Optional(
|
||||||
|
FEATURE_DISABLE_FRONTEND_INJECTION, default=False
|
||||||
|
): vol.Coerce(bool),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
# Determine which specific claims will be used from the id_token
|
# Determine which specific claims will be used from the id_token
|
||||||
|
|||||||
107
custom_components/auth_oidc/endpoints/injected_auth_page.py
Normal file
107
custom_components/auth_oidc/endpoints/injected_auth_page.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Injected authorization page, replacing the original"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from aiohttp import web
|
||||||
|
from aiofiles import open as async_open
|
||||||
|
|
||||||
|
PATH = "/auth/authorize"
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_file(path: str) -> str:
|
||||||
|
"""Read a file from the static path."""
|
||||||
|
async with async_open(path, mode="r") as f:
|
||||||
|
return await f.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def frontend_injection(hass: HomeAssistant, sso_name: str) -> None:
|
||||||
|
"""Inject new frontend code into /auth/authorize."""
|
||||||
|
router = hass.http.app.router
|
||||||
|
frontend_path = None
|
||||||
|
|
||||||
|
for resource in router.resources():
|
||||||
|
if resource.canonical != "/auth/authorize":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This path doesn't actually work, gives 404, effectively disabling the old matcher
|
||||||
|
resource.add_prefix("/auth/oidc/unused")
|
||||||
|
|
||||||
|
# Now get the original frontend path from this resource to obtain the GET route
|
||||||
|
routes = iter(resource)
|
||||||
|
route = next(
|
||||||
|
(r for r in routes if r.method == "GET"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if route is not None:
|
||||||
|
if not route.handler or not isinstance(route.handler, partial):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unexpected route handler type %s for /auth/authorize",
|
||||||
|
type(route.handler),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# The original frontend path is the first argument of the handler
|
||||||
|
frontend_path = route.handler.args[0]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get the path to the original frontend resource
|
||||||
|
if frontend_path is None:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Failed to find GET route for /auth/authorize, cannot inject OIDC frontend code"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Inject our new script into the existing frontend code
|
||||||
|
# First fetch the frontend path into memory
|
||||||
|
frontend_code = await read_file(frontend_path)
|
||||||
|
|
||||||
|
# Inject JS and register that route
|
||||||
|
frontend_code = frontend_code.replace(
|
||||||
|
"</body>",
|
||||||
|
"<script src='/auth/oidc/static/injection.js'></script><script>window.sso_name = '"
|
||||||
|
+ sso_name
|
||||||
|
+ "';</script></body>",
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.http.async_register_static_paths(
|
||||||
|
[
|
||||||
|
StaticPathConfig(
|
||||||
|
"/auth/oidc/static/injection.js",
|
||||||
|
hass.config.path("custom_components/auth_oidc/static/injection.js"),
|
||||||
|
cache_headers=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# If everything is succesful, register a fake view that just returns the modified HTML
|
||||||
|
hass.http.register_view(OIDCInjectedAuthPage(frontend_code))
|
||||||
|
_LOGGER.info("Performed OIDC frontend injection")
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCInjectedAuthPage(HomeAssistantView):
|
||||||
|
"""OIDC Plugin Injected Auth Page."""
|
||||||
|
|
||||||
|
requires_auth = False
|
||||||
|
url = PATH
|
||||||
|
name = "auth:oidc:authorize_page"
|
||||||
|
|
||||||
|
def __init__(self, html: str) -> None:
|
||||||
|
"""Initialize the injected auth page."""
|
||||||
|
self.html = html
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def inject(hass: HomeAssistant, sso_name: str) -> None:
|
||||||
|
"""Inject the OIDC auth page into the frontend."""
|
||||||
|
try:
|
||||||
|
await frontend_injection(hass, sso_name)
|
||||||
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
_LOGGER.error("Failed to inject OIDC auth page: %s", e)
|
||||||
|
|
||||||
|
async def get(self, _) -> web.Response:
|
||||||
|
"""Return the screen"""
|
||||||
|
return web.Response(text=self.html, content_type="text/html")
|
||||||
45
custom_components/auth_oidc/static/injection.js
Normal file
45
custom_components/auth_oidc/static/injection.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
function safeSetTextContent(element, value) {
|
||||||
|
if (!element) return
|
||||||
|
var textNode = Array.from(element.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0)
|
||||||
|
if (!textNode) return
|
||||||
|
textNode.textContent = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSSOButton() {
|
||||||
|
const loginHeader = document.querySelector(".card-content > ha-auth-flow > form > h1")
|
||||||
|
safeSetTextContent(loginHeader, "Log in to Home Assistant")
|
||||||
|
|
||||||
|
const codeField = document.querySelector(".mdc-text-field__input[name=code]")
|
||||||
|
const loginButton = document.querySelector("mwc-button:not(.sso)")
|
||||||
|
|
||||||
|
if (codeField) {
|
||||||
|
codeField.placeholder = "One-time code"
|
||||||
|
codeField.autofocus = false
|
||||||
|
codeField.autocomplete = "off"
|
||||||
|
setTimeout(() => {
|
||||||
|
codeField.blur()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ssoButton = document.querySelector("mwc-button.sso")
|
||||||
|
if (!ssoButton) {
|
||||||
|
ssoButton = document.createElement("mwc-button")
|
||||||
|
ssoButton.classList.add("sso")
|
||||||
|
ssoButton.innerText = "Log in with " + window.sso_name
|
||||||
|
ssoButton.setAttribute("raised", "")
|
||||||
|
ssoButton.style.marginRight = "1em"
|
||||||
|
ssoButton.style.display = "none"
|
||||||
|
ssoButton.addEventListener("click", () => {
|
||||||
|
location.href = "/auth/oidc/redirect"
|
||||||
|
})
|
||||||
|
loginButton.parentElement.prepend(ssoButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSetTextContent(loginButton, codeField ? "Log in with code" : "Log in")
|
||||||
|
ssoButton.style.display = codeField ? "" : "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList, observer) => {
|
||||||
|
addSSOButton()
|
||||||
|
})
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true })
|
||||||
@@ -132,6 +132,7 @@ Here's a table of all options that you can set:
|
|||||||
| `features.automatic_person_creation` | `boolean` | No | `true` | Automatically creates a person entry for new user profiles created by this integration. Recommended if you would like to assign presence detection to OIDC users. |
|
| `features.automatic_person_creation` | `boolean` | No | `true` | Automatically creates a person entry for new user profiles created by this integration. Recommended if you would like to assign presence detection to OIDC users. |
|
||||||
| `features.disable_rfc7636` | `boolean`| No | `false` | Disables PKCE (RFC 7636) for OIDC providers that don't support it. You should not need this with most providers. |
|
| `features.disable_rfc7636` | `boolean`| No | `false` | Disables PKCE (RFC 7636) for OIDC providers that don't support it. You should not need this with most providers. |
|
||||||
| `features.include_groups_scope` | `boolean` | No | `true` | Include the 'groups' scope in the OIDC request. Set to `false` to exclude it. |
|
| `features.include_groups_scope` | `boolean` | No | `true` | Include the 'groups' scope in the OIDC request. Set to `false` to exclude it. |
|
||||||
|
| `features.disable_frontend_changes` | `boolean` | No | `false` | Set to `true` to disable all changes made to the HA frontend for better compatbility with future HA versions, or if you are not comfortable with injecting Javascript into the existing frontend code. |
|
||||||
| `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name.
|
| `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name.
|
||||||
| `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username.
|
| `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username.
|
||||||
| `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). |
|
| `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). |
|
||||||
|
|||||||
Reference in New Issue
Block a user