Reimplement UI injection (#236)

This commit is contained in:
Christiaan Goossens
2026-04-13 22:51:31 +02:00
committed by GitHub
parent fdc93e2719
commit fd3643685d
36 changed files with 3772 additions and 1114 deletions

View File

@@ -27,7 +27,6 @@ from .config import (
ROLES, ROLES,
NETWORK, NETWORK,
FEATURES_INCLUDE_GROUPS_SCOPE, FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_DISABLE_FRONTEND_INJECTION,
FEATURES_FORCE_HTTPS, FEATURES_FORCE_HTTPS,
REQUIRED_SCOPES, REQUIRED_SCOPES,
) )
@@ -40,6 +39,7 @@ from .endpoints import (
OIDCFinishView, OIDCFinishView,
OIDCCallbackView, OIDCCallbackView,
OIDCInjectedAuthPage, OIDCInjectedAuthPage,
OIDCDeviceSSE,
) )
from .tools.oidc_client import OIDCClient from .tools.oidc_client import OIDCClient
from .provider import OpenIDAuthProvider 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) provider = OpenIDAuthProvider(hass, hass.auth._store, my_config)
providers[(provider.type, provider.id)] = provider providers[(provider.type, provider.id)] = provider
# Get current provider count
has_other_auth_providers = len(hass.auth._providers) > 0
providers.update(hass.auth._providers) providers.update(hass.auth._providers)
hass.auth._providers = providers hass.auth._providers = providers
# pylint: enable=protected-access # pylint: enable=protected-access
@@ -137,33 +141,22 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
) )
# Register the views # Register the views
is_frontend_injection_enabled = (
features_config.get(FEATURES_DISABLE_FRONTEND_INJECTION, False) is False
)
name = display_name name = display_name
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name) name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
force_https = features_config.get(FEATURES_FORCE_HTTPS, False) force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
hass.http.register_view( hass.http.register_view(
OIDCWelcomeView( OIDCWelcomeView(provider, name, force_https, has_other_auth_providers)
name,
# Welcome view is not enabled if frontend injection is enabled
not is_frontend_injection_enabled,
force_https,
) )
) hass.http.register_view(OIDCDeviceSSE(provider))
hass.http.register_view(OIDCRedirectView(oidc_client, force_https)) hass.http.register_view(OIDCRedirectView(oidc_client, provider, force_https))
hass.http.register_view(OIDCCallbackView(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") _LOGGER.info("Registered OIDC views")
# Inject OIDC code into the frontend for /auth/authorize if the user has the # Inject OIDC code into the frontend for /auth/authorize for automatic redirect
# frontend injection feature enabled await OIDCInjectedAuthPage.inject(hass)
if is_frontend_injection_enabled:
await OIDCInjectedAuthPage.inject(hass, name)
else:
_LOGGER.info("OIDC frontend changes are disabled, skipping injection")
return True return True

View File

@@ -28,7 +28,6 @@ FEATURES_AUTOMATIC_USER_LINKING = "automatic_user_linking"
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation" FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
FEATURES_DISABLE_PKCE = "disable_rfc7636" FEATURES_DISABLE_PKCE = "disable_rfc7636"
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope" FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
FEATURES_DISABLE_FRONTEND_INJECTION = "disable_frontend_changes"
FEATURES_FORCE_HTTPS = "force_https" FEATURES_FORCE_HTTPS = "force_https"
CLAIMS = "claims" CLAIMS = "claims"
CLAIMS_DISPLAY_NAME = "display_name" CLAIMS_DISPLAY_NAME = "display_name"

View File

@@ -14,7 +14,6 @@ from .const import (
FEATURES_AUTOMATIC_PERSON_CREATION, FEATURES_AUTOMATIC_PERSON_CREATION,
FEATURES_DISABLE_PKCE, FEATURES_DISABLE_PKCE,
FEATURES_INCLUDE_GROUPS_SCOPE, FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_DISABLE_FRONTEND_INJECTION,
FEATURES_FORCE_HTTPS, FEATURES_FORCE_HTTPS,
CLAIMS, CLAIMS,
CLAIMS_DISPLAY_NAME, CLAIMS_DISPLAY_NAME,
@@ -72,10 +71,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional( vol.Optional(
FEATURES_INCLUDE_GROUPS_SCOPE, default=True FEATURES_INCLUDE_GROUPS_SCOPE, default=True
): vol.Coerce(bool), ): vol.Coerce(bool),
# Disable frontend injection of OIDC login button
vol.Optional(
FEATURES_DISABLE_FRONTEND_INJECTION, default=False
): vol.Coerce(bool),
# Force HTTPS on all generated URLs (like redirect_uri) # Force HTTPS on all generated URLs (like redirect_uri)
vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce( vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce(
bool bool

View File

@@ -621,21 +621,18 @@ class OIDCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["client_id"] = "invalid_client_id" errors["client_id"] = "invalid_client_id"
return errors, None 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 # Build updated data
data_updates = {"client_id": client_id} 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: if client_secret:
data_updates["client_secret"] = client_secret data_updates["client_secret"] = client_secret
elif "client_secret" in entry.data and not client_secret: elif "client_secret" in entry.data:
# Remove client secret if switching from confidential to public data_updates["client_secret"] = entry.data["client_secret"]
data_updates = {**entry.data, **data_updates}
data_updates.pop("client_secret", None)
return errors, data_updates return errors, data_updates

View File

@@ -5,3 +5,4 @@ from .finish import OIDCFinishView as OIDCFinishView
from .injected_auth_page import OIDCInjectedAuthPage as OIDCInjectedAuthPage from .injected_auth_page import OIDCInjectedAuthPage as OIDCInjectedAuthPage
from .redirect import OIDCRedirectView as OIDCRedirectView from .redirect import OIDCRedirectView as OIDCRedirectView
from .welcome import OIDCWelcomeView as OIDCWelcomeView from .welcome import OIDCWelcomeView as OIDCWelcomeView
from .device_sse import OIDCDeviceSSE as OIDCDeviceSSE

View File

@@ -4,7 +4,7 @@ from homeassistant.components.http import HomeAssistantView
from aiohttp import web from aiohttp import web
from ..tools.oidc_client import OIDCClient from ..tools.oidc_client import OIDCClient
from ..provider import OpenIDAuthProvider 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" PATH = "/auth/oidc/callback"
@@ -29,42 +29,49 @@ class OIDCCallbackView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
"""Receive 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 params = request.rel_url.query
code = params.get("code") code = params.get("code")
state = params.get("state") state = params.get("state")
if not (code and state): if not (code and state):
view_html = await get_view( return await error_response("Missing code or state parameter.")
"error",
{
"error": "Missing code or state parameter.",
},
)
return web.Response(text=view_html, content_type="text/html")
# 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) redirect_uri = get_url("/auth/oidc/callback", self.force_https)
user_details = await self.oidc_client.async_complete_token_flow( user_details = await self.oidc_client.async_complete_token_flow(
redirect_uri, code, state redirect_uri, code, state
) )
if user_details is None: if user_details is None:
view_html = await get_view( return await error_response(
"error", "Failed to get user details, see Home Assistant logs for more information.",
{ status=500,
"error": "Failed to get user details, "
+ "see Home Assistant logs for more information.",
},
) )
return web.Response(text=view_html, content_type="text/html")
if user_details.get("role") == "invalid": if user_details.get("role") == "invalid":
view_html = await get_view( return await error_response(
"error", "User is not in the correct group to access Home Assistant, "
{
"error": "User is not in the correct group to access Home Assistant, "
+ "contact your administrator!", + "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) # Finalize on the state
raise web.HTTPFound(get_url("/auth/oidc/finish?code=" + code, self.force_https)) 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))

View 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

View File

@@ -2,7 +2,12 @@
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from aiohttp import web 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" PATH = "/auth/oidc/finish"
@@ -14,41 +19,62 @@ class OIDCFinishView(HomeAssistantView):
url = PATH url = PATH
name = "auth:oidc:finish" 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: 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") return await template_response("finish", {})
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")
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
"""Receive response.""" """Receive response."""
# Get code from the message body # Get cookie to get the state_id
data = await request.post() state_id = await get_valid_state_id(request, self.oidc_provider)
code = data.get("code") if not state_id:
return await error_response("Missing state cookie, please restart login.")
if not code: # Get redirect_uri from the state
return web.Response(text="No code received", status=500) redirect_uri = await self.oidc_provider.async_get_redirect_uri_for_state(
state_id
# 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",
},
) )
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", {})

View File

@@ -1,6 +1,5 @@
"""Injected authorization page, replacing the original""" """Injected authorization page, replacing the original"""
import json
import logging import logging
from functools import partial from functools import partial
from homeassistant.components.http import HomeAssistantView, StaticPathConfig from homeassistant.components.http import HomeAssistantView, StaticPathConfig
@@ -19,7 +18,7 @@ async def read_file(path: str) -> str:
return await f.read() 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.""" """Inject new frontend code into /auth/authorize."""
router = hass.http.app.router router = hass.http.app.router
frontend_path = None frontend_path = None
@@ -62,11 +61,8 @@ async def frontend_injection(hass: HomeAssistant, sso_name: str) -> None:
frontend_code = await read_file(frontend_path) frontend_code = await read_file(frontend_path)
# Inject JS and register that route # Inject JS and register that route
injection_js = "<script src='/auth/oidc/static/injection.js?v=3'></script>" injection_js = "<script src='/auth/oidc/static/injection.js?v=4'></script>"
sso_name_js = f"<script>window.sso_name = {json.dumps(sso_name)};</script>" frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>")
frontend_code = frontend_code.replace(
"</body>", f"{injection_js}{sso_name_js}</body>"
)
await hass.http.async_register_static_paths( await hass.http.async_register_static_paths(
[ [
@@ -100,10 +96,10 @@ class OIDCInjectedAuthPage(HomeAssistantView):
self.html = html self.html = html
@staticmethod @staticmethod
async def inject(hass: HomeAssistant, sso_name: str) -> None: async def inject(hass: HomeAssistant) -> None:
"""Inject the OIDC auth page into the frontend.""" """Inject the OIDC auth page into the frontend."""
try: try:
await frontend_injection(hass, sso_name) await frontend_injection(hass)
except Exception as e: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Failed to inject OIDC auth page: %s", e) _LOGGER.error("Failed to inject OIDC auth page: %s", e)

View File

@@ -1,11 +1,13 @@
"""Redirect route to redirect the user to the external OIDC server, """Redirect route to redirect the user to the external OIDC server,
can either be linked to directly or accessed through the welcome page.""" can either be linked to directly or accessed through the welcome page."""
from urllib.parse import quote
from aiohttp import web from aiohttp import web
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from ..provider import OpenIDAuthProvider
from ..tools.oidc_client import OIDCClient 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" PATH = "/auth/oidc/redirect"
@@ -17,28 +19,44 @@ class OIDCRedirectView(HomeAssistantView):
url = PATH url = PATH
name = "auth:oidc:redirect" 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_client = oidc_client
self.oidc_provider = oidc_provider
self.force_https = force_https self.force_https = force_https
async def get(self, _: web.Request) -> web.Response: async def get(self, req: web.Request) -> web.Response:
"""Receive 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: try:
redirect_uri = get_url("/auth/oidc/callback", self.force_https) 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: 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: except RuntimeError:
pass pass
view_html = await get_view( return await error_response(
"error", "Integration is misconfigured, discovery could not be obtained.",
{"error": "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""" """POST"""
return await self.get(request) return await self.get(req)

View File

@@ -1,8 +1,12 @@
"""Welcome route to show the user the OIDC login button and give instructions.""" """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 aiohttp import web
from homeassistant.components.http import HomeAssistantView 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" PATH = "/auth/oidc/welcome"
@@ -14,16 +18,90 @@ class OIDCWelcomeView(HomeAssistantView):
url = PATH url = PATH
name = "auth:oidc:welcome" 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.name = name
self.is_enabled = is_enabled
self.force_https = force_https 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.""" """Receive response."""
if not self.is_enabled: # Get the query parameter with the redirect_uri
raise web.HTTPTemporaryRedirect(get_url("/", self.force_https)) redirect_uri = req.query.get("redirect_uri")
view_html = await get_view("welcome", {"name": self.name}) # If set, determine if this is a mobile client based on the redirect_uri,
return web.Response(text=view_html, content_type="text/html") # 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

View File

@@ -22,7 +22,6 @@ from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.components import http, person from homeassistant.components import http, person
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import voluptuous as vol
from .config.const import ( from .config.const import (
FEATURES, FEATURES,
@@ -30,13 +29,14 @@ from .config.const import (
FEATURES_AUTOMATIC_PERSON_CREATION, FEATURES_AUTOMATIC_PERSON_CREATION,
DEFAULT_TITLE, DEFAULT_TITLE,
) )
from .stores.code_store import CodeStore from .stores.state_store import StateStore
from .tools.types import UserDetails from .tools.types import UserDetails
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PROVIDER_TYPE = "auth_oidc" PROVIDER_TYPE = "auth_oidc"
HASS_PROVIDER_TYPE = "homeassistant" HASS_PROVIDER_TYPE = "homeassistant"
COOKIE_NAME = "auth_oidc_state"
class InvalidAuthError(HomeAssistantError): class InvalidAuthError(HomeAssistantError):
@@ -68,7 +68,7 @@ class OpenIDAuthProvider(AuthProvider):
) )
self._user_meta: dict[UserDetails] = {} self._user_meta: dict[UserDetails] = {}
self._code_store: CodeStore | None = None self._state_store: StateStore | None = None
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
features = config.get( features = config.get(
@@ -89,29 +89,120 @@ class OpenIDAuthProvider(AuthProvider):
async def async_initialize(self) -> None: async def async_initialize(self) -> None:
"""Initialize the auth provider.""" """Initialize the auth provider."""
# Init the code store first # Init the store first
# Use the same technique as the HomeAssistant auth provider for storage # Use the same technique as the HomeAssistant auth provider for storage
# (/auth/providers/homeassistant.py#L392) # (/auth/providers/homeassistant.py#L392)
async with self._init_lock: async with self._init_lock:
if self._code_store is not None: if self._state_store is not None:
return return
store = CodeStore(self.hass) store = StateStore(self.hass)
await store.async_load() await store.async_load()
self._code_store = store self._state_store = store
self._user_meta = {} self._user_meta = {}
# Listen for user creation events # Listen for user creation events
self.hass.bus.async_listen(EVENT_USER_ADDED, self.async_user_created) self.hass.bus.async_listen(EVENT_USER_ADDED, self.async_user_created)
async def async_get_subject(self, code: str) -> Optional[str]: def _resolve_ip(self, ip: str | None = None) -> str | None:
"""Retrieve user from the code, return subject and save meta """Resolve client IP from explicit input or current request context."""
for later use with this provider instance.""" if ip:
if self._code_store is None: return ip
await self.async_initialize()
assert self._code_store is not None
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: if user_data is None:
return None return None
@@ -119,14 +210,6 @@ class OpenIDAuthProvider(AuthProvider):
self._user_meta[sub] = user_data self._user_meta[sub] = user_data
return sub 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]: async def _async_find_user_by_username(self, username: str) -> Optional[User]:
"""Find a user by username.""" """Find a user by username."""
users = await self.store.async_get_users() users = await self.store.async_get_users()
@@ -145,6 +228,18 @@ class OpenIDAuthProvider(AuthProvider):
return None 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) # Handler for user created and related functions (person creation)
# ==== # ====
@@ -271,7 +366,7 @@ class OpenIDAuthProvider(AuthProvider):
class OpenIdLoginFlow(LoginFlow): class OpenIdLoginFlow(LoginFlow):
"""Handler for the login flow.""" """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 # Verify a dummy hash to make it last a bit longer
# as security measure (limits the amount of attempts you have in 5 min) # as security measure (limits the amount of attempts you have in 5 min)
# Similar to what the HomeAssistant auth provider does # Similar to what the HomeAssistant auth provider does
@@ -280,7 +375,7 @@ class OpenIdLoginFlow(LoginFlow):
# Actually look up the auth provider after, # Actually look up the auth provider after,
# this doesn't take a lot of time (regardless of it's in there or not) # 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: if sub:
return await self.async_finish( return await self.async_finish(
{ {
@@ -290,54 +385,23 @@ class OpenIdLoginFlow(LoginFlow):
raise InvalidAuthError 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( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> AuthFlowResult:
"""Handle the step of the form.""" """Handle the step of the form."""
# Try to use the user input first # Check if the cookie is present to login
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
req = http.current_request.get() req = http.current_request.get()
if req and req.cookies: if req and req.cookies:
code_cookie = req.cookies.get("auth_oidc_code") state_cookie = req.cookies.get(COOKIE_NAME)
if code_cookie: if state_cookie:
_LOGGER.debug("Code cookie found on login: %s", code_cookie) _LOGGER.debug("State cookie found on login: %s", state_cookie)
try: try:
return await self._finalize_user(code_cookie) return await self._finalize_user(state_cookie)
except InvalidAuthError: except InvalidAuthError:
pass pass
# If none are available, just show the form # If no cookie is found, abort.
return self._show_login_form() # User should either be redirected or start manually on the welcome
return self.async_abort(reason="no_oidc_cookie_found")
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)

View File

@@ -1,215 +1,82 @@
function safeSetTextContent(element, value) { /**
if (!element) return * OIDC Frontend Redirect injection script
var textNode = Array.from(element.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0) * This script is injected because the 'hass-oidc-auth' custom component is active.
if (!textNode || textNode.textContent === value) return */
textNode.textContent = value
function attempt_oidc_redirect() {
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
// 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;
} }
let firstFocus = true const originalUrl = urlParams.get('redirect_uri');
let showCodeOverride = null if (!originalUrl) {
console.warn('[OIDC] No OAuth2 redirect_uri parameter found in the URL. Frontend redirect cancelled.');
function isMobile() { return;
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() { try {
if (showCodeOverride !== null) return showCodeOverride // Parse the redirect URI
return isMobile() const redirectUrl = new URL(originalUrl);
// 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);
} }
let ssoButton = null window.stop(); // Stop loading the current page before redirecting
let codeButton = null
let codeMessage = null
let codeToggle = null
let codeToggleText = null
function update() { // Redirect to the OIDC auth URL
const sso_name = window.sso_name || "Single Sign-On" const base64encodeUrl = btoa(window.location.href);
const loginHeader = document.querySelector(".card-content > ha-auth-flow > form > h1") const oidcAuthUrl = '/auth/oidc/welcome?redirect_uri=' + encodeURIComponent(base64encodeUrl);
const authForm = document.querySelector("ha-auth-form") window.location.href = oidcAuthUrl;
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
} }
})
// ==== function click_alternative_provider_instead() {
// Code input
if (codeField) {
if (codeField.placeholder !== "One-time code") {
codeField.placeholder = "One-time code"
codeField.autofocus = false
codeField.autocomplete = "off"
if (firstFocus) {
firstFocus = false
if (document.activeElement === codeField) {
setTimeout(() => { setTimeout(() => {
codeField.blur() // Find ha-auth-flow
let check = setInterval(() => { const authFlowElement = document.querySelector('ha-auth-flow');
const helperText = document.querySelector("#helper-text")
const invalidTextField = document.querySelector(".mdc-text-field--invalid") if (!authFlowElement) {
const validationMsg = document.querySelector(".mdc-text-field-helper-text--validation-msg") console.warn("[OIDC] ha-auth-flow element not found. Not automatically selecting HA provider.");
if (helperText && invalidTextField && validationMsg) { return;
clearInterval(check)
safeSetTextContent(helperText, "")
invalidTextField.classList.remove("mdc-text-field--invalid")
validationMsg.classList.remove("mdc-text-field-helper-text--validation-msg")
}
}, 1)
}, 0)
}
}
} }
if (errorAlert && errorAlert.textContent.trim().length === 0) { // Check if the text "Login aborted" is present on the page
errorAlert.setAttribute("title", "Invalid Code") 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) { // Click the first ha-list-item element inside the ha-pick-auth-provider
codeMessage = document.createElement("p") const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item');
codeMessage.innerHTML = `<b>Please login on a different device to continue.</b><br/>You can also use your mobile webbrowser.` if (!firstListItem) {
authForm.parentElement.insertBefore(codeMessage, authForm) console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting HA provider.");
return;
} }
if (codeMessage) { firstListItem.click();
codeMessage.style.display = showCode() ? "" : "none" }, 500);
} }
if (showCode() && loginButton !== null && !codeButton) { // Run OIDC injection upon load
codeButton = document.createElement("ha-button") (() => {
codeButton.id = "code_button" attempt_oidc_redirect();
codeButton.classList.add("code") click_alternative_provider_instead();
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 = ""
}
}
}
// 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)

File diff suppressed because one or more lines are too long

View File

@@ -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

View 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

View File

@@ -1,8 +1,17 @@
"""Helper functions for the integration.""" """Helper functions for the integration."""
from typing import TYPE_CHECKING
from homeassistant.components import http from homeassistant.components import http
from aiohttp import web
from ..views.loader import AsyncTemplateRenderer 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: def get_url(path: str, force_https: bool) -> str:
"""Returns the requested path appended to the current request base URL.""" """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() renderer = AsyncTemplateRenderer()
return await renderer.render_template(f"{template}.html", **parameters) 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)

View File

@@ -289,9 +289,6 @@ class OIDCDiscoveryClient:
class OIDCClient: class OIDCClient:
"""OIDC Client implementation for Python, including PKCE.""" """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 to be used
http_session: aiohttp.ClientSession = None http_session: aiohttp.ClientSession = None
@@ -312,6 +309,9 @@ class OIDCClient:
self.client_id = client_id self.client_id = client_id
self.scope = scope self.scope = scope
# Stores code_verifier and nonce for active authorization flows.
self.flows: dict[str, dict[str, str]] = {}
# Optional parameters # Optional parameters
self.client_secret = kwargs.get("client_secret") self.client_secret = kwargs.get("client_secret")
@@ -544,7 +544,9 @@ class OIDCClient:
_LOGGER.warning("JWT verification failed: %s", e) _LOGGER.warning("JWT verification failed: %s", e)
return None 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.""" """Generates the authorization URL for the OIDC flow."""
try: try:
discovery_document = await self._fetch_discovery_document() discovery_document = await self._fetch_discovery_document()
@@ -552,7 +554,6 @@ class OIDCClient:
# Generate random nonce & state # Generate random nonce & state
nonce = self._generate_random_url_string() nonce = self._generate_random_url_string()
state = self._generate_random_url_string()
# Generate PKCE (RFC 7636) parameters # Generate PKCE (RFC 7636) parameters
code_verifier = self._generate_random_url_string(32) 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.""" """Completes the OIDC token flow to obtain a user's details."""
try: try:
if state not in self.flows: flow = self.flows.pop(state, None)
if flow is None:
raise OIDCStateInvalid raise OIDCStateInvalid
flow = self.flows[state]
discovery_document = await self._fetch_discovery_document() discovery_document = await self._fetch_discovery_document()
token_endpoint = discovery_document["token_endpoint"] token_endpoint = discovery_document["token_endpoint"]

View File

@@ -16,3 +16,26 @@ class UserDetails(dict):
username: str username: str
# Home Assistant role to assign to this user # Home Assistant role to assign to this user
role: Literal["system-admin", "system-users", "invalid"] 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

View File

@@ -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 %}

View File

@@ -4,28 +4,63 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="text-center"> <div>
<div class="my-6"> <h1 class="text-2xl font-bold mb-4 text-center">Logged in!</h1>
<h2 class="text-xl font-semibold mb-6 text-gray-800">I want to login to this browser</h2>
<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"> <form method="post">
<input type="hidden" name="code" value="{{ code }}"> <button
<button type="submit" id="continue-on-this-device"
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"> type="submit"
Login to Home Assistant in this browser 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> </button>
</form> </form>
</div> </div>
<hr class="my-12"> <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">
<div class="my-6"> <span class="text-lg font-semibold">Use a code from another device</span>
<h2 class="text-xl font-semibold mb-4 text-gray-800">I am on a mobile device</h2> </div>
<p class="mb-4">Your one-time code is: <b class="text-blue-600 text-xl">{{ code }}</b></p> <div class="border-t border-gray-200 pt-4">
<p class="mb-4 text-sm">You have 5 minutes to use this code on any device.<br />The code can only <p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
be used once.</p> 6-digit code.</p>
<p class="mb-4 text-sm">Please type the code into your app manually. If you don't see a code input, select <p class="mb-4 text-sm text-gray-600">Input that code here and click Approve to login on the other device.
'Login with </p>
OpenID Connect (SSO)' first.</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>
</div> </div>
{% endblock %} {% endblock %}

View 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 %}

View File

@@ -12,41 +12,53 @@
dashboard</a></p> dashboard</a></p>
</div> </div>
<h1 class="text-2xl font-bold mb-4">Home Assistant</h1> {% if code %}
<p class="mb-4">You have been invited to login to Home Assistant.<br />Start the login process below.</p>
<div> <div>
<button id="oidc-login-btn" <p id="device-instructions">Please login to Home Assistant on another device and enter this code when asked:</p>
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"> <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">
Login with {{ name }} {{ code }}
</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>
</div> </div>
</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 class="mt-6 text-sm">After login, you will be granted a one-time code to login to any device. You may complete </p>
this login on your desktop or any mobile browser and then use the token for any desktop or the Home Assistant
app.</p>
</div> </div>
<script> <script>
// Hide the login button and show the loader when clicked const source = new EventSource('/auth/oidc/device-sse');
document.getElementById('oidc-login-btn').addEventListener('click', function () {
this.classList.add('hidden'); source.addEventListener('ready', function () {
document.getElementById('loader').classList.remove('hidden'); source.close();
window.location.href = '/auth/oidc/redirect';
// 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 // Show the direct login button if we already have a token
if (localStorage.getItem('hassTokens')) { if (localStorage.getItem('hassTokens')) {
document.getElementById('signed-in').classList.remove('hidden'); document.getElementById('signed-in').classList.remove('hidden');

View File

@@ -13,11 +13,11 @@ dependencies = [
"joserfc~=1.6.0", "joserfc~=1.6.0",
] ]
readme = "README.md" readme = "README.md"
requires-python = "~=3.14.2" requires-python = "~=3.14.4"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"homeassistant~=2026.3", "homeassistant~=2026.4",
"pylint~=4.0", "pylint~=4.0",
"pytest~=9.0.0", "pytest~=9.0.0",
"pytest-asyncio~=1.3.0", "pytest-asyncio~=1.3.0",
@@ -35,10 +35,9 @@ managed = true
override-dependencies = [ override-dependencies = [
"orjson>=3.11.6,<3.12.0", "orjson>=3.11.6,<3.12.0",
"pyjwt>=2.12.0,<2.13.0", "pyjwt>=2.12.0,<2.13.0",
"pyopenssl>=26.0.0", "pillow>=12.2.0,<12.3.0",
"cryptography>=46.0.6,<46.1", "pytest>=9.0.3,<9.1.0",
"requests>=2.33.0,<2.34", "uv>=0.11.6,<0.12.0",
"pygments>=2.20.0,<2.21"
] ]
[tool.hatch.metadata] [tool.hatch.metadata]

View File

@@ -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()

View File

@@ -1,6 +1,10 @@
"""Tests for the Auth Provider registration in HA""" """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 import pytest
from homeassistant.core import HomeAssistant 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 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: async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool:
"""Set up the auth_oidc component.""" """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) auth_providers = hass.auth.get_auth_providers(DOMAIN)
assert len(auth_providers) == 1 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] provider = hass.auth.get_auth_providers(DOMAIN)[0]
flow = await provider.async_login_flow({})
result = await flow.async_step_init({"code": code}) with patch(
assert result["type"] == FlowResultType.CREATE_ENTRY "custom_components.auth_oidc.provider.http.current_request"
assert result["data"] is not None ) 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() assert sub == MockOIDCServer.get_final_subject()
# Get credentials # 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 is not None
assert credentials.data["sub"] == sub assert credentials.data["sub"] == sub
@@ -70,36 +116,49 @@ async def login_user(hass: HomeAssistant, code: str):
return user return user
async def get_login_code(hass: HomeAssistant, hass_client): async def get_login_state(hass: HomeAssistant, hass_client):
"""Helper to get a login code.""" """Helper to complete the browser login flow and return the OIDC state id."""
client = await hass_client() 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) resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
assert resp.status == 302 assert resp.status == 200
location = resp.headers["Location"] html = await resp.text()
parsed_url = urlparse(location) 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) query_params = parse_qs(parsed_url.query)
state = query_params["state"][0] assert query_params["state"][0] == state_id
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
resp = session.get(location, allow_redirects=False) resp = session.get(auth_url, allow_redirects=False)
assert resp.status == 200 assert resp.status == 200
# Mock OIDC returns JSON
json_parsed = await resp.json() json_parsed = await resp.json()
assert "code" in json_parsed and json_parsed["code"] assert "code" in json_parsed and json_parsed["code"]
code = json_parsed["code"] code = json_parsed["code"]
client = await hass_client()
resp = await client.get( 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 assert resp.status == 302
location = resp.headers["Location"] assert resp.headers["Location"].endswith("/auth/oidc/finish")
assert "/auth/oidc/finish?code=" in location
# Get the code from the finish URL return state_id
code = location.split("code=")[1]
return code
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -120,16 +179,16 @@ async def test_full_login(hass: HomeAssistant, hass_client):
with mock_oidc_responses(): with mock_oidc_responses():
# Actually start the login and get a code # 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 # 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" assert user.name == "Test Name"
# Login again to see if we trigger the re-use path # Login again to see if we trigger the re-use path
code2 = await get_login_code(hass, hass_client) state_id2 = await get_login_state(hass, hass_client)
user2 = await login_user(hass, code2) user2 = await login_user(hass, state_id2)
assert user2.id == user.id 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) await hass.auth.async_link_user(user, credential)
# Actually start the login and get a code # 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
user2 = await login_user(hass, code) user2 = await login_user(hass, state_id)
assert user2.id == user.id # Assert that the user was linked 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, {}) await async_setup_component(hass, PERSON_DOMAIN, {})
with mock_oidc_responses(): with mock_oidc_responses():
code = await get_login_code(hass, hass_client) state_id = await get_login_state(hass, hass_client)
user = await login_user(hass, code) user = await login_user(hass, state_id)
assert user.is_active assert user.is_active
# Find the person associated to this user using the PersonRegistry API # 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 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 @pytest.mark.asyncio
async def test_login_shows_form(hass: HomeAssistant): async def test_login_shows_form(hass: HomeAssistant):
"""Test a login""" """Test a login"""
@@ -220,10 +309,38 @@ async def test_login_shows_form(hass: HomeAssistant):
flow = await provider.async_login_flow({}) flow = await provider.async_login_flow({})
result = await flow.async_step_init({}) result = await flow.async_step_init({})
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.ABORT
assert result["step_id"] == "mfa" assert result["reason"] == "no_oidc_cookie_found"
# Attempt an invalid code
result = await flow.async_step_init({"code": "invalid"}) @pytest.mark.asyncio
assert result["type"] == FlowResultType.FORM async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
assert result["errors"] == {"base": "invalid_auth"} """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"

View File

@@ -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

View 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

View 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")

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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 ( from custom_components.auth_oidc.config.const import (
OIDC_PROVIDERS, OIDC_PROVIDERS,
CLIENT_ID, CLIENT_ID,
@@ -170,12 +170,6 @@ async def test_full_config_flow_success(hass: HomeAssistant):
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].data == expected_data 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 @pytest.mark.asyncio
async def test_options_flow_success(hass: HomeAssistant): 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 len(entries) == 1
assert entries[0].data[CLIENT_ID] == new_client_id assert entries[0].data[CLIENT_ID] == new_client_id
assert entries[0].data[CLIENT_SECRET] == new_client_secret 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

View File

@@ -1,12 +1,9 @@
"""Tests for the registered webpages""" """Tests for the registered webpages"""
import base64
import os import os
from auth_oidc.config.const import ( from unittest.mock import AsyncMock, MagicMock, patch
DISCOVERY_URL, from auth_oidc.config.const import DISCOVERY_URL, CLIENT_ID
CLIENT_ID,
FEATURES,
FEATURES_DISABLE_FRONTEND_INJECTION,
)
import pytest import pytest
from homeassistant.core import HomeAssistant 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 homeassistant.components.http import StaticPathConfig, DOMAIN as HTTP_DOMAIN
from custom_components.auth_oidc import 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 = { mock_config = {
DOMAIN: { DOMAIN: {
CLIENT_ID: "dummy", CLIENT_ID: "dummy",
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration", 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) result = await async_setup_component(hass, DOMAIN, mock_config)
assert result assert result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_page_registration(hass: HomeAssistant, hass_client): 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() client = await hass_client()
resp = await client.get("/auth/oidc/welcome", allow_redirects=False) resp = await client.get("/auth/oidc/welcome", allow_redirects=False)
assert resp.status == 200 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 @pytest.mark.asyncio
async def test_redirect_page_registration(hass: HomeAssistant, hass_client): 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) await setup(hass)
client = await hass_client() client = await hass_client()
resp = await client.get("/auth/oidc/redirect", allow_redirects=False) resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
assert resp.status == 200 assert resp.status == 302
text = await resp.text()
assert "Integration is misconfigured" in text
resp2 = await client.post("/auth/oidc/redirect", allow_redirects=False) 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 @pytest.mark.asyncio
@@ -80,45 +254,301 @@ async def test_callback_registration(hass: HomeAssistant, hass_client):
client = await hass_client() client = await hass_client()
resp = await client.get("/auth/oidc/callback", allow_redirects=False) resp = await client.get("/auth/oidc/callback", allow_redirects=False)
assert resp.status == 200 assert resp.status == 400
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_finish_registration(hass: HomeAssistant, hass_client): async def test_callback_rejects_missing_code_or_state(hass: HomeAssistant, hass_client):
"""Test that finish page is reachable.""" """Callback must reject requests missing either code or state."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
resp = await client.get("/auth/oidc/finish", allow_redirects=False) redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
assert resp.status == 200 encoded = encode_redirect_uri(redirect_uri)
text = await resp.text() resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}",
# Should miss the code parameter if called without it allow_redirects=False,
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
) )
assert resp2.status == 302 state = resp_welcome.cookies["auth_oidc_state"].value
assert resp2.headers["Location"] == "/?storeToken=true"
assert resp2.cookies["auth_oidc_code"].value == "456888" 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 # 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() client = await hass_client()
resp = await client.get("/auth/authorize", allow_redirects=False) 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() text = await resp.text()
assert "<script src='/auth/oidc/static/injection.js" in 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

View File

@@ -19,28 +19,25 @@ async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setup_success_yaml(hass: HomeAssistant): @pytest.mark.parametrize(
"""Test successful setup of a YAML configuration.""" "config",
await setup( [
hass,
{ {
"client_id": "dummy", "client_id": "dummy",
"discovery_url": "https://example.com/.well-known/openid-configuration", "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", "client_id": "dummy",
"discovery_url": "https://example.com/.well-known/openid-configuration", "discovery_url": "https://example.com/.well-known/openid-configuration",
ADDITIONAL_SCOPES: "email phone", 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, True,
) )

View File

@@ -1,11 +1,21 @@
"""Tests for the helpers and validation tools""" """Tests for the helpers and validation tools"""
from unittest.mock import patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from aiohttp.test_utils import make_mocked_request 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 ( from custom_components.auth_oidc.tools.validation import (
validate_client_id, validate_client_id,
sanitize_client_secret, sanitize_client_secret,
@@ -38,6 +48,85 @@ async def test_get_view():
assert data.startswith("<!DOCTYPE html>") 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 @pytest.mark.asyncio
async def test_validate_url(): async def test_validate_url():
"""Test the validate_url helper.""" """Test the validate_url helper."""

View 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
View 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
View File

@@ -1,15 +1,14 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.14.2, <3.15" requires-python = ">=3.14.4, <3.15"
[manifest] [manifest]
overrides = [ overrides = [
{ name = "cryptography", specifier = ">=46.0.6,<46.1" },
{ name = "orjson", specifier = ">=3.11.6,<3.12.0" }, { 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 = "pyjwt", specifier = ">=2.12.0,<2.13.0" },
{ name = "pyopenssl", specifier = ">=26.0.0" }, { name = "pytest", specifier = ">=9.0.3,<9.1.0" },
{ name = "requests", specifier = ">=2.33.0,<2.34" }, { name = "uv", specifier = ">=0.11.6,<0.12.0" },
] ]
[[package]] [[package]]
@@ -985,7 +984,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "homeassistant", specifier = "~=2026.3" }, { name = "homeassistant", specifier = "~=2026.4" },
{ name = "pylint", specifier = "~=4.0" }, { name = "pylint", specifier = "~=4.0" },
{ name = "pytest", specifier = "~=9.0.0" }, { name = "pytest", specifier = "~=9.0.0" },
{ name = "pytest-asyncio", specifier = "~=1.3.0" }, { name = "pytest-asyncio", specifier = "~=1.3.0" },
@@ -1337,25 +1336,25 @@ wheels = [
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.11.8" version = "3.11.7"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -1378,35 +1377,35 @@ wheels = [
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.1.1" version = "12.2.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -1786,7 +1785,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.0" version = "9.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1795,9 +1794,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { 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 = [ 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]] [[package]]
@@ -2357,28 +2356,28 @@ wheels = [
[[package]] [[package]]
name = "uv" name = "uv"
version = "0.11.1" version = "0.11.6"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]