Reimplement UI injection (#236)
This commit is contained in:
committed by
GitHub
parent
fdc93e2719
commit
fd3643685d
@@ -27,7 +27,6 @@ from .config import (
|
||||
ROLES,
|
||||
NETWORK,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE,
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION,
|
||||
FEATURES_FORCE_HTTPS,
|
||||
REQUIRED_SCOPES,
|
||||
)
|
||||
@@ -40,6 +39,7 @@ from .endpoints import (
|
||||
OIDCFinishView,
|
||||
OIDCCallbackView,
|
||||
OIDCInjectedAuthPage,
|
||||
OIDCDeviceSSE,
|
||||
)
|
||||
from .tools.oidc_client import OIDCClient
|
||||
from .provider import OpenIDAuthProvider
|
||||
@@ -96,6 +96,10 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
|
||||
provider = OpenIDAuthProvider(hass, hass.auth._store, my_config)
|
||||
|
||||
providers[(provider.type, provider.id)] = provider
|
||||
|
||||
# Get current provider count
|
||||
has_other_auth_providers = len(hass.auth._providers) > 0
|
||||
|
||||
providers.update(hass.auth._providers)
|
||||
hass.auth._providers = providers
|
||||
# pylint: enable=protected-access
|
||||
@@ -137,33 +141,22 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
|
||||
)
|
||||
|
||||
# Register the views
|
||||
is_frontend_injection_enabled = (
|
||||
features_config.get(FEATURES_DISABLE_FRONTEND_INJECTION, False) is False
|
||||
)
|
||||
name = display_name
|
||||
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
|
||||
|
||||
force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
|
||||
|
||||
hass.http.register_view(
|
||||
OIDCWelcomeView(
|
||||
name,
|
||||
# Welcome view is not enabled if frontend injection is enabled
|
||||
not is_frontend_injection_enabled,
|
||||
force_https,
|
||||
OIDCWelcomeView(provider, name, force_https, has_other_auth_providers)
|
||||
)
|
||||
)
|
||||
hass.http.register_view(OIDCRedirectView(oidc_client, force_https))
|
||||
hass.http.register_view(OIDCDeviceSSE(provider))
|
||||
hass.http.register_view(OIDCRedirectView(oidc_client, provider, force_https))
|
||||
hass.http.register_view(OIDCCallbackView(oidc_client, provider, force_https))
|
||||
hass.http.register_view(OIDCFinishView())
|
||||
hass.http.register_view(OIDCFinishView(provider))
|
||||
|
||||
_LOGGER.info("Registered OIDC views")
|
||||
|
||||
# Inject OIDC code into the frontend for /auth/authorize if the user has the
|
||||
# frontend injection feature enabled
|
||||
if is_frontend_injection_enabled:
|
||||
await OIDCInjectedAuthPage.inject(hass, name)
|
||||
else:
|
||||
_LOGGER.info("OIDC frontend changes are disabled, skipping injection")
|
||||
# Inject OIDC code into the frontend for /auth/authorize for automatic redirect
|
||||
await OIDCInjectedAuthPage.inject(hass)
|
||||
|
||||
return True
|
||||
|
||||
@@ -28,7 +28,6 @@ FEATURES_AUTOMATIC_USER_LINKING = "automatic_user_linking"
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
|
||||
FEATURES_DISABLE_PKCE = "disable_rfc7636"
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION = "disable_frontend_changes"
|
||||
FEATURES_FORCE_HTTPS = "force_https"
|
||||
CLAIMS = "claims"
|
||||
CLAIMS_DISPLAY_NAME = "display_name"
|
||||
|
||||
@@ -14,7 +14,6 @@ from .const import (
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION,
|
||||
FEATURES_DISABLE_PKCE,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE,
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION,
|
||||
FEATURES_FORCE_HTTPS,
|
||||
CLAIMS,
|
||||
CLAIMS_DISPLAY_NAME,
|
||||
@@ -72,10 +71,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE, default=True
|
||||
): vol.Coerce(bool),
|
||||
# Disable frontend injection of OIDC login button
|
||||
vol.Optional(
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION, default=False
|
||||
): vol.Coerce(bool),
|
||||
# Force HTTPS on all generated URLs (like redirect_uri)
|
||||
vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce(
|
||||
bool
|
||||
|
||||
@@ -621,21 +621,18 @@ class OIDCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["client_id"] = "invalid_client_id"
|
||||
return errors, None
|
||||
|
||||
# Determine confidentiality by presence of client secret
|
||||
client_secret = user_input.get(CONF_CLIENT_SECRET, "").strip()
|
||||
# If secret is empty, keep the existing one (if any)
|
||||
if not client_secret:
|
||||
client_secret = entry.data.get("client_secret")
|
||||
|
||||
# Build updated data
|
||||
data_updates = {"client_id": client_id}
|
||||
|
||||
# The optional secret field is submitted explicitly when the form is used.
|
||||
# An empty value means the user wants to keep the existing secret.
|
||||
if CONF_CLIENT_SECRET in user_input:
|
||||
client_secret = user_input.get(CONF_CLIENT_SECRET, "").strip()
|
||||
|
||||
if client_secret:
|
||||
data_updates["client_secret"] = client_secret
|
||||
elif "client_secret" in entry.data and not client_secret:
|
||||
# Remove client secret if switching from confidential to public
|
||||
data_updates = {**entry.data, **data_updates}
|
||||
data_updates.pop("client_secret", None)
|
||||
elif "client_secret" in entry.data:
|
||||
data_updates["client_secret"] = entry.data["client_secret"]
|
||||
|
||||
return errors, data_updates
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ from .finish import OIDCFinishView as OIDCFinishView
|
||||
from .injected_auth_page import OIDCInjectedAuthPage as OIDCInjectedAuthPage
|
||||
from .redirect import OIDCRedirectView as OIDCRedirectView
|
||||
from .welcome import OIDCWelcomeView as OIDCWelcomeView
|
||||
from .device_sse import OIDCDeviceSSE as OIDCDeviceSSE
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.components.http import HomeAssistantView
|
||||
from aiohttp import web
|
||||
from ..tools.oidc_client import OIDCClient
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import get_url, get_view
|
||||
from ..tools.helpers import error_response, get_url, get_valid_state_id
|
||||
|
||||
PATH = "/auth/oidc/callback"
|
||||
|
||||
@@ -29,42 +29,49 @@ class OIDCCallbackView(HomeAssistantView):
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
# Get the OIDC query parameters
|
||||
params = request.rel_url.query
|
||||
code = params.get("code")
|
||||
state = params.get("state")
|
||||
|
||||
if not (code and state):
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "Missing code or state parameter.",
|
||||
},
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
return await error_response("Missing code or state parameter.")
|
||||
|
||||
# Check if the states match
|
||||
if state != state_id:
|
||||
return await error_response(
|
||||
"State parameter does not match, possible CSRF attack."
|
||||
)
|
||||
|
||||
# Complete the OIDC flow to get user details
|
||||
redirect_uri = get_url("/auth/oidc/callback", self.force_https)
|
||||
user_details = await self.oidc_client.async_complete_token_flow(
|
||||
redirect_uri, code, state
|
||||
)
|
||||
if user_details is None:
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "Failed to get user details, "
|
||||
+ "see Home Assistant logs for more information.",
|
||||
},
|
||||
return await error_response(
|
||||
"Failed to get user details, see Home Assistant logs for more information.",
|
||||
status=500,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
if user_details.get("role") == "invalid":
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "User is not in the correct group to access Home Assistant, "
|
||||
return await error_response(
|
||||
"User is not in the correct group to access Home Assistant, "
|
||||
+ "contact your administrator!",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
code = await self.oidc_provider.async_save_user_info(user_details)
|
||||
raise web.HTTPFound(get_url("/auth/oidc/finish?code=" + code, self.force_https))
|
||||
# Finalize on the state
|
||||
success = await self.oidc_provider.async_save_user_info(state_id, user_details)
|
||||
if not success:
|
||||
return await error_response(
|
||||
"Failed to save user information, session probably expired. Please sign in again.",
|
||||
status=500,
|
||||
)
|
||||
|
||||
raise web.HTTPFound(get_url("/auth/oidc/finish", self.force_https))
|
||||
|
||||
70
custom_components/auth_oidc/endpoints/device_sse.py
Normal file
70
custom_components/auth_oidc/endpoints/device_sse.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""SSE handler for OIDC device authentication."""
|
||||
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import get_valid_state_id
|
||||
|
||||
PATH = "/auth/oidc/device-sse"
|
||||
|
||||
|
||||
class OIDCDeviceSSE(HomeAssistantView):
|
||||
"""OIDC Plugin SSE Handler."""
|
||||
|
||||
requires_auth = False
|
||||
url = PATH
|
||||
name = "auth:oidc:device-sse"
|
||||
|
||||
def __init__(self, oidc_provider: OpenIDAuthProvider) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
|
||||
async def get(self, req: web.Request) -> web.Response:
|
||||
"""Check for mobile sign-in completion with short server-side polling."""
|
||||
state_id = await get_valid_state_id(req, self.oidc_provider)
|
||||
if not state_id:
|
||||
raise web.HTTPBadRequest(text="Missing session cookie")
|
||||
|
||||
timeout_seconds = 300
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(req)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if (
|
||||
await self.oidc_provider.async_get_redirect_uri_for_state(state_id)
|
||||
is None
|
||||
):
|
||||
await response.write(b"event: expired\ndata: false\n\n")
|
||||
break
|
||||
|
||||
ready = await self.oidc_provider.async_is_state_ready(state_id)
|
||||
if ready:
|
||||
await response.write(b"event: ready\ndata: true\n\n")
|
||||
break
|
||||
|
||||
if asyncio.get_running_loop().time() - started_at >= timeout_seconds:
|
||||
await response.write(b"event: timeout\ndata: false\n\n")
|
||||
break
|
||||
|
||||
await response.write(b"event: waiting\ndata: false\n\n")
|
||||
await asyncio.sleep(0.5)
|
||||
except (ConnectionResetError, RuntimeError):
|
||||
# Client disconnected while listening for state changes.
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await response.write_eof()
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
|
||||
return response
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from aiohttp import web
|
||||
from ..tools.helpers import get_view
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import (
|
||||
error_response,
|
||||
get_valid_state_id,
|
||||
template_response,
|
||||
)
|
||||
|
||||
PATH = "/auth/oidc/finish"
|
||||
|
||||
@@ -14,41 +19,62 @@ class OIDCFinishView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:finish"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Show the finish screen to allow the user to view their code."""
|
||||
"""Show the finish screen to pick between login & device code."""
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
code = request.query.get("code")
|
||||
|
||||
if not code:
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{"error": "Missing code to show the finish screen."},
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
view_html = await get_view("finish", {"code": code})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
return await template_response("finish", {})
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
# Get code from the message body
|
||||
data = await request.post()
|
||||
code = data.get("code")
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
if not code:
|
||||
return web.Response(text="No code received", status=500)
|
||||
|
||||
# Return redirect to the main page for sign in with a cookie
|
||||
raise web.HTTPFound(
|
||||
location="/?storeToken=true",
|
||||
headers={
|
||||
# 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.
|
||||
# It can be really short lifetime as we redirect immediately (5 seconds)
|
||||
"set-cookie": "auth_oidc_code="
|
||||
+ code
|
||||
+ "; Path=/auth/login_flow; SameSite=Strict; HttpOnly; Max-Age=5",
|
||||
},
|
||||
# Get redirect_uri from the state
|
||||
redirect_uri = await self.oidc_provider.async_get_redirect_uri_for_state(
|
||||
state_id
|
||||
)
|
||||
|
||||
if not redirect_uri:
|
||||
return await error_response("Invalid state, please restart login.")
|
||||
|
||||
# Get the message body
|
||||
data = await request.post()
|
||||
device_code = data.get("device_code")
|
||||
|
||||
# We are trying sign-in on this browser
|
||||
if not device_code:
|
||||
# Add to the URL correctly (also handle case where it's just the root)
|
||||
separator = "?"
|
||||
if "?" in redirect_uri:
|
||||
separator = "&"
|
||||
|
||||
# Redirect to this new URL for login
|
||||
new_url = (
|
||||
redirect_uri + separator + "storeToken=true&skip_oidc_redirect=true"
|
||||
)
|
||||
raise web.HTTPFound(location=new_url)
|
||||
|
||||
# Check if we can link this device
|
||||
linked = await self.oidc_provider.async_link_state_to_code(
|
||||
state_id, device_code
|
||||
)
|
||||
|
||||
if not linked:
|
||||
return await error_response(
|
||||
"Failed to link state to device code, please restart login."
|
||||
)
|
||||
|
||||
return await template_response("device_success", {})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Injected authorization page, replacing the original"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
|
||||
@@ -19,7 +18,7 @@ async def read_file(path: str) -> str:
|
||||
return await f.read()
|
||||
|
||||
|
||||
async def frontend_injection(hass: HomeAssistant, sso_name: str) -> None:
|
||||
async def frontend_injection(hass: HomeAssistant) -> None:
|
||||
"""Inject new frontend code into /auth/authorize."""
|
||||
router = hass.http.app.router
|
||||
frontend_path = None
|
||||
@@ -62,11 +61,8 @@ async def frontend_injection(hass: HomeAssistant, sso_name: str) -> None:
|
||||
frontend_code = await read_file(frontend_path)
|
||||
|
||||
# Inject JS and register that route
|
||||
injection_js = "<script src='/auth/oidc/static/injection.js?v=3'></script>"
|
||||
sso_name_js = f"<script>window.sso_name = {json.dumps(sso_name)};</script>"
|
||||
frontend_code = frontend_code.replace(
|
||||
"</body>", f"{injection_js}{sso_name_js}</body>"
|
||||
)
|
||||
injection_js = "<script src='/auth/oidc/static/injection.js?v=4'></script>"
|
||||
frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>")
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
@@ -100,10 +96,10 @@ class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
self.html = html
|
||||
|
||||
@staticmethod
|
||||
async def inject(hass: HomeAssistant, sso_name: str) -> None:
|
||||
async def inject(hass: HomeAssistant) -> None:
|
||||
"""Inject the OIDC auth page into the frontend."""
|
||||
try:
|
||||
await frontend_injection(hass, sso_name)
|
||||
await frontend_injection(hass)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error("Failed to inject OIDC auth page: %s", e)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Redirect route to redirect the user to the external OIDC server,
|
||||
can either be linked to directly or accessed through the welcome page."""
|
||||
|
||||
from urllib.parse import quote
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.oidc_client import OIDCClient
|
||||
from ..tools.helpers import get_url, get_view
|
||||
from ..tools.helpers import error_response, get_url, get_valid_state_id, get_view
|
||||
|
||||
PATH = "/auth/oidc/redirect"
|
||||
|
||||
@@ -17,28 +19,44 @@ class OIDCRedirectView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:redirect"
|
||||
|
||||
def __init__(self, oidc_client: OIDCClient, force_https: bool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
oidc_client: OIDCClient,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
) -> None:
|
||||
self.oidc_client = oidc_client
|
||||
self.oidc_provider = oidc_provider
|
||||
self.force_https = force_https
|
||||
|
||||
async def get(self, _: web.Request) -> web.Response:
|
||||
async def get(self, req: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(req, self.oidc_provider)
|
||||
|
||||
if not state_id:
|
||||
# Direct access to the redirect endpoint, go to welcome page instead
|
||||
welcome_url = get_url("/auth/oidc/welcome", self.force_https)
|
||||
raise web.HTTPFound(welcome_url)
|
||||
|
||||
try:
|
||||
redirect_uri = get_url("/auth/oidc/callback", self.force_https)
|
||||
auth_url = await self.oidc_client.async_get_authorization_url(redirect_uri)
|
||||
auth_url = await self.oidc_client.async_get_authorization_url(
|
||||
redirect_uri, state_id
|
||||
)
|
||||
|
||||
if auth_url:
|
||||
raise web.HTTPFound(auth_url)
|
||||
view_html = await get_view("redirect", {"url": quote(auth_url)})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{"error": "Integration is misconfigured, discovery could not be obtained."},
|
||||
return await error_response(
|
||||
"Integration is misconfigured, discovery could not be obtained.",
|
||||
status=500,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
async def post(self, req: web.Request) -> web.Response:
|
||||
"""POST"""
|
||||
return await self.get(request)
|
||||
return await self.get(req)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Welcome route to show the user the OIDC login button and give instructions."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from ..tools.helpers import get_url, get_view
|
||||
from ..tools.helpers import error_response, get_url, template_response
|
||||
from ..provider import OpenIDAuthProvider
|
||||
|
||||
PATH = "/auth/oidc/welcome"
|
||||
|
||||
@@ -14,16 +18,90 @@ class OIDCWelcomeView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:welcome"
|
||||
|
||||
def __init__(self, name: str, is_enabled: bool, force_https: bool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
name: str,
|
||||
force_https: bool,
|
||||
has_other_auth_providers: bool,
|
||||
) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
self.name = name
|
||||
self.is_enabled = is_enabled
|
||||
self.force_https = force_https
|
||||
self.has_other_auth_providers = has_other_auth_providers
|
||||
|
||||
async def get(self, _: web.Request) -> web.Response:
|
||||
def determine_if_mobile(self, redirect_uri: str) -> bool:
|
||||
"""Determine if the client is a mobile client based on the redirect_uri."""
|
||||
oauth2_url = urlparse(redirect_uri)
|
||||
client_id = parse_qs(oauth2_url.query).get("client_id")
|
||||
|
||||
# If the client_id starts with https://home-assistant.io/ we assume it's a mobile client
|
||||
return bool(client_id and client_id[0].startswith("https://home-assistant.io/"))
|
||||
|
||||
async def get(self, req: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
if not self.is_enabled:
|
||||
raise web.HTTPTemporaryRedirect(get_url("/", self.force_https))
|
||||
# Get the query parameter with the redirect_uri
|
||||
redirect_uri = req.query.get("redirect_uri")
|
||||
|
||||
view_html = await get_view("welcome", {"name": self.name})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
# If set, determine if this is a mobile client based on the redirect_uri,
|
||||
# otherwise assume it's not mobile
|
||||
if redirect_uri:
|
||||
try:
|
||||
# decodeURIComponent(btoa(...)) -> unquote first, then base64 decode
|
||||
redirect_uri = base64.b64decode(
|
||||
unquote(redirect_uri), validate=True
|
||||
).decode("utf-8")
|
||||
is_mobile = self.determine_if_mobile(redirect_uri)
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
return await error_response(
|
||||
"Invalid redirect_uri, please restart login."
|
||||
)
|
||||
else:
|
||||
# Backwards compatibility with older versions that directly go to /auth/oidc/welcome
|
||||
# If not set, redirect back to the main page and assume that this is a web client
|
||||
redirect_uri = get_url("/", self.force_https)
|
||||
is_mobile = False
|
||||
|
||||
# Create OIDC state with the redirect_uri so we can use it later in the flow
|
||||
state_id = await self.oidc_provider.async_create_state(redirect_uri)
|
||||
cookie_header = self.oidc_provider.get_cookie_header(
|
||||
state_id, secure=self.force_https or req.url.scheme == "https"
|
||||
)
|
||||
|
||||
# If this is the only provider and we are on desktop,
|
||||
# automatically go through the OIDC login
|
||||
if not is_mobile and not self.has_other_auth_providers:
|
||||
raise web.HTTPFound(
|
||||
location=get_url("/auth/oidc/redirect", self.force_https),
|
||||
headers=cookie_header,
|
||||
)
|
||||
|
||||
# Otherwise display the screen with either mobile sign in or the buttons
|
||||
# First generate code if mobile
|
||||
code = None
|
||||
if is_mobile:
|
||||
# Create a code to login
|
||||
code = await self.oidc_provider.async_generate_device_code(state_id)
|
||||
if not code:
|
||||
return await error_response(
|
||||
"Failed to generate device code, please restart login.",
|
||||
status=500,
|
||||
)
|
||||
|
||||
# And add the other link if we have other auth providers
|
||||
other_link = None
|
||||
if self.has_other_auth_providers:
|
||||
other_link = get_url("/?skip_oidc_redirect=true", self.force_https)
|
||||
|
||||
# And display
|
||||
response = await template_response(
|
||||
"welcome",
|
||||
{
|
||||
"name": self.name,
|
||||
"other_link": other_link,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
response.headers.update(cookie_header)
|
||||
return response
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.components import http, person
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import voluptuous as vol
|
||||
|
||||
from .config.const import (
|
||||
FEATURES,
|
||||
@@ -30,13 +29,14 @@ from .config.const import (
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION,
|
||||
DEFAULT_TITLE,
|
||||
)
|
||||
from .stores.code_store import CodeStore
|
||||
from .stores.state_store import StateStore
|
||||
from .tools.types import UserDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE = "auth_oidc"
|
||||
HASS_PROVIDER_TYPE = "homeassistant"
|
||||
COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
@@ -68,7 +68,7 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
)
|
||||
|
||||
self._user_meta: dict[UserDetails] = {}
|
||||
self._code_store: CodeStore | None = None
|
||||
self._state_store: StateStore | None = None
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
features = config.get(
|
||||
@@ -89,29 +89,120 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
|
||||
# Init the code store first
|
||||
# Init the store first
|
||||
# Use the same technique as the HomeAssistant auth provider for storage
|
||||
# (/auth/providers/homeassistant.py#L392)
|
||||
async with self._init_lock:
|
||||
if self._code_store is not None:
|
||||
if self._state_store is not None:
|
||||
return
|
||||
|
||||
store = CodeStore(self.hass)
|
||||
store = StateStore(self.hass)
|
||||
await store.async_load()
|
||||
self._code_store = store
|
||||
self._state_store = store
|
||||
self._user_meta = {}
|
||||
|
||||
# Listen for user creation events
|
||||
self.hass.bus.async_listen(EVENT_USER_ADDED, self.async_user_created)
|
||||
|
||||
async def async_get_subject(self, code: str) -> Optional[str]:
|
||||
"""Retrieve user from the code, return subject and save meta
|
||||
for later use with this provider instance."""
|
||||
if self._code_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._code_store is not None
|
||||
def _resolve_ip(self, ip: str | None = None) -> str | None:
|
||||
"""Resolve client IP from explicit input or current request context."""
|
||||
if ip:
|
||||
return ip
|
||||
|
||||
user_data = await self._code_store.receive_userinfo_for_code(code)
|
||||
req = http.current_request.get()
|
||||
if req and req.remote:
|
||||
return req.remote
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_state(self, redirect_uri: str, ip: str | None = None) -> str:
|
||||
"""Create a new OIDC state and return the state id."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_create_state_from_url(
|
||||
redirect_uri, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_generate_device_code(self, state_id: str) -> Optional[str]:
|
||||
"""Generate a device code for the state, used for device login."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_generate_code_for_state(state_id)
|
||||
|
||||
async def async_save_user_info(
|
||||
self, state_id: str, user_info: dict[str, dict | str]
|
||||
) -> bool:
|
||||
"""Save user info to the given state."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_add_userinfo_to_state(state_id, user_info)
|
||||
|
||||
async def async_get_redirect_uri_for_state(
|
||||
self, state_id: str, ip: str | None = None
|
||||
) -> Optional[str]:
|
||||
"""Get the redirect_uri for the given state."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_get_redirect_uri_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_is_state_valid(self, state_id: str, ip: str | None = None) -> bool:
|
||||
"""Check if a state exists, belongs to this IP, and is not expired."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return (
|
||||
await self._state_store.async_get_redirect_uri_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
async def async_is_state_ready(self, state_id: str, ip: str | None = None) -> bool:
|
||||
"""Check if the state has received the user info from the OIDC callback."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_is_state_ready(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_link_state_to_code(
|
||||
self, state_id: str, code: str, ip: str | None = None
|
||||
) -> bool:
|
||||
"""Link two states together by copying the user info from one to the other."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_link_state_to_code(
|
||||
state_id, code, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_get_subject(
|
||||
self, state_id: str, ip: str | None = None
|
||||
) -> Optional[str]:
|
||||
"""Retrieve user from the state_id, return subject and save meta
|
||||
for later use with this provider instance."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
# This also deletes the state as we are using it for sign-in
|
||||
user_data = await self._state_store.async_receive_userinfo_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
if user_data is None:
|
||||
return None
|
||||
|
||||
@@ -119,14 +210,6 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
self._user_meta[sub] = user_data
|
||||
return sub
|
||||
|
||||
async def async_save_user_info(self, user_info: dict[str, dict | str]) -> str:
|
||||
"""Save user info and return a code."""
|
||||
if self._code_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._code_store is not None
|
||||
|
||||
return await self._code_store.async_generate_code_for_userinfo(user_info)
|
||||
|
||||
async def _async_find_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Find a user by username."""
|
||||
users = await self.store.async_get_users()
|
||||
@@ -145,6 +228,18 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
|
||||
return None
|
||||
|
||||
def get_cookie_header(self, state_id: str, secure: bool = False):
|
||||
"""Get the cookie header to set the state_id cookie."""
|
||||
secure_flag = "; Secure" if secure else ""
|
||||
return {
|
||||
# Set a cookie for the other pages to know the state_id
|
||||
# Keep cookie lifetime aligned with state lifetime in storage (5 minutes).
|
||||
"set-cookie": f"{COOKIE_NAME}="
|
||||
+ state_id
|
||||
+ "; Path=/auth/; SameSite=Strict; HttpOnly; Max-Age=300"
|
||||
+ secure_flag,
|
||||
}
|
||||
|
||||
# ====
|
||||
# Handler for user created and related functions (person creation)
|
||||
# ====
|
||||
@@ -271,7 +366,7 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
class OpenIdLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def _finalize_user(self, code: str) -> AuthFlowResult:
|
||||
async def _finalize_user(self, state_id: str) -> AuthFlowResult:
|
||||
# Verify a dummy hash to make it last a bit longer
|
||||
# as security measure (limits the amount of attempts you have in 5 min)
|
||||
# Similar to what the HomeAssistant auth provider does
|
||||
@@ -280,7 +375,7 @@ class OpenIdLoginFlow(LoginFlow):
|
||||
|
||||
# Actually look up the auth provider after,
|
||||
# this doesn't take a lot of time (regardless of it's in there or not)
|
||||
sub = await self._auth_provider.async_get_subject(code)
|
||||
sub = await self._auth_provider.async_get_subject(state_id)
|
||||
if sub:
|
||||
return await self.async_finish(
|
||||
{
|
||||
@@ -290,54 +385,23 @@ class OpenIdLoginFlow(LoginFlow):
|
||||
|
||||
raise InvalidAuthError
|
||||
|
||||
def _show_login_form(
|
||||
self, errors: Optional[dict[str, str]] = None
|
||||
) -> AuthFlowResult:
|
||||
if errors is None:
|
||||
errors = {}
|
||||
|
||||
# Show the login form
|
||||
# Abuses the MFA form, as it works better for our usecase
|
||||
# UI suggestions are welcome (make a PR!)
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("code"): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> AuthFlowResult:
|
||||
"""Handle the step of the form."""
|
||||
|
||||
# Try to use the user input first
|
||||
if user_input is not None and "code" in user_input:
|
||||
try:
|
||||
return await self._finalize_user(user_input["code"])
|
||||
except InvalidAuthError:
|
||||
return self._show_login_form({"base": "invalid_auth"})
|
||||
|
||||
# If not available, check the cookie
|
||||
# Check if the cookie is present to login
|
||||
req = http.current_request.get()
|
||||
if req and req.cookies:
|
||||
code_cookie = req.cookies.get("auth_oidc_code")
|
||||
state_cookie = req.cookies.get(COOKIE_NAME)
|
||||
|
||||
if code_cookie:
|
||||
_LOGGER.debug("Code cookie found on login: %s", code_cookie)
|
||||
if state_cookie:
|
||||
_LOGGER.debug("State cookie found on login: %s", state_cookie)
|
||||
try:
|
||||
return await self._finalize_user(code_cookie)
|
||||
return await self._finalize_user(state_cookie)
|
||||
except InvalidAuthError:
|
||||
pass
|
||||
|
||||
# If none are available, just show the form
|
||||
return self._show_login_form()
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> AuthFlowResult:
|
||||
# This is a dummy step function just to use the nicer MFA UI instead
|
||||
return await self.async_step_init(user_input)
|
||||
# If no cookie is found, abort.
|
||||
# User should either be redirected or start manually on the welcome
|
||||
return self.async_abort(reason="no_oidc_cookie_found")
|
||||
|
||||
@@ -1,215 +1,82 @@
|
||||
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 || textNode.textContent === value) return
|
||||
textNode.textContent = value
|
||||
}
|
||||
/**
|
||||
* OIDC Frontend Redirect injection script
|
||||
* This script is injected because the 'hass-oidc-auth' custom component is active.
|
||||
*/
|
||||
|
||||
let firstFocus = true
|
||||
let showCodeOverride = null
|
||||
function attempt_oidc_redirect() {
|
||||
// Get URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
function isMobile() {
|
||||
const clientId = new URL(location.href).searchParams.get("client_id")
|
||||
return clientId && clientId.startsWith("https://home-assistant.io/iOS") || clientId.startsWith("https://home-assistant.io/android")
|
||||
}
|
||||
|
||||
function showCode() {
|
||||
if (showCodeOverride !== null) return showCodeOverride
|
||||
return isMobile()
|
||||
}
|
||||
|
||||
let ssoButton = null
|
||||
let codeButton = null
|
||||
let codeMessage = null
|
||||
let codeToggle = null
|
||||
let codeToggleText = null
|
||||
|
||||
function update() {
|
||||
const sso_name = window.sso_name || "Single Sign-On"
|
||||
const loginHeader = document.querySelector(".card-content > ha-auth-flow > form > h1")
|
||||
const authForm = document.querySelector("ha-auth-form")
|
||||
const codeField = document.querySelector(".mdc-text-field__input[name=code]")
|
||||
const haButtons = document.querySelectorAll("ha-button:not(.sso)")
|
||||
const errorAlert = document.querySelector("ha-auth-form ha-alert[alert-type=error]")
|
||||
const loginOptionList = document.querySelector("ha-pick-auth-provider")?.shadowRoot?.querySelector("ha-list")
|
||||
const forgotPasswordLink = document.querySelector(".forgot-password")
|
||||
|
||||
// Iterate over haButtons to find one with text "Login with code"
|
||||
let loginButton = null
|
||||
haButtons.forEach(button => {
|
||||
if (button.textContent.trim() === "Log in") {
|
||||
loginButton = button
|
||||
// Check if we have skip_oidc_redirect directly here
|
||||
if (urlParams.get('skip_oidc_redirect') === 'true') {
|
||||
// No console log because this is intended behavior
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
// ====
|
||||
// Code input
|
||||
if (codeField) {
|
||||
if (codeField.placeholder !== "One-time code") {
|
||||
codeField.placeholder = "One-time code"
|
||||
codeField.autofocus = false
|
||||
codeField.autocomplete = "off"
|
||||
const originalUrl = urlParams.get('redirect_uri');
|
||||
if (!originalUrl) {
|
||||
console.warn('[OIDC] No OAuth2 redirect_uri parameter found in the URL. Frontend redirect cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstFocus) {
|
||||
firstFocus = false
|
||||
try {
|
||||
// Parse the redirect URI
|
||||
const redirectUrl = new URL(originalUrl);
|
||||
|
||||
if (document.activeElement === codeField) {
|
||||
// Check if redirect URI has a query parameter to stop OIDC injection
|
||||
if (redirectUrl.searchParams.get('skip_oidc_redirect') === 'true') {
|
||||
// No console log because this is intended behavior
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OIDC] Invalid redirect_uri parameter:', error);
|
||||
}
|
||||
|
||||
window.stop(); // Stop loading the current page before redirecting
|
||||
|
||||
// Redirect to the OIDC auth URL
|
||||
const base64encodeUrl = btoa(window.location.href);
|
||||
const oidcAuthUrl = '/auth/oidc/welcome?redirect_uri=' + encodeURIComponent(base64encodeUrl);
|
||||
window.location.href = oidcAuthUrl;
|
||||
}
|
||||
|
||||
function click_alternative_provider_instead() {
|
||||
setTimeout(() => {
|
||||
codeField.blur()
|
||||
let check = setInterval(() => {
|
||||
const helperText = document.querySelector("#helper-text")
|
||||
const invalidTextField = document.querySelector(".mdc-text-field--invalid")
|
||||
const validationMsg = document.querySelector(".mdc-text-field-helper-text--validation-msg")
|
||||
if (helperText && invalidTextField && validationMsg) {
|
||||
clearInterval(check)
|
||||
safeSetTextContent(helperText, "")
|
||||
invalidTextField.classList.remove("mdc-text-field--invalid")
|
||||
validationMsg.classList.remove("mdc-text-field-helper-text--validation-msg")
|
||||
}
|
||||
}, 1)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
// Find ha-auth-flow
|
||||
const authFlowElement = document.querySelector('ha-auth-flow');
|
||||
|
||||
if (!authFlowElement) {
|
||||
console.warn("[OIDC] ha-auth-flow element not found. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorAlert && errorAlert.textContent.trim().length === 0) {
|
||||
errorAlert.setAttribute("title", "Invalid Code")
|
||||
// Check if the text "Login aborted" is present on the page
|
||||
if (!authFlowElement.innerText.includes('Login aborted')) {
|
||||
console.warn("[OIDC] 'Login aborted' text not found. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
authForm.style.display = showCode() ? "" : "none"
|
||||
// Find the ha-pick-auth-provider element
|
||||
const authProviderElement = document.querySelector('ha-pick-auth-provider');
|
||||
|
||||
if (!authProviderElement) {
|
||||
console.warn("[OIDC] ha-pick-auth-provider not found. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (authForm && !codeMessage) {
|
||||
codeMessage = document.createElement("p")
|
||||
codeMessage.innerHTML = `<b>Please login on a different device to continue.</b><br/>You can also use your mobile webbrowser.`
|
||||
authForm.parentElement.insertBefore(codeMessage, authForm)
|
||||
// Click the first ha-list-item element inside the ha-pick-auth-provider
|
||||
const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item');
|
||||
if (!firstListItem) {
|
||||
console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeMessage) {
|
||||
codeMessage.style.display = showCode() ? "" : "none"
|
||||
}
|
||||
|
||||
if (showCode() && loginButton !== null && !codeButton) {
|
||||
codeButton = document.createElement("ha-button")
|
||||
codeButton.id = "code_button"
|
||||
codeButton.classList.add("code")
|
||||
codeButton.innerText = "Log in with code"
|
||||
codeButton.setAttribute("raised", "")
|
||||
codeButton.style.marginRight = "1em"
|
||||
|
||||
// Copy the onclick handler the loginButton
|
||||
codeButton.addEventListener("click", () => {
|
||||
loginButton.click()
|
||||
})
|
||||
loginButton.parentElement.prepend(codeButton)
|
||||
} else if (!showCode() && loginButton !== null &&codeButton) {
|
||||
codeButton.remove()
|
||||
codeButton = null
|
||||
}
|
||||
|
||||
// ====
|
||||
// Toggle button
|
||||
if (loginOptionList && !codeToggle && !isMobile()) {
|
||||
codeToggle = document.createElement("ha-list-item")
|
||||
codeToggle.setAttribute("hasmeta", "")
|
||||
codeToggleText = document.createTextNode("")
|
||||
codeToggle.appendChild(codeToggleText)
|
||||
const codeToggleIcon = document.createElement("ha-icon-next")
|
||||
codeToggleIcon.setAttribute("slot", "meta")
|
||||
codeToggle.appendChild(codeToggleIcon)
|
||||
|
||||
let ranHandler = false;
|
||||
codeToggle.addEventListener("click", () => {
|
||||
ranHandler = true;
|
||||
showCodeOverride = !showCode()
|
||||
update()
|
||||
})
|
||||
|
||||
loginOptionList.addEventListener("click", (ev) => {
|
||||
if (!ranHandler) {
|
||||
showCodeOverride = false;
|
||||
codeMessage = null;
|
||||
}
|
||||
ranHandler = false;
|
||||
})
|
||||
|
||||
loginOptionList.appendChild(codeToggle)
|
||||
}
|
||||
|
||||
if (codeToggle) {
|
||||
codeToggle.style.display = codeField ? "" : "none"
|
||||
}
|
||||
|
||||
if (codeToggleText) {
|
||||
codeToggleText.textContent = showCode() ? "Single-Sign On" : "One-time device code"
|
||||
}
|
||||
|
||||
// ====
|
||||
// SSO Page
|
||||
const shouldShowSSOButton = !showCode() && !!codeField
|
||||
const isOurScreen = showCode() || shouldShowSSOButton
|
||||
|
||||
if (loginButton !== null && !ssoButton) {
|
||||
ssoButton = document.createElement("ha-button")
|
||||
ssoButton.id = "sso_button"
|
||||
ssoButton.classList.add("sso")
|
||||
ssoButton.innerText = "Log in with " + sso_name
|
||||
ssoButton.setAttribute("raised", "")
|
||||
ssoButton.style.marginRight = "1em"
|
||||
ssoButton.addEventListener("click", () => {
|
||||
location.href = "/auth/oidc/redirect"
|
||||
ssoButton.innerHTML = "Redirecting, please wait..."
|
||||
ssoButton.disabled = true
|
||||
})
|
||||
loginButton.parentElement.prepend(ssoButton)
|
||||
}
|
||||
|
||||
if (ssoButton) {
|
||||
ssoButton.style.display = (!showCode() && codeField) ? "" : "none"
|
||||
}
|
||||
|
||||
// ====
|
||||
// Header hidden on our screens
|
||||
if (loginHeader) {
|
||||
if (isOurScreen) {
|
||||
// Hide the header on our screens
|
||||
loginHeader.style.display = "none"
|
||||
if (loginButton !== null) {
|
||||
loginButton.style.display = "none"
|
||||
}
|
||||
forgotPasswordLink.style.display = "none"
|
||||
} else {
|
||||
// Show the header on the login screen
|
||||
loginHeader.style.display = ""
|
||||
if (loginButton !== null) {
|
||||
loginButton.style.display = ""
|
||||
}
|
||||
forgotPasswordLink.style.display = ""
|
||||
}
|
||||
}
|
||||
firstListItem.click();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Hide the content until ready
|
||||
let ready = false
|
||||
document.querySelector(".content").style.display = "none"
|
||||
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
update()
|
||||
|
||||
if (!ready) {
|
||||
ready = Boolean(ssoButton && codeMessage && codeToggle && codeToggleText)
|
||||
if (ready) document.querySelector(".content").style.display = ""
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
if (!ready) {
|
||||
console.warn("hass-oidc-auth: Document was not ready after 300ms seconds, force displaying. This may indicate a problem with the UI injection.")
|
||||
}
|
||||
|
||||
// Force display the content
|
||||
document.querySelector(".content").style.display = "";
|
||||
update();
|
||||
}, 300)
|
||||
// Run OIDC injection upon load
|
||||
(() => {
|
||||
attempt_oidc_redirect();
|
||||
click_alternative_provider_instead();
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -1,81 +0,0 @@
|
||||
"""Code Store, stores the codes and their associated authenticated user temporarily."""
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast, Optional
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..tools.types import UserDetails
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_provider.auth_oidc.codes"
|
||||
|
||||
|
||||
class CodeStore:
|
||||
"""Holds the codes and associated data"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = Store[dict[str, UserDetails]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._data: dict[str, dict[str, dict | str]] | None = None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
if (data := await self._store.async_load()) is None:
|
||||
data = cast(dict[str, UserDetails], {})
|
||||
self._data = data
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._data is not None:
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
"""Generate a random six-digit code."""
|
||||
return "".join(random.choices(string.digits, k=6))
|
||||
|
||||
async def async_generate_code_for_userinfo(self, user_info: UserDetails) -> str:
|
||||
"""Generates a one time code and adds it to the database for 5 minutes."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
code = self._generate_code()
|
||||
expiration = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
|
||||
self._data[code] = {
|
||||
"user_info": user_info,
|
||||
"code": code,
|
||||
"expiration": expiration.isoformat(),
|
||||
}
|
||||
|
||||
await self._async_save()
|
||||
return code
|
||||
|
||||
async def receive_userinfo_for_code(self, code: str) -> Optional[UserDetails]:
|
||||
"""Retrieve user info based on the code."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
user_data = self._data.get(code)
|
||||
|
||||
if user_data:
|
||||
# We should now wipe it from the database, as it's one time use code
|
||||
self._data.pop(code)
|
||||
await self._async_save()
|
||||
|
||||
if user_data and datetime.fromisoformat(user_data["expiration"]) > datetime.now(
|
||||
timezone.utc
|
||||
):
|
||||
return user_data["user_info"]
|
||||
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
"""Get the internal data for testing purposes."""
|
||||
return self._data
|
||||
191
custom_components/auth_oidc/stores/state_store.py
Normal file
191
custom_components/auth_oidc/stores/state_store.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""State Store, store authentication states (redirect_uri)."""
|
||||
|
||||
import secrets
|
||||
import random
|
||||
import string
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast, Optional
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..tools.types import OIDCState, UserDetails
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_provider.auth_oidc.states"
|
||||
MAX_DEVICE_CODE_ATTEMPTS = 10
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Holds the authentication states and associated data"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = Store[dict[str, OIDCState]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._data: dict[str, OIDCState] | None = None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
if (data := await self._store.async_load()) is None:
|
||||
data = cast(dict[str, OIDCState], {})
|
||||
self._data = data
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._data is not None:
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
def _generate_id(self) -> str:
|
||||
"""Generate a random identifier."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
"""Generate a random six-digit code."""
|
||||
return "".join(random.choices(string.digits, k=6))
|
||||
|
||||
def _is_expired(self, state: OIDCState) -> bool:
|
||||
"""Check if a state is expired."""
|
||||
return datetime.fromisoformat(state["expiration"]) < datetime.now(timezone.utc)
|
||||
|
||||
def _is_valid(self, state: OIDCState, ip: str | None) -> bool:
|
||||
"""Check if a state is valid"""
|
||||
return (
|
||||
not self._is_expired(state)
|
||||
and bool(state["redirect_uri"])
|
||||
and ip is not None
|
||||
and state["ip_address"] == ip
|
||||
)
|
||||
|
||||
async def async_create_state_from_url(self, redirect_uri: str, ip: str) -> str:
|
||||
"""Generates a the OIDC state adds it to the database for 5 minutes."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state_id = self._generate_id()
|
||||
expiration = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
|
||||
self._data[state_id] = {
|
||||
"id": state_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"device_code": None,
|
||||
"device_code_attempts": 0,
|
||||
"user_details": None,
|
||||
"expiration": expiration.isoformat(),
|
||||
"ip_address": ip,
|
||||
}
|
||||
|
||||
await self._async_save()
|
||||
return state_id
|
||||
|
||||
async def async_generate_code_for_state(self, state_id: str) -> Optional[str]:
|
||||
"""Generates a one time code for the state to link device clients."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
try:
|
||||
code = self._generate_code()
|
||||
self._data[state_id]["device_code"] = code
|
||||
await self._async_save()
|
||||
return code
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
async def async_add_userinfo_to_state(
|
||||
self, state_id: str, user_info: UserDetails
|
||||
) -> bool:
|
||||
"""Add userinfo to existing state to complete login"""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
try:
|
||||
self._data[state_id]["user_details"] = user_info
|
||||
await self._async_save()
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
async def async_get_redirect_uri_for_state(
|
||||
self, state_id: str, ip: str
|
||||
) -> Optional[str]:
|
||||
"""Get the redirect_uri for a given state_id."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state = self._data.get(state_id)
|
||||
if state and self._is_valid(state, ip):
|
||||
return state["redirect_uri"]
|
||||
|
||||
return None
|
||||
|
||||
async def async_is_state_ready(self, state_id: str, ip: str) -> bool:
|
||||
"""Check if the state has received the user info from the OIDC callback."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state = self._data.get(state_id)
|
||||
return (
|
||||
state is not None
|
||||
and state["user_details"] is not None
|
||||
and self._is_valid(state, ip)
|
||||
)
|
||||
|
||||
async def async_link_state_to_code(
|
||||
self, state_id: str, code: str, ip: str | None
|
||||
) -> bool:
|
||||
"""Link a state to a device code, used for mobile sign-in."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state_data = self._data.get(state_id)
|
||||
if (
|
||||
state_data
|
||||
and self._is_valid(state_data, ip)
|
||||
and state_data["user_details"] is not None
|
||||
):
|
||||
attempts = state_data.get("device_code_attempts", 0)
|
||||
if attempts >= MAX_DEVICE_CODE_ATTEMPTS:
|
||||
return False
|
||||
|
||||
# Find the state with the matching device code and link it
|
||||
for state in self._data.values():
|
||||
if state["device_code"] == code and not self._is_expired(state):
|
||||
# Set user details on the device state to allow it to complete login
|
||||
state["user_details"] = state_data["user_details"]
|
||||
|
||||
# Delete the 'donor' state as it's one time use
|
||||
self._data.pop(state_id)
|
||||
|
||||
# Save and return true
|
||||
await self._async_save()
|
||||
return True
|
||||
|
||||
state_data["device_code_attempts"] = attempts + 1
|
||||
await self._async_save()
|
||||
|
||||
return False
|
||||
|
||||
async def async_receive_userinfo_for_state(
|
||||
self, state_id: str, ip: str
|
||||
) -> Optional[OIDCState]:
|
||||
"""Retrieve user info based on the state_id."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
user_data = self._data.get(state_id)
|
||||
|
||||
if user_data:
|
||||
# We should now wipe it from the database, as it's one time use
|
||||
self._data.pop(state_id)
|
||||
await self._async_save()
|
||||
|
||||
if user_data and self._is_valid(user_data, ip):
|
||||
return user_data["user_details"]
|
||||
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
"""Get the internal data for testing purposes."""
|
||||
return self._data
|
||||
@@ -1,8 +1,17 @@
|
||||
"""Helper functions for the integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import http
|
||||
from aiohttp import web
|
||||
|
||||
from ..views.loader import AsyncTemplateRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..provider import OpenIDAuthProvider
|
||||
|
||||
STATE_COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
def get_url(path: str, force_https: bool) -> str:
|
||||
"""Returns the requested path appended to the current request base URL."""
|
||||
@@ -22,3 +31,39 @@ async def get_view(template: str, parameters: dict | None = None) -> str:
|
||||
|
||||
renderer = AsyncTemplateRenderer()
|
||||
return await renderer.render_template(f"{template}.html", **parameters)
|
||||
|
||||
|
||||
def get_state_id(request: web.Request) -> str | None:
|
||||
"""Return the current OIDC state cookie, if present."""
|
||||
return request.cookies.get(STATE_COOKIE_NAME)
|
||||
|
||||
|
||||
async def get_valid_state_id(
|
||||
request: web.Request, oidc_provider: "OpenIDAuthProvider"
|
||||
) -> str | None:
|
||||
"""Return state id only when cookie exists and state is still valid."""
|
||||
state_id = get_state_id(request)
|
||||
if not state_id:
|
||||
return None
|
||||
|
||||
if not await oidc_provider.async_is_state_valid(state_id):
|
||||
return None
|
||||
|
||||
return state_id
|
||||
|
||||
|
||||
def html_response(html: str, status: int = 200) -> web.Response:
|
||||
"""Return an HTML response with the standard content type."""
|
||||
return web.Response(text=html, content_type="text/html", status=status)
|
||||
|
||||
|
||||
async def template_response(
|
||||
template: str, parameters: dict | None = None
|
||||
) -> web.Response:
|
||||
"""Render a template and return it as an HTML response."""
|
||||
return html_response(await get_view(template, parameters))
|
||||
|
||||
|
||||
async def error_response(message: str, status: int = 400) -> web.Response:
|
||||
"""Render the shared error view."""
|
||||
return html_response(await get_view("error", {"error": message}), status=status)
|
||||
|
||||
@@ -289,9 +289,6 @@ class OIDCDiscoveryClient:
|
||||
class OIDCClient:
|
||||
"""OIDC Client implementation for Python, including PKCE."""
|
||||
|
||||
# Flows stores the state, code_verifier and nonce of all current flows.
|
||||
flows = {}
|
||||
|
||||
# HTTP session to be used
|
||||
http_session: aiohttp.ClientSession = None
|
||||
|
||||
@@ -312,6 +309,9 @@ class OIDCClient:
|
||||
self.client_id = client_id
|
||||
self.scope = scope
|
||||
|
||||
# Stores code_verifier and nonce for active authorization flows.
|
||||
self.flows: dict[str, dict[str, str]] = {}
|
||||
|
||||
# Optional parameters
|
||||
self.client_secret = kwargs.get("client_secret")
|
||||
|
||||
@@ -544,7 +544,9 @@ class OIDCClient:
|
||||
_LOGGER.warning("JWT verification failed: %s", e)
|
||||
return None
|
||||
|
||||
async def async_get_authorization_url(self, redirect_uri: str) -> Optional[str]:
|
||||
async def async_get_authorization_url(
|
||||
self, redirect_uri: str, state: str
|
||||
) -> Optional[str]:
|
||||
"""Generates the authorization URL for the OIDC flow."""
|
||||
try:
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
@@ -552,7 +554,6 @@ class OIDCClient:
|
||||
|
||||
# Generate random nonce & state
|
||||
nonce = self._generate_random_url_string()
|
||||
state = self._generate_random_url_string()
|
||||
|
||||
# Generate PKCE (RFC 7636) parameters
|
||||
code_verifier = self._generate_random_url_string(32)
|
||||
@@ -644,11 +645,10 @@ class OIDCClient:
|
||||
"""Completes the OIDC token flow to obtain a user's details."""
|
||||
|
||||
try:
|
||||
if state not in self.flows:
|
||||
flow = self.flows.pop(state, None)
|
||||
if flow is None:
|
||||
raise OIDCStateInvalid
|
||||
|
||||
flow = self.flows[state]
|
||||
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
token_endpoint = discovery_document["token_endpoint"]
|
||||
|
||||
|
||||
@@ -16,3 +16,26 @@ class UserDetails(dict):
|
||||
username: str
|
||||
# Home Assistant role to assign to this user
|
||||
role: Literal["system-admin", "system-users", "invalid"]
|
||||
|
||||
|
||||
class OIDCState(dict):
|
||||
"""OIDC State representation"""
|
||||
|
||||
# ID of this state
|
||||
id: str
|
||||
|
||||
# User friendly device code
|
||||
device_code: str | None
|
||||
|
||||
# The redirect_uri associated with this state,
|
||||
# to be able to redirect the user back after authentication
|
||||
redirect_uri: str
|
||||
|
||||
# User details, if available
|
||||
user_details: UserDetails | None
|
||||
|
||||
# Expiration time of this state, in ISO format
|
||||
expiration: str
|
||||
|
||||
# IP address
|
||||
ip_address: str | None
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Done!{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="text-center">
|
||||
<p id="mobile-success-message" class="mb-4">You have successfully logged in on your mobile device. It should continue the login soon. <br/><br/>You have been logged out on this device.</p>
|
||||
<div class="my-6">
|
||||
<a id="restart-login-button" href='/auth/oidc/redirect'
|
||||
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">Restart</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,28 +4,63 @@
|
||||
{{ 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>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4 text-center">Logged in!</h1>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-gray-300 bg-gray-50 p-6 text-left">
|
||||
<h2 class="mb-2 text-lg font-semibold text-gray-800">Continue on this device</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">Tap Continue to login to Home Assistant on this device.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="code" value="{{ code }}">
|
||||
<button type="submit"
|
||||
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 to Home Assistant in this browser
|
||||
<button
|
||||
id="continue-on-this-device"
|
||||
type="submit"
|
||||
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 hover:cursor-pointer"
|
||||
>
|
||||
Continue on this device
|
||||
</button>
|
||||
</form>
|
||||
</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 class="rounded-lg border border-gray-300 bg-white p-6 text-left">
|
||||
<div class="mb-4 flex items-center justify-between text-gray-700">
|
||||
<span class="text-lg font-semibold">Use a code from another device</span>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
|
||||
6-digit code.</p>
|
||||
<p class="mb-4 text-sm text-gray-600">Input that code here and click Approve to login on the other device.
|
||||
</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input
|
||||
type="tel"
|
||||
id="device-code-input"
|
||||
name="device_code"
|
||||
required
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
placeholder="123456"
|
||||
class="mb-2 w-full rounded-md border border-gray-300 px-5 py-3 text-center text-base
|
||||
tracking-[0.15em] text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-400
|
||||
focus:ring-opacity-75"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
id="approve-login-button"
|
||||
type="submit"
|
||||
class="w-full py-2 px-4 bg-white text-blue-600
|
||||
font-semibold rounded-lg border border-blue-500 shadow-md hover:bg-gray-100
|
||||
hover:text-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400
|
||||
focus:ring-opacity-75 hover:cursor-pointer"
|
||||
>
|
||||
Approve login on the other device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
custom_components/auth_oidc/views/templates/redirect.html
Normal file
28
custom_components/auth_oidc/views/templates/redirect.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}OIDC Redirect{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="text-center">
|
||||
<div role="status" id="loader" class="items-center justify-center flex">
|
||||
<svg aria-hidden="true" class="w-10 h-10 text-gray-200 animate-spin fill-blue-600" viewBox="0 0 100 101"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
</svg>
|
||||
<span class="sr-only">Redirecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Redirect after loading the page to show the redirect visual
|
||||
setTimeout(() => {
|
||||
auth_url = decodeURIComponent("{{ url }}");
|
||||
window.location.href = auth_url;
|
||||
}, 0);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -12,41 +12,53 @@
|
||||
dashboard</a></p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{% if code %}
|
||||
<div>
|
||||
<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 {{ name }}
|
||||
</button>
|
||||
|
||||
<div role="status" id="loader" class="items-center justify-center flex hidden">
|
||||
<svg aria-hidden="true" class="w-10 h-10 text-gray-200 animate-spin fill-blue-600" viewBox="0 0 100 101"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
</svg>
|
||||
<span class="sr-only">Redirecting...</span>
|
||||
<p id="device-instructions">Please login to Home Assistant on another device and enter this code when asked:</p>
|
||||
<div class="mt-4 text-3xl tracking-wide font-bold bg-gray-100 border border-gray-300 rounded-lg py-4 px-6 inline-block" id="device-code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-600">
|
||||
The login will continue automatically when you complete the login on your other device. Please keep the app open.
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
const source = new EventSource('/auth/oidc/device-sse');
|
||||
|
||||
<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>
|
||||
// Hide the login button and show the loader when clicked
|
||||
document.getElementById('oidc-login-btn').addEventListener('click', function () {
|
||||
this.classList.add('hidden');
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
window.location.href = '/auth/oidc/redirect';
|
||||
source.addEventListener('ready', function () {
|
||||
source.close();
|
||||
|
||||
// Perform a POST request to the finish endpoint to complete the login.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/auth/oidc/finish';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
|
||||
source.addEventListener('error', function () {
|
||||
source.close();
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div>
|
||||
<a id="login-button" href="/auth/oidc/redirect" 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
|
||||
hover:cursor-pointer
|
||||
">
|
||||
Login with {{ name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if other_link %}
|
||||
<p class=" mt-4 text-sm text-center">
|
||||
<a id="alternative-sign-in-link" href="{{ other_link }}" class="text-gray-600 hover:underline">Use alternative sign-in method</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
// Show the direct login button if we already have a token
|
||||
if (localStorage.getItem('hassTokens')) {
|
||||
document.getElementById('signed-in').classList.remove('hidden');
|
||||
|
||||
@@ -13,11 +13,11 @@ dependencies = [
|
||||
"joserfc~=1.6.0",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = "~=3.14.2"
|
||||
requires-python = "~=3.14.4"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"homeassistant~=2026.3",
|
||||
"homeassistant~=2026.4",
|
||||
"pylint~=4.0",
|
||||
"pytest~=9.0.0",
|
||||
"pytest-asyncio~=1.3.0",
|
||||
@@ -35,10 +35,9 @@ managed = true
|
||||
override-dependencies = [
|
||||
"orjson>=3.11.6,<3.12.0",
|
||||
"pyjwt>=2.12.0,<2.13.0",
|
||||
"pyopenssl>=26.0.0",
|
||||
"cryptography>=46.0.6,<46.1",
|
||||
"requests>=2.33.0,<2.34",
|
||||
"pygments>=2.20.0,<2.21"
|
||||
"pillow>=12.2.0,<12.3.0",
|
||||
"pytest>=9.0.3,<9.1.0",
|
||||
"uv>=0.11.6,<0.12.0",
|
||||
]
|
||||
|
||||
[tool.hatch.metadata]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Tests for the code store"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
import pytest
|
||||
|
||||
from auth_oidc.stores.code_store import CodeStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_code_store_generate_and_receive_code(hass: HomeAssistant):
|
||||
"""Test generating and receiving a code."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
code_store = CodeStore(hass)
|
||||
|
||||
# Simulate loading with empty data
|
||||
store_mock.async_load.return_value = {}
|
||||
await code_store.async_load()
|
||||
assert code_store.get_data() == {}
|
||||
|
||||
user_info = {"sub": "user1", "name": "Test User"}
|
||||
code = await code_store.async_generate_code_for_userinfo(user_info)
|
||||
assert code in code_store.get_data()
|
||||
|
||||
# Should return user_info and remove the code
|
||||
with patch("custom_components.auth_oidc.stores.code_store.datetime") as dt_mock:
|
||||
dt_mock.utcnow.return_value = datetime.now(timezone.utc)
|
||||
dt_mock.fromisoformat.side_effect = datetime.fromisoformat
|
||||
result = await code_store.receive_userinfo_for_code(code)
|
||||
assert result == user_info
|
||||
assert code not in code_store.get_data()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_code_store_expired_code(hass):
|
||||
"""Test that expired codes return None."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
code_store = CodeStore(hass)
|
||||
store_mock.async_load.return_value = {}
|
||||
await code_store.async_load()
|
||||
assert code_store.get_data() == {}
|
||||
|
||||
user_info = {"sub": "user2", "name": "Expired User"}
|
||||
code = await code_store.async_generate_code_for_userinfo(user_info)
|
||||
|
||||
# Patch expiration to be in the past
|
||||
code_store.get_data()[code]["expiration"] = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
).isoformat()
|
||||
|
||||
with patch("custom_components.auth_oidc.stores.code_store.datetime") as dt_mock:
|
||||
dt_mock.utcnow.return_value = datetime.now(timezone.utc)
|
||||
dt_mock.fromisoformat.side_effect = datetime.fromisoformat
|
||||
result = await code_store.receive_userinfo_for_code(code)
|
||||
assert result is None
|
||||
assert code not in code_store.get_data()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_code_store_data_not_loaded(hass):
|
||||
"""Test that using the store before loading raises RuntimeError."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
code_store = CodeStore(hass)
|
||||
|
||||
# Data is not loaded yet, should result in RuntimeError
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await code_store.async_generate_code_for_userinfo({"sub": "user3"})
|
||||
with pytest.raises(RuntimeError):
|
||||
await code_store.receive_userinfo_for_code("123456")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_code_store_generate_code_length(hass):
|
||||
"""Test that generated codes are 6 digits."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
code_store = CodeStore(hass)
|
||||
store_mock.async_load.return_value = {}
|
||||
await code_store.async_load()
|
||||
assert code_store.get_data() == {}
|
||||
user_info = {"sub": "user4"}
|
||||
code = await code_store.async_generate_code_for_userinfo(user_info)
|
||||
assert len(code) == 6
|
||||
assert code.isdigit()
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Tests for the Auth Provider registration in HA"""
|
||||
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import base64
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -19,6 +23,8 @@ from custom_components.auth_oidc.config.const import (
|
||||
)
|
||||
from .mocks.oidc_server import MockOIDCServer, mock_oidc_responses
|
||||
|
||||
FAKE_REDIR_URL = "http://example.com/auth/authorize?response_type=code&redirect_uri=http%3A%2F%2Fexample.com%3A8123%2F%3Fauth_callback%3D1&client_id=http%3A%2F%2Fexample.com%3A8123%2F&state=example"
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool:
|
||||
"""Set up the auth_oidc component."""
|
||||
@@ -45,23 +51,63 @@ async def test_setup_success_auth_provider_registration(hass: HomeAssistant):
|
||||
auth_providers = hass.auth.get_auth_providers(DOMAIN)
|
||||
assert len(auth_providers) == 1
|
||||
|
||||
# Public auth-provider contract: OIDC provider does not support HA MFA
|
||||
assert auth_providers[0].support_mfa is False
|
||||
|
||||
async def login_user(hass: HomeAssistant, code: str):
|
||||
"""Helper to login a user."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_ip_fallback_fails_closed_without_request_context(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Provider should not invent a shared IP when request context is missing."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
flow = await provider.async_login_flow({})
|
||||
|
||||
result = await flow.async_step_init({"code": code})
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] is not None
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = None
|
||||
assert provider._resolve_ip() is None
|
||||
|
||||
data = result["data"]
|
||||
sub = data["sub"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssistant):
|
||||
"""Cookie header should include Secure when HTTPS is in use."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
cookie_header = provider.get_cookie_header("state-id", secure=True)["set-cookie"]
|
||||
|
||||
assert "SameSite=Strict" in cookie_header
|
||||
assert "HttpOnly" in cookie_header
|
||||
assert "Secure" in cookie_header
|
||||
|
||||
|
||||
async def login_user(hass: HomeAssistant, state_id: str):
|
||||
"""Helper to login a user from the stored OIDC state."""
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
# This helper runs outside an HTTP request, so pass the known local test IP.
|
||||
sub = await provider.async_get_subject(state_id, "127.0.0.1")
|
||||
assert sub == MockOIDCServer.get_final_subject()
|
||||
|
||||
# Get credentials
|
||||
credentials = await provider.async_get_or_create_credentials(data)
|
||||
credentials = await provider.async_get_or_create_credentials({"sub": sub})
|
||||
assert credentials is not None
|
||||
assert credentials.data["sub"] == sub
|
||||
|
||||
@@ -70,36 +116,49 @@ async def login_user(hass: HomeAssistant, code: str):
|
||||
return user
|
||||
|
||||
|
||||
async def get_login_code(hass: HomeAssistant, hass_client):
|
||||
"""Helper to get a login code."""
|
||||
async def get_login_state(hass: HomeAssistant, hass_client):
|
||||
"""Helper to complete the browser login flow and return the OIDC state id."""
|
||||
client = await hass_client()
|
||||
|
||||
redirect_uri = FAKE_REDIR_URL
|
||||
encoded_redirect_uri = base64.b64encode(redirect_uri.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 200
|
||||
state_id = resp.cookies["auth_oidc_state"].value
|
||||
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 302
|
||||
location = resp.headers["Location"]
|
||||
parsed_url = urlparse(location)
|
||||
assert resp.status == 200
|
||||
html = await resp.text()
|
||||
match = re.search(r'decodeURIComponent\("([^"]+)"\)', html)
|
||||
assert match is not None
|
||||
auth_url = unquote(match.group(1))
|
||||
|
||||
parsed_url = urlparse(auth_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
state = query_params["state"][0]
|
||||
assert query_params["state"][0] == state_id
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
resp = session.get(location, allow_redirects=False)
|
||||
resp = session.get(auth_url, allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
# Mock OIDC returns JSON
|
||||
json_parsed = await resp.json()
|
||||
assert "code" in json_parsed and json_parsed["code"]
|
||||
|
||||
code = json_parsed["code"]
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={code}&state={state}", allow_redirects=False
|
||||
f"/auth/oidc/callback?code={code}&state={state_id}", allow_redirects=False
|
||||
)
|
||||
|
||||
assert resp.status == 302
|
||||
location = resp.headers["Location"]
|
||||
assert "/auth/oidc/finish?code=" in location
|
||||
assert resp.headers["Location"].endswith("/auth/oidc/finish")
|
||||
|
||||
# Get the code from the finish URL
|
||||
code = location.split("code=")[1]
|
||||
return code
|
||||
return state_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -120,16 +179,16 @@ async def test_full_login(hass: HomeAssistant, hass_client):
|
||||
|
||||
with mock_oidc_responses():
|
||||
# Actually start the login and get a code
|
||||
code = await get_login_code(hass, hass_client)
|
||||
state_id = await get_login_state(hass, hass_client)
|
||||
|
||||
# Use the code to login directly with the registered auth provider
|
||||
# Use the stored state to login directly with the registered auth provider
|
||||
# Inspired by tests for the built-in providers
|
||||
user = await login_user(hass, code)
|
||||
user = await login_user(hass, state_id)
|
||||
assert user.name == "Test Name"
|
||||
|
||||
# Login again to see if we trigger the re-use path
|
||||
code2 = await get_login_code(hass, hass_client)
|
||||
user2 = await login_user(hass, code2)
|
||||
state_id2 = await get_login_state(hass, hass_client)
|
||||
user2 = await login_user(hass, state_id2)
|
||||
assert user2.id == user.id
|
||||
|
||||
|
||||
@@ -161,10 +220,10 @@ async def test_login_with_linking(hass: HomeAssistant, hass_client):
|
||||
await hass.auth.async_link_user(user, credential)
|
||||
|
||||
# Actually start the login and get a code
|
||||
code = await get_login_code(hass, hass_client)
|
||||
state_id = await get_login_state(hass, hass_client)
|
||||
|
||||
# Use the code to login directly with the registered auth provider
|
||||
user2 = await login_user(hass, code)
|
||||
# Use the stored state to login directly with the registered auth provider
|
||||
user2 = await login_user(hass, state_id)
|
||||
assert user2.id == user.id # Assert that the user was linked
|
||||
|
||||
|
||||
@@ -187,8 +246,8 @@ async def test_login_with_person_create(hass: HomeAssistant, hass_client):
|
||||
await async_setup_component(hass, PERSON_DOMAIN, {})
|
||||
|
||||
with mock_oidc_responses():
|
||||
code = await get_login_code(hass, hass_client)
|
||||
user = await login_user(hass, code)
|
||||
state_id = await get_login_state(hass, hass_client)
|
||||
user = await login_user(hass, state_id)
|
||||
assert user.is_active
|
||||
|
||||
# Find the person associated to this user using the PersonRegistry API
|
||||
@@ -200,6 +259,36 @@ async def test_login_with_person_create(hass: HomeAssistant, hass_client):
|
||||
assert person["user_id"] == user.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_without_person_create_does_not_create_person(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Test that person creation can be disabled."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
},
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
await async_setup_component(hass, PERSON_DOMAIN, {})
|
||||
|
||||
with mock_oidc_responses():
|
||||
state_id = await get_login_state(hass, hass_client)
|
||||
user = await login_user(hass, state_id)
|
||||
assert user.is_active
|
||||
|
||||
person_store = hass.data[PERSON_DOMAIN][1]
|
||||
persons = person_store.async_items()
|
||||
assert len(persons) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_shows_form(hass: HomeAssistant):
|
||||
"""Test a login"""
|
||||
@@ -220,10 +309,38 @@ async def test_login_shows_form(hass: HomeAssistant):
|
||||
flow = await provider.async_login_flow({})
|
||||
|
||||
result = await flow.async_step_init({})
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_oidc_cookie_found"
|
||||
|
||||
# Attempt an invalid code
|
||||
result = await flow.async_step_init({"code": "invalid"})
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
|
||||
"""A cookie that does not map to a valid state should fail closed."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
},
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
flow = await provider.async_login_flow({})
|
||||
|
||||
fake_request = SimpleNamespace(
|
||||
cookies={"auth_oidc_state": "missing-state"}, remote="127.0.0.1"
|
||||
)
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = fake_request
|
||||
|
||||
result = await flow.async_step_init({})
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_oidc_cookie_found"
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Tests for the OIDC client"""
|
||||
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from auth_oidc import DOMAIN
|
||||
from auth_oidc.tools.oidc_client import OIDCDiscoveryClient, OIDCDiscoveryInvalid
|
||||
from auth_oidc.config.const import (
|
||||
DISCOVERY_URL,
|
||||
CLIENT_ID,
|
||||
)
|
||||
|
||||
from .mocks.oidc_server import MockOIDCServer, mock_oidc_responses
|
||||
|
||||
EXAMPLE_CLIENT_ID = "dummyclient"
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant):
|
||||
"""Set up the integration within Home Assistant"""
|
||||
mock_config = {
|
||||
DOMAIN: {
|
||||
CLIENT_ID: EXAMPLE_CLIENT_ID,
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
}
|
||||
}
|
||||
|
||||
result = await async_setup_component(hass, DOMAIN, mock_config)
|
||||
assert result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_oidc_flow(hass: HomeAssistant, hass_client):
|
||||
"""Test that one full OIDC flow works if OIDC is mocked."""
|
||||
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
# Start by going to /auth/oidc/redirect
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 302
|
||||
assert resp.headers["Location"].startswith(MockOIDCServer.get_authorize_url())
|
||||
|
||||
# Parse the location header and test the query params for correctness
|
||||
location = resp.headers["Location"]
|
||||
parsed_url = urlparse(location)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
assert "response_type" in query_params and query_params.get(
|
||||
"response_type"
|
||||
) == ["code"]
|
||||
assert "client_id" in query_params and query_params.get("client_id") == [
|
||||
EXAMPLE_CLIENT_ID
|
||||
]
|
||||
assert "scope" in query_params and query_params.get("scope") == [
|
||||
"openid profile groups"
|
||||
]
|
||||
assert "state" in query_params and query_params["state"]
|
||||
state = query_params["state"][0]
|
||||
assert len(state) >= 16 # Ensure state is sufficiently long
|
||||
assert (
|
||||
"redirect_uri" in query_params
|
||||
and query_params["redirect_uri"]
|
||||
and query_params["redirect_uri"][0].endswith("/auth/oidc/callback")
|
||||
) # TODO: Also test that the URL itself is correct
|
||||
assert "nonce" in query_params and query_params["nonce"]
|
||||
assert "code_challenge_method" in query_params and query_params.get(
|
||||
"code_challenge_method"
|
||||
) == ["S256"]
|
||||
assert "code_challenge" in query_params and query_params["code_challenge"]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
resp = session.get(location, allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
json_parsed = await resp.json()
|
||||
assert "code" in json_parsed and json_parsed["code"]
|
||||
|
||||
# Now go back to the callback with a sample code
|
||||
code = json_parsed["code"]
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={code}&state={state}", allow_redirects=False
|
||||
)
|
||||
|
||||
# TODO: Test if logged text contains our login
|
||||
# TODO: Test if the code actually works
|
||||
assert resp.status == 302
|
||||
assert "/auth/oidc/finish?code=" in resp.headers["Location"]
|
||||
|
||||
|
||||
async def discovery_test_through_redirect(
|
||||
hass_client, caplog, scenario: str, match_log_line: str
|
||||
):
|
||||
"""Test that discovery document retrieval fails gracefully through redirect endpoint."""
|
||||
with mock_oidc_responses(scenario):
|
||||
# Start by going to /auth/oidc/redirect
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
|
||||
# Find matching log line
|
||||
assert match_log_line in caplog.text
|
||||
|
||||
# Assert that we get a 200 response with an error message
|
||||
assert resp.status == 200
|
||||
text = await resp.text()
|
||||
assert "Integration is misconfigured, discovery could not be obtained." in text
|
||||
|
||||
|
||||
async def direct_discovery_test(
|
||||
hass: HomeAssistant,
|
||||
scenario: str,
|
||||
match_type: str,
|
||||
match_log_line: str | None = None,
|
||||
):
|
||||
"""Test that discovery document retrieval fails with nice error directly."""
|
||||
with mock_oidc_responses(scenario):
|
||||
session = async_get_clientsession(hass)
|
||||
client = OIDCDiscoveryClient(
|
||||
MockOIDCServer.get_discovery_url(),
|
||||
session,
|
||||
{
|
||||
"id_token_signing_alg": "RS256",
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(OIDCDiscoveryInvalid) as exc_info:
|
||||
await client.fetch_discovery_document()
|
||||
|
||||
assert exc_info.value.type == match_type
|
||||
assert exc_info.value.get_detail_string().startswith("type: " + match_type)
|
||||
|
||||
if match_log_line:
|
||||
assert match_log_line in exc_info.value.get_detail_string()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_failures(hass: HomeAssistant, hass_client, caplog):
|
||||
"""Test that discovery document retrieval fails gracefully."""
|
||||
|
||||
await setup(hass)
|
||||
|
||||
# Empty scenario
|
||||
await discovery_test_through_redirect(
|
||||
hass_client, caplog, "empty", "is missing required endpoint: issuer"
|
||||
)
|
||||
await direct_discovery_test(hass, "empty", "missing_endpoint", "endpoint: issuer")
|
||||
|
||||
# Missing authorization_endpoint
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"only_issuer",
|
||||
"is missing required endpoint: authorization_endpoint",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "only_issuer", "missing_endpoint", "endpoint: authorization_endpoint"
|
||||
)
|
||||
|
||||
# Missing token_endpoint
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"missing_token",
|
||||
"is missing required endpoint: token_endpoint",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "missing_token", "missing_endpoint", "endpoint: token_endpoint"
|
||||
)
|
||||
|
||||
# Missing jwks_uri
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"missing_jwks",
|
||||
"is missing required endpoint: jwks_uri",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "missing_jwks", "missing_endpoint", "endpoint: jwks_uri"
|
||||
)
|
||||
|
||||
# Invalid response_modes_supported
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_response_modes",
|
||||
"does not support required 'query' response mode, only supports: ['post']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_response_modes", "does_not_support_response_mode", "post"
|
||||
)
|
||||
|
||||
# Invalid grant_types supported
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_grant_types",
|
||||
"does not support required 'authorization_code' grant type, only supports: ['refresh_token']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_grant_types", "does_not_support_grant_type", "refresh_token"
|
||||
)
|
||||
|
||||
# Invalid response types
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_response_types",
|
||||
"does not support required 'code' response type, only supports: ['token']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_response_types", "does_not_support_response_type", "token"
|
||||
)
|
||||
|
||||
# Invalid code_challenge types
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_code_challenge_types",
|
||||
"does not support required 'S256' code challenge method, only supports: ['plain']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"invalid_code_challenge_types",
|
||||
"does_not_support_required_code_challenge_method",
|
||||
"plain",
|
||||
)
|
||||
|
||||
# Invalid id_token_signing alg
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_id_token_signing_alg",
|
||||
"does not have 'id_token_signing_alg_values_supported' field",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_id_token_signing_alg", "missing_id_token_signing_alg_values"
|
||||
)
|
||||
|
||||
# Not matching id_token_signing alg
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"wrong_id_token_signing_alg",
|
||||
"does not support requested id_token_signing_alg 'RS256', only supports: ['HS256']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"wrong_id_token_signing_alg",
|
||||
"does_not_support_id_token_signing_alg",
|
||||
"requested: RS256, supported: ['HS256']",
|
||||
)
|
||||
|
||||
# Invalid URL
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_url",
|
||||
"has invalid URL in endpoint: jwks_uri (/jwks)",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"invalid_url",
|
||||
"invalid_endpoint",
|
||||
"endpoint: jwks_uri, url: /jwks",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_jwks_fetch(hass: HomeAssistant):
|
||||
"""Test direct fetch of JWKS."""
|
||||
with mock_oidc_responses():
|
||||
session = async_get_clientsession(hass)
|
||||
client = OIDCDiscoveryClient(
|
||||
MockOIDCServer.get_discovery_url(),
|
||||
session,
|
||||
{
|
||||
"id_token_signing_alg": "RS256",
|
||||
},
|
||||
)
|
||||
|
||||
await client.fetch_discovery_document()
|
||||
jwks = await client.fetch_jwks()
|
||||
assert "keys" in jwks
|
||||
690
tests/test_hass_oidc_client_integration.py
Normal file
690
tests/test_hass_oidc_client_integration.py
Normal file
@@ -0,0 +1,690 @@
|
||||
"""Tests for the OIDC client"""
|
||||
|
||||
import base64
|
||||
import asyncio
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, unquote, urlparse, urlencode
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from custom_components.auth_oidc import DOMAIN
|
||||
from custom_components.auth_oidc.tools.oidc_client import (
|
||||
OIDCDiscoveryClient,
|
||||
OIDCDiscoveryInvalid,
|
||||
)
|
||||
from custom_components.auth_oidc.config.const import (
|
||||
DISCOVERY_URL,
|
||||
CLIENT_ID,
|
||||
)
|
||||
|
||||
from .mocks.oidc_server import MockOIDCServer, mock_oidc_responses
|
||||
|
||||
EXAMPLE_CLIENT_ID = "http://example.com/"
|
||||
WEB_CLIENT_ID = "https://example.com"
|
||||
MOBILE_CLIENT_ID = "https://home-assistant.io/Android"
|
||||
|
||||
# Helper functions
|
||||
|
||||
|
||||
def encode_redirect_uri(redirect_uri: str) -> str:
|
||||
"""Helper to encode redirect URI for welcome page."""
|
||||
return base64.b64encode(redirect_uri.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def create_redirect_uri(client_id: str) -> str:
|
||||
"""Create a redirect URI for Home Assistant Android app."""
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"redirect_uri": client_id,
|
||||
"client_id": client_id,
|
||||
"state": "example",
|
||||
}
|
||||
|
||||
return f"http://example.com/auth/authorize?{urlencode(params)}"
|
||||
|
||||
|
||||
async def get_welcome_for_client(client, redirect_uri: str) -> tuple[str, str, int]:
|
||||
"""Go to welcome page and return state cookie, HTML content, and status.
|
||||
|
||||
Returns:
|
||||
Tuple of (state_id, html_content, status_code)
|
||||
"""
|
||||
encoded_uri = encode_redirect_uri(redirect_uri)
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
state = resp.cookies["auth_oidc_state"].value
|
||||
html = await resp.text() if resp.status == 200 else ""
|
||||
return state, html, resp.status
|
||||
|
||||
|
||||
async def get_redirect_auth_url(client) -> str:
|
||||
"""Go to redirect page and extract the authorization URL.
|
||||
|
||||
Returns:
|
||||
The full authorization URL to send to the OIDC provider
|
||||
"""
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
html = await resp.text()
|
||||
|
||||
match = re.search(r'decodeURIComponent\("([^"]+)"\)', html)
|
||||
assert match is not None, "Authorization URL not found in redirect page"
|
||||
return unquote(match.group(1))
|
||||
|
||||
|
||||
async def complete_callback_and_finish(client, code: str, state: str):
|
||||
"""Complete the callback and finish flow.
|
||||
|
||||
Returns:
|
||||
The state_id cookie value after completion
|
||||
"""
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={code}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 302
|
||||
assert resp.headers["Location"].endswith("/auth/oidc/finish")
|
||||
|
||||
resp_finish = await client.get("/auth/oidc/finish", allow_redirects=False)
|
||||
assert resp_finish.status == 200
|
||||
finish_html = await resp_finish.text()
|
||||
assert 'id="continue-on-this-device"' in finish_html
|
||||
assert 'id="device-code-input"' in finish_html
|
||||
assert 'id="approve-login-button"' in finish_html
|
||||
|
||||
|
||||
async def verify_back_redirect(client, expected_redirect_uri: str):
|
||||
"""Verify that POST to finish without body redirects back to the original redirect_uri."""
|
||||
resp_finish_post = await client.post("/auth/oidc/finish", allow_redirects=False)
|
||||
assert resp_finish_post.status == 302
|
||||
assert (
|
||||
resp_finish_post.headers["Location"]
|
||||
== unquote(expected_redirect_uri) + "&storeToken=true&skip_oidc_redirect=true"
|
||||
)
|
||||
|
||||
|
||||
async def listen_for_sse_events(
|
||||
resp_sse,
|
||||
expected_event: str,
|
||||
timeout_seconds: int = 5,
|
||||
) -> list[str]:
|
||||
"""Listen for SSE events and return once the expected event is received.
|
||||
|
||||
Args:
|
||||
resp_sse: The SSE response stream
|
||||
expected_event: The event type to listen for (e.g., "waiting" or "ready")
|
||||
timeout_seconds: Maximum time to wait for the event
|
||||
|
||||
Returns:
|
||||
List of received event lines
|
||||
"""
|
||||
|
||||
if resp_sse is None:
|
||||
raise ValueError("resp_sse cannot be None")
|
||||
|
||||
received_events = []
|
||||
|
||||
async def stream_reader():
|
||||
try:
|
||||
async for line in resp_sse.content:
|
||||
decoded_line = line.decode("utf-8").strip()
|
||||
if not decoded_line:
|
||||
continue
|
||||
|
||||
received_events.append(decoded_line)
|
||||
|
||||
# Check if this is an event line
|
||||
if decoded_line.startswith("event:"):
|
||||
event_type = decoded_line.split(":", 1)[1].strip()
|
||||
if event_type == expected_event:
|
||||
# Found the expected event, return successfully.
|
||||
return True
|
||||
|
||||
# Device SSE may emit multiple waiting events before ready.
|
||||
if expected_event == "ready" and event_type == "waiting":
|
||||
continue
|
||||
|
||||
raise AssertionError(
|
||||
f"Unexpected event type '{event_type}'. Expected: {expected_event}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(stream_reader(), timeout=timeout_seconds)
|
||||
if result:
|
||||
return received_events
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise AssertionError(
|
||||
f"Timeout after {timeout_seconds}s waiting for '{expected_event}' event"
|
||||
) from exc
|
||||
|
||||
raise AssertionError(f"Failed to receive '{expected_event}' event")
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant):
|
||||
"""Set up the integration within Home Assistant"""
|
||||
mock_config = {
|
||||
DOMAIN: {
|
||||
CLIENT_ID: EXAMPLE_CLIENT_ID,
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
}
|
||||
}
|
||||
|
||||
result = await async_setup_component(hass, DOMAIN, mock_config)
|
||||
assert result
|
||||
|
||||
|
||||
# Actual tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_oidc_flow(hass: HomeAssistant, hass_client):
|
||||
"""Test that one full OIDC flow works if OIDC is mocked."""
|
||||
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
# Go to welcome and get state cookie
|
||||
state, _, status = await get_welcome_for_client(client, redirect_uri)
|
||||
assert status == 200
|
||||
assert state is not None
|
||||
|
||||
# Get authorization URL from redirect page
|
||||
authorization_url = await get_redirect_auth_url(client)
|
||||
assert authorization_url.startswith(MockOIDCServer.get_authorize_url())
|
||||
|
||||
# Parse the rendered redirect URL and test the query params for correctness
|
||||
parsed_url = urlparse(authorization_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
assert "response_type" in query_params and query_params.get(
|
||||
"response_type"
|
||||
) == ["code"]
|
||||
assert "client_id" in query_params and query_params.get("client_id") == [
|
||||
EXAMPLE_CLIENT_ID
|
||||
]
|
||||
assert "scope" in query_params and query_params.get("scope") == [
|
||||
"openid profile groups"
|
||||
]
|
||||
assert "state" in query_params and query_params["state"]
|
||||
assert query_params["state"][0] == state
|
||||
assert len(query_params["state"][0]) >= 16 # Ensure state is sufficiently long
|
||||
assert (
|
||||
"redirect_uri" in query_params
|
||||
and query_params["redirect_uri"]
|
||||
and query_params["redirect_uri"][0].endswith("/auth/oidc/callback")
|
||||
)
|
||||
assert "nonce" in query_params and query_params["nonce"]
|
||||
assert "code_challenge_method" in query_params and query_params.get(
|
||||
"code_challenge_method"
|
||||
) == ["S256"]
|
||||
assert "code_challenge" in query_params and query_params["code_challenge"]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
resp = session.get(authorization_url, allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
# JSON response from mock server, normally would be interactive
|
||||
json_parsed = await resp.json()
|
||||
assert "code" in json_parsed and json_parsed["code"]
|
||||
|
||||
# Now go back to the callback with a sample code
|
||||
code = json_parsed["code"]
|
||||
|
||||
await complete_callback_and_finish(client, code, state)
|
||||
|
||||
# POST to finish without any POST body should result in 302 back to the original redirect_uri
|
||||
await verify_back_redirect(client, redirect_uri)
|
||||
|
||||
|
||||
async def discovery_test_through_redirect(
|
||||
hass_client, caplog, scenario: str, match_log_line: str
|
||||
):
|
||||
"""Test that discovery document retrieval fails gracefully through redirect endpoint."""
|
||||
with mock_oidc_responses(scenario):
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encode_redirect_uri(redirect_uri)}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
|
||||
# Find matching log line
|
||||
assert match_log_line in caplog.text
|
||||
|
||||
# Assert that we get an error response with an error message
|
||||
assert resp.status == 500
|
||||
text = await resp.text()
|
||||
assert "Integration is misconfigured, discovery could not be obtained." in text
|
||||
|
||||
|
||||
async def direct_discovery_test(
|
||||
hass: HomeAssistant,
|
||||
scenario: str,
|
||||
match_type: str,
|
||||
match_log_line: str | None = None,
|
||||
):
|
||||
"""Test that discovery document retrieval fails with nice error directly."""
|
||||
with mock_oidc_responses(scenario):
|
||||
session = async_get_clientsession(hass)
|
||||
client = OIDCDiscoveryClient(
|
||||
MockOIDCServer.get_discovery_url(),
|
||||
session,
|
||||
{
|
||||
"id_token_signing_alg": "RS256",
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(OIDCDiscoveryInvalid) as exc_info:
|
||||
await client.fetch_discovery_document()
|
||||
|
||||
assert exc_info.value.type == match_type
|
||||
assert exc_info.value.get_detail_string().startswith("type: " + match_type)
|
||||
|
||||
if match_log_line:
|
||||
assert match_log_line in exc_info.value.get_detail_string()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_failures(hass: HomeAssistant, hass_client, caplog):
|
||||
"""Test that discovery document retrieval fails gracefully."""
|
||||
|
||||
await setup(hass)
|
||||
|
||||
# Empty scenario
|
||||
await discovery_test_through_redirect(
|
||||
hass_client, caplog, "empty", "is missing required endpoint: issuer"
|
||||
)
|
||||
await direct_discovery_test(hass, "empty", "missing_endpoint", "endpoint: issuer")
|
||||
|
||||
# Missing authorization_endpoint
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"only_issuer",
|
||||
"is missing required endpoint: authorization_endpoint",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "only_issuer", "missing_endpoint", "endpoint: authorization_endpoint"
|
||||
)
|
||||
|
||||
# Missing token_endpoint
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"missing_token",
|
||||
"is missing required endpoint: token_endpoint",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "missing_token", "missing_endpoint", "endpoint: token_endpoint"
|
||||
)
|
||||
|
||||
# Missing jwks_uri
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"missing_jwks",
|
||||
"is missing required endpoint: jwks_uri",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "missing_jwks", "missing_endpoint", "endpoint: jwks_uri"
|
||||
)
|
||||
|
||||
# Invalid response_modes_supported
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_response_modes",
|
||||
"does not support required 'query' response mode, only supports: ['post']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_response_modes", "does_not_support_response_mode", "post"
|
||||
)
|
||||
|
||||
# Invalid grant_types supported
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_grant_types",
|
||||
"does not support required 'authorization_code' grant type, only supports: ['refresh_token']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_grant_types", "does_not_support_grant_type", "refresh_token"
|
||||
)
|
||||
|
||||
# Invalid response types
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_response_types",
|
||||
"does not support required 'code' response type, only supports: ['token']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_response_types", "does_not_support_response_type", "token"
|
||||
)
|
||||
|
||||
# Invalid code_challenge types
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_code_challenge_types",
|
||||
"does not support required 'S256' code challenge method, only supports: ['plain']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"invalid_code_challenge_types",
|
||||
"does_not_support_required_code_challenge_method",
|
||||
"plain",
|
||||
)
|
||||
|
||||
# Invalid id_token_signing alg
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_id_token_signing_alg",
|
||||
"does not have 'id_token_signing_alg_values_supported' field",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass, "invalid_id_token_signing_alg", "missing_id_token_signing_alg_values"
|
||||
)
|
||||
|
||||
# Not matching id_token_signing alg
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"wrong_id_token_signing_alg",
|
||||
"does not support requested id_token_signing_alg 'RS256', only supports: ['HS256']",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"wrong_id_token_signing_alg",
|
||||
"does_not_support_id_token_signing_alg",
|
||||
"requested: RS256, supported: ['HS256']",
|
||||
)
|
||||
|
||||
# Invalid URL
|
||||
await discovery_test_through_redirect(
|
||||
hass_client,
|
||||
caplog,
|
||||
"invalid_url",
|
||||
"has invalid URL in endpoint: jwks_uri (/jwks)",
|
||||
)
|
||||
await direct_discovery_test(
|
||||
hass,
|
||||
"invalid_url",
|
||||
"invalid_endpoint",
|
||||
"endpoint: jwks_uri, url: /jwks",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_jwks_fetch(hass: HomeAssistant):
|
||||
"""Test direct fetch of JWKS."""
|
||||
with mock_oidc_responses():
|
||||
session = async_get_clientsession(hass)
|
||||
client = OIDCDiscoveryClient(
|
||||
MockOIDCServer.get_discovery_url(),
|
||||
session,
|
||||
{
|
||||
"id_token_signing_alg": "RS256",
|
||||
},
|
||||
)
|
||||
|
||||
await client.fetch_discovery_document()
|
||||
jwks = await client.fetch_jwks()
|
||||
assert "keys" in jwks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_login_flow_two_browsers(hass: HomeAssistant, hass_client):
|
||||
"""Test device login flow with two separate browser sessions.
|
||||
|
||||
This simulates:
|
||||
- Mobile device (Device 1) generating a device code and waiting via SSE
|
||||
- Desktop browser (Device 2) completing full OAuth flow and linking the code
|
||||
- Mobile device receiving ready event after code is linked
|
||||
"""
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
# ==================== DEVICE 1: Mobile ====================
|
||||
# Mobile client starts the login flow
|
||||
mobile_client = await hass_client()
|
||||
mobile_redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
|
||||
mobile_state, mobile_html, status = await get_welcome_for_client(
|
||||
mobile_client, mobile_redirect_uri
|
||||
)
|
||||
assert status == 200
|
||||
assert mobile_state is not None
|
||||
assert 'id="device-instructions"' in mobile_html
|
||||
assert 'id="device-code"' in mobile_html
|
||||
|
||||
# Extract device code from the welcome page.
|
||||
# The code is rendered in a div with id="device-code".
|
||||
device_code_match = re.search(
|
||||
r'id=["\']device-code["\'][^>]*>\s*([^<\s]+)\s*<',
|
||||
mobile_html,
|
||||
)
|
||||
assert device_code_match is not None, (
|
||||
"Device code should be generated for mobile client"
|
||||
)
|
||||
mobile_device_code = device_code_match.group(1)
|
||||
assert len(mobile_device_code) > 0
|
||||
|
||||
# ==================== DEVICE 2: Desktop ====================
|
||||
# Desktop client in a separate session
|
||||
desktop_client = await hass_client()
|
||||
desktop_redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
desktop_state, _, status = await get_welcome_for_client(
|
||||
desktop_client, desktop_redirect_uri
|
||||
)
|
||||
assert status in [200, 302]
|
||||
assert desktop_state is not None
|
||||
|
||||
# Desktop goes through redirect to get the authorization URL
|
||||
authorization_url = await get_redirect_auth_url(desktop_client)
|
||||
assert authorization_url.startswith(MockOIDCServer.get_authorize_url())
|
||||
|
||||
# Desktop gets the authorization code from OIDC provider
|
||||
session = async_get_clientsession(hass)
|
||||
resp_auth = session.get(authorization_url, allow_redirects=False)
|
||||
assert resp_auth.status == 200
|
||||
json_auth = await resp_auth.json()
|
||||
assert "code" in json_auth
|
||||
desktop_code = json_auth["code"]
|
||||
|
||||
await complete_callback_and_finish(desktop_client, desktop_code, desktop_state)
|
||||
|
||||
# ==================== Mobile Device Finalizes Flow ====================
|
||||
# Mobile device polls SSE and keeps the connection open throughout
|
||||
resp_sse = await mobile_client.get(
|
||||
"/auth/oidc/device-sse", allow_redirects=False
|
||||
)
|
||||
assert resp_sse.status == 200
|
||||
|
||||
# Listen for waiting events for up to 5 seconds
|
||||
await listen_for_sse_events(resp_sse, "waiting", timeout_seconds=5)
|
||||
|
||||
# Actually submit the mobile code using POST
|
||||
resp_code = await desktop_client.post(
|
||||
"/auth/oidc/finish",
|
||||
data={"device_code": mobile_device_code},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_code.status == 200
|
||||
assert resp_code.headers.get("Content-Type", "").startswith("text/html")
|
||||
html_code = await resp_code.text()
|
||||
assert 'id="mobile-success-message"' in html_code
|
||||
assert 'id="restart-login-button"' in html_code
|
||||
|
||||
# ==================== Mobile Device Receives Ready Event ====================
|
||||
# After desktop flow is completed, mobile SSE should receive a ready event on same connection
|
||||
await listen_for_sse_events(resp_sse, "ready", timeout_seconds=5)
|
||||
|
||||
# POST to finish without any POST body should result in 302 back to the original redirect_uri
|
||||
await verify_back_redirect(mobile_client, mobile_redirect_uri)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_rejects_device_code_when_state_not_ready(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Submitting a device code must fail if callback did not complete for this browser."""
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
# Device session that owns the device code.
|
||||
mobile_client = await hass_client()
|
||||
mobile_redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
_, mobile_html, status = await get_welcome_for_client(
|
||||
mobile_client, mobile_redirect_uri
|
||||
)
|
||||
assert status == 200
|
||||
|
||||
device_code_match = re.search(
|
||||
r'id=["\']device-code["\'][^>]*>\s*([^<\s]+)\s*<',
|
||||
mobile_html,
|
||||
)
|
||||
assert device_code_match is not None
|
||||
mobile_device_code = device_code_match.group(1)
|
||||
|
||||
# Separate browser starts but does not complete callback flow.
|
||||
desktop_client = await hass_client()
|
||||
desktop_redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
_, _, desktop_status = await get_welcome_for_client(
|
||||
desktop_client, desktop_redirect_uri
|
||||
)
|
||||
assert desktop_status in [200, 302]
|
||||
|
||||
# Negative branch: try to finalize before desktop state has user info.
|
||||
resp = await desktop_client.post(
|
||||
"/auth/oidc/finish",
|
||||
data={"device_code": mobile_device_code},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 400
|
||||
text = await resp.text()
|
||||
assert "Failed to link state to device code" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_shows_error_if_userinfo_save_fails(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Callback should return error page when state save fails after successful token flow."""
|
||||
await setup(hass)
|
||||
|
||||
with (
|
||||
mock_oidc_responses(),
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_save_user_info",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
state, _, status = await get_welcome_for_client(client, redirect_uri)
|
||||
assert status == 200
|
||||
|
||||
authorization_url = await get_redirect_auth_url(client)
|
||||
session = async_get_clientsession(hass)
|
||||
resp_auth = session.get(authorization_url, allow_redirects=False)
|
||||
json_auth = await resp_auth.json()
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={json_auth['code']}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 500
|
||||
text = await resp.text()
|
||||
assert "Failed to save user information, session probably expired." in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_nonce_mismatch(hass: HomeAssistant, hass_client):
|
||||
"""Callback should fail closed when the returned nonce does not match the stored flow nonce."""
|
||||
await setup(hass)
|
||||
|
||||
with (
|
||||
mock_oidc_responses(),
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.OIDCClient._parse_id_token",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"sub": "test-user",
|
||||
"nonce": "mismatched-nonce",
|
||||
"name": "Test Name",
|
||||
"preferred_username": "testuser",
|
||||
"groups": [],
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
state, _, status = await get_welcome_for_client(client, redirect_uri)
|
||||
assert status == 200
|
||||
|
||||
authorization_url = await get_redirect_auth_url(client)
|
||||
session = async_get_clientsession(hass)
|
||||
resp_auth = session.get(authorization_url, allow_redirects=False)
|
||||
json_auth = await resp_auth.json()
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={json_auth['code']}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 500
|
||||
text = await resp.text()
|
||||
assert "Failed to get user details" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_replay_is_rejected(hass: HomeAssistant, hass_client):
|
||||
"""A callback replay with the same state should be rejected after first successful use."""
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
state, _, status = await get_welcome_for_client(client, redirect_uri)
|
||||
assert status == 200
|
||||
|
||||
authorization_url = await get_redirect_auth_url(client)
|
||||
session = async_get_clientsession(hass)
|
||||
resp_auth = session.get(authorization_url, allow_redirects=False)
|
||||
json_auth = await resp_auth.json()
|
||||
code = json_auth["code"]
|
||||
|
||||
# First callback should succeed.
|
||||
first = await client.get(
|
||||
f"/auth/oidc/callback?code={code}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert first.status == 302
|
||||
|
||||
# Replay should fail because the state flow has already been consumed.
|
||||
replay = await client.get(
|
||||
f"/auth/oidc/callback?code={code}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert replay.status == 500
|
||||
replay_text = await replay.text()
|
||||
assert "Failed to get user details" in replay_text
|
||||
690
tests/test_hass_oidc_client_unit.py
Normal file
690
tests/test_hass_oidc_client_unit.py
Normal file
@@ -0,0 +1,690 @@
|
||||
"""Unit tests for OIDC client token and security behavior."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from joserfc import errors as joserfc_errors, jwt, jwk
|
||||
|
||||
from custom_components.auth_oidc.tools.oidc_client import (
|
||||
HTTPClientError,
|
||||
OIDCClient,
|
||||
OIDCDiscoveryInvalid,
|
||||
OIDCIdTokenSigningAlgorithmInvalid,
|
||||
OIDCTokenResponseInvalid,
|
||||
OIDCUserinfoInvalid,
|
||||
http_raise_for_status,
|
||||
)
|
||||
|
||||
|
||||
def make_client(hass: HomeAssistant, **kwargs) -> OIDCClient:
|
||||
"""Build an OIDC client with explicit defaults for unit testing."""
|
||||
return OIDCClient(
|
||||
hass=hass,
|
||||
discovery_url="https://issuer/.well-known/openid-configuration",
|
||||
client_id="test-client",
|
||||
scope="openid profile",
|
||||
features=kwargs.pop("features", {}),
|
||||
claims=kwargs.pop("claims", {}),
|
||||
roles=kwargs.pop("roles", {}),
|
||||
network=kwargs.pop("network", {}),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def make_jwt(
|
||||
header: dict | None,
|
||||
payload: dict | None = None,
|
||||
signature: str = "sig",
|
||||
) -> str:
|
||||
"""Build a compact JWT string for parser-focused tests."""
|
||||
|
||||
def _b64url_json(data: dict) -> str:
|
||||
encoded = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(encoded).rstrip(b"=").decode("utf-8")
|
||||
|
||||
protected = _b64url_json(header) if header is not None else ""
|
||||
claims = _b64url_json(payload or {"sub": "subject"})
|
||||
return f"{protected}.{claims}.{signature}"
|
||||
|
||||
|
||||
def make_signed_hs256_jwt(secret: str, claims: dict) -> str:
|
||||
"""Build a real HS256 signed JWT for parser validation tests."""
|
||||
jwk_obj = jwk.import_key(
|
||||
{
|
||||
"kty": "oct",
|
||||
"k": base64.urlsafe_b64encode(secret.encode()).decode().rstrip("="),
|
||||
"alg": "HS256",
|
||||
}
|
||||
)
|
||||
return jwt.encode({"alg": "HS256"}, claims, jwk_obj)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_token_flow_rejects_missing_state(hass: HomeAssistant):
|
||||
"""Flow state must exist; missing state should fail closed."""
|
||||
client = make_client(hass)
|
||||
|
||||
result = await client.async_complete_token_flow(
|
||||
"https://example.com/callback", "code", "missing-state"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_token_flow_rejects_nonce_mismatch(hass: HomeAssistant):
|
||||
"""Nonce mismatch should reject the token flow."""
|
||||
client = make_client(hass)
|
||||
client.flows["state-1"] = {"code_verifier": "verifier", "nonce": "expected"}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"token_endpoint": "https://issuer/token"}),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_make_token_request",
|
||||
new=AsyncMock(return_value={"id_token": "id", "access_token": "access"}),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_parse_id_token",
|
||||
new=AsyncMock(return_value={"sub": "abc", "nonce": "wrong"}),
|
||||
),
|
||||
):
|
||||
result = await client.async_complete_token_flow(
|
||||
"https://example.com/callback", "code", "state-1"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert "state-1" not in client.flows
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_token_flow_handles_token_request_failure(hass: HomeAssistant):
|
||||
"""Token endpoint failures should return None to caller."""
|
||||
client = make_client(hass)
|
||||
client.flows["state-2"] = {"code_verifier": "verifier", "nonce": "nonce"}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"token_endpoint": "https://issuer/token"}),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_make_token_request",
|
||||
new=AsyncMock(side_effect=OIDCTokenResponseInvalid()),
|
||||
),
|
||||
):
|
||||
result = await client.async_complete_token_flow(
|
||||
"https://example.com/callback", "code", "state-2"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_user_details_handles_non_list_groups(hass: HomeAssistant):
|
||||
"""Non-list groups should not accidentally grant roles."""
|
||||
client = make_client(hass, roles={"user": "users", "admin": "admins"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"issuer": "https://issuer"}),
|
||||
):
|
||||
details = await client.parse_user_details(
|
||||
{
|
||||
"sub": "subject",
|
||||
"name": "Display Name",
|
||||
"preferred_username": "username",
|
||||
"groups": "admins",
|
||||
},
|
||||
"access-token",
|
||||
)
|
||||
|
||||
assert details["role"] == "invalid"
|
||||
assert details["display_name"] == "Display Name"
|
||||
assert details["username"] == "username"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_user_details_uses_userinfo_for_missing_claims(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Missing claims in id_token should be filled from userinfo when available."""
|
||||
client = make_client(hass)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"issuer": "https://issuer",
|
||||
"userinfo_endpoint": "https://issuer/userinfo",
|
||||
}
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_userinfo",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"name": "From UserInfo",
|
||||
"preferred_username": "userinfo-user",
|
||||
"groups": ["admins"],
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
details = await client.parse_user_details({"sub": "subject"}, "access-token")
|
||||
|
||||
expected_sub = hashlib.sha256("https://issuer.subject".encode("utf-8")).hexdigest()
|
||||
assert details["sub"] == expected_sub
|
||||
assert details["display_name"] == "From UserInfo"
|
||||
assert details["username"] == "userinfo-user"
|
||||
assert details["role"] == "system-admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_user_details_assigns_system_users_role(hass: HomeAssistant):
|
||||
"""Configured user role should map to system-users when group is present."""
|
||||
client = make_client(hass, roles={"user": "users", "admin": "admins"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"issuer": "https://issuer"}),
|
||||
):
|
||||
details = await client.parse_user_details(
|
||||
{
|
||||
"sub": "subject",
|
||||
"name": "Display Name",
|
||||
"preferred_username": "username",
|
||||
"groups": ["users"],
|
||||
},
|
||||
"access-token",
|
||||
)
|
||||
|
||||
assert details["role"] == "system-users"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_user_details_admin_role_overrides_user_role(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Admin group should take precedence when both user and admin groups are present."""
|
||||
client = make_client(hass, roles={"user": "users", "admin": "admins"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"issuer": "https://issuer"}),
|
||||
):
|
||||
details = await client.parse_user_details(
|
||||
{
|
||||
"sub": "subject",
|
||||
"name": "Display Name",
|
||||
"preferred_username": "username",
|
||||
"groups": ["users", "admins"],
|
||||
},
|
||||
"access-token",
|
||||
)
|
||||
|
||||
assert details["role"] == "system-admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authorization_url_omits_pkce_when_disabled(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Authorization URL should omit PKCE params when compatibility mode disables PKCE."""
|
||||
client = make_client(hass, features={"disable_rfc7636": True})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(
|
||||
return_value={"authorization_endpoint": "https://issuer/authorize"}
|
||||
),
|
||||
):
|
||||
url = await client.async_get_authorization_url(
|
||||
"https://example.com/callback", "state-xyz"
|
||||
)
|
||||
|
||||
assert url is not None
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
assert query["state"] == ["state-xyz"]
|
||||
assert "nonce" in query
|
||||
assert "code_challenge" not in query
|
||||
assert "code_challenge_method" not in query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_returns_none_when_kid_missing(hass: HomeAssistant):
|
||||
"""ID token without kid should be rejected."""
|
||||
client = make_client(hass)
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt({"alg": "RS256"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": []}),
|
||||
):
|
||||
parsed = await client._parse_id_token(token)
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_returns_none_when_kid_not_found(hass: HomeAssistant):
|
||||
"""ID token with unknown kid should be rejected."""
|
||||
client = make_client(hass)
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt({"alg": "RS256", "kid": "missing"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": [{"kid": "other"}]}),
|
||||
):
|
||||
parsed = await client._parse_id_token(token)
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_rejects_hs_without_client_secret(hass: HomeAssistant):
|
||||
"""HMAC-signed id_token requires client_secret and must fail otherwise."""
|
||||
client = make_client(hass, id_token_signing_alg="HS256")
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt({"alg": "HS256"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": []}),
|
||||
):
|
||||
with pytest.raises(OIDCIdTokenSigningAlgorithmInvalid):
|
||||
await client._parse_id_token(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_returns_none_when_decode_fails_jose(hass: HomeAssistant):
|
||||
"""Jose decode/verification failures should be handled without raising to callers."""
|
||||
client = make_client(hass)
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt({"alg": "RS256", "kid": "kid1"})
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": [{"kid": "kid1", "kty": "RSA"}]}),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.jwk.import_key",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.jwt.decode",
|
||||
side_effect=joserfc_errors.JoseError("bad token"),
|
||||
),
|
||||
):
|
||||
parsed = await client._parse_id_token(token)
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_rejects_wrong_signing_algorithm(hass: HomeAssistant):
|
||||
"""ID token signed with unexpected alg should be rejected."""
|
||||
client = make_client(hass, id_token_signing_alg="RS256")
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt({"alg": "HS256"})
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": []}),
|
||||
):
|
||||
with pytest.raises(OIDCIdTokenSigningAlgorithmInvalid):
|
||||
await client._parse_id_token(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_rejects_missing_header(hass: HomeAssistant):
|
||||
"""ID token without protected header should be rejected."""
|
||||
client = make_client(hass)
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
token = make_jwt(None)
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": []}),
|
||||
):
|
||||
parsed = await client._parse_id_token(token)
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_id_token_rejects_invalid_registered_claims(hass: HomeAssistant):
|
||||
"""Invalid aud/iss/sub style claim validation should fail closed."""
|
||||
hs_secret = "top-secret-value"
|
||||
|
||||
client = make_client(
|
||||
hass,
|
||||
id_token_signing_alg="HS256",
|
||||
client_secret=hs_secret,
|
||||
)
|
||||
client.discovery_document = {
|
||||
"issuer": "https://issuer",
|
||||
"jwks_uri": "https://issuer/jwks",
|
||||
}
|
||||
|
||||
now = int(time.time())
|
||||
token = make_signed_hs256_jwt(
|
||||
hs_secret,
|
||||
{
|
||||
"sub": "abc",
|
||||
"aud": "wrong-audience",
|
||||
"iss": "https://wrong-issuer",
|
||||
"nbf": now,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_jwks",
|
||||
new=AsyncMock(return_value={"keys": []}),
|
||||
):
|
||||
parsed = await client._parse_id_token(token)
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authorization_url_returns_none_when_discovery_fails(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Discovery failures should return None from authorization URL generation."""
|
||||
client = make_client(hass)
|
||||
|
||||
with patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(side_effect=OIDCDiscoveryInvalid()),
|
||||
):
|
||||
url = await client.async_get_authorization_url(
|
||||
"https://example.com/callback", "state-1"
|
||||
)
|
||||
|
||||
assert url is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_token_flow_omits_code_verifier_when_pkce_disabled(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""When PKCE is disabled, token request should omit code_verifier."""
|
||||
client = make_client(hass, features={"disable_rfc7636": True})
|
||||
client.flows["state-3"] = {"code_verifier": "verifier", "nonce": "nonce"}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_discovery_document",
|
||||
new=AsyncMock(return_value={"token_endpoint": "https://issuer/token"}),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_make_token_request",
|
||||
new=AsyncMock(return_value={"id_token": "id", "access_token": "access"}),
|
||||
) as make_token_request,
|
||||
patch.object(
|
||||
client,
|
||||
"_parse_id_token",
|
||||
new=AsyncMock(return_value={"sub": "abc", "nonce": "nonce"}),
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"parse_user_details",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"sub": "abc",
|
||||
"display_name": "n",
|
||||
"username": "u",
|
||||
"role": "system-users",
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await client.async_complete_token_flow(
|
||||
"https://example.com/callback", "code", "state-3"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
token_params = make_token_request.await_args.args[1]
|
||||
assert "code_verifier" not in token_params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_raise_for_status_noop_on_ok_response():
|
||||
"""Status helper should not raise for successful responses."""
|
||||
response = MagicMock()
|
||||
response.ok = True
|
||||
|
||||
await http_raise_for_status(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_raise_for_status_raises_http_client_error_with_body():
|
||||
"""Status helper should include response body in raised exception."""
|
||||
response = MagicMock()
|
||||
response.ok = False
|
||||
response.reason = "Bad Request"
|
||||
response.status = 400
|
||||
response.request_info = MagicMock()
|
||||
response.history = ()
|
||||
response.headers = {}
|
||||
response.text = AsyncMock(return_value="problem details")
|
||||
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
await http_raise_for_status(response)
|
||||
|
||||
assert "400 (Bad Request)" in str(exc_info.value)
|
||||
assert "problem details" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_http_session_reuses_existing_session(hass: HomeAssistant):
|
||||
"""Session helper should return existing session when already created."""
|
||||
client = make_client(hass)
|
||||
existing_session = MagicMock()
|
||||
client.http_session = existing_session
|
||||
|
||||
session = await client._get_http_session()
|
||||
|
||||
assert session is existing_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_http_session_applies_tls_verify_flag(hass: HomeAssistant):
|
||||
"""Session helper should pass tls_verify setting into TCP connector."""
|
||||
client = make_client(hass, network={"tls_verify": False})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.aiohttp.TCPConnector",
|
||||
return_value=MagicMock(),
|
||||
) as tcp_connector,
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.aiohttp.ClientSession",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
await client._get_http_session()
|
||||
|
||||
tcp_connector.assert_called_once_with(verify_ssl=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_http_session_uses_custom_ca_path(hass: HomeAssistant):
|
||||
"""Session helper should create SSL context when custom CA path is configured."""
|
||||
client = make_client(
|
||||
hass,
|
||||
network={"tls_verify": True, "tls_ca_path": "/tmp/test-ca.pem"},
|
||||
)
|
||||
fake_ssl_context = object()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hass.loop,
|
||||
"run_in_executor",
|
||||
new=AsyncMock(return_value=fake_ssl_context),
|
||||
) as run_in_executor,
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.aiohttp.TCPConnector",
|
||||
return_value=MagicMock(),
|
||||
) as tcp_connector,
|
||||
patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.aiohttp.ClientSession",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
await client._get_http_session()
|
||||
|
||||
run_in_executor.assert_awaited_once()
|
||||
tcp_connector.assert_called_once_with(verify_ssl=True, ssl=fake_ssl_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_token_request_returns_json_on_success(hass: HomeAssistant):
|
||||
"""Token request helper should return JSON payload for successful responses."""
|
||||
client = make_client(hass)
|
||||
response = MagicMock()
|
||||
response.ok = True
|
||||
response.json = AsyncMock(return_value={"access_token": "token"})
|
||||
|
||||
context_manager = AsyncMock()
|
||||
context_manager.__aenter__.return_value = response
|
||||
session = MagicMock()
|
||||
session.post.return_value = context_manager
|
||||
|
||||
with patch.object(client, "_get_http_session", new=AsyncMock(return_value=session)):
|
||||
payload = await client._make_token_request(
|
||||
"https://issuer/token", {"code": "abc"}
|
||||
)
|
||||
|
||||
assert payload == {"access_token": "token"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_token_request_raises_invalid_on_non_400_http_error(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Token request helper should map upstream HTTP errors to OIDCTokenResponseInvalid."""
|
||||
client = make_client(hass)
|
||||
response = MagicMock()
|
||||
response.ok = False
|
||||
response.reason = "Server Error"
|
||||
response.status = 500
|
||||
response.request_info = MagicMock()
|
||||
response.history = ()
|
||||
response.headers = {}
|
||||
response.text = AsyncMock(return_value="boom")
|
||||
|
||||
context_manager = AsyncMock()
|
||||
context_manager.__aenter__.return_value = response
|
||||
session = MagicMock()
|
||||
session.post.return_value = context_manager
|
||||
|
||||
with patch.object(client, "_get_http_session", new=AsyncMock(return_value=session)):
|
||||
with pytest.raises(OIDCTokenResponseInvalid):
|
||||
await client._make_token_request("https://issuer/token", {"code": "abc"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_userinfo_returns_json_on_success(hass: HomeAssistant):
|
||||
"""Userinfo helper should return JSON payload for successful responses."""
|
||||
client = make_client(hass)
|
||||
response = MagicMock()
|
||||
response.ok = True
|
||||
response.json = AsyncMock(return_value={"sub": "abc"})
|
||||
|
||||
context_manager = AsyncMock()
|
||||
context_manager.__aenter__.return_value = response
|
||||
session = MagicMock()
|
||||
session.get.return_value = context_manager
|
||||
|
||||
with patch.object(client, "_get_http_session", new=AsyncMock(return_value=session)):
|
||||
payload = await client._get_userinfo("https://issuer/userinfo", "access")
|
||||
|
||||
assert payload == {"sub": "abc"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_userinfo_raises_invalid_on_http_error(hass: HomeAssistant):
|
||||
"""Userinfo helper should map upstream HTTP errors to OIDCUserinfoInvalid."""
|
||||
client = make_client(hass)
|
||||
response = MagicMock()
|
||||
response.ok = False
|
||||
response.reason = "Unavailable"
|
||||
response.status = 503
|
||||
response.request_info = MagicMock()
|
||||
response.history = ()
|
||||
response.headers = {}
|
||||
response.text = AsyncMock(return_value="oops")
|
||||
|
||||
context_manager = AsyncMock()
|
||||
context_manager.__aenter__.return_value = response
|
||||
session = MagicMock()
|
||||
session.get.return_value = context_manager
|
||||
|
||||
with patch.object(client, "_get_http_session", new=AsyncMock(return_value=session)):
|
||||
with pytest.raises(OIDCUserinfoInvalid):
|
||||
await client._get_userinfo("https://issuer/userinfo", "access")
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
|
||||
from custom_components.auth_oidc import DOMAIN, async_setup_entry
|
||||
from custom_components.auth_oidc import DOMAIN
|
||||
from custom_components.auth_oidc.config.const import (
|
||||
OIDC_PROVIDERS,
|
||||
CLIENT_ID,
|
||||
@@ -170,12 +170,6 @@ async def test_full_config_flow_success(hass: HomeAssistant):
|
||||
assert len(entries) == 1
|
||||
assert entries[0].data == expected_data
|
||||
|
||||
# You can also assert that `async_setup_entry` was called for this entry
|
||||
# (assuming it's mocked or you let it run if it's simple)
|
||||
# The PHCC `hass` fixture automatically mocks `async_setup_entry`
|
||||
# and `async_unload_entry` for you, making it easy to test that they're called.
|
||||
assert await async_setup_entry(hass, entries[0]) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_flow_success(hass: HomeAssistant):
|
||||
@@ -362,3 +356,294 @@ async def test_reconfigure_flow_success(hass: HomeAssistant):
|
||||
assert len(entries) == 1
|
||||
assert entries[0].data[CLIENT_ID] == new_client_id
|
||||
assert entries[0].data[CLIENT_SECRET] == new_client_secret
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconfigure_flow_rejects_invalid_client_id(hass: HomeAssistant):
|
||||
"""Reconfigure should keep the form open when the client ID is invalid."""
|
||||
initial_data = {
|
||||
"provider": "authentik",
|
||||
CLIENT_ID: DEMO_CLIENT_ID,
|
||||
CLIENT_SECRET: DEMO_CLIENT_SECRET,
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
DISPLAY_NAME: OIDC_PROVIDERS["authentik"]["name"],
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: True,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE: True,
|
||||
},
|
||||
CLAIMS: {
|
||||
CLAIMS_DISPLAY_NAME: OIDC_PROVIDERS["authentik"]["claims"]["display_name"],
|
||||
CLAIMS_USERNAME: OIDC_PROVIDERS["authentik"]["claims"]["username"],
|
||||
CLAIMS_GROUPS: OIDC_PROVIDERS["authentik"]["claims"]["groups"],
|
||||
},
|
||||
ROLES: {ROLE_ADMINS: DEMO_ADMIN_ROLE, ROLE_USERS: DEMO_USER_ROLE},
|
||||
}
|
||||
|
||||
entry = config_entries.ConfigEntry(
|
||||
version=1,
|
||||
minor_version=0,
|
||||
domain=DOMAIN,
|
||||
title=OIDC_PROVIDERS["authentik"]["name"],
|
||||
data=initial_data,
|
||||
source=config_entries.SOURCE_USER,
|
||||
entry_id="1",
|
||||
unique_id="test_unique_id",
|
||||
options={},
|
||||
pref_disable_new_entities=False,
|
||||
pref_disable_polling=False,
|
||||
discovery_keys=[],
|
||||
subentries_data=None,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_add(entry)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_RECONFIGURE,
|
||||
"entry_id": entry.entry_id,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"client_id": " ", "client_secret": DEMO_CLIENT_SECRET},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert result["errors"]["client_id"] == "invalid_client_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconfigure_flow_keeps_client_secret_when_blank(hass: HomeAssistant):
|
||||
"""Submitting a blank secret should keep the existing client secret."""
|
||||
initial_data = {
|
||||
"provider": "authentik",
|
||||
CLIENT_ID: DEMO_CLIENT_ID,
|
||||
CLIENT_SECRET: DEMO_CLIENT_SECRET,
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
DISPLAY_NAME: OIDC_PROVIDERS["authentik"]["name"],
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: True,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE: True,
|
||||
},
|
||||
CLAIMS: {
|
||||
CLAIMS_DISPLAY_NAME: OIDC_PROVIDERS["authentik"]["claims"]["display_name"],
|
||||
CLAIMS_USERNAME: OIDC_PROVIDERS["authentik"]["claims"]["username"],
|
||||
CLAIMS_GROUPS: OIDC_PROVIDERS["authentik"]["claims"]["groups"],
|
||||
},
|
||||
ROLES: {ROLE_ADMINS: DEMO_ADMIN_ROLE, ROLE_USERS: DEMO_USER_ROLE},
|
||||
}
|
||||
|
||||
entry = config_entries.ConfigEntry(
|
||||
version=1,
|
||||
minor_version=0,
|
||||
domain=DOMAIN,
|
||||
title=OIDC_PROVIDERS["authentik"]["name"],
|
||||
data=initial_data,
|
||||
source=config_entries.SOURCE_USER,
|
||||
entry_id="1",
|
||||
unique_id="test_unique_id",
|
||||
options={},
|
||||
pref_disable_new_entities=False,
|
||||
pref_disable_polling=False,
|
||||
discovery_keys=[],
|
||||
subentries_data=None,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_add(entry)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_RECONFIGURE,
|
||||
"entry_id": entry.entry_id,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"client_id": DEMO_CLIENT_ID, "client_secret": ""},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
updated_entry = hass.config_entries.async_get_entry(entry.entry_id)
|
||||
assert updated_entry is not None
|
||||
assert updated_entry.data[CLIENT_SECRET] == DEMO_CLIENT_SECRET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_actions_route_to_other_steps(hass: HomeAssistant):
|
||||
"""Validation actions should route to the requested flow step."""
|
||||
with mock_oidc_responses():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"provider": "authentik"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"discovery_url": MockOIDCServer.get_discovery_url()},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "validate_connection"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"action": "fix_discovery"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_url"
|
||||
|
||||
with mock_oidc_responses():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"provider": "authentik"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"discovery_url": MockOIDCServer.get_discovery_url()},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "validate_connection"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"action": "change_provider"}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_flow_aborts_when_yaml_configured(hass: HomeAssistant):
|
||||
"""The user flow should abort when YAML config already owns the provider."""
|
||||
hass.data[DOMAIN] = {"yaml_config": {"client_id": DEMO_CLIENT_ID}}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "yaml_configured"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_flow_aborts_when_entry_already_exists(hass: HomeAssistant):
|
||||
"""The flow should not create a second OIDC config entry."""
|
||||
entry = config_entries.ConfigEntry(
|
||||
version=1,
|
||||
minor_version=0,
|
||||
domain=DOMAIN,
|
||||
title=OIDC_PROVIDERS["authentik"]["name"],
|
||||
data={"provider": "authentik"},
|
||||
source=config_entries.SOURCE_USER,
|
||||
entry_id="1",
|
||||
unique_id="test_unique_id",
|
||||
options={},
|
||||
pref_disable_new_entities=False,
|
||||
pref_disable_polling=False,
|
||||
discovery_keys=[],
|
||||
subentries_data=None,
|
||||
)
|
||||
await hass.config_entries.async_add(entry)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_url_validation_rejects_invalid_url(hass: HomeAssistant):
|
||||
"""Discovery URL validation should reject malformed inputs."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"provider": "authentik"}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"discovery_url": "not-a-valid-oidc-url"}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_url"
|
||||
assert result["errors"]["discovery_url"] == "invalid_url_format"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_provider_skips_groups_config(hass: HomeAssistant):
|
||||
"""Providers without group support should go straight to user linking."""
|
||||
with mock_oidc_responses():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"provider": "generic"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"discovery_url": MockOIDCServer.get_discovery_url()},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"action": "continue"}
|
||||
)
|
||||
assert result["step_id"] == "client_config"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"client_id": DEMO_CLIENT_ID, "client_secret": DEMO_CLIENT_SECRET},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user_linking"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_groups_disabled_skips_roles_and_creates_entry(hass: HomeAssistant):
|
||||
"""Disabling groups should skip role configuration and omit roles from entry data."""
|
||||
with mock_oidc_responses():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"provider": "authentik"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"discovery_url": MockOIDCServer.get_discovery_url()},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"action": "continue"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"client_id": DEMO_CLIENT_ID, "client_secret": DEMO_CLIENT_SECRET},
|
||||
)
|
||||
assert result["step_id"] == "groups_config"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"enable_groups": False}
|
||||
)
|
||||
assert result["step_id"] == "user_linking"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"enable_user_linking": True}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert "roles" not in result["data"]
|
||||
assert result["data"][FEATURES][FEATURES_INCLUDE_GROUPS_SCOPE] is False
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Tests for the registered webpages"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from auth_oidc.config.const import (
|
||||
DISCOVERY_URL,
|
||||
CLIENT_ID,
|
||||
FEATURES,
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION,
|
||||
)
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from auth_oidc.config.const import DISCOVERY_URL, CLIENT_ID
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,62 +11,239 @@ from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.http import StaticPathConfig, DOMAIN as HTTP_DOMAIN
|
||||
|
||||
from custom_components.auth_oidc import DOMAIN
|
||||
from custom_components.auth_oidc.endpoints.injected_auth_page import (
|
||||
OIDCInjectedAuthPage,
|
||||
frontend_injection,
|
||||
)
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant, enable_frontend_changes: bool = None):
|
||||
WEB_CLIENT_ID = "https://example.com"
|
||||
MOBILE_CLIENT_ID = "https://home-assistant.io/Android"
|
||||
|
||||
|
||||
def create_redirect_uri(client_id: str) -> str:
|
||||
"""Build a redirect URI that includes a client_id query parameter."""
|
||||
return f"http://example.com/auth/authorize?client_id={client_id}"
|
||||
|
||||
|
||||
def encode_redirect_uri(redirect_uri: str) -> str:
|
||||
"""Encode redirect_uri in the same way as frontend btoa()."""
|
||||
return base64.b64encode(redirect_uri.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
async def setup(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
mock_config = {
|
||||
DOMAIN: {
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
FEATURES: {
|
||||
FEATURES_DISABLE_FRONTEND_INJECTION: not enable_frontend_changes
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if enable_frontend_changes is None:
|
||||
del mock_config[DOMAIN][FEATURES][FEATURES_DISABLE_FRONTEND_INJECTION]
|
||||
|
||||
result = await async_setup_component(hass, DOMAIN, mock_config)
|
||||
assert result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_page_registration(hass: HomeAssistant, hass_client):
|
||||
"""Test that welcome page is present if frontend changes are disabled."""
|
||||
"""Test that welcome page is present."""
|
||||
|
||||
await setup(hass, enable_frontend_changes=False)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/welcome", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_page_registration_with_changes(hass: HomeAssistant, hass_client):
|
||||
"""Test that welcome page is redirect if frontend changes are enabled."""
|
||||
|
||||
await setup(hass, enable_frontend_changes=True)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/welcome", allow_redirects=False)
|
||||
assert resp.status == 307
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_page_registration(hass: HomeAssistant, hass_client):
|
||||
"""Test that redirect page shows OIDC misconfiguration error if OIDC server is not reachable."""
|
||||
"""Test that redirect page can be reached."""
|
||||
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
text = await resp.text()
|
||||
assert "Integration is misconfigured" in text
|
||||
assert resp.status == 302
|
||||
|
||||
resp2 = await client.post("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp2.status == 200
|
||||
assert resp2.status == 302
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_rejects_invalid_encoded_redirect_uri(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Welcome should reject malformed base64 redirect_uri values."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
"/auth/oidc/welcome?redirect_uri=%25%25%25",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert "Invalid redirect_uri, please restart login." in await resp.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_sets_strict_state_cookie_flags(hass: HomeAssistant, hass_client):
|
||||
"""Welcome should set secure cookie flags for the OIDC state cookie."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
assert resp.status in (200, 302)
|
||||
assert "auth_oidc_state" in resp.cookies
|
||||
|
||||
set_cookie = resp.headers.get("Set-Cookie", "")
|
||||
assert "Path=/auth/" in set_cookie
|
||||
assert "SameSite=Strict" in set_cookie
|
||||
assert "HttpOnly" in set_cookie
|
||||
assert "Max-Age=300" in set_cookie
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_mobile_device_code_generation_failure(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Welcome should error if device code generation fails for mobile clients."""
|
||||
await setup(hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_generate_device_code",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 500
|
||||
assert (
|
||||
"Failed to generate device code, please restart login." in await resp.text()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_shows_alternative_sign_in_link_when_other_providers_exist(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Welcome should render fallback auth link when other providers are present."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 200
|
||||
text = await resp.text()
|
||||
assert 'id="login-button"' in text
|
||||
assert 'id="alternative-sign-in-link"' in text
|
||||
assert "skip_oidc_redirect=true" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_desktop_auto_redirects_without_other_providers(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Welcome should auto-redirect desktop clients when no other providers exist."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = [] # Clear initial providers out
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 302
|
||||
assert "/auth/oidc/redirect" in resp.headers["Location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_without_cookie_goes_to_welcome(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Redirect endpoint should bounce to welcome when no state cookie exists."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 302
|
||||
assert "/auth/oidc/welcome" in resp.headers["Location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_shows_error_on_oidc_runtime_error(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Redirect should show a configuration error when OIDC URL generation raises."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status in (200, 302)
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.OIDCClient.async_get_authorization_url",
|
||||
new=AsyncMock(side_effect=RuntimeError("broken discovery")),
|
||||
):
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 500
|
||||
assert (
|
||||
"Integration is misconfigured, discovery could not be obtained."
|
||||
in await resp.text()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_shows_error_when_auth_url_empty(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Redirect should show error page if OIDC returns no authorization URL."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status in (200, 302)
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.OIDCClient.async_get_authorization_url",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 500
|
||||
assert (
|
||||
"Integration is misconfigured, discovery could not be obtained."
|
||||
in await resp.text()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -80,45 +254,301 @@ async def test_callback_registration(hass: HomeAssistant, hass_client):
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/callback", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_registration(hass: HomeAssistant, hass_client):
|
||||
"""Test that finish page is reachable."""
|
||||
|
||||
async def test_callback_rejects_missing_code_or_state(hass: HomeAssistant, hass_client):
|
||||
"""Callback must reject requests missing either code or state."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/finish", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
text = await resp.text()
|
||||
|
||||
# Should miss the code parameter if called without it
|
||||
assert "Missing code" in text
|
||||
|
||||
resp2 = await client.get("/auth/oidc/finish?code=123456", allow_redirects=False)
|
||||
assert resp2.status == 200
|
||||
text2 = await resp2.text()
|
||||
assert "Missing code" not in text2
|
||||
assert "123456" in text2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_post(hass: HomeAssistant, hass_client):
|
||||
"""Test that finish page works with POST."""
|
||||
|
||||
await setup(hass)
|
||||
client = await hass_client()
|
||||
resp = await client.post("/auth/oidc/finish", data={}, allow_redirects=False)
|
||||
assert resp.status == 500
|
||||
|
||||
resp2 = await client.post(
|
||||
"/auth/oidc/finish", data={"code": "456888"}, allow_redirects=False
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp2.status == 302
|
||||
assert resp2.headers["Location"] == "/?storeToken=true"
|
||||
assert resp2.cookies["auth_oidc_code"].value == "456888"
|
||||
state = resp_welcome.cookies["auth_oidc_state"].value
|
||||
|
||||
resp_missing_code = await client.get(
|
||||
f"/auth/oidc/callback?state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_missing_code.status == 400
|
||||
assert "Missing code or state parameter." in await resp_missing_code.text()
|
||||
|
||||
resp_missing_state = await client.get(
|
||||
"/auth/oidc/callback?code=testcode",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_missing_state.status == 400
|
||||
assert "Missing code or state parameter." in await resp_missing_state.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_state_mismatch(hass: HomeAssistant, hass_client):
|
||||
"""Callback must reject state mismatch to protect against CSRF."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
state = resp_welcome.cookies["auth_oidc_state"].value
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code=testcode&state={state}-other",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert "State parameter does not match, possible CSRF attack." in await resp.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_when_user_details_fetch_fails(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""Callback should error when token exchange/userinfo retrieval fails."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
state = resp_welcome.cookies["auth_oidc_state"].value
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.OIDCClient.async_complete_token_flow",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code=testcode&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 500
|
||||
assert (
|
||||
"Failed to get user details, see Home Assistant logs for more information."
|
||||
in await resp.text()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_invalid_role(hass: HomeAssistant, hass_client):
|
||||
"""Callback should reject users marked with invalid role."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
state = resp_welcome.cookies["auth_oidc_state"].value
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.oidc_client.OIDCClient.async_complete_token_flow",
|
||||
new=AsyncMock(return_value={"sub": "abc", "role": "invalid"}),
|
||||
):
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code=testcode&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 403
|
||||
assert (
|
||||
"User is not in the correct group to access Home Assistant"
|
||||
in await resp.text()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("method", "data"),
|
||||
[
|
||||
("get", None),
|
||||
("post", {}),
|
||||
("post", {"device_code": "456888"}),
|
||||
],
|
||||
)
|
||||
async def test_finish_requires_state_cookie(
|
||||
hass: HomeAssistant, hass_client, method: str, data: dict | None
|
||||
):
|
||||
"""Finish endpoint should require the OIDC state cookie for both GET and POST."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
request = getattr(client, method)
|
||||
if data is None:
|
||||
resp = await request("/auth/oidc/finish", allow_redirects=False)
|
||||
else:
|
||||
resp = await request("/auth/oidc/finish", data=data, allow_redirects=False)
|
||||
|
||||
assert resp.status == 400
|
||||
assert "Missing state cookie" in await resp.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_post_rejects_invalid_state(hass: HomeAssistant, hass_client):
|
||||
"""Finish POST should error when the state cookie does not resolve to redirect_uri."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status in (200, 302)
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_get_redirect_uri_for_state",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await client.post("/auth/oidc/finish", allow_redirects=False)
|
||||
assert resp.status == 400
|
||||
assert "Invalid state, please restart login." in await resp.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_sse_requires_state_cookie(hass: HomeAssistant, hass_client):
|
||||
"""SSE endpoint should reject requests without state cookie."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/device-sse", allow_redirects=False)
|
||||
assert resp.status == 400
|
||||
assert "Missing session cookie" in await resp.text()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_sse_emits_expired_for_unknown_state(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""SSE should emit expired when the state can no longer be resolved."""
|
||||
await setup(hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_get_redirect_uri_for_state",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status == 200
|
||||
|
||||
resp = await client.get("/auth/oidc/device-sse", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
payload = await resp.text()
|
||||
assert "event: expired" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_sse_emits_timeout(hass: HomeAssistant, hass_client):
|
||||
"""SSE should emit timeout if the polling window is exceeded."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status == 200
|
||||
|
||||
fake_loop = MagicMock()
|
||||
fake_loop.time.side_effect = [0, 301]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_get_redirect_uri_for_state",
|
||||
new=AsyncMock(return_value=redirect_uri),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_is_state_ready",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.endpoints.device_sse.asyncio.get_running_loop",
|
||||
return_value=fake_loop,
|
||||
),
|
||||
):
|
||||
resp = await client.get("/auth/oidc/device-sse", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
payload = await resp.text()
|
||||
assert "event: timeout" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_sse_handles_runtime_error_and_returns_cleanly(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""SSE should swallow runtime errors from stream loop and finish response."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status == 200
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_get_redirect_uri_for_state",
|
||||
new=AsyncMock(return_value=redirect_uri),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_is_state_ready",
|
||||
new=AsyncMock(side_effect=RuntimeError("disconnect")),
|
||||
),
|
||||
):
|
||||
resp = await client.get("/auth/oidc/device-sse", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_sse_ignores_write_eof_connection_reset(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""SSE should ignore ConnectionResetError while closing the stream."""
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(MOBILE_CLIENT_ID)
|
||||
encoded = encode_redirect_uri(redirect_uri)
|
||||
resp_welcome = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp_welcome.status == 200
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_get_redirect_uri_for_state",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"custom_components.auth_oidc.endpoints.device_sse.web.StreamResponse.write_eof",
|
||||
new=AsyncMock(side_effect=ConnectionResetError),
|
||||
),
|
||||
):
|
||||
resp = await client.get("/auth/oidc/device-sse", allow_redirects=False)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
# Test the frontend injection
|
||||
@@ -141,7 +571,7 @@ async def test_frontend_injection(hass: HomeAssistant, hass_client):
|
||||
]
|
||||
)
|
||||
|
||||
await setup(hass, enable_frontend_changes=True)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/authorize", allow_redirects=False)
|
||||
@@ -149,4 +579,52 @@ async def test_frontend_injection(hass: HomeAssistant, hass_client):
|
||||
text = await resp.text()
|
||||
|
||||
assert "<script src='/auth/oidc/static/injection.js" in text
|
||||
assert 'window.sso_name = "OpenID Connect (SSO)";' in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_frontend_injection_logs_and_returns_when_route_handler_is_unexpected(
|
||||
hass: HomeAssistant, caplog
|
||||
):
|
||||
"""frontend_injection should log and return if the GET handler shape is unexpected."""
|
||||
|
||||
await async_setup_component(hass, HTTP_DOMAIN, {})
|
||||
|
||||
class FakeRoute:
|
||||
method = "GET"
|
||||
handler = object()
|
||||
|
||||
class FakeResource:
|
||||
canonical = "/auth/authorize"
|
||||
|
||||
def __init__(self):
|
||||
self.prefix = None
|
||||
|
||||
def add_prefix(self, prefix):
|
||||
self.prefix = prefix
|
||||
|
||||
def __iter__(self):
|
||||
return iter([FakeRoute()])
|
||||
|
||||
with patch.object(hass.http.app.router, "resources", return_value=[FakeResource()]):
|
||||
await frontend_injection(hass)
|
||||
|
||||
assert "Unexpected route handler type" in caplog.text
|
||||
assert (
|
||||
"Failed to find GET route for /auth/authorize, cannot inject OIDC frontend code"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_inject_logs_errors(hass: HomeAssistant, caplog):
|
||||
"""OIDCInjectedAuthPage.inject should swallow unexpected injection errors."""
|
||||
|
||||
await async_setup_component(hass, HTTP_DOMAIN, {})
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.endpoints.injected_auth_page.frontend_injection",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
await OIDCInjectedAuthPage.inject(hass)
|
||||
|
||||
assert "Failed to inject OIDC auth page: boom" in caplog.text
|
||||
|
||||
@@ -19,28 +19,25 @@ async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_success_yaml(hass: HomeAssistant):
|
||||
"""Test successful setup of a YAML configuration."""
|
||||
await setup(
|
||||
hass,
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
{
|
||||
"client_id": "dummy",
|
||||
"discovery_url": "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_success_yaml_with_optional(hass: HomeAssistant):
|
||||
"""Test successful setup of a YAML configuration with optional parameters."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
"client_id": "dummy",
|
||||
"discovery_url": "https://example.com/.well-known/openid-configuration",
|
||||
ADDITIONAL_SCOPES: "email phone",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_setup_success_yaml(hass: HomeAssistant, config: dict):
|
||||
"""YAML setup should succeed for minimal and optional-scope configurations."""
|
||||
await setup(
|
||||
hass,
|
||||
config,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"""Tests for the helpers and validation tools"""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from aiohttp import web
|
||||
|
||||
from custom_components.auth_oidc.tools.helpers import get_url, get_view
|
||||
from custom_components.auth_oidc.tools.helpers import (
|
||||
STATE_COOKIE_NAME,
|
||||
error_response,
|
||||
get_state_id,
|
||||
get_url,
|
||||
get_valid_state_id,
|
||||
get_view,
|
||||
html_response,
|
||||
template_response,
|
||||
)
|
||||
from custom_components.auth_oidc.tools.validation import (
|
||||
validate_client_id,
|
||||
sanitize_client_secret,
|
||||
@@ -38,6 +48,85 @@ async def test_get_view():
|
||||
assert data.startswith("<!DOCTYPE html>")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_state_id():
|
||||
"""State cookie helper should return cookie value when present."""
|
||||
request = make_mocked_request(
|
||||
"GET", "/", headers={"Cookie": f"{STATE_COOKIE_NAME}=abc"}
|
||||
)
|
||||
assert get_state_id(request) == "abc"
|
||||
|
||||
request_without_cookie = make_mocked_request("GET", "/")
|
||||
assert get_state_id(request_without_cookie) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_valid_state_id():
|
||||
"""Valid-state helper should return only existing and valid cookie states."""
|
||||
provider = MagicMock()
|
||||
provider.async_is_state_valid = AsyncMock(return_value=True)
|
||||
|
||||
request = make_mocked_request(
|
||||
"GET", "/", headers={"Cookie": f"{STATE_COOKIE_NAME}=state-1"}
|
||||
)
|
||||
state_id = await get_valid_state_id(request, provider)
|
||||
|
||||
assert state_id == "state-1"
|
||||
provider.async_is_state_valid.assert_awaited_once_with("state-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_valid_state_id_invalid_or_missing_cookie():
|
||||
"""Valid-state helper should reject missing and invalid states."""
|
||||
provider = MagicMock()
|
||||
provider.async_is_state_valid = AsyncMock(return_value=False)
|
||||
|
||||
request = make_mocked_request(
|
||||
"GET", "/", headers={"Cookie": f"{STATE_COOKIE_NAME}=state-2"}
|
||||
)
|
||||
assert await get_valid_state_id(request, provider) is None
|
||||
provider.async_is_state_valid.assert_awaited_once_with("state-2")
|
||||
|
||||
request_without_cookie = make_mocked_request("GET", "/")
|
||||
provider.async_is_state_valid.reset_mock()
|
||||
assert await get_valid_state_id(request_without_cookie, provider) is None
|
||||
provider.async_is_state_valid.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_response_and_template_helpers():
|
||||
"""Response helpers should preserve status and render HTML views."""
|
||||
response = html_response("<p>ok</p>", status=418)
|
||||
assert isinstance(response, web.Response)
|
||||
assert response.status == 418
|
||||
assert response.content_type == "text/html"
|
||||
assert response.text == "<p>ok</p>"
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.helpers.get_view",
|
||||
new=AsyncMock(return_value="<p>rendered</p>"),
|
||||
) as mocked_get_view:
|
||||
rendered = await template_response("welcome", {"name": "OIDC"})
|
||||
|
||||
assert rendered.status == 200
|
||||
assert rendered.text == "<p>rendered</p>"
|
||||
mocked_get_view.assert_awaited_once_with("welcome", {"name": "OIDC"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_response():
|
||||
"""Error response helper should render the shared error template with status."""
|
||||
with patch(
|
||||
"custom_components.auth_oidc.tools.helpers.get_view",
|
||||
new=AsyncMock(return_value="<p>error</p>"),
|
||||
) as mocked_get_view:
|
||||
rendered = await error_response("boom", status=500)
|
||||
|
||||
assert rendered.status == 500
|
||||
assert rendered.text == "<p>error</p>"
|
||||
mocked_get_view.assert_awaited_once_with("error", {"error": "boom"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_url():
|
||||
"""Test the validate_url helper."""
|
||||
|
||||
53
tests/test_provider_catalog.py
Normal file
53
tests/test_provider_catalog.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for the provider catalog helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.auth_oidc.config.const import OIDC_PROVIDERS, REPO_ROOT_URL
|
||||
from custom_components.auth_oidc.config.provider_catalog import (
|
||||
get_provider_config,
|
||||
get_provider_docs_url,
|
||||
get_provider_name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_key", "expected_name", "expected_supports_groups"),
|
||||
[
|
||||
("authentik", "Authentik", True),
|
||||
("generic", "OpenID Connect (SSO)", False),
|
||||
],
|
||||
)
|
||||
def test_get_provider_config_and_name(
|
||||
provider_key, expected_name, expected_supports_groups
|
||||
):
|
||||
"""Known providers should resolve to their configured metadata."""
|
||||
config = get_provider_config(provider_key)
|
||||
|
||||
assert config == OIDC_PROVIDERS[provider_key]
|
||||
assert get_provider_name(provider_key) == expected_name
|
||||
assert config["supports_groups"] is expected_supports_groups
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_key", [None, "unknown", ""])
|
||||
def test_provider_fallbacks(provider_key):
|
||||
"""Unknown providers should fall back to neutral defaults."""
|
||||
assert get_provider_config(provider_key or "unknown") == {}
|
||||
assert get_provider_name(provider_key) == "Unknown Provider"
|
||||
assert (
|
||||
get_provider_docs_url(provider_key) == f"{REPO_ROOT_URL}/docs/configuration.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_key", "expected_suffix"),
|
||||
[
|
||||
("authentik", "/docs/provider-configurations/authentik.md"),
|
||||
("authelia", "/docs/provider-configurations/authelia.md"),
|
||||
("pocketid", "/docs/provider-configurations/pocket-id.md"),
|
||||
("kanidm", "/docs/provider-configurations/kanidm.md"),
|
||||
("microsoft", "/docs/provider-configurations/microsoft-entra.md"),
|
||||
],
|
||||
)
|
||||
def test_provider_docs_urls(provider_key, expected_suffix):
|
||||
"""Known providers should point to provider-specific docs."""
|
||||
assert get_provider_docs_url(provider_key) == f"{REPO_ROOT_URL}{expected_suffix}"
|
||||
260
tests/test_state_store.py
Normal file
260
tests/test_state_store.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Tests for the state store."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from auth_oidc.stores.state_store import MAX_DEVICE_CODE_ATTEMPTS, StateStore
|
||||
|
||||
TEST_IP = "127.0.0.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_generate_and_receive_state(hass: HomeAssistant):
|
||||
"""Test creating a state, storing user info, and receiving it once."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
assert state_store.get_data() == {}
|
||||
|
||||
redirect_uri = "https://example.com/callback"
|
||||
state_id = await state_store.async_create_state_from_url(redirect_uri, TEST_IP)
|
||||
assert state_id in state_store.get_data()
|
||||
assert (
|
||||
await state_store.async_get_redirect_uri_for_state(state_id, TEST_IP)
|
||||
== redirect_uri
|
||||
)
|
||||
|
||||
user_info = {
|
||||
"sub": "user1",
|
||||
"display_name": "Test User",
|
||||
"username": "testuser",
|
||||
"role": "system-users",
|
||||
}
|
||||
assert (
|
||||
await state_store.async_add_userinfo_to_state(state_id, user_info) is True
|
||||
)
|
||||
assert state_id in state_store.get_data()
|
||||
assert await state_store.async_is_state_ready(state_id, TEST_IP) is True
|
||||
assert state_id in state_store.get_data()
|
||||
|
||||
result = await state_store.async_receive_userinfo_for_state(state_id, TEST_IP)
|
||||
assert result == user_info
|
||||
assert state_id not in state_store.get_data()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_generate_code_and_link_state(hass: HomeAssistant):
|
||||
"""Test generating a device code and linking another state to it."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
|
||||
donor_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/donor", TEST_IP
|
||||
)
|
||||
target_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/target", TEST_IP
|
||||
)
|
||||
|
||||
code = await state_store.async_generate_code_for_state(target_state)
|
||||
assert code is not None
|
||||
assert len(code) == 6
|
||||
assert code.isdigit()
|
||||
|
||||
user_info = {
|
||||
"sub": "user2",
|
||||
"display_name": "Device User",
|
||||
"username": "deviceuser",
|
||||
"role": "system-admin",
|
||||
}
|
||||
assert (
|
||||
await state_store.async_add_userinfo_to_state(donor_state, user_info)
|
||||
is True
|
||||
)
|
||||
assert donor_state in state_store.get_data()
|
||||
|
||||
assert (
|
||||
await state_store.async_link_state_to_code(donor_state, code, TEST_IP)
|
||||
is True
|
||||
)
|
||||
assert donor_state not in state_store.get_data()
|
||||
assert await state_store.async_is_state_ready(target_state, TEST_IP) is True
|
||||
assert target_state in state_store.get_data()
|
||||
assert (
|
||||
await state_store.async_receive_userinfo_for_state(target_state, TEST_IP)
|
||||
== user_info
|
||||
)
|
||||
assert target_state not in state_store.get_data()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_link_state_returns_false_for_wrong_code(hass: HomeAssistant):
|
||||
"""Test linking fails when the device code does not match any state."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
|
||||
donor_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/donor", TEST_IP
|
||||
)
|
||||
target_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/target", TEST_IP
|
||||
)
|
||||
await state_store.async_generate_code_for_state(target_state)
|
||||
|
||||
user_info = {
|
||||
"sub": "user3",
|
||||
"display_name": "Wrong Code User",
|
||||
"username": "wrongcode",
|
||||
"role": "system-users",
|
||||
}
|
||||
assert (
|
||||
await state_store.async_add_userinfo_to_state(donor_state, user_info)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await state_store.async_link_state_to_code(donor_state, "000000", TEST_IP)
|
||||
is False
|
||||
)
|
||||
assert donor_state in state_store.get_data()
|
||||
assert await state_store.async_is_state_ready(target_state, TEST_IP) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_throttles_device_code_link_attempts(hass: HomeAssistant):
|
||||
"""Test that repeated wrong device codes are throttled per state."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
|
||||
donor_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/donor", TEST_IP
|
||||
)
|
||||
target_state = await state_store.async_create_state_from_url(
|
||||
"https://example.com/target", TEST_IP
|
||||
)
|
||||
code = await state_store.async_generate_code_for_state(target_state)
|
||||
assert code is not None
|
||||
|
||||
user_info = {
|
||||
"sub": "user-throttle",
|
||||
"display_name": "Throttle User",
|
||||
"username": "throttle",
|
||||
"role": "system-users",
|
||||
}
|
||||
assert await state_store.async_add_userinfo_to_state(donor_state, user_info)
|
||||
|
||||
for _ in range(MAX_DEVICE_CODE_ATTEMPTS):
|
||||
assert (
|
||||
await state_store.async_link_state_to_code(
|
||||
donor_state, "000000", TEST_IP
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
assert (
|
||||
await state_store.async_link_state_to_code(donor_state, code, TEST_IP)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_expired_state(hass: HomeAssistant):
|
||||
"""Test that expired states are treated as invalid."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
|
||||
state_id = await state_store.async_create_state_from_url(
|
||||
"https://example.com/expired", TEST_IP
|
||||
)
|
||||
state_store.get_data()[state_id]["expiration"] = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
).isoformat()
|
||||
|
||||
assert (
|
||||
await state_store.async_get_redirect_uri_for_state(state_id, TEST_IP)
|
||||
is None
|
||||
)
|
||||
assert await state_store.async_is_state_ready(state_id, TEST_IP) is False
|
||||
assert (
|
||||
await state_store.async_receive_userinfo_for_state(state_id, TEST_IP)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_data_not_loaded(hass: HomeAssistant):
|
||||
"""Test that using the store before loading raises RuntimeError."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_create_state_from_url(
|
||||
"https://example.com", TEST_IP
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_generate_code_for_state("state")
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_add_userinfo_to_state(
|
||||
"state",
|
||||
{
|
||||
"sub": "user4",
|
||||
"display_name": "Not Loaded",
|
||||
"username": "notloaded",
|
||||
"role": "system-users",
|
||||
},
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_get_redirect_uri_for_state("state", TEST_IP)
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_is_state_ready("state", TEST_IP)
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_link_state_to_code("state", "123456", TEST_IP)
|
||||
with pytest.raises(RuntimeError):
|
||||
await state_store.async_receive_userinfo_for_state("state", TEST_IP)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_store_missing_keys(hass: HomeAssistant):
|
||||
"""Test that missing keys raise correct responses."""
|
||||
store_mock = AsyncMock()
|
||||
with patch("homeassistant.helpers.storage.Store", return_value=store_mock):
|
||||
state_store = StateStore(hass)
|
||||
|
||||
# async_generate_code_for_state returns None if state_id is not found
|
||||
store_mock.async_load.return_value = {}
|
||||
await state_store.async_load()
|
||||
assert await state_store.async_generate_code_for_state("nonexistent") is None
|
||||
|
||||
# async_add_userinfo_to_state returns False if state_id is not found
|
||||
user_info = {
|
||||
"sub": "user5",
|
||||
"display_name": "Missing Keys",
|
||||
"username": "missingkeys",
|
||||
"role": "system-users",
|
||||
}
|
||||
assert (
|
||||
await state_store.async_add_userinfo_to_state("nonexistent", user_info)
|
||||
is False
|
||||
)
|
||||
145
uv.lock
generated
145
uv.lock
generated
@@ -1,15 +1,14 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14.2, <3.15"
|
||||
requires-python = ">=3.14.4, <3.15"
|
||||
|
||||
[manifest]
|
||||
overrides = [
|
||||
{ name = "cryptography", specifier = ">=46.0.6,<46.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.6,<3.12.0" },
|
||||
{ name = "pygments", specifier = ">=2.20.0,<2.21" },
|
||||
{ name = "pillow", specifier = ">=12.2.0,<12.3.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0,<2.13.0" },
|
||||
{ name = "pyopenssl", specifier = ">=26.0.0" },
|
||||
{ name = "requests", specifier = ">=2.33.0,<2.34" },
|
||||
{ name = "pytest", specifier = ">=9.0.3,<9.1.0" },
|
||||
{ name = "uv", specifier = ">=0.11.6,<0.12.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -985,7 +984,7 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "homeassistant", specifier = "~=2026.3" },
|
||||
{ name = "homeassistant", specifier = "~=2026.4" },
|
||||
{ name = "pylint", specifier = "~=4.0" },
|
||||
{ name = "pytest", specifier = "~=9.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = "~=1.3.0" },
|
||||
@@ -1337,25 +1336,25 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.8"
|
||||
version = "3.11.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1378,35 +1377,35 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1786,7 +1785,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.0"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1795,9 +1794,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2357,28 +2356,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.11.1"
|
||||
version = "0.11.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/e9/691eb77e5e767cdec695db3f91ec259bbb66f9af7c86a8dbe462ef72a120/uv-0.11.1.tar.gz", hash = "sha256:8aa7e4983fabb06d0ba58e8b8c969d568ce495ad5f2f0426af97b55720f0dee1", size = 4007244, upload-time = "2026-03-24T23:14:18.269Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/a95c44fba785c27a966087154a8f6825774d49a38b3c5cd35f80e07ca5ca/uv-0.11.1-py3-none-linux_armv6l.whl", hash = "sha256:424b5b412d37838ea6dc11962f037be98b92e83c6ec755509e2af8a4ca3fbf2a", size = 23320598, upload-time = "2026-03-24T23:13:44.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/de/b7e24956a2508debf2addefcad93c72165069370f914d90db6264e0cf96a/uv-0.11.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c2133b0532af0217bf252d981bded8bff0c770f174f91f20655f88705f28c03f", size = 22832732, upload-time = "2026-03-24T23:13:33.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/1ac91bc704c22a427a44262f09e208ae897817a856d0e8dc0d60e4032e92/uv-0.11.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1a7b74e5a15b9bc6e61ce807adeca5a2807f557d3f06a5586de1da309d844c1d", size = 21406409, upload-time = "2026-03-24T23:14:32.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/1d/f767701e1160538d25ee6c1d49ce1e72442970b6658365afdd57339d10e0/uv-0.11.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fb1f32ec6c7dffb7ae71afaf6bf1defca0bd20a73a25e61226210c0a3e8bb13d", size = 23154066, upload-time = "2026-03-24T23:14:07.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/21/d2cfa3571557ba68ffd530656b1d7159fe59a6b01be94595351b1eec1c29/uv-0.11.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:0d5cf3c1c96f8afd67072d80479a58c2d69471916bac4ac36cc55f2aa025dc8e", size = 22922490, upload-time = "2026-03-24T23:13:25.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/3c/68119f555b2ec152235951cc9aa0f40006c5f03d17c98adaab6a3d36d42b/uv-0.11.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5829a254c64b19420b9e48186182d162b01f8da0130e770cbb8851fd138bb820", size = 22923054, upload-time = "2026-03-24T23:14:03.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ce/0df944835519372b1d698acaa388baa874cf69a6183b5f0980cb8855b81a/uv-0.11.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4259027e80f4dcc9ae3dceddcd5407173d334484737166fc212e96bb760d6ea", size = 24576177, upload-time = "2026-03-24T23:14:25.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/04/0076335413c618fe086e5a4762103634552e638a841e12a4bb8f5137d710/uv-0.11.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6169eb49d1d2b5df7a7079162e1242e49ad46c6590c55f05b182fa526963763", size = 25207026, upload-time = "2026-03-24T23:14:11.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/57/79c0479e12c2291ad9777be53d813957fa38283975b708eead8e855ba725/uv-0.11.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c96a7310a051b1013efffe082f31d718bce0538d4abc20a716d529bf226b7c44", size = 24393748, upload-time = "2026-03-24T23:13:48.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/9ef73c8b6ef04b0cead7d8f1547034568e3e58f3397b55b83167e587f84a/uv-0.11.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ccc438dbb905240a3630265feb25be1bda61656ec7c32682a83648a686f4aa", size = 24518525, upload-time = "2026-03-24T23:13:41.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/a3/035c7c2feb2139efb5d70f2e9f68912c34f7d92ee2429bacd708824483bb/uv-0.11.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44f528ba3d66321cea829770982cccb14af142203e4e19d00ff0c23b28e3cd33", size = 23270167, upload-time = "2026-03-24T23:13:51.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/59/2dd782b537bfd1e41cb06de4f4a529fe2f9bd10034fb3fcce225ec86c1a5/uv-0.11.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4fcc3d5fdea24181d77e7765bf9d16cdd9803fd524820c62c66f91b2e2644d5b", size = 24011976, upload-time = "2026-03-24T23:13:37.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f0/9983e6f31d495cc548f1e211cab5b89a3716f406a2d9d8134b8245ec103c/uv-0.11.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5de9e43a32079b8d57093542b0cd8415adba5ed9944fa49076c0927f3ff927e1", size = 24029605, upload-time = "2026-03-24T23:14:28.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/dc/9c59e803bfc1b9d6c4c4b7374689c688e9dc0a1ecc2375399d3a59fd4a58/uv-0.11.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f13ae98a938effae5deb587a63e7e42f05d6ba9c1661903ef538e4e87b204f8c", size = 23702811, upload-time = "2026-03-24T23:14:21.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/77/b1cbfdac0b2dd3e7aa420e9dad1abe8badb47eabd8741a9993586b14f8dc/uv-0.11.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:57d38e8b6f6937e1521da568adf846bb89439c73e146e89a8ab2cfe7bb15657a", size = 24714239, upload-time = "2026-03-24T23:13:29.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d3/94917751acbbb5e053cb366004ae8be3c9664f82aef7de54f55e38ec15cb/uv-0.11.1-py3-none-win32.whl", hash = "sha256:36f4552b24acaa4699b02baeb1bb928202bb98d426dcc5041ab7ebae082a6430", size = 22404606, upload-time = "2026-03-24T23:13:55.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/87/8dadfe03944a4a493cd58b6f4f13e5181069a0048aeb2fae7da2c587a542/uv-0.11.1-py3-none-win_amd64.whl", hash = "sha256:d6a1c4cdb1064e9ceaa59e89a7489dd196222a0b90cfb77ca37a909b5e024ea0", size = 24850092, upload-time = "2026-03-24T23:14:15.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1b/dad559273df0c8263533afa4a28570cf6804272f379df9830b528a9cf8bc/uv-0.11.1-py3-none-win_arm64.whl", hash = "sha256:3bc9632033c7a280342f9b304bd12eccb47d6965d50ea9ee57ecfaf4f1f393c4", size = 23376127, upload-time = "2026-03-24T23:13:59.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user