Compare commits
2 Commits
v0.2.0-pre
...
v0.3.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d61861343 | ||
|
|
597d9cdf7d |
@@ -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
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
19
custom_components/auth_oidc/views/base.html
Normal file
19
custom_components/auth_oidc/views/base.html
Normal 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>
|
||||
16
custom_components/auth_oidc/views/error.html
Normal file
16
custom_components/auth_oidc/views/error.html
Normal 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 %}
|
||||
27
custom_components/auth_oidc/views/finish.html
Normal file
27
custom_components/auth_oidc/views/finish.html
Normal 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 %}
|
||||
60
custom_components/auth_oidc/views/loader.py
Normal file
60
custom_components/auth_oidc/views/loader.py
Normal 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
|
||||
29
custom_components/auth_oidc/views/welcome.html
Normal file
29
custom_components/auth_oidc/views/welcome.html
Normal 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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user