2 Commits

Author SHA1 Message Date
Christiaan Goossens
0d61861343 UI Improvements (#7)
* Initial version with UI templates

* Implement basic screens

* Linting & bump to 0.3.0

* Tick off some TODOs
2024-12-27 16:52:32 +01:00
Christiaan Goossens
597d9cdf7d Add reference to poll 2024-12-27 14:23:14 +01:00
16 changed files with 218 additions and 37 deletions

View File

@@ -308,7 +308,7 @@ max-locals=15
max-parents=7
# Maximum number of positional arguments for function / method.
max-positional-arguments=5
#max-positional-arguments=5
# Maximum number of public methods for a class (see R0904).
max-public-methods=20

View File

@@ -55,8 +55,8 @@ Currently, this is a pre-alpha, so I welcome issues but I cannot guarantee I can
- [X] Basic flow
- [X] Implement a final link back to the main page from the finish page
- [ ] Improve welcome screen UI, should render a simple centered Tailwind UI instructing users that you should login externally to obtain a code.
- [ ] Improve finish screen UI, showing the code clearly with a copy button and instructions to paste it into Home Assistant.
- [X] Improve welcome screen UI, should render a simple centered Tailwind UI instructing users that you should login externally to obtain a code.
- [X] Improve finish screen UI, showing the code clearly with instructions to paste it into Home Assistant.
- [ ] Implement error handling on top of this proof of concept (discovery, JWKS, OIDC)
- [ ] Make id_token claim used for the group (admin/user) configurable
- [ ] Make id_token claim used for the username configurable
@@ -65,10 +65,10 @@ Currently, this is a pre-alpha, so I welcome issues but I cannot guarantee I can
- [X] Configure Github Actions to automatically lint and build the package
- [ ] Configure Dependabot for automatic updates
- [ ] Configure tests
- [ ] Consider use of setup UI instead of YAML
- [ ] Consider use of setup UI instead of YAML (see https://github.com/christiaangoossens/hass-oidc-auth/discussions/6)
Currently impossible TODOs (waiting for assistance from HA devs, not possible without forking HA frontend & apps right now):
Currently waiting on HA feature additions:
- [ ] Update the HA frontend code to allow a redirection to be requested from an auth provider instead of manually opening welcome page (possibly after https://github.com/home-assistant/frontend/pull/23204)
- [ ] Implement this redirection logic to open a new tab on desktop (#23204 uses popup)
- [ ] Implement this redirection logic to open a Android Custom Tab (Android) / SFSafariViewController (iOS), instead of opening the link in the HA webview
- [ ] Implement this redirection logic to open a Android Custom Tab (Android) / SFSafariViewController (iOS), instead of opening the link in the HA webview

View File

@@ -4,7 +4,7 @@ from homeassistant.components.http import HomeAssistantView
from aiohttp import web
from ..oidc_client import OIDCClient
from ..provider import OpenIDAuthProvider
from ..helpers import get_url
from ..helpers import get_url, get_view
PATH = "/auth/oidc/callback"
@@ -30,21 +30,29 @@ class OIDCCallbackView(HomeAssistantView):
state = params.get("state")
if not (code and state):
return web.Response(
headers={"content-type": "text/html"},
text="<h1>Error</h1><p>Missing code or state parameter</p>",
view_html = await get_view(
"error",
{
"error": "Missing code or state parameter.",
"link": get_url("/auth/oidc/redirect"),
},
)
return web.Response(text=view_html, content_type="text/html")
redirect_uri = get_url("/auth/oidc/callback")
user_details = await self.oidc_client.async_complete_token_flow(
redirect_uri, code, state
)
if user_details is None:
return web.Response(
headers={"content-type": "text/html"},
text="<h1>Error</h1><p>Failed to get user details, see console.</p>",
view_html = await get_view(
"error",
{
"error": "Failed to get user details, "
+ "see Home Assistant logs for more information.",
"link": get_url("/auth/oidc/redirect"),
},
)
return web.Response(text=view_html, content_type="text/html")
code = await self.oidc_provider.async_save_user_info(user_details)
return web.HTTPFound(get_url("/auth/oidc/finish?code=" + code))

View File

@@ -2,8 +2,7 @@
from homeassistant.components.http import HomeAssistantView
from aiohttp import web
from ..helpers import get_url
from ..helpers import get_view, get_url
PATH = "/auth/oidc/finish"
@@ -21,18 +20,16 @@ class OIDCFinishView(HomeAssistantView):
code = request.query.get("code", "FAIL")
link = get_url("/")
view_html = await get_view("finish", {"code": code, "link": link})
return web.Response(
headers={
"content-type": "text/html",
# Set a cookie to enable autologin on only the specific path used
# for the POST request, with all strict parameters set
# This cookie should not be read by any Javascript or any other paths.
"set-cookie": "auth_oidc_code="
+ code
+ "; Path=/auth/login_flow; SameSite=Strict; HttpOnly; Max-Age=300",
},
text=f"<h1>Done!</h1><p>Your code is: <b>{code}</b></p>"
+ "<p>Please return to the Home Assistant login "
+ "screen (or your mobile app) and fill in this code into the single login field. "
+ "It should be visible if you "
+ "select 'Login with OpenID Connect (SSO)'.</p><p><a href='"
+ link
+ "'>Click here to login automatically (on desktop).</a></p>",
text=view_html,
)

View File

@@ -5,7 +5,7 @@ from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from ..oidc_client import OIDCClient
from ..helpers import get_url
from ..helpers import get_url, get_view
PATH = "/auth/oidc/redirect"
@@ -29,10 +29,14 @@ class OIDCRedirectView(HomeAssistantView):
if auth_url:
return web.HTTPFound(auth_url)
return web.Response(
headers={"content-type": "text/html"},
text="<h1>Plugin is misconfigured, discovery could not be obtained</h1>",
view_html = await get_view(
"error",
{
"error": "Integration is misconfigured, discovery could not be obtained.",
"link": get_url("/auth/oidc/redirect"),
},
)
return web.Response(text=view_html, content_type="text/html")
async def post(self, request: web.Request) -> web.Response:
"""POST"""

View File

@@ -2,6 +2,7 @@
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from ..helpers import get_view
PATH = "/auth/oidc/welcome"
@@ -15,8 +16,5 @@ class OIDCWelcomeView(HomeAssistantView):
async def get(self, _: web.Request) -> web.Response:
"""Receive response."""
return web.Response(
headers={"content-type": "text/html"},
text="<h1>OIDC Login</h1><p><a href='/auth/oidc/redirect'>Login with OIDC</a></p>",
)
view_html = await get_view("welcome")
return web.Response(text=view_html, content_type="text/html")

View File

@@ -1,6 +1,7 @@
"""Helper functions for the integration."""
from homeassistant.components import http
from .views.loader import AsyncTemplateRenderer
def get_url(path: str) -> str:
@@ -10,3 +11,12 @@ def get_url(path: str) -> str:
base_uri = str(req.url).split("/auth", 2)[0]
return f"{base_uri}{path}"
async def get_view(template: str, parameters: dict | None = None) -> str:
"""Returns the generated HTML of the requested view."""
if parameters is None:
parameters = {}
renderer = AsyncTemplateRenderer()
return await renderer.render_template(f"{template}.html", **parameters)

View File

@@ -14,7 +14,9 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/christiaangoossens/hass-oidc-auth/issues",
"requirements": [
"python-jose>=3.3.0"
"python-jose>=3.3.0",
"aiofiles>=24.1.0",
"jinja2>=3.1.4"
],
"version": "0.2.0"
"version": "0.3.0"
}

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" class="h-full min-h-full max-h-full">
<head>
{% block head %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
{% endblock %}
</head>
<body class="bg-gray-200 flex items-center justify-center h-full">
<div class="bg-white p-6 rounded-lg shadow-lg max-w-md">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Oops!{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">Login failed.</h1>
<p class="mb-4">{{ error }}</p>
<div class="my-6">
<a href='{{ link }}'
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">Try
again</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Logged in!{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block content %}
<div class="text-center">
<div class="my-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800">I want to login to this browser</h2>
<a href='{{ link }}'
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">Click
here to login automatically</a>
</div>
<hr class="my-12">
<div class="my-6">
<h2 class="text-xl font-semibold mb-4 text-gray-800">I am on a mobile device</h2>
<p class="mb-4">Your one-time code is: <b class="text-blue-600 text-xl">{{ code }}</b></p>
<p class="mb-4 text-sm">You have 5 minutes to use this code on any device.<br />The code can only
be used once.</p>
<p class="mb-4 text-sm">Please type the code into your app manually. If you don't see a code input, select
'Login with
OpenID Connect (SSO)' first.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
"""Jinja2 Async Environment"""
import logging
from os import path
from typing import Dict, Any
from jinja2 import Environment, DictLoader
from aiofiles.os import scandir as async_scandir
from aiofiles import open as async_open
_LOGGER = logging.getLogger(__name__)
templates: Dict[str, str] = {}
class AsyncTemplateRenderer:
"""An asynchronous template renderer that caches rendered templates."""
def __init__(self, template_dir: str = None):
self.template_dir = template_dir or path.dirname(path.abspath(__file__))
async def fetch_templates(self) -> None:
"""Fetches all HTML files from the template directory."""
templates.clear()
files = await async_scandir(self.template_dir)
for file in files:
if file.is_dir():
continue
filename = file.name
if filename.endswith(".html"):
template_path = path.join(self.template_dir, filename)
try:
_LOGGER.debug("Fetching template %s from disk", filename)
async with async_open(
template_path, mode="r", encoding="utf-8"
) as f:
content = await f.read()
templates[filename] = content
except (OSError, IOError) as e:
_LOGGER.warning("Error reading template file %s: %s", filename, e)
async def render_template(self, template_name: str, **kwargs: Any) -> str:
"""Renders a template with the given parameters."""
if not templates:
await (
self.fetch_templates()
) # If the templates haven't been fetched, fetch them
if template_name not in templates:
raise ValueError(f"Template '{template_name}' not found.")
env = Environment(loader=DictLoader(templates), enable_async=True)
template = env.get_template(template_name)
# Render template
rendered_output = await template.render_async(**kwargs)
return rendered_output

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}OIDC Login{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block content %}
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">Home Assistant</h1>
<p class="mb-4">You have been invited to login to Home Assistant.<br />Start the login process below.</p>
<button id="oidc-login-btn"
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">
Login with OpenID Connect (SSO)
</button>
<p class="mt-6 text-sm">After login, you will be granted a one-time code to login to any device. You may complete
this login on your desktop or any mobile browser and then use the token for any desktop or the Home Assistant
app.</p>
</div>
<script>
document.getElementById('oidc-login-btn').addEventListener('click', function () {
this.innerHTML = 'Redirecting...';
this.disabled = true;
this.classList.add('bg-gray-500');
window.location.href = '/auth/oidc/redirect';
});
</script>
{% endblock %}

View File

@@ -1,6 +1,6 @@
[project]
name = "hass-auth-oidc"
version = "0.2.0"
version = "0.3.0"
description = "OIDC component for Home Assistant"
authors = [
{ name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" }
@@ -8,6 +8,8 @@ authors = [
license = "MIT"
dependencies = [
"python-jose>=3.3.0",
"aiofiles>=24.1.0",
"jinja2>=3.1.4",
]
readme = "README.md"
requires-python = ">= 3.13"

View File

@@ -14,6 +14,8 @@ acme==3.0.1
# via hass-nabucasa
aiodns==3.2.0
# via homeassistant
aiofiles==24.1.0
# via hass-auth-oidc
aiohappyeyeballs==2.4.4
# via aiohttp
aiohasupervisor==0.2.1
@@ -145,6 +147,7 @@ ifaddr==0.2.0
isort==5.13.2
# via pylint
jinja2==3.1.4
# via hass-auth-oidc
# via homeassistant
jmespath==1.0.1
# via boto3
@@ -206,7 +209,7 @@ pyric==0.1.6.3
python-dateutil==2.9.0.post0
# via botocore
python-jose==3.3.0
# via hass-oidc
# via hass-auth-oidc
python-slugify==8.0.4
# via homeassistant
pytz==2024.2

View File

@@ -10,13 +10,19 @@
# universal: false
-e file:.
aiofiles==24.1.0
# via hass-auth-oidc
ecdsa==0.19.0
# via python-jose
jinja2==3.1.5
# via hass-auth-oidc
markupsafe==3.0.2
# via jinja2
pyasn1==0.6.1
# via python-jose
# via rsa
python-jose==3.3.0
# via hass-oidc
# via hass-auth-oidc
rsa==4.9
# via python-jose
six==1.17.0