Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763ab5cd5a | ||
|
|
843c415f88 | ||
|
|
9d9025164a | ||
|
|
d251ebfb92 | ||
|
|
d3c359064d | ||
|
|
c7370ed266 | ||
|
|
04abb0fdb3 | ||
|
|
7f657411ad | ||
|
|
1bcc65d649 | ||
|
|
819b3fb679 | ||
|
|
8205c846f6 | ||
|
|
f51e84849e | ||
|
|
5250fd2de9 | ||
|
|
a154ffc197 | ||
|
|
c0a6e03fa7 | ||
|
|
fe706abdb5 |
20
.github/ISSUE_TEMPLATE/0-anything-else.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/0-anything-else.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Anything else
|
||||
about: If your issue isn't any of the other types below (please review those first)
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe your issue**
|
||||
A clear and concise description of what the issue is.
|
||||
|
||||
**Version**
|
||||
- Home Assistant version: `for example 2026.4.1`
|
||||
- Home Assistant install method: `for example Container or HA OS`
|
||||
- Integration version: `for example 1.0.2`
|
||||
- If applicable for a frontend bug, browser (type and version): `for example Chrome 102`
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something is going wrong (or not matching expected behavior)
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Version**
|
||||
- Home Assistant version: `for example 2026.4.1`
|
||||
- Home Assistant install method: `for example Container or HA OS`
|
||||
- Integration version: `for example 1.0.2`
|
||||
- If applicable for a frontend bug, browser (type and version): `for example Chrome 102`
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/ideas
|
||||
about: Please submit your feature request in the Ideas section of the Discussions.
|
||||
- name: Question
|
||||
url: https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/q-a
|
||||
about: You can ask your question in the Q&A section of the Discussions.
|
||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
|
||||
2
.github/workflows/security.yaml
vendored
2
.github/workflows/security.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Scan dependencies for vulnerabilities
|
||||
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
|
||||
24
README.md
24
README.md
@@ -15,7 +15,7 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/">
|
||||
<img src="./docs/logo.png" alt="Logo" width="80" height="80">
|
||||
<img src="https://raw.githubusercontent.com/christiaangoossens/hass-oidc-auth/main/docs/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3 align="center">OpenID Connect for Home Assistant</h3>
|
||||
@@ -24,11 +24,11 @@
|
||||
OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration,<br/>with a strong focus on <b>security, stability and accessibility.</b>
|
||||
<br />
|
||||
<br />
|
||||
<a href="./docs/configuration.md">YAML Configuration Guide</a>
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/configuration.md">YAML Configuration Guide</a>
|
||||
·
|
||||
<a href="./CONTRIBUTING.md">Contribution Guide</a>
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/CONTRIBUTING.md">Contribution Guide</a>
|
||||
·
|
||||
<a href="./docs/faq.md">Frequently Asked Questions (FAQ)</a>
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/faq.md">Frequently Asked Questions (FAQ)</a>
|
||||
<br />
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions?discussions_q=is%3Aopen+category%3AAnnouncements+category%3APolls">Announcements & Polls</a>
|
||||
·
|
||||
@@ -50,7 +50,7 @@ The core values for this integration are:
|
||||
|
||||
**TLDR**: *Login to Home Assistant with this integration should 'just work', every time, for everyone in your household ([even your dad](https://github.com/home-assistant/architecture/issues/832#issuecomment-1328052330)), securely.*
|
||||
|
||||
If you are deciding if this integration is the right fit for your setup, please see the [Frequently Asked Questions (FAQ)](./docs/faq.md) for more information.
|
||||
If you are deciding if this integration is the right fit for your setup, please see the [Frequently Asked Questions (FAQ)](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/faq.md) for more information.
|
||||
|
||||
|
||||
## Installation guide
|
||||
@@ -65,9 +65,9 @@ Next, set up your OIDC provider. You can find setup guides for common providers
|
||||
|
||||
| <img src="https://goauthentik.io/img/icon_top_brand_colour.svg" width="100"> | <img src="https://www.authelia.com/images/branding/logo-cropped.png" width="100"> | <img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"> |
|
||||
|:-----------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------:|
|
||||
| [authentik](./docs/provider-configurations/authentik.md) | [Authelia](./docs/provider-configurations/authelia.md) | [Pocket ID](./docs/provider-configurations/pocket-id.md) |
|
||||
| [authentik](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/authentik.md) | [Authelia](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/authelia.md) | [Pocket ID](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/pocket-id.md) |
|
||||
|
||||
You can also find additional provider guides in the [the Provider Configurations folder](./docs/provider-configurations). If your provider isn't specified, you can use either a **public client** (recommended) or **confidential client** with the callback URL set to `<your HA URL>/auth/oidc/callback`.
|
||||
You can also find additional provider guides in the [the Provider Configurations folder](https://github.com/christiaangoossens/hass-oidc-auth/tree/main/docs/provider-configurations). If your provider isn't specified, you can use either a **public client** (recommended) or **confidential client** with the callback URL set to `<your HA URL>/auth/oidc/callback`.
|
||||
|
||||
Finally, choose your preferred configuration style (UI or YAML). After configuration, you should automatically be sent to the OIDC login page(s) if you open Home Assistant (web or app).
|
||||
|
||||
@@ -81,23 +81,23 @@ Many configuration options are available through this method, but some advanced
|
||||
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
|
||||
3. Follow the prompts on screen carefully.
|
||||
|
||||

|
||||

|
||||
|
||||
### Configuration by YAML
|
||||
|
||||
Alternatively, you can configure the integration using YAML. You can find a full configuration guide for YAML here: [YAML Configuration Guide](./docs/configuration.md).
|
||||
Alternatively, you can configure the integration using YAML. You can find a full configuration guide for YAML here: [YAML Configuration Guide](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/configuration.md).
|
||||
|
||||
## Contributions
|
||||
Contributions are very welcome! If you program in Python or have worked with Home Assistant integrations before, please try to contribute. You can find more information in the [Contribution Guide](./CONTRIBUTING.md).
|
||||
Contributions are very welcome! If you program in Python or have worked with Home Assistant integrations before, please try to contribute. You can find more information in the [Contribution Guide](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/CONTRIBUTING.md).
|
||||
|
||||
### Security issue?
|
||||
Please see [SECURITY.md](./SECURITY.md) for more information on how to submit your security issue securely. You can find previously found vulnerabilities and their corresponding security advisories at the [Security Advisories page](https://github.com/christiaangoossens/hass-oidc-auth/security/advisories).
|
||||
Please see [SECURITY.md](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/SECURITY.md) for more information on how to submit your security issue securely. You can find previously found vulnerabilities and their corresponding security advisories at the [Security Advisories page](https://github.com/christiaangoossens/hass-oidc-auth/security/advisories).
|
||||
|
||||
## Background
|
||||
If you would like to read the background/open letter that led to this component, you can find it at https://github.com/orgs/home-assistant/discussions/48. It is currently one of the most upvoted feature requests for Home Assistant.
|
||||
|
||||
## License
|
||||
Distributed under the MIT license with no warranty. You are fully liable for configuring this integration correctly to keep your Home Assistant installation secure. Use at your own risk. The full license can be found in [LICENSE.md](./LICENSE.md)
|
||||
Distributed under the MIT license with no warranty. You are fully liable for configuring this integration correctly to keep your Home Assistant installation secure. Use at your own risk. The full license can be found in [LICENSE.md](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/LICENSE.md)
|
||||
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
|
||||
@@ -90,24 +90,60 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry):
|
||||
return False
|
||||
|
||||
|
||||
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||
"""Set up the OIDC provider with the given configuration."""
|
||||
providers = OrderedDict()
|
||||
|
||||
async def _register_oidc_provider(hass: HomeAssistant, my_config: dict):
|
||||
"""Register the OIDC provider in Home Assistant's auth system."""
|
||||
# Use private APIs until there is a real auth platform
|
||||
|
||||
# pylint: disable=protected-access
|
||||
providers = OrderedDict()
|
||||
provider = OpenIDAuthProvider(hass, hass.auth._store, my_config)
|
||||
|
||||
existing_auth_providers = hass.auth._providers.copy()
|
||||
_LOGGER.debug("Current auth providers: %s", list(existing_auth_providers.keys()))
|
||||
auth_provider_count = len(existing_auth_providers)
|
||||
has_trusted_networks_provider_first = False
|
||||
|
||||
if auth_provider_count > 0:
|
||||
# Pop the first provider from the existing providers to check if it's trusted_networks
|
||||
first_provider_key, first_provider_obj = next(
|
||||
iter(existing_auth_providers.items())
|
||||
)
|
||||
existing_auth_providers.pop(first_provider_key)
|
||||
|
||||
if first_provider_key[0] == "trusted_networks":
|
||||
_LOGGER.info(
|
||||
"Trusted Networks provider detected as the first auth provider. "
|
||||
+ "Keeping registration order intact."
|
||||
)
|
||||
providers[first_provider_key] = first_provider_obj
|
||||
has_trusted_networks_provider_first = True
|
||||
else:
|
||||
# Reset back to what we had before
|
||||
existing_auth_providers = hass.auth._providers.copy()
|
||||
|
||||
# Register OIDC at the start of the array
|
||||
# OIDC needs to be first because it needs to process the login cookie after sign-in
|
||||
providers[(provider.type, provider.id)] = provider
|
||||
|
||||
# Get current provider count
|
||||
has_other_auth_providers = len(hass.auth._providers) > 0
|
||||
# Add back any other providers that were already registered
|
||||
providers.update(existing_auth_providers)
|
||||
|
||||
providers.update(hass.auth._providers)
|
||||
_LOGGER.debug("Final auth providers: %s", list(providers.values()))
|
||||
hass.auth._providers = providers
|
||||
# pylint: enable=protected-access
|
||||
|
||||
_LOGGER.info("Registered OIDC provider")
|
||||
return provider, auth_provider_count, has_trusted_networks_provider_first
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||
"""Set up the OIDC provider with the given configuration."""
|
||||
(
|
||||
provider,
|
||||
auth_provider_count,
|
||||
has_trusted_networks_provider_first,
|
||||
) = await _register_oidc_provider(hass, my_config)
|
||||
|
||||
# Set the correct scopes
|
||||
# Always use 'openid' & 'profile' as they are specified in the OIDC spec
|
||||
@@ -160,14 +196,18 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
|
||||
]
|
||||
)
|
||||
|
||||
has_only_trusted_networks = (
|
||||
auth_provider_count == 1 and has_trusted_networks_provider_first
|
||||
)
|
||||
|
||||
hass.http.register_view(
|
||||
OIDCWelcomeView(
|
||||
provider,
|
||||
OIDCWelcomeOptions(
|
||||
name=name,
|
||||
force_https=force_https,
|
||||
has_other_auth_providers=has_other_auth_providers,
|
||||
prefers_skipping=default_redirect,
|
||||
has_other_auth_providers=auth_provider_count > 0,
|
||||
prefers_skipping=default_redirect or has_only_trusted_networks,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -179,6 +219,8 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
|
||||
_LOGGER.info("Registered OIDC views")
|
||||
|
||||
# Inject OIDC code into the frontend for /auth/authorize for automatic redirect
|
||||
await OIDCInjectedAuthPage.inject(hass, force_https)
|
||||
await OIDCInjectedAuthPage.inject(
|
||||
hass, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Dict
|
||||
|
||||
DEFAULT_TITLE = "OpenID Connect (SSO)"
|
||||
DOMAIN = "auth_oidc"
|
||||
REPO_ROOT_URL = "https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.1"
|
||||
REPO_ROOT_URL = "https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.2"
|
||||
|
||||
## ===
|
||||
## Config keys
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.http import HomeAssistantView, StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .welcome import PATH as WELCOME_PATH
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import get_url
|
||||
|
||||
PATH = "/auth/authorize"
|
||||
@@ -24,7 +25,12 @@ async def read_file(path: str) -> str:
|
||||
return await f.read()
|
||||
|
||||
|
||||
async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
async def frontend_injection(
|
||||
hass: HomeAssistant,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Inject new frontend code into /auth/authorize."""
|
||||
router = hass.http.app.router
|
||||
frontend_path = None
|
||||
@@ -67,7 +73,7 @@ async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
frontend_code = await read_file(frontend_path)
|
||||
|
||||
# Inject JS and register that route
|
||||
injection_js = "<script src='/auth/oidc/static/injection.js?v=6'></script>"
|
||||
injection_js = "<script src='/auth/oidc/static/injection.js?v=7'></script>"
|
||||
frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>")
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
@@ -81,7 +87,11 @@ async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
)
|
||||
|
||||
# If everything is succesful, register a fake view that just returns the modified HTML
|
||||
hass.http.register_view(OIDCInjectedAuthPage(frontend_code, force_https))
|
||||
hass.http.register_view(
|
||||
OIDCInjectedAuthPage(
|
||||
frontend_code, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
)
|
||||
_LOGGER.info("Performed OIDC frontend injection")
|
||||
|
||||
|
||||
@@ -92,21 +102,36 @@ class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:authorize_page"
|
||||
|
||||
def __init__(self, html: str, force_https: bool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
html: str,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Initialize the injected auth page."""
|
||||
self.html = html
|
||||
self.provider = provider
|
||||
self.force_https = force_https
|
||||
self.has_trusted_networks_provider_first = has_trusted_networks_provider_first
|
||||
|
||||
@staticmethod
|
||||
async def inject(hass: HomeAssistant, force_https: bool) -> None:
|
||||
async def inject(
|
||||
hass: HomeAssistant,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Inject the OIDC auth page into the frontend."""
|
||||
|
||||
try:
|
||||
await frontend_injection(hass, force_https)
|
||||
await frontend_injection(
|
||||
hass, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error("Failed to inject OIDC auth page: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _should_do_oidc_redirect(req: web.Request) -> bool:
|
||||
def _should_do_oidc_redirect(self, req: web.Request) -> bool:
|
||||
"""Check if we should redirect to the OIDC flow."""
|
||||
# Set when we return from finish
|
||||
if req.query.get("skip_oidc_redirect") == "true":
|
||||
@@ -118,14 +143,25 @@ class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
if not redirect_uri:
|
||||
return False
|
||||
|
||||
# Check if we are on a trusted network if we have trusted networks registered first
|
||||
if (
|
||||
self.has_trusted_networks_provider_first
|
||||
and self.provider.is_trusted_network_host()
|
||||
):
|
||||
return False
|
||||
|
||||
# Handle both encoded and plain redirect_uri values.
|
||||
decoded_redirect_uri = unquote(redirect_uri)
|
||||
return "skip_oidc_redirect=true" not in decoded_redirect_uri
|
||||
|
||||
def _get_welcome_redirect_location(self, req: web.Request) -> str:
|
||||
"""Build the welcome URL for the injected auth page redirect."""
|
||||
url = str(req.url)
|
||||
if self.force_https:
|
||||
url = url.replace("http://", "https://")
|
||||
|
||||
encoded_current_url = quote(
|
||||
base64.b64encode(str(req.url).encode("utf-8")).decode("ascii")
|
||||
base64.b64encode(url.encode("utf-8")).decode("ascii")
|
||||
)
|
||||
return get_url(
|
||||
f"{WELCOME_PATH}?redirect_uri={encoded_current_url}",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Welcome route to show the user the OIDC login button and give instructions."""
|
||||
|
||||
from ast import List
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import urlparse, parse_qs, unquote, urlencode
|
||||
@@ -29,7 +28,7 @@ class OIDCWelcomeView(HomeAssistantView):
|
||||
self.has_other_auth_providers = options.get("has_other_auth_providers")
|
||||
self.prefers_skipping = options.get("prefers_skipping")
|
||||
|
||||
async def _process_url(self, redirect_uri: str) -> List[str, bool]:
|
||||
async def _process_url(self, redirect_uri: str) -> tuple[str, bool]:
|
||||
"""Processes the redirect URI to determine if we need setTokens and if this is mobile."""
|
||||
# decodeURIComponent(btoa(...)) -> unquote first, then base64 decode
|
||||
redirect_uri = base64.b64decode(unquote(redirect_uri), validate=True).decode(
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"jinja2",
|
||||
"joserfc"
|
||||
],
|
||||
"version": "1.0.1"
|
||||
"version": "1.0.2"
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import logging
|
||||
|
||||
from typing import Dict, Optional
|
||||
import asyncio
|
||||
from homeassistant.auth import EVENT_USER_ADDED
|
||||
from ipaddress import (
|
||||
ip_address,
|
||||
IPv4Address,
|
||||
IPv6Address,
|
||||
)
|
||||
from homeassistant.auth import EVENT_USER_ADDED, InvalidAuthError as HAInvalidAuthError
|
||||
from homeassistant.auth.providers import (
|
||||
AUTH_PROVIDERS,
|
||||
AuthProvider,
|
||||
@@ -20,7 +25,6 @@ from homeassistant.auth.providers import (
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.components import http, person
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .config.const import (
|
||||
FEATURES,
|
||||
@@ -31,6 +35,8 @@ from .config.const import (
|
||||
from .stores.state_store import StateStore
|
||||
from .tools.types import UserDetails
|
||||
|
||||
type IPAddress = IPv4Address | IPv6Address
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE = "auth_oidc"
|
||||
@@ -38,7 +44,7 @@ HASS_PROVIDER_TYPE = "homeassistant"
|
||||
COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
class InvalidAuthError(HAInvalidAuthError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@@ -59,8 +65,11 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
{
|
||||
# Currently register as default, might be used when we have multiple OIDC providers
|
||||
CONF_ID: "default",
|
||||
# Name displayed in the UI
|
||||
CONF_NAME: config.get("display_name", DEFAULT_TITLE),
|
||||
# Stable label for HA's native auth-picker row. Kept fixed so the
|
||||
# frontend-injection script can match it without threading the
|
||||
# user-configurable display_name through. The user's display_name
|
||||
# is still rendered on the welcome page.
|
||||
CONF_NAME: DEFAULT_TITLE,
|
||||
# Type
|
||||
CONF_TYPE: PROVIDER_TYPE,
|
||||
},
|
||||
@@ -114,6 +123,41 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
|
||||
return None
|
||||
|
||||
def is_trusted_network_host(self) -> bool:
|
||||
"""Check if the current request is coming from a trusted network host."""
|
||||
ip = self._resolve_ip()
|
||||
if ip is None:
|
||||
return False
|
||||
|
||||
# Check if trusted networks auth provider is present
|
||||
trusted_network_provider = self.hass.auth.get_auth_provider(
|
||||
"trusted_networks", None
|
||||
)
|
||||
if not trusted_network_provider:
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Trusted networks present and checking if we should OIDC redirect"
|
||||
)
|
||||
|
||||
try:
|
||||
trusted_network_provider.async_validate_access(ip_address(ip))
|
||||
_LOGGER.info("IP %s is in a trusted network, skipping OIDC flow", ip)
|
||||
return True
|
||||
except HAInvalidAuthError:
|
||||
# Log the error
|
||||
_LOGGER.info(
|
||||
"IP %s is not in a trusted network, proceeding with OIDC flow", ip
|
||||
)
|
||||
return False
|
||||
# Catch every other error, HA might have changed the API.
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
_LOGGER.warning(
|
||||
"Error while validating trusted network for IP %s: %s", ip, e
|
||||
)
|
||||
return False
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
/**
|
||||
* hass-oidc-auth - UX script to automatically select the Home Assistant auth provider when the "Login aborted" message is shown.
|
||||
* Frontend helpers for /auth/authorize: auto-select on aborted login,
|
||||
* and route picker clicks on our provider through /auth/oidc/welcome.
|
||||
*/
|
||||
|
||||
const OIDC_PROVIDER_NAME = "OpenID Connect (SSO)" // matches provider.py CONF_NAME
|
||||
const OIDC_WELCOME_PATH = "/auth/oidc/welcome"
|
||||
|
||||
let authFlowElement = null
|
||||
let pickerIntercepted = false
|
||||
|
||||
function interceptPickerRow(authProviderElement) {
|
||||
if (pickerIntercepted) return
|
||||
if (!authProviderElement) return
|
||||
if (!authProviderElement.shadowRoot) {
|
||||
console.warn("[OIDC] ha-pick-auth-provider has no shadowRoot; HA frontend may have changed.")
|
||||
return
|
||||
}
|
||||
const items = authProviderElement.shadowRoot.querySelectorAll('ha-list-item')
|
||||
if (items.length === 0) return // not yet populated; retry on next mutation
|
||||
for (const item of items) {
|
||||
if ((item.innerText || '').trim() !== OIDC_PROVIDER_NAME) continue
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopImmediatePropagation()
|
||||
e.preventDefault()
|
||||
window.location.href =
|
||||
OIDC_WELCOME_PATH +
|
||||
'?redirect_uri=' + encodeURIComponent(btoa(window.location.href))
|
||||
}, true)
|
||||
pickerIntercepted = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
// Find ha-auth-flow
|
||||
authFlowElement = document.querySelector('ha-auth-flow');
|
||||
authFlowElement = document.querySelector('ha-auth-flow')
|
||||
if (!authFlowElement) return
|
||||
|
||||
if (!authFlowElement) {
|
||||
return;
|
||||
}
|
||||
const authProviderElement = document.querySelector('ha-pick-auth-provider')
|
||||
|
||||
// Check if the text "Login aborted" is present on the page
|
||||
if (!authFlowElement.innerText.includes('Login aborted')) {
|
||||
return;
|
||||
}
|
||||
// Intercept picker clicks so the OIDC cookie is set before submit.
|
||||
interceptPickerRow(authProviderElement)
|
||||
|
||||
// Find the ha-pick-auth-provider element
|
||||
const authProviderElement = document.querySelector('ha-pick-auth-provider');
|
||||
|
||||
if (!authProviderElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Click the first ha-list-item element inside the ha-pick-auth-provider
|
||||
const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item');
|
||||
if (!firstListItem) {
|
||||
console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
firstListItem.click();
|
||||
// Auto-select on "Login aborted".
|
||||
if (!authFlowElement.innerText.includes('Login aborted')) return
|
||||
if (!authProviderElement) return
|
||||
const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item')
|
||||
if (!firstListItem) {
|
||||
console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting OIDC provider.")
|
||||
return
|
||||
}
|
||||
firstListItem.click()
|
||||
}
|
||||
|
||||
// Hide the content until ready
|
||||
@@ -58,4 +76,5 @@ setTimeout(() => {
|
||||
|
||||
// Force display the content
|
||||
document.querySelector(".content").style.display = "";
|
||||
update();
|
||||
}, 300)
|
||||
@@ -372,7 +372,7 @@ class OIDCClient:
|
||||
tcp_connector_args["ssl"] = ssl_context
|
||||
|
||||
self.http_session = aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(**tcp_connector_args)
|
||||
trust_env=True, connector=aiohttp.TCPConnector(**tcp_connector_args)
|
||||
)
|
||||
return self.http_session
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Here are some documentation links for specific providers that you may want to fo
|
||||
* [Kanidm](./provider-configurations/kanidm.md)
|
||||
* [Microsoft Entra ID](./provider-configurations/microsoft-entra.md)
|
||||
* [Zitadel](./provider-configurations/zitadel.md)
|
||||
* [Keycloak](./provider-configurations/keycloak.md)
|
||||
|
||||
_Missing a provider? Submit your guide using a PR._
|
||||
|
||||
|
||||
77
docs/provider-configurations/keycloak.md
Normal file
77
docs/provider-configurations/keycloak.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Keycloak
|
||||
|
||||
|
||||
## Step 1. Install the integration
|
||||
|
||||
Make sure that you have fully installed the latest release of the integration. The easiest way to install the integration is through [the Home Assistant Community Store (HACS)](https://hacs.xyz/).
|
||||
|
||||
After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
|
||||
|
||||
## Step 2. Configure Keycloak
|
||||
|
||||
1. Log in to your Keycloak Admin Console and select the Realm you want to use.
|
||||
2. Navigate to **Clients** and click **Create client**.
|
||||
* **Client ID**: `homeassistant` (or a name of your choice).
|
||||
* **Client Authentication**: Turn **ON** if you want to use a Client Secret (Confidential Client), or leave **OFF** for a Public Client.
|
||||
* **Valid redirect URIs**: `https://<your HA URL>/auth/oidc/callback`
|
||||
* Save the client. If you enabled Client Authentication, go to the **Credentials** tab and copy your **Client Secret**.
|
||||
|
||||
*(If you are using the UI configuration in Home Assistant, you can stop here and proceed to Step 3. Group and role mapping is only supported via `configuration.yaml`.)*
|
||||
|
||||
3. Navigate to **Groups** and create the groups you want to use for Home Assistant access.
|
||||
* Example: `homeassistant` (for standard users) and `homeassistantadmin` (for administrators).
|
||||
* Assign your users to these groups.
|
||||
|
||||
### Step 2.1 Configure the Group Mapper (YAML only)
|
||||
|
||||
By default, Keycloak does not send a user's groups in the OIDC token in a format that Home Assistant expects. You must create a specific mapper:
|
||||
|
||||
> [!NOTE]
|
||||
> If you name the scope something other than `groups`, you have to set `claims.groups` to the correct name and `groups_scope` to the new name in your Home Assistant configuration.
|
||||
|
||||
1. In Keycloak, go to **Client Scopes**. Create a dedicated scope `groups` and assign it to your `homeassistant` client as a Default Scope.
|
||||
2. Click into the scope and go to the **Mappers** tab.
|
||||
3. Click **Configure a new mapper** (or Add mapper -> By configuration) and select **Group Membership**.
|
||||
4. Configure the mapper exactly as follows:
|
||||
* **Name**: `groups`
|
||||
* **Token Claim Name**: `groups`
|
||||
* **Full group path**: **OFF** *(Important: This ensures Home Assistant receives `homeassistant` instead of the full path `/users/homeassistant`, if you use nested groups)*.
|
||||
* **Add to ID token**: **ON**
|
||||
* **Add to access token**: **ON**
|
||||
* **Add to userinfo**: **ON**
|
||||
5. Save the mapper.
|
||||
|
||||
## Step 3. Home Assistant Configuration
|
||||
|
||||
You can configure this via the UI, or by using `configuration.yaml`.
|
||||
|
||||
### Option A: Configuration via UI (Simple)
|
||||
|
||||
The UI flow is the easiest way to get started. Note that the UI does not currently offer group/role customization for OpenID Connect (SSO), so the group mapper setup from Keycloak is not needed.
|
||||
|
||||
1. Go to **Settings** -> **Devices & Services** in Home Assistant.
|
||||
2. Click **Add Integration** and search for **OpenID Connect**.
|
||||
3. As OIDC Provider select **OpenID Connect (SSO)**.
|
||||
4. Follow the UI flow and enter the following details:
|
||||
* **Discovery URL**: `https://<your-keycloak-domain>/realms/<your-realm>/.well-known/openid-configuration`
|
||||
* **Client ID**: The Client ID you created in Keycloak (e.g., `homeassistant`).
|
||||
* **Client Secret**: The Client Secret from Keycloak (if Client Authentication was enabled).
|
||||
5. Finish the setup in the UI.
|
||||
|
||||
### Option B: Configuration via `configuration.yaml` (Advanced / Group Mapping)
|
||||
|
||||
Here is the minimal `configuration.yaml` setup for Keycloak if you want to use group-based role mapping:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "homeassistant"
|
||||
client_secret: !secret oidc_client_secret # Remove this line if Client Authentication is OFF in Keycloak
|
||||
discovery_url: "https://<your-keycloak-domain>/realms/<your-realm>/.well-known/openid-configuration"
|
||||
|
||||
roles:
|
||||
# These must exactly match the group names you created in Keycloak
|
||||
user: homeassistant
|
||||
admin: homeassistantadmin
|
||||
```
|
||||
142
package-lock.json
generated
142
package-lock.json
generated
@@ -369,27 +369,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/cli": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
|
||||
"integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.4.tgz",
|
||||
"integrity": "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"@tailwindcss/node": "4.2.4",
|
||||
"@tailwindcss/oxide": "4.2.4",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"mri": "^1.2.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
"tailwindcss": "4.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"tailwindcss": "dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
@@ -398,36 +398,36 @@
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
|
||||
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.4",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.4",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
|
||||
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -441,9 +441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
|
||||
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -457,9 +457,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
|
||||
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -473,9 +473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
|
||||
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -489,9 +489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
|
||||
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -505,9 +505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
|
||||
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -524,9 +524,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
|
||||
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -543,9 +543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
|
||||
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -562,9 +562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
|
||||
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -581,9 +581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
|
||||
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -668,9 +668,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -684,9 +684,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
|
||||
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -709,13 +709,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -1070,15 +1070,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.auth import InvalidAuthError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -17,6 +19,8 @@ from custom_components.auth_oidc import DOMAIN
|
||||
from custom_components.auth_oidc.config.const import (
|
||||
DISCOVERY_URL,
|
||||
CLIENT_ID,
|
||||
DEFAULT_TITLE,
|
||||
DISPLAY_NAME,
|
||||
FEATURES,
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION,
|
||||
FEATURES_AUTOMATIC_USER_LINKING,
|
||||
@@ -24,6 +28,10 @@ from custom_components.auth_oidc.config.const import (
|
||||
from .mocks.oidc_server import MockOIDCServer, mock_oidc_responses
|
||||
|
||||
FAKE_REDIR_URL = "http://example.com/auth/authorize?response_type=code&redirect_uri=http%3A%2F%2Fexample.com%3A8123%2F%3Fauth_callback%3D1&client_id=http%3A%2F%2Fexample.com%3A8123%2F&state=example"
|
||||
DEFAULT_CONFIG = {
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
}
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool:
|
||||
@@ -40,10 +48,7 @@ async def test_setup_success_auth_provider_registration(hass: HomeAssistant):
|
||||
"""Test successful setup"""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -55,6 +60,24 @@ async def test_setup_success_auth_provider_registration(hass: HomeAssistant):
|
||||
assert auth_providers[0].support_mfa is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_name_is_stable_regardless_of_display_name(hass: HomeAssistant):
|
||||
"""CONF_NAME stays at DEFAULT_TITLE so injection.js can match the picker
|
||||
row regardless of the configured display_name."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
DISPLAY_NAME: "Custom / Branded IdP",
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
assert provider.name == DEFAULT_TITLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_ip_fallback_fails_closed_without_request_context(
|
||||
hass: HomeAssistant,
|
||||
@@ -62,10 +85,7 @@ async def test_provider_ip_fallback_fails_closed_without_request_context(
|
||||
"""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",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -83,10 +103,7 @@ async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssis
|
||||
"""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",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -98,6 +115,142 @@ async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssis
|
||||
assert "Secure" in cookie_header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_true_for_allowed_ip(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Provider should detect trusted network host when trusted provider allows the IP."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("trusted_networks", None), TrustedNetworksAllowProvider()),
|
||||
((provider.type, provider.id), provider),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_false_for_disallowed_ip(
|
||||
hass: HomeAssistant, caplog
|
||||
):
|
||||
"""Provider should return False when trusted provider denies the current IP."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
class TrustedNetworksDenyProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
raise InvalidAuthError("Not in trusted_networks")
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("trusted_networks", None), TrustedNetworksDenyProvider()),
|
||||
((provider.type, provider.id), provider),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is False
|
||||
assert any(
|
||||
level >= 0
|
||||
and "is not in a trusted network, proceeding with OIDC flow" in message
|
||||
for _, level, message in caplog.record_tuples
|
||||
)
|
||||
assert not any(
|
||||
level >= 0 and "Error while validating trusted network for IP" in message
|
||||
for _, level, message in caplog.record_tuples
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_false_without_trusted_provider(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Provider should return False when trusted_networks auth provider is absent."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
# Without actually getting the IP, should also be false
|
||||
assert provider.is_trusted_network_host() is False
|
||||
|
||||
# With the IP, should be false
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_redirects_when_only_trusted_networks_and_not_in_trusted_network(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""When only trusted_networks is present, welcome should redirect regardless of IP."""
|
||||
|
||||
class TrustedNetworksDenyProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
raise InvalidAuthError("Not in trusted_networks")
|
||||
|
||||
# Simulate that only trusted_networks is registered before OIDC provider setup
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("trusted_networks", None), TrustedNetworksDenyProvider()),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
# Now setup the OIDC provider which should detect trusted_networks as the only other provider
|
||||
await setup(hass, DEFAULT_CONFIG, True)
|
||||
|
||||
client = await hass_client()
|
||||
encoded_redirect_uri = base64.b64encode(FAKE_REDIR_URL.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/welcome?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
# Should redirect straight to the OIDC redirect endpoint
|
||||
assert resp.status == 302
|
||||
assert resp.headers["Location"].endswith("/auth/oidc/redirect")
|
||||
|
||||
|
||||
async def login_user(hass: HomeAssistant, state_id: str):
|
||||
"""Helper to login a user from the stored OIDC state."""
|
||||
|
||||
@@ -167,8 +320,7 @@ async def test_full_login(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -198,8 +350,7 @@ async def test_login_with_linking(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: True,
|
||||
@@ -233,8 +384,7 @@ async def test_login_with_person_create(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: True,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -267,8 +417,7 @@ async def test_login_without_person_create_does_not_create_person(
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -295,8 +444,7 @@ async def test_login_shows_form(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -319,8 +467,7 @@ async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -352,8 +499,7 @@ async def test_login_with_no_cookie_aborts(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, unquote, urlparse, urlencode
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
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.provider import COOKIE_NAME
|
||||
from custom_components.auth_oidc.tools.oidc_client import (
|
||||
OIDCDiscoveryClient,
|
||||
OIDCDiscoveryInvalid,
|
||||
@@ -248,6 +251,47 @@ async def test_full_oidc_flow(hass: HomeAssistant, hass_client):
|
||||
await verify_back_redirect(client, redirect_uri)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_init_completes_with_state_cookie(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""The provider login flow init step should finalize when the auth cookie is present."""
|
||||
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()
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={json_auth['code']}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 302
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
flow = await provider.async_login_flow({})
|
||||
|
||||
fake_request = SimpleNamespace(
|
||||
cookies={COOKIE_NAME: 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.CREATE_ENTRY
|
||||
|
||||
|
||||
async def discovery_test_through_redirect(
|
||||
hass_client, caplog, scenario: str, match_log_line: str
|
||||
):
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import base64
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlencode
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiohttp import web
|
||||
|
||||
from auth_oidc.config.const import (
|
||||
DISCOVERY_URL,
|
||||
CLIENT_ID,
|
||||
@@ -25,6 +28,22 @@ from custom_components.auth_oidc.endpoints.injected_auth_page import (
|
||||
)
|
||||
|
||||
MOBILE_CLIENT_ID = "https://home-assistant.io/Android"
|
||||
WELCOME_PATH = "/auth/oidc/welcome"
|
||||
INJECTION_SCRIPT_MARKER = "<script src='/auth/oidc/static/injection.js"
|
||||
|
||||
|
||||
def assert_redirects_to_welcome(resp) -> None:
|
||||
"""Assert a response redirects to the OIDC welcome endpoint."""
|
||||
assert resp.status == 302
|
||||
location = resp.headers["Location"]
|
||||
parsed_location = urlparse(location)
|
||||
assert parsed_location.path == WELCOME_PATH
|
||||
|
||||
|
||||
async def assert_normal_login_screen(resp) -> None:
|
||||
"""Assert we stayed on the auth page and render the injected normal login HTML."""
|
||||
assert resp.status == 200
|
||||
assert INJECTION_SCRIPT_MARKER in await resp.text()
|
||||
|
||||
|
||||
def create_redirect_uri(client_id: str) -> str:
|
||||
@@ -310,7 +329,9 @@ async def test_welcome_desktop_auto_redirects_without_other_providers(
|
||||
"""Welcome should auto-redirect desktop clients when no other providers exist."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = [] # Clear initial providers out
|
||||
hass.auth._providers = {} # Clear initial providers out
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
@@ -334,7 +355,7 @@ async def test_redirect_without_cookie_goes_to_welcome(
|
||||
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"]
|
||||
assert_redirects_to_welcome(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -730,10 +751,7 @@ async def test_frontend_injection(
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/authorize", allow_redirects=False)
|
||||
assert resp.status == 200 # 200 because there is no redirect_uri
|
||||
text = await resp.text()
|
||||
|
||||
assert "<script src='/auth/oidc/static/injection.js" in text
|
||||
await assert_normal_login_screen(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -760,8 +778,12 @@ async def test_frontend_injection_logs_and_returns_when_route_handler_is_unexpec
|
||||
def __iter__(self):
|
||||
return iter([FakeRoute()])
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
with patch.object(hass.http.app.router, "resources", return_value=[FakeResource()]):
|
||||
await frontend_injection(hass, force_https=False)
|
||||
await frontend_injection(
|
||||
hass, provider, force_https=False, has_trusted_networks_provider_first=False
|
||||
)
|
||||
|
||||
assert "Unexpected route handler type" in caplog.text
|
||||
assert (
|
||||
@@ -780,7 +802,10 @@ async def test_injected_auth_page_inject_logs_errors(hass: HomeAssistant, caplog
|
||||
"custom_components.auth_oidc.endpoints.injected_auth_page.frontend_injection",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
await OIDCInjectedAuthPage.inject(hass, force_https=False)
|
||||
provider = MagicMock()
|
||||
await OIDCInjectedAuthPage.inject(
|
||||
hass, provider, force_https=False, has_trusted_networks_provider_first=False
|
||||
)
|
||||
|
||||
assert "Failed to inject OIDC auth page: boom" in caplog.text
|
||||
|
||||
@@ -836,5 +861,116 @@ async def test_injected_auth_page_returns_original_html_when_skipped(
|
||||
client = await hass_client()
|
||||
response = await client.get(request_target, allow_redirects=False)
|
||||
|
||||
assert response.status == 200
|
||||
assert "<script src='/auth/oidc/static/injection.js" in await response.text()
|
||||
await assert_normal_login_screen(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_trusted_networks_bypass_skips_oidc_redirect(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
):
|
||||
"""Trusted network hosts should bypass OIDC redirect when trusted_networks is first."""
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[(("trusted_networks", None), TrustedNetworksAllowProvider())]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup_mock_authorize_route(hass)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
encoded_redirect_uri = quote(create_redirect_uri(client.make_url("/")), safe="")
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/authorize?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
await assert_normal_login_screen(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_ignores_trusted_networks_when_not_first(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
):
|
||||
"""OIDC redirect should continue when trusted_networks is not the first provider."""
|
||||
|
||||
class DummyProvider:
|
||||
pass
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# Keep trusted_networks present but not first, so bypass should not apply.
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("homeassistant", None), DummyProvider()),
|
||||
(("trusted_networks", None), TrustedNetworksAllowProvider()),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup_mock_authorize_route(hass)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
encoded_redirect_uri = quote(create_redirect_uri(client.make_url("/")), safe="")
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/authorize?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
assert_redirects_to_welcome(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_converts_http_to_https_in_redirect(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""_get_welcome_redirect_location should convert HTTP to HTTPS when force_https is True."""
|
||||
await setup(hass)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
injected_page = OIDCInjectedAuthPage(
|
||||
html="<html></html>",
|
||||
provider=provider,
|
||||
force_https=True,
|
||||
has_trusted_networks_provider_first=False,
|
||||
)
|
||||
|
||||
# Create a mock request with HTTP URL
|
||||
mock_req = MagicMock(spec=web.Request)
|
||||
mock_req.url = "http://example.com/auth/authorize?redirect_uri=test"
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.endpoints.injected_auth_page.get_url"
|
||||
) as mock_get_url:
|
||||
mock_get_url.return_value = "https://example.com/auth/oidc/welcome?redirect_uri=..."
|
||||
|
||||
# pylint: disable=protected-access
|
||||
injected_page._get_welcome_redirect_location(mock_req)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
# Verify that the URL was converted from HTTP to HTTPS before being passed to get_url
|
||||
call_args = mock_get_url.call_args
|
||||
assert call_args is not None
|
||||
welcome_path_with_redirect = call_args[0][0] # First positional argument to get_url
|
||||
|
||||
# Extract the redirect_uri parameter and decode it
|
||||
parsed = urlparse(welcome_path_with_redirect)
|
||||
query_params = parse_qs(parsed.query)
|
||||
encoded_redirect_uri = query_params.get("redirect_uri", [None])[0]
|
||||
|
||||
# Decode the base64-encoded redirect_uri
|
||||
if encoded_redirect_uri:
|
||||
decoded_redirect_uri = base64.b64decode(unquote(encoded_redirect_uri)).decode("utf-8")
|
||||
# Verify it contains https:// instead of http://
|
||||
assert decoded_redirect_uri.startswith("https://example.com")
|
||||
|
||||
150
uv.lock
generated
150
uv.lock
generated
@@ -396,7 +396,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bleak"
|
||||
version = "3.0.1"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dbus-fast", marker = "sys_platform == 'linux'" },
|
||||
@@ -413,9 +413,9 @@ dependencies = [
|
||||
{ name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" },
|
||||
{ name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/9f/dd19d92309e4a37823210827f0f42460e69603254309b99499622b511294/bleak-3.0.1.tar.gz", hash = "sha256:c8ff077519f8c30a972fd0d22f47a54b981184b2f2a0886d02e55acadbc1045d", size = 124162, upload-time = "2026-03-25T15:43:01.769Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/9c/839e4ff0393070396c656fa6616d0d2512f60b571c1263183e709db1c365/bleak-3.0.1-py3-none-any.whl", hash = "sha256:49f93f24ce96610529842da2d9856e7f46597e25966c0f1cfc737f0191566de6", size = 144735, upload-time = "2026-03-25T15:43:00.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -505,30 +505,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.91"
|
||||
version = "1.43.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/cd/bba36079f5d4bd63db7385e6b9dc1845db32407c3f18f56aaddafb75097f/boto3-1.43.2.tar.gz", hash = "sha256:be951cc22769fbcda73fac523b031ee38db45c3ae2b0d828c76b8f6e8e683073", size = 113108, upload-time = "2026-05-01T19:43:13.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e5/c9cee72ef678dabcc27acaf8228a2d4157ad26b00e1cc5d48886f8a94c2c/boto3-1.43.2-py3-none-any.whl", hash = "sha256:796e859cfb5e93c55276ce746f8020f691eda6b68a0ec4ce4f6fd07a1cca6859", size = 140501, upload-time = "2026-05-01T19:43:10.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.91"
|
||||
version = "1.43.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/b0/65d4c85f16367fb6147d391652d0c386f24b029536f7026e7b98740166cd/botocore-1.43.2.tar.gz", hash = "sha256:7b2ec87b6d0720bff920451ce930e71c2a99cdea48d0eaa66ccf0b21ea747e03", size = 15301186, upload-time = "2026-05-01T19:42:59.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/52/f57ded73f1527a18e0712281eb49c4ae240038bb4dc7083fd288b4adc811/botocore-1.43.2-py3-none-any.whl", hash = "sha256:b823454d751a1c24bb403b5b07ab65007689654abb21787df923684e0743976c", size = 14982693, upload-time = "2026-05-01T19:42:54.602Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -542,11 +542,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1006,7 +1006,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "homeassistant"
|
||||
version = "2026.4.3"
|
||||
version = "2026.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiodns" },
|
||||
@@ -1060,9 +1060,9 @@ dependencies = [
|
||||
{ name = "yarl" },
|
||||
{ name = "zeroconf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/f2/5b9699e8cd58fa6e2176bc4f47d4ab29bb71f355e3acf502c97e6c21969a/homeassistant-2026.4.3.tar.gz", hash = "sha256:3fbe8754be4d5bc4cea62735911517c5e31e02db32f94c64993bec73427eea76", size = 32400391, upload-time = "2026-04-17T20:24:23.388Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/3d/041a66485642537286c4b1a3ee66ace7ce43cfb61737e224cf01e6b59c97/homeassistant-2026.4.4.tar.gz", hash = "sha256:10f997fb7c00b2f8abbe30f343469428665b68d5a1e665feee2b827d9d815212", size = 32333699, upload-time = "2026-04-24T18:58:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f3/33594ff103bea6ec0a9848e7b54d8c2e17ecfaecd43c3a8cdb015e538f6f/homeassistant-2026.4.3-py3-none-any.whl", hash = "sha256:7e9ba7505d3cd63a5e7283eb104534ece8c4becd463d4a7e0c39cd0adf503c03", size = 53576817, upload-time = "2026-04-17T20:24:17.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/cb/120eb528c6eb5a21bc726aff1bc47898e5353a677ec18d33d65b11b8f3f8/homeassistant-2026.4.4-py3-none-any.whl", hash = "sha256:cd83240d320e9842822810b4c4aa13a132dbf7954d10576f406e66fca645467f", size = 53499095, upload-time = "2026-04-24T18:58:24.834Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1104,11 +1104,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1358,11 +1358,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.1"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1409,11 +1409,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.0.1"
|
||||
version = "26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1865,7 +1865,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-homeassistant-custom-component"
|
||||
version = "0.13.324"
|
||||
version = "0.13.325"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohasupervisor" },
|
||||
@@ -1896,9 +1896,9 @@ dependencies = [
|
||||
{ name = "syrupy" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/5a/4034219fd9500d5b06a32a76ca0a7dacdc18e10dc35ddd39ea42bdcdb4ab/pytest_homeassistant_custom_component-0.13.324.tar.gz", hash = "sha256:d973c5618be31fe3683e63899e94ae0ccd014c39649b7780c477d4c2ef204bc2", size = 69945, upload-time = "2026-04-18T05:34:37.724Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/40/e14846938decc2073b78d2c88a08444973aae82b7fb263ba0073aca89793/pytest_homeassistant_custom_component-0.13.325.tar.gz", hash = "sha256:12924ad407b6601748d3a3c7883423fd0d34b1f782f14b615d1b61d994f371fc", size = 69950, upload-time = "2026-04-25T05:37:23.337Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7a/a96a3a5ae43762b2cb478d90da35258c8a1b7081c7c83e3f6fc0c91281e0/pytest_homeassistant_custom_component-0.13.324-py3-none-any.whl", hash = "sha256:504cbd6eab1bf673ca6cd7ec7849653ae07916f6c7375bc81490d43131f882c6", size = 75791, upload-time = "2026-04-18T05:34:35.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c7/51cac710f1b6679550d04e162899dbdf4e7289ba715e5c4a9bff6bec7cfa/pytest_homeassistant_custom_component-0.13.325-py3-none-any.whl", hash = "sha256:9b758a092f74722e060d252ac80d71b6ae1ec2a712d55d0c9d378266e74e2c05", size = 75792, upload-time = "2026-04-25T05:37:21.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1988,11 +1988,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.1.post1"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2102,39 +2102,39 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.11"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2292,11 +2292,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.1"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2355,28 +2355,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.11.7"
|
||||
version = "0.11.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/cd/4393fecb083897e956f016d4e66d0b8a496a08fe2e03cbda32a1e91da7ee/uv-0.11.8.tar.gz", hash = "sha256:bb2cf302b8503629aab6f0090a05551e6f8cfc2d687ca059cad7ec9e11214335", size = 4098020, upload-time = "2026-04-27T13:15:31.625Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/84/dcb676a3e36a3a2b44dc2e4dfea471b8cd709025e27cce3e588b176fd899/uv-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:a53e704a780a9e78a50f5a880e99a690f84e6fb9e82610903ce26f47c271d74c", size = 23664296, upload-time = "2026-04-27T13:15:15.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/05/557aa070fda7b8460bbbe1e867e8e5b80602c5b30ed77d1d94fc5acae518/uv-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d414fc3795b6f56fb6b1fa359537930924fdfe857750a144d2aedf3077be3f1d", size = 23087321, upload-time = "2026-04-27T13:15:36.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/62/82953018801a250e16b091ef4b5e95e939b2f01224363d6fc80f600b7eff/uv-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0d402e182ab581e934c159cc9edf25ec6e08d32f29aa797980e949afefc87cd", size = 21747142, upload-time = "2026-04-27T13:15:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/4c/477f2abe16f9a3d3c73077f15615878a303eef3760115ec946be58ecb9b2/uv-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:877c9af3b3955a35ef739e5b2ba79c56dae5c4d50420a7ed908c0901e1c8c807", size = 23425861, upload-time = "2026-04-27T13:15:10.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/63/19f46193e49f0c9bf33346a4d726313871864db16e7cdd1c0a63bc112000/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8278144df8d80a83f770c264a5e79ea50791316d2a0dda869e53b3c1174142a8", size = 23215551, upload-time = "2026-04-27T13:15:38.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3e/5595b265df848a33cd060b10e8f763a46d67521ac9f6c314e8a4ad5329d7/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3494ad32465f4e02259cfb104d24efe5bb8f7a782351f0354de9385415fb310", size = 23224170, upload-time = "2026-04-27T13:15:18.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/b3/6ca95e690b52542caa1dae10ede57732f90c629946ab5f027ff746f87deb/uv-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4421e27e81f85bce3bdb75986c38b5f9bfab9cdccaf3d977cf124b3f0f0b989", size = 24730048, upload-time = "2026-04-27T13:15:13.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/49/71b7322067c85a3736a22a300072b0566991fe3f95b81bed793508ff5315/uv-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91943e77fc962752d4f64ad5739219858395981078051c740b28b52963b366aa", size = 25585906, upload-time = "2026-04-27T13:15:41.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/16/4e84cd5131327fe86d4784ebfc8a983149f4e6b811476ef271fc548b29e6/uv-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41fbba287efcc9bc9505a60549b3a223220da720eacd03be8c23d9daaafa44f4", size = 24795740, upload-time = "2026-04-27T13:15:49.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/01/df175979018743cc5ba6e2fb9dcec916868271e8d88cf0b9df8fd805a0df/uv-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d97bb2920d6cddc07faa475013461294cc09b77ec8139278416c6e54b938d037", size = 24824980, upload-time = "2026-04-27T13:15:53.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/95/93c7f595f7136fb32807442860c55d0faed2cd3d7da4b7105ed3c2535d5f/uv-0.11.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fb6a755305eb1e081dfe6a8bc007dbae2d26fe75e551656ca7c9cd08fba21d26", size = 23526790, upload-time = "2026-04-27T13:15:04.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/02/77430b89e172c20cc549b07a5b1dfda0c882c161b6d82781d3150a7063ac/uv-0.11.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:841ecbb38532698f73b14b49dc5f0c5e756194c7fcf6e5c6b7ed3859200fe91b", size = 24280498, upload-time = "2026-04-27T13:15:43.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/e3/23e4a2bb91e3880e017e6116886e2d0bde14ba6aa95ddc458160ee630e7c/uv-0.11.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b3ff2b20c1897105ebe7ed7f9b1b331c7171da029bc1e35970ce31dc086141c1", size = 24375233, upload-time = "2026-04-27T13:15:25.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/67/fb7dc17cea816a667d1be2632525aa1687566bfafd17bdac561a7a6c9484/uv-0.11.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ad381228b0170ef9646902c7e908d4a10a7ecc3da8139450506cf70c7e7f3e80", size = 23904818, upload-time = "2026-04-27T13:15:23.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/91/b920e35f54f8c6b51f2c639e8170bb80a47a739a1442fea33a479bc93a3d/uv-0.11.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0172b5215544844cd3db0fa3c73a2eb74999b3f00cd2527dde578725076d7b65", size = 25015448, upload-time = "2026-04-27T13:15:46.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e8/3771956dc1c94b8484789bb8070d91872080d0af99332b8bdec7218c2bfd/uv-0.11.8-py3-none-win32.whl", hash = "sha256:e71c1dd23cbb480f3952c3a95b4fd00f96bd618e2a94583fc9388c500af3070d", size = 22823583, upload-time = "2026-04-27T13:15:33.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/a91a9c60dcae0e1e3da06377d38f32118a523697d461fe41bc9f117ecf59/uv-0.11.8-py3-none-win_amd64.whl", hash = "sha256:306c624c68d95dd7ea3647675323d72c1abc25f91c3e92ae4cd6f0f11b508726", size = 25407438, upload-time = "2026-04-27T13:15:28.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/5d/defa29fe617e6f07d4e514089e9d36fd9f44ede869e597e39ff7d69f6917/uv-0.11.8-py3-none-win_arm64.whl", hash = "sha256:a9853456696d579f206135c9dda7227a6ed8311b8a9a0b9b2008c4ae81950efe", size = 23914243, upload-time = "2026-04-27T13:15:07.717Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user