26 Commits

Author SHA1 Message Date
renovate[bot]
763ab5cd5a Lock file maintenance (#305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 07:33:38 +02:00
Christiaan Goossens
843c415f88 Respect force https on the initial redirect URI (#303)
* Also force HTTPS on the redirect URI

* Format & test
2026-05-01 15:09:34 +02:00
Ramon
9d9025164a Redirect native-picker click on OIDC provider to /auth/oidc/welcome (fix dead-end) (#266)
Fixes #252
2026-05-01 14:57:40 +02:00
Andrew Marshall
d251ebfb92 Allow environment config of HTTP proxy for auth server connection (#299)
You can set `HTTP_PROXY` on the container/host level, see https://docs.aiohttp.org/en/stable/client_advanced.html#proxy-support
2026-05-01 14:53:17 +02:00
Christiaan Goossens
d3c359064d Do not reveal existance of trusted networks provider (#302)
* Skip welcome page if the only other provider is trusted networks

* Add test
2026-05-01 14:27:23 +02:00
Christiaan Goossens
c7370ed266 Implement trusted_networks support (#283)
* Implement bypass for trusted_networks

* Trusted Network tests

* Test cleanup

* Improve integration tests

* Defensive programming

* Fix wrong import issue
2026-05-01 14:03:14 +02:00
Christiaan Goossens
04abb0fdb3 Further improvements to issue templates (#296)
* Update bug_report.md

* Update 0-anything-else.md
2026-04-28 11:30:37 +02:00
Christiaan Goossens
7f657411ad Improve issue templates (#295)
* Cleanup config

* Update anything else
2026-04-28 11:28:13 +02:00
Christiaan Goossens
1bcc65d649 Configure Issue Templates (#294)
* Added premade issue templates

* Add links

* Put anything else at the end
2026-04-28 11:24:52 +02:00
Niklas Richter
819b3fb679 Add Keycloak configuration guide and refine setup instructions (#291)
Added a comprehensive guide for configuring Keycloak integration with Home Assistant, including installation, configuration steps, and necessary mappers.
2026-04-27 15:23:30 +02:00
renovate[bot]
8205c846f6 Lock file maintenance (#292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 08:41:57 +02:00
renovate[bot]
f51e84849e Update astral-sh/setup-uv action to v8 (#265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 10:31:25 +02:00
renovate[bot]
5250fd2de9 Update tailwindcss monorepo to v4.2.4 (#290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 10:31:09 +02:00
Christiaan Goossens
a154ffc197 Bump to 1.0.2 (#280) 2026-04-21 21:44:15 +02:00
Christiaan Goossens
c0a6e03fa7 Make all URLs in the README absolute for HACS (#279) 2026-04-21 21:40:54 +02:00
Christiaan Goossens
fe706abdb5 Fix type casting error (#278) 2026-04-21 21:34:11 +02:00
Christiaan Goossens
1e5b89fa32 Bump to 1.0.1 (#275) 2026-04-20 20:07:49 +02:00
Christiaan Goossens
2a5d3e589f Update Provider docs for 1.0.0 (#274)
* Add docs for Authentik #253
* Update Authelia guide #254
* Update Pocket ID guide #255
2026-04-20 19:43:29 +02:00
Christiaan Goossens
3ba65adc8b Allow for skipping the welcome screen (even if HA username/password is still registered) (#272)
* Allow for skipping the welcome screen (even if HA username/password is still registered)

* Linting & formatting

* Typing & tests
2026-04-20 14:27:46 +02:00
Christiaan Goossens
f90a7d5346 Ship brand icons with the integrations (#271)
* Upload icons

* Correct path
2026-04-20 14:01:12 +02:00
Christiaan Goossens
084e0e606e Enable cache headers on styling (#270) 2026-04-20 13:55:45 +02:00
Christiaan Goossens
16c45544d3 Add FAQ section on unsupported source code installs (#269) 2026-04-20 13:10:30 +02:00
renovate[bot]
556e9a0fbf Lock file maintenance (#267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 12:43:50 +02:00
Christiaan Goossens
a027c532fe Fix license link (#257) 2026-04-15 15:40:16 +02:00
Christiaan Goossens
681610241d Fix links & typos (#256) 2026-04-15 15:34:34 +02:00
Christiaan Goossens
02babe0022 README updates for 1.0.0 (#250)
* Stable README changes

* Simplify texts

* Add link to FAQ

* Add information about proxy setups

* Syncing changes from README to FAQ

* Improve wording

* Remove outdated Usage Guide

* Add placeholder usage guide
2026-04-15 15:10:25 +02:00
41 changed files with 1249 additions and 495 deletions

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,10 @@ If you are not a programmer, you can still contribute by:
- If you want to, contributing financially through [Github Sponsors](https://github.com/sponsors/christiaangoossens)
## Code contributions
You may also submit Pull Requests (PRs) to add features yourself! You can find a list that we are currently working on below. Please note that workflows will be run on your pull request and a pull request will only be merged when all checks pass and a review has been conducted (together with a manual test).
You may also submit Pull Requests (PRs) to add features yourself! You can find TODOs to work on in the [Issue Tracker](https://github.com/christiaangoossens/hass-oidc-auth/issues), the [Feature Requests](https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/ideas) and in the [FAQ](./docs/faq.md).
Please note that workflows will be run on your pull request (linting, tests, security audit) and a pull request will only be merged when all checks pass and a review has been conducted (together with a manual test).
### Development
This project uses the uv package manager for development. You can find installation instructions here: https://docs.astral.sh/uv/getting-started/installation/. Start by installing the dependencies using `uv sync` and then point your editor towards the environment created in the .venv directory.

View File

@@ -15,21 +15,20 @@
<br />
<div align="center">
<a href="https://github.com/christiaangoossens/hass-oidc-auth/">
<img src="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>
<p align="center">
OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration
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/usage.md">Usage Guide</a>
<a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/configuration.md">YAML Configuration Guide</a>
&middot;
<a href="./docs/configuration.md">Configuration Guide</a>
<a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/CONTRIBUTING.md">Contribution Guide</a>
&middot;
<a href="./CONTRIBUTING.md">Contribution Guide</a>
<br />
<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>
&middot;
@@ -41,52 +40,64 @@
</p>
</div>
Provides an OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration. Through this integration, you can create an SSO (single-sign-on) environment within your self-hosted application stack / homelab.
Provides a **stable and secure** OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration. With this integration, you can create a single-sign-on (SSO) environment in your self-hosted application stack / homelab.
### Background
If you would like to read the background/open letter that lead to this component, you can find the original post at https://community.home-assistant.io/t/open-letter-for-improving-home-assistants-authentication-system-oidc-sso/494223. It is currently one of the most upvoted feature requests for Home Assistant.
The core values for this integration are:
1. **Security**: strict adherence to the [OpenID Connect specification](https://openid.net/specs/openid-connect-core-1_0.html), [RFC 6749 (OAuth2)](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 7519 (JWT)](https://datatracker.ietf.org/doc/html/rfc7519), [RFC 7636 (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) and [RFC 9700 (OAuth2 Security Best Practices)](https://datatracker.ietf.org/doc/html/rfc9700) as well as a focus on security tests in the automated test suite.
2. **Stability**: minimal patching of the core Home Assistant code such that updates of HA are less likely to break the integration and leave you without a way to login.
3. **Accessibility**: the integration should work for everyone as much as possible with default settings, regardless of your preferred authentication method.
**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)](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/faq.md) for more information.
> [!TIP]
> If you support the addition of this feature to the Home Assistant core, please upvote https://github.com/orgs/home-assistant/discussions/48. It's the successor of the Home Assistant Community post mentioned above (with almost 900 upvotes).
## Installation guide
1. Add this repository to [HACS](https://hacs.xyz/) (or search for "OpenID Connect" in HACS).
The easiest way to install the integration is through [the Home Assistant Community Store (HACS)](https://hacs.xyz/). You can find usage instructions for HACS here: https://hacs.xyz/docs/use/.
After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
2. Add the YAML configuration that matches your OIDC provider to `configuration.yaml`. See the [Configuration Guide](./docs/configuration.md) for more details or pick your OIDC provider below:
Next, set up your OIDC provider. You can find setup guides for common providers here:
| <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) |
| <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](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) |
By default, the integration assumes you configure Home Assistant as a **public client** and thus only specify the `client_id` and no `client_secret`. For example, your configuration might look like:
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`.
```yaml
auth_oidc:
client_id: "example"
discovery_url: "https://example.com/.well-known/openid-configuration"
```
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).
When registering Home Assistant at your OIDC provider, use `<your HA URL>/auth/oidc/callback` as the callback URL and select 'public client'. You should now get the `client_id` and `issuer_url` or `discovery_url` to fill in.
### Configuration in the HA UI
3. Restart Home Assistant
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI.
4. Login through the OIDC Welcome URL at `<your HA URL>/auth/oidc/welcome`. You will have to go there manually for now. For example, it might be located at http://homeassistant.local:8123/auth/oidc/welcome.
Many configuration options are available through this method, but some advanced features are only available in YAML to simplify the setup process in the UI.
More (detailed) usage instructions can be found in the [Usage Guide](./docs/usage.md).
1. Open Home Assistant and go to **Settings -> Devices & Services**.
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
3. Follow the prompts on screen carefully.
![UI Configuration GIF](https://raw.githubusercontent.com/christiaangoossens/hass-oidc-auth/main/docs/ui-config-steps/ui-configuration.gif)
### Configuration by YAML
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
Contibutions are very welcome! If you program in Python or have worked with Home Assistant integrations before, please try to contribute. A list of requested contributions/future goals is 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).
Please see the [Contribution Guide](./CONTRIBUTING.md) for more information.
### Security issue?
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).
### Found a security issue?
Please see [SECURITY.md](./SECURITY.md) for more information on how to submit your security issue securely. You can find previously found vulnerablities 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 -->
@@ -100,4 +111,4 @@ Distributed under the MIT license with no warranty. You are fully liable for con
[issues-shield]: https://img.shields.io/github/issues/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
[issues-url]: https://github.com/christiaangoossens/hass-oidc-auth/issues
[license-shield]: https://img.shields.io/github/license/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
[license-url]: https://github.com/christiaangoossens/hass-oidc-auth/blob/master/LICENSE.txt
[license-url]: https://github.com/christiaangoossens/hass-oidc-auth/blob/main/LICENSE.md

View File

@@ -6,6 +6,7 @@ from typing import OrderedDict
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.components.http import StaticPathConfig
# Import and re-export config schema explictly
# pylint: disable=useless-import-alias
@@ -27,6 +28,7 @@ from .config import (
ROLES,
NETWORK,
FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_DEFAULT_REDIRECT,
FEATURES_FORCE_HTTPS,
REQUIRED_SCOPES,
)
@@ -42,6 +44,7 @@ from .endpoints import (
OIDCDeviceSSE,
)
from .tools.oidc_client import OIDCClient
from .tools.types import OIDCWelcomeOptions
from .provider import OpenIDAuthProvider
_LOGGER = logging.getLogger(__name__)
@@ -87,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
@@ -145,9 +184,32 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
default_redirect = features_config.get(FEATURES_DEFAULT_REDIRECT, False)
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/auth/oidc/static/style.css",
hass.config.path("custom_components/auth_oidc/static/style.css"),
cache_headers=True,
),
]
)
has_only_trusted_networks = (
auth_provider_count == 1 and has_trusted_networks_provider_first
)
hass.http.register_view(
OIDCWelcomeView(provider, name, force_https, has_other_auth_providers)
OIDCWelcomeView(
provider,
OIDCWelcomeOptions(
name=name,
force_https=force_https,
has_other_auth_providers=auth_provider_count > 0,
prefers_skipping=default_redirect or has_only_trusted_networks,
),
)
)
hass.http.register_view(OIDCDeviceSSE(provider))
hass.http.register_view(OIDCRedirectView(oidc_client, provider, force_https))
@@ -157,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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.0-rc3"
REPO_ROOT_URL = "https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.2"
## ===
## Config keys
@@ -27,6 +27,7 @@ FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
FEATURES_DISABLE_PKCE = "disable_rfc7636"
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
FEATURES_FORCE_HTTPS = "force_https"
FEATURES_DEFAULT_REDIRECT = "default_redirect"
CLAIMS = "claims"
CLAIMS_DISPLAY_NAME = "display_name"
CLAIMS_USERNAME = "username"

View File

@@ -15,6 +15,7 @@ from .const import (
FEATURES_DISABLE_PKCE,
FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_FORCE_HTTPS,
FEATURES_DEFAULT_REDIRECT,
CLAIMS,
CLAIMS_DISPLAY_NAME,
CLAIMS_USERNAME,
@@ -75,6 +76,13 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce(
bool
),
# Welcome page will be skipped automatically if there are no
# other auth providers.
# This flag enables this behavior regardless of the amount
# of other auth providers.
vol.Optional(
FEATURES_DEFAULT_REDIRECT, default=False
): vol.Coerce(bool),
}
),
# Determine which specific claims will be used from the id_token

View File

@@ -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(
@@ -75,18 +81,17 @@ async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
StaticPathConfig(
"/auth/oidc/static/injection.js",
hass.config.path("custom_components/auth_oidc/static/injection.js"),
cache_headers=False,
),
StaticPathConfig(
"/auth/oidc/static/style.css",
hass.config.path("custom_components/auth_oidc/static/style.css"),
cache_headers=False,
),
cache_headers=True,
)
]
)
# 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")
@@ -97,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":
@@ -123,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}",

View File

@@ -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
@@ -8,6 +7,7 @@ from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from ..tools.helpers import error_response, get_url, template_response
from ..provider import OpenIDAuthProvider
from ..tools.types import OIDCWelcomeOptions
PATH = "/auth/oidc/welcome"
@@ -20,18 +20,15 @@ class OIDCWelcomeView(HomeAssistantView):
name = "auth:oidc:welcome"
def __init__(
self,
oidc_provider: OpenIDAuthProvider,
name: str,
force_https: bool,
has_other_auth_providers: bool,
self, oidc_provider: OpenIDAuthProvider, options: OIDCWelcomeOptions
) -> None:
self.oidc_provider = oidc_provider
self.name = name
self.force_https = force_https
self.has_other_auth_providers = has_other_auth_providers
self.name = options.get("name")
self.force_https = options.get("force_https")
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(
@@ -108,7 +105,9 @@ class OIDCWelcomeView(HomeAssistantView):
# If this is the only provider and we are on desktop,
# automatically go through the OIDC login
if not is_mobile and not self.has_other_auth_providers:
if not is_mobile and (
not self.has_other_auth_providers or self.prefers_skipping
):
raise web.HTTPFound(
location=get_url("/auth/oidc/redirect", self.force_https),
headers=cookie_header,

View File

@@ -18,5 +18,5 @@
"jinja2",
"joserfc"
],
"version": "1.0.0-rc3"
"version": "1.0.2"
}

View File

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

View File

@@ -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 = "";
}, 300)
update();
}, 300)

View File

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

View File

@@ -39,3 +39,19 @@ class OIDCState(dict):
# IP address
ip_address: str | None
class OIDCWelcomeOptions(dict):
"""Options for the welcome screen"""
# User friendly SSO name to display
name: str
# Does the user force HTTPS on all generated URLs?
force_https: bool
# Has the user registered any other auth providers?
has_other_auth_providers: bool
# Does the user prefer to skip the welcome screen?
prefers_skipping: bool

View File

@@ -6,7 +6,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/auth/oidc/static/style.css">
<link rel="stylesheet" href="/auth/oidc/static/style.css?v=2">
{% endblock %}
</head>

View File

@@ -24,11 +24,8 @@
</div>
<div class="rounded-lg border border-gray-300 bg-white p-6 text-left">
<div class="mb-4 flex items-center justify-between text-gray-700">
<span class="text-lg font-semibold">Use a code from another device</span>
</div>
<div class="border-t border-gray-200 pt-4">
<p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
<h2 class="mb-2 text-lg font-semibold text-gray-800">Use a code from another device</h2>
<p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
6-digit code.</p>
<p class="mb-4 text-sm text-gray-600">Input that code here and click Approve to login on the other device.
</p>
@@ -60,7 +57,6 @@
Approve login on the other device
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,9 +1,12 @@
# Configuration methods
# UI Configuration
Currently, the only available configuration method is YAML in your `configuration.yaml` file. In the future, we will also add limited UI configuration for the most common configurations (Authentik, Authelia and Pocket-ID). Advanced users will need to use the YAML configuration in any case.
If you want to use the (limited) UI configuration method, please see [the README](../README.md).
# YAML Configuration
For now, this integration is configured using YAML in your `configuration.yaml` file. By default, only two fields are required:
You can configure this integration using YAML in your `configuration.yaml` file. All features of the integration will always be available within the YAML configuration.
By default, only two fields are required:
```yaml
auth_oidc:
@@ -11,7 +14,7 @@ auth_oidc:
discovery_url: ""
```
The default settings assume that you configure Home Assistant as a **public client**, without a client secret. If so, you should only need to provide the `client_id` from your OIDC provider and it's discovery URL (ending in `.well-known/openid-configuration`).
The default settings assume that you configure Home Assistant as a **public client**, without a client secret. If so, you should only need to provide the `client_id` from your OIDC provider and its discovery URL (ending in `.well-known/openid-configuration`).
You don't have to configure other settings in most cases, as they have secure defaults set. If your provider requires manually configuring the callback URL, use `<your HA URL>/auth/oidc/callback`.
## Provider Configurations
@@ -23,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._
@@ -75,6 +79,19 @@ auth_oidc:
This will show the provider on the login screen as: "Login with Example".
### Skipping the welcome screen
If you would like to skip the welcome screen, you can either enable the `features.default_redirect` feature, or [disable the Home Assistant auth provider](https://github.com/christiaangoossens/hass-oidc-auth/discussions/67).
If you want to keep the default login (backup login) enabled, but still skip the welcome screen by default, you can configure the following yaml:
```yaml
auth_oidc:
features:
default_redirect: true
```
If you have this feature enabled and you would like to use the backup login, make sure to append `?skip_oidc_redirect=true` to your login URL. For example, if your HA is at `https://ha.example.com`, you can go to `https://ha.example.com/?skip_oidc_redirect=true` to see the HA username/password login screen.
### Forcing HTTPS
First check if you are setting the header `X-Forwarded-Proto` in your proxy and if the [proxy settings for Home Assistant](https://www.home-assistant.io/integrations/http/#use_x_forwarded_for) are configured correctly. You should also check if IP addresses in your logs actually match the origin IP (instead of proxy IP). If you cannot find any mistakes, you may use the following config option to force HTTPS regardless:
@@ -86,7 +103,7 @@ auth_oidc:
### Disabling registration for new users
This integration does not allow disabling registration for new users, as there is no way to abort registration that late in the process while providing a good user experience.
You can however set both roles to groups that only contain certain users or to a non-existant group.
You can however set both roles to groups that only contain certain users or to a non-existent group.
```yaml
auth_oidc:
@@ -151,18 +168,18 @@ Here's a table of all options that you can set:
| `discovery_url` | `string` | Yes | | The OIDC well-known configuration URL. |
| `display_name` | `string` | No | `"OpenID Connect (SSO)"` | The name to display on the login screen, both for the Home Assistant screen and the OIDC welcome screen. |
| `id_token_signing_alg` | `string` | No | `RS256` | The signing algorithm that is used for your id_tokens.
| `groups_scope` | `string` | No | `groups` | Override the default grups scope with another scope of your choice. |
| `groups_scope` | `string` | No | `groups` | Override the default groups scope with another scope of your choice. |
| `additional_scopes`|`list of strings`| No | `empty list` | Add additional scopes to request for custom identity provider configurations in addition to the automatic `openid` and `profile` scopes and the `groups_scope` configuration option |
| `features.automatic_user_linking` | `boolean`| No | `false` | Automatically links users to existing Home Assistant users based on the OIDC username claim. Disabled by default for security. When disabled, OIDC users will get their own new user profile upon first login. |
| `features.automatic_person_creation` | `boolean` | No | `true` | Automatically creates a person entry for new user profiles created by this integration. Recommended if you would like to assign presence detection to OIDC users. |
| `features.disable_rfc7636` | `boolean`| No | `false` | Disables PKCE (RFC 7636) for OIDC providers that don't support it. You should not need this with most providers. |
| `features.include_groups_scope` | `boolean` | No | `true` | Include the 'groups' scope in the OIDC request. Set to `false` to exclude it. |
| `features.disable_frontend_changes` | `boolean` | No | `false` | Set to `true` to disable all changes made to the HA frontend for better compatbility with future HA versions, or if you are not comfortable with injecting Javascript into the existing frontend code. |
| `features.force_https` | `boolean` | No | `false` | Set to `true` to force all URLs generated to use `https` instead of automatically determining based on the request scheme or `X-Forwarded-Proto`. |
| `features.default_redirect` | `boolean` | No | `false` | Set to `true` to always skip the welcome screen (on desktop), regardless of if there are any other auth providers registered. |
| `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name.
| `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username.
| `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). |
| `roles.admin` | `string` | No | `admins` | Group name to require for users to get the 'admin' role in Home Assistant. Defaults to 'admins', the default group name for admins in Authentik. Doesn't do anything if no groups claim is found in your token. |
| `roles.user` | `string` | No | | Group name to require for users to get the 'user' role in Home Assistant. Defaults to giving all users this role, unless configured. |
| `network.tls_verify` | `boolean` | No | `true` | Verify TLS certificate. You may want to set this set to `false` when testing locally. |
| `network.tls_verify` | `boolean` | No | `true` | Verify TLS certificate. You may want to set this to `false` when testing locally. |
| `network.tls_ca_path` | `string` | No | | Path to file containing a private certificate authority chain. |

59
docs/faq.md Normal file
View File

@@ -0,0 +1,59 @@
# Frequently Asked Questions
## What are the values of this project? Why would I choose this integration over alternatives?
Provides a **stable and secure** OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration. With this integration, you can create a single-sign-on (SSO) environment in your self-hosted application stack / homelab.
The core values for this integration are:
1. **Security**: strict adherence to the [OpenID Connect specification](https://openid.net/specs/openid-connect-core-1_0.html), [RFC 6749 (OAuth2)](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 7519 (JWT)](https://datatracker.ietf.org/doc/html/rfc7519), [RFC 7636 (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) and [RFC 9700 (OAuth2 Security Best Practices)](https://datatracker.ietf.org/doc/html/rfc9700) as well as a focus on security tests in the automated test suite.
2. **Stability**: minimal patching of the core Home Assistant code such that updates of HA are less likely to break the integration and leave you without a way to login.
3. **Accessibility**: the integration should work for everyone as much as possible with default settings, regardless of your preferred authentication method.
**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.*
## Is the integration stable?
Yes, this integration has been tested in production environments for multiple years and has almost full automated test coverage to test both security and regressions. Security issues as well as dependency updates are actively monitored through automated pipelines and [a security policy is available here](../SECURITY.md).
## What does this integration not do (yet)?
The integration is currently very suitable for homelab use, but not for enterprise use, because these specs/todos have not been implemented yet:
- [OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html): users that are disabled at the IdP do not get logged out in Home Assistant until their refresh token expires/they logout manually
- [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html): logout in Home Assistant does not automatically log the user out at the IdP
- [OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-backchannel-1_0.html)
- *Open TODO*: Permissions are only set upon first login (https://github.com/christiaangoossens/hass-oidc-auth/discussions/187), as permission changes would necessitate revoking refresh tokens/implementing session management
- Other RFC's and best practices with regards to token expiration and revocation in the app itself
These features are hard to implement correctly within a custom integration, as they involve the full authentication lifecycle. Home Assistant does currently implement some features to see which refresh tokens were issued (and thus which sessions are open), which work well with this integration, but lacks any further security focussed features.
For home use where users rarely change permissions/status, these features aren't commonly required. However, if you would like to help implement any of these specifications (while sticking to the value of 'Stability' and minimal Home Assistant core code patching), feel free to create a PR.
## Why does this integration only allow for sign-in on mobile with a device code?
Several attempts have been made at implementing a direct mobile sign-in, but due to many issues (which can be found in https://github.com/orgs/home-assistant/discussions/48 and https://github.com/christiaangoossens/hass-oidc-auth/discussions/95), an approach was chosen that works for all setups and all authentication methods. The mobile apps now show a code, which can be entered into either the Chrome (Android)/Safari (iOS) apps on the mobile device or on another computer, after which the app automatically links and continues with the setup.
If you would like to make another attempt at implementing direct sign-in anyway, please submit a PR.
## I am using a proxy setup where my reverse proxy authenticates users
This integration is intended to be public-facing (as most OIDC apps). If you are authenticating users at the reverse proxy level (such as if you are migrating from https://github.com/BeryJu/hass-auth-header), **you should remove this authentication layer after installing this integration.**.
In general, make sure to set your Home Assistant configuration correctly for your reverse proxy as well (see https://www.home-assistant.io/integrations/http/#reverse-proxies). It is important that the original visitor IP is passed through to Home Assistant for optimal security.
## Help! I have no styling/CSS!
Did you install the integration using HACS, or directly using the `hass-oidc-auth.zip` from the release? If so, this might be a bug. Please submit an issue so we can debug this.
If you installed the integration by cloning master/downloading the source code (either from Github or the releases page), this is intended. You are using a development build in that case, for which you need to build the styles yourself using these instructions: [CONTRIBUTING.md](./CONTRIBUTING.md#compiling-css).
Installing development builds is not recommended! See the question below for more information.
## Why should I only install releases (either from Github or HACS) instead of using the source code?
The `main` branch is in constant development, both manually (PR's) as well as automatically (Renovate Bot/dependency management). While a CI pipeline with tests is run on the `main` branch, manual tests are only performed upon PR merge and during the release creation.
Therefore, issues that occur because of the use of the `main` branch (or any other specific commmit/release source code) will not be processed. At any time, the state of `main` may be broken, as it is only intended for development.
Releases, available at [Releases](https://github.com/christiaangoossens/hass-oidc-auth/releases), by constrast, are immutable and tested manually before release. While a release may be deleted, releases cannot be altered after publishing.
This ensures that - even if a security issue with my accounts occured - no malicious code enters an existing release and that the release is installed exactly as intended.
HACS automatically - and only - uses these releases and is thus the recommended install method. If you want to install a release manually, please specifically use the `hass-oidc-auth.zip` (and not the release source code zip), such that you get styling.

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,6 +1,22 @@
# Authelia
## Public client configuration
> [!TIP]
> This guide describes configuring Authelia using the UI method. You can also configure Authelia by hand with YAML. Instructions for configuring any provider using YAML can be found here: [YAML Configuration Guide](../configuration.md).
## 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/). You can find usage instructions for HACS here: https://hacs.xyz/docs/use/.
After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
## Step 2. Configure Authelia
You can choose between configuring Authelia as a public or confidential client.
### Public client configuration
> [!NOTE]
> This configuration strictly requires a HTTPS redirect uri.
@@ -17,24 +33,11 @@ identity_providers:
public: true
require_pkce: true
pkce_challenge_method: 'S256'
authorization_policy: 'two_factor'
redirect_uris:
- 'https://hass.example.com/auth/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'groups'
id_token_signed_response_alg: 'RS256'
```
Home Assistant `configuration.yaml`
```yaml
auth_oidc:
client_id: "homeassistant"
discovery_url: "https://auth.example.com/.well-known/openid-configuration"
```
## Confidential client configuration:
### Confidential client configuration:
Authelia `configuration.yml`
```yaml
@@ -49,21 +52,43 @@ identity_providers:
public: false
require_pkce: true
pkce_challenge_method: 'S256'
authorization_policy: 'two_factor'
redirect_uris:
- 'https://hass.example.com/auth/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'groups'
id_token_signed_response_alg: 'RS256'
token_endpoint_auth_method: 'client_secret_post'
```
Home Assistant `configuration.yaml`
```yaml
auth_oidc:
client_id: "homeassistant"
client_secret: "insecure_secret"
discovery_url: "https://auth.example.com/.well-known/openid-configuration"
```
## Step 3. Home Assistant configuration
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI. You can also use YAML setup, for which you can find the configuration guide here: [YAML Configuration Guide](../configuration.md).
1. Open Home Assistant and go to **Settings -> Devices & Services**.
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
![UI Configuration GIF](../ui-config-steps/ui-configuration.gif)
3. Now click "Authelia" and continue to the next screen
4. Set the discovery URL to `https://<your Authelia URL>/.well-known/openid-configuration` and click **Submit**
![Picture of the relevant configuration screen: discovery-url](../ui-config-steps/discovery-url.png)
5. Your URL will be tested. You may see an error, such as the picture below. Check your URL and verify that Home Assistant can access your Authelia installation. Change the URL or retry.
![Picture of the relevant configuration screen: discovery-url-failure](../ui-config-steps/discovery-url-failure.png)
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
![Picture of the relevant configuration screen: discovery-url-success](../ui-config-steps/discovery-url-success.png)
7. You will then be prompted to fill in the client details, the **Client ID** and the **Client Secret** (if you used the Public Client type in the Authelia configuration, there is no Client Secret required). Paste them in the relevant input boxes and continue setup with **Submit**.
![Picture of the relevant configuration screen: client-details](../ui-config-steps/client-details.png)
8. You will then be asked about **Groups & Role Configuration** and **User Linking**. Configure these options as you wish or leave the defaults in place. You can also change these settings later by opening the integration settings and clicking the reconfiguration icon.
![Reconfiguration Configuration GIF](../ui-config-steps/ui-reconfigure.gif)
## Done!
You should now automatically see the welcome screen upon opening your Home Assistant URL. On the welcome screen you can choose to either start login through SSO or to use an alternative login method, which will bring you back to the normal Home Assistant username/password login screen.

View File

@@ -1,40 +1,63 @@
# Authentik
# authentik
## Public client configuration
Under construction.
> [!TIP]
> This guide describes configuring authentik using the UI method. You can also configure authentik by hand with YAML. Instructions for configuring any provider using YAML can be found here: [YAML Configuration Guide](../configuration.md).
## Confidential client configuration
1. From the admin interface, go to `Applications > Providers` and click on `Create`
2. Select `OAuth2/OpenID Provider` and click `Next`
3. Fill the following details:
- Name: `Home Assistant Provider`
- Authorization flow: `default-provider-authorization-explicit-consent`
- Client type: `Confidential`
- Client ID: `homeassistant`
- Client Secret: **Copy this value**
- Redirect URIs/Origins: Click on `Add entry` (You can use either DNS, Internal/External IP or localhost)
- Strict: https://hass.example.com/auth/oidc/callback
4. Click `Finish` to save the provider configuration
5. Open the created Provider
6. On the Assigned to application section click on `Create`:
- Name: `Home Assistant`
- Slug: `home-assistant`
- Provider: `Home Assistant Provider`
Then save the configuration
## Step 1. Install the integration
## Home Assistant configuration
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/). You can find usage instructions for HACS here: https://hacs.xyz/docs/use/.
> [!IMPORTANT]
> For HTTPS configuration make sure to have a public valid SSL certificate (i.e. LetsEncrypt), if not, use HTTP instead (more insecure) or add your Authentik CA certificate to `network.tls_ca_path`.
After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
After installing this HACS addon, edit your `configuration.yaml` file and add:
```yaml
auth_oidc:
client_id: "homeassistant"
client_secret: "client_secret"
discovery_url: "https://auth.example.com/application/o/home-assistant/.well-known/openid-configuration"
```
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
Restart Home Assistant and go to https://hass.example.com/auth/oidc/welcome
## Step 2. Configure authentik
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Applications > Applications** and click **Create with Provider** to create an application and provider pair. (Alternatively you can first create a provider separately, then create the application and connect it with the provider.)
- **Application**: provide a descriptive name, an optional group for the type of application, the policy engine mode, and optional UI settings.
- Choose a **Provider Type**: select **OAuth2/OpenID Connect** as the provider type.
- **Configure the Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and the following required configurations.
- Note the **Client ID**, **Client Secret**, and **slug** values because they will be required later.
- Set a `Strict` redirect URI to `https://<your HA URL>/auth/oidc/callback`.
- Select any available signing key (to use the RS256 `id_token_signing_alg`)
- Configure Bindings (optional): you can create a binding (policy, group, or user) to manage the listing and access to applications on a user's **My applications** page.
## Step 3. Home Assistant configuration
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI. You can also use YAML setup, for which you can find the configuration guide here: [YAML Configuration Guide](../configuration.md).
1. Open Home Assistant and go to **Settings -> Devices & Services**.
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
![UI Configuration GIF](../ui-config-steps/ui-configuration.gif)
3. Now click "Authentik" and continue to the next screen
4. Set the discovery URL to `https://<your Authentik URL>/application/o/<application_slug>/.well-known/openid-configuration` using the **slug** from the earlier authentik configuration step and click **Submit**
![Picture of the relevant configuration screen: discovery-url](../ui-config-steps/discovery-url.png)
5. Your URL will be tested. You may see an error, such as the picture below. Check your URL and verify that Home Assistant can access your authentik installation. Change the URL or retry.
![Picture of the relevant configuration screen: discovery-url-failure](../ui-config-steps/discovery-url-failure.png)
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
![Picture of the relevant configuration screen: discovery-url-success](../ui-config-steps/discovery-url-success.png)
7. You will then be prompted to fill in the client details, the **Client ID** and the **Client Secret** (if you used the Public Client type in authentik, there is no Client Secret required). Paste them in the relevant input boxes and continue setup with **Submit**.
![Picture of the relevant configuration screen: client-details](../ui-config-steps/client-details.png)
8. You will then be asked about **Groups & Role Configuration** and **User Linking**. Configure these options as you wish or leave the defaults in place. You can also change these settings later by opening the integration settings and clicking the reconfiguration icon.
![Reconfiguration Configuration GIF](../ui-config-steps/ui-reconfigure.gif)
## Done!
You should now automatically see the welcome screen upon opening your Home Assistant URL. On the welcome screen you can choose to either start login through SSO or to use an alternative login method, which will bring you back to the normal Home Assistant username/password login screen.

View 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:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](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
```

View File

@@ -1,8 +1,23 @@
# Pocket ID
## Public client configuration
> [!TIP]
> This guide describes configuring Pocket ID using the UI method. You can also configure Pocket ID by hand with YAML. Instructions for configuring any provider using YAML can be found here: [YAML Configuration Guide](../configuration.md).
## 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/). You can find usage instructions for HACS here: https://hacs.xyz/docs/use/.
After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
## Step 2. Configure Pocket ID
You can choose between configuring Pocket ID as a public or confidential client.
### Public client configuration
### Pocket ID configuration
1. Login to Pocket ID and go to `OIDC Clients`
2. Click on `Add OIDC Client`
@@ -16,19 +31,8 @@
5. Click on `Show more details` and note down your `Client ID` and `OIDC Discovery URL` since you will need them later.
### Home Assistant configuration
1. Add following configuration in Home Assistant's configuration.yaml:
```yaml
auth_oidc:
client_id: <The Client ID you have noted down>
discovery_url: <The OIDC Discovery URL you have noted down> (for example: https://id.example.com/.well-known/openid-configuration)
```
### Confidential client configuration:
2. Restart Home Assistant and go to your Home Assistant OIDC URL (for example: https://hass.example.com/auth/oidc/welcome)
## Confidential client configuration
### Pocket ID configuration
1. Login to Pocket ID and go to `OIDC Clients`
2. Click on `Add OIDC Client`
@@ -43,16 +47,39 @@ auth_oidc:
- `Client ID`
- `Client secret`
- `OIDC Discovery URL`
### Home Assistant configuration
1. Add following configuration in Home Assistant's configuration.yaml:
```yaml
auth_oidc:
client_id: <The Client ID you have noted down>
client_secret: <The Client secret you have noted down>
discovery_url: <The OIDC Discovery URL you have noted down> (for example: https://id.example.com/.well-known/openid-configuration)
```
2. Restart Home Assistant and go to your Home Assistant OIDC URL (for example: https://hass.example.com/auth/oidc/welcome)
## Step 3. Home Assistant configuration
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI. You can also use YAML setup, for which you can find the configuration guide here: [YAML Configuration Guide](../configuration.md).
1. Open Home Assistant and go to **Settings -> Devices & Services**.
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
![UI Configuration GIF](../ui-config-steps/ui-configuration.gif)
3. Now click "Pocket ID" and continue to the next screen
4. Set the discovery URL to `https://<your Pocket ID URL>/.well-known/openid-configuration` and click **Submit**
![Picture of the relevant configuration screen: discovery-url](../ui-config-steps/discovery-url.png)
5. Your URL will be tested. You may see an error, such as the picture below. Check your URL and verify that Home Assistant can access your Pocket ID installation. Change the URL or retry.
![Picture of the relevant configuration screen: discovery-url-failure](../ui-config-steps/discovery-url-failure.png)
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
![Picture of the relevant configuration screen: discovery-url-success](../ui-config-steps/discovery-url-success.png)
7. You will then be prompted to fill in the client details, the **Client ID** and the **Client Secret** (if you used the Public Client type in the Pocket ID configuration, there is no Client Secret required). Paste them in the relevant input boxes and continue setup with **Submit**.
![Picture of the relevant configuration screen: client-details](../ui-config-steps/client-details.png)
8. You will then be asked about **Groups & Role Configuration** and **User Linking**. Configure these options as you wish or leave the defaults in place. You can also change these settings later by opening the integration settings and clicking the reconfiguration icon.
![Reconfiguration Configuration GIF](../ui-config-steps/ui-reconfigure.gif)
## Done!
You should now automatically see the welcome screen upon opening your Home Assistant URL. On the welcome screen you can choose to either start login through SSO or to use an alternative login method, which will bring you back to the normal Home Assistant username/password login screen.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -1,84 +1,3 @@
# How do I use the OIDC Integration for Home Assistant?
# Usage Guide
Here's a step by step guide to use the integration:
### Step 1: HACS
Install the integration through [HACS](https://hacs.xyz/). You can add it automatically using the button below, or use the Github URL and type `Integration` in the manual Custom Repository add dialog.
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
### Step 2: Configuration of the integration
The integration is currently configurable through YAML only. See the [Configuration Guide](./configuration.md) for more details or pick your OIDC provider below (additional providers are available in the Configuration Guide):
| <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](./provider-configurations/authentik.md) | [Authelia](./provider-configurations/authelia.md) | [Pocket ID](./provider-configurations/pocket-id.md) |
By default, the integration assumes you configure Home Assistant as a **public client** and thus only specify the `client_id` and no `client_secret`. For example, your configuration might look like:
```yaml
auth_oidc:
client_id: "example"
discovery_url: "https://example.com/.well-known/openid-configuration"
```
When registering Home Assistant at your OIDC provider, use `<your HA URL>/auth/oidc/callback` as the callback URL and select 'public client'. You should now get the `client_id` and `issuer_url` or `discovery_url` to fill in.
### Step 3: Restart
Restart Home Assistant. You can do so by going to the Reparations/Update section in Home Assistant.
### Step 4: Go to the OIDC login screen
After restarting Home Assistant, you should now be able to get to the login screen. You can find it at `<your HA URL>/auth/oidc/welcome`. You will have to go there manually for now. For example, it might be located at http://homeassistant.local:8123/auth/oidc/welcome.
It should look like this:
![image](https://github.com/user-attachments/assets/7320b7d3-b9f9-4268-ba1f-4deb0c6805ea)
If you have configured everything correctly, you should be redirected to your OIDC Provider after clicking the button. Please login there.
You should return to a screen like this:
![image](https://github.com/user-attachments/assets/d9c305bd-4a93-4a97-ae55-dba6361d92c8)
Either click the automatic sign in button or copy the code.
This screen will give you a one-time code to login that expires in 5 minutes.
#### Step 4a: Automatic login
If you would like to login automatically, click the button. It will log you in to your user in the current browser window.
#### Step 4b: Code login
If you would like to login using the code, go to your normal Home Assistant URL without any user logged in, such as on your mobile device/wall tablet/smart watch. You will now see the following screen:
![image](https://github.com/user-attachments/assets/4ed2b408-53e4-429e-920a-7628ddbcfc02)
If you don't, you likely see:
![image](https://github.com/user-attachments/assets/80629c60-793e-4933-8b45-283234798ffb)
If so, click "OpenID Connect (SSO)" to get to the first screen. If you have configured a [display name](./configuration.md#configuring-a-display-name-for-your-oidc-provider), that will show instead.
Enter your code into the single input field:
![image](https://github.com/user-attachments/assets/f031a41c-5a85-44b8-8517-3feabaa44fd5)
Upon clicking login, you should now login.
If the code is wrong, you will see this instead:
![image](https://github.com/user-attachments/assets/317d20e4-0e10-40f7-bb68-5cf456faf87d)
#### Step 5: Logged in
You will be logged in after following this guide.
With the default configuration, [a person entry](https://www.home-assistant.io/integrations/person/) will be created for every new OIDC user logging in. New OIDC users will get their own fresh user, linked to their persistent ID (subject) at the OpenID Connect provider. You may change your name, username or email at the provider and still have the same Home Assistant user profile.
# How can I make this easier for my users?
You can link the user directly to one of these following URLs:
- `/auth/oidc/welcome` (if you would like a nice welcome screen for your users)
- `/auth/oidc/redirect` (if you would like to just redirect them without a welcome screen)
For a seamless user experience, configure a new domain on your proxy to redirect to the `/auth/oidc/welcome` path or configure that path on your homelab dashboard or in your OIDC provider (such as in the app settings in Authentik). Users will then always start on the OIDC welcome page, which will allow them to visit the dashboard if they are already logged in.
*Note: do not replace the standard path with a redirect to the OIDC screen. This breaks login with code.*
The usage instructions have moved to [the main README](../README.md)

142
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -2,9 +2,17 @@
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 auth_oidc.config.const import DISCOVERY_URL, CLIENT_ID
from aiohttp import web
from auth_oidc.config.const import (
DISCOVERY_URL,
CLIENT_ID,
FEATURES,
FEATURES_DEFAULT_REDIRECT,
)
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
import pytest
@@ -20,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:
@@ -98,6 +122,28 @@ async def test_redirect_page_registration(
assert resp2.status == 302
@pytest.mark.asyncio
async def test_welcome_page_default_redirect(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Test that the welcome page returns a redirect when default_redirect is preferred."""
mock_config = {
DOMAIN: {
CLIENT_ID: "dummy",
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
FEATURES: {FEATURES_DEFAULT_REDIRECT: True},
}
}
result = await async_setup_component(hass, DOMAIN, mock_config)
assert result
client = await hass_client()
resp = await client.get("/auth/oidc/welcome", allow_redirects=False)
assert resp.status == 302
@pytest.mark.asyncio
async def test_welcome_rejects_invalid_encoded_redirect_uri(
hass: HomeAssistant, hass_client: ClientSessionGenerator
@@ -283,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()
@@ -307,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
@@ -703,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
@@ -733,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 (
@@ -753,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
@@ -809,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")

197
uv.lock generated
View File

@@ -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.88"
version = "1.43.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/bb/7d4435cca6fccf235dd40c891c731bcb9078e815917b57ebadd1e0ffabaf/boto3-1.42.88.tar.gz", hash = "sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19", size = 113238, upload-time = "2026-04-10T19:41:06.912Z" }
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/0a/2b/8bfddb39a19f5fbc16a869f1a394771e6223f07160dbc0ff6b38e05ea0ae/boto3-1.42.88-py3-none-any.whl", hash = "sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc", size = 140557, upload-time = "2026-04-10T19:41:05.309Z" },
{ 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.88"
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/93/50/87966238f7aa3f7e5f87081185d5a407a95ede8b551e11bbe134ca3306dc/botocore-1.42.88.tar.gz", hash = "sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301", size = 15195331, upload-time = "2026-04-10T19:40:57.012Z" }
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/2a/46/ad14e41245adb8b0c83663ba13e822b68a0df08999dd250e75b0750fdf6c/botocore-1.42.88-py3-none-any.whl", hash = "sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a", size = 14876640, upload-time = "2026-04-10T19:40:53.663Z" },
{ 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]]
@@ -890,7 +890,7 @@ wheels = [
[[package]]
name = "habluetooth"
version = "6.0.0"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-interrupt" },
@@ -902,26 +902,27 @@ dependencies = [
{ name = "btsocket" },
{ name = "dbus-fast", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/57/9f21b5614d984f5e311099f875182969363a3d0d25c73e0eb295ad99ec6c/habluetooth-6.0.0.tar.gz", hash = "sha256:e1e50a7e8009e54f7ec23d44d2959124bde1116e87e23aaebbc6cdce3205711c", size = 50463, upload-time = "2026-04-04T02:17:48.571Z" }
sdist = { url = "https://files.pythonhosted.org/packages/39/94/fcf28cd5bb9c427eb67feb0d3c0a31b7c2821be31e0d88406be9d53d4428/habluetooth-6.1.0.tar.gz", hash = "sha256:9b5ac9cb9a07bb9690f04e8587abdb5ffebb69e66163f55fbedf99d90fc554c9", size = 50852, upload-time = "2026-04-19T22:11:47.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/5d/8a5c1fe88470407f916764c0991f7b856bc1c4cea9ddafbff7114609d6d7/habluetooth-6.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:df52e45e3f827d09cda100c80201f86402a79efed1d9f634f52d5e74f16081ad", size = 577517, upload-time = "2026-04-04T02:34:36.924Z" },
{ url = "https://files.pythonhosted.org/packages/be/38/d8c32f0d16f8b924bc2d7cb8b402322bfc51d1396f914c077559783ed2bd/habluetooth-6.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a783c487bf75d9edd711048fcdc16ffc230a9e46afb795b2f9473277f591a8d", size = 679235, upload-time = "2026-04-04T02:34:38.406Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/9f02ed6271d338562dd04863a972b47d7b81b19b0235ca962f0d716514e3/habluetooth-6.0.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4fbb7492309634296cd95911b54439e208c8d1c003b80db1c5d858c614b08639", size = 647563, upload-time = "2026-04-04T02:34:40.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/9c/35c8c2eacfd4ef81916908c571cae6ef1789e3a929c056d9ec2c13986966/habluetooth-6.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:719857e4a33538d6dc25a89a734ab86a694256a87e3e27acfc2e524c5416be86", size = 718619, upload-time = "2026-04-04T02:34:42.06Z" },
{ url = "https://files.pythonhosted.org/packages/48/c2/e96630466133d2572bd9459d57739047c53f9290823ed9e90800591cf6c5/habluetooth-6.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf67ff07f8784308ad698a14562731b153087fdbb8eac9067640de2fb9e5599d", size = 688598, upload-time = "2026-04-04T02:34:44.002Z" },
{ url = "https://files.pythonhosted.org/packages/55/11/b97d71ebe182fdd1e130d520e1e33d375e4b67db421b9ffda30345d4c68f/habluetooth-6.0.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90ae8d7f1d173a3dd872d006e410870d5b517b08caec80084adc879893ce3ef5", size = 652250, upload-time = "2026-04-04T02:34:45.748Z" },
{ url = "https://files.pythonhosted.org/packages/af/53/46356f493a710f48428f81b9861f7679eb80cd23db92b9773a1de725af42/habluetooth-6.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:648e3aeb33e440f746cf5dd73ddd23ec58560431d55756a88cd91664463cfcd1", size = 724701, upload-time = "2026-04-04T02:34:47.585Z" },
{ url = "https://files.pythonhosted.org/packages/44/ad/8d16720c114ce34166ce5edb63389e22e8e0f8305f3bac1d7043ebfee87e/habluetooth-6.0.0-cp314-cp314-win32.whl", hash = "sha256:2d030866124a0f56b9a2d3f6941685673ca39932e80dd150e1f389b6cc2abd34", size = 470304, upload-time = "2026-04-04T02:34:49.445Z" },
{ url = "https://files.pythonhosted.org/packages/9d/74/ccd0afa1e857d2098d7c9c8ddd742d8c330bbdb4593864e4c997b6ef0e16/habluetooth-6.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f196dc23c1291db2b29a3137df39a182f89554e1c02f2355f44dd4b3a8ef9ce7", size = 544551, upload-time = "2026-04-04T02:34:51.042Z" },
{ url = "https://files.pythonhosted.org/packages/d1/83/eaacde907945580e9fd50e613f170cd4c32a2607d0b1720ba58e0db53484/habluetooth-6.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f422e812fc969dbb236808341ce4c4c81616fd90dc6cfade62815a79f34743bd", size = 1145637, upload-time = "2026-04-04T02:34:52.555Z" },
{ url = "https://files.pythonhosted.org/packages/bf/53/ebd308d8c57959def76d0c16dff2407e2baf5727c06e12365baf1f45bfeb/habluetooth-6.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf8e23f58c0e816185db17221f33a9de1ffd5fae57e1c6e3eb5632732d9d7b1", size = 1300632, upload-time = "2026-04-04T02:34:54.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/6c/371e21087113505d39d128da3629498c30305a5042951f263569cfc5c5db/habluetooth-6.0.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f71cd27ff431bb251d196d70be2593ec770239405c5450c96cf6d495341e9a4", size = 1219888, upload-time = "2026-04-04T02:34:56.053Z" },
{ url = "https://files.pythonhosted.org/packages/6f/29/0d9de94ec3b8e6174d0b3ad4357c553e7de9edf3a4199ddafb0fd72b1585/habluetooth-6.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:157c4e9cef4a6b356af6cbe080ce360c6264d42d8d9369e755a924ca09e2458f", size = 1362404, upload-time = "2026-04-04T02:34:57.802Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5e/aabd9f17fc366d52d0b3d0cf799d21114005cb3c218e5c02678ce519f698/habluetooth-6.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:764ea1bde89fe5b548338cf0ef7cd9a43e04b3a0f833f09a020b8d1cf5ce0472", size = 1320521, upload-time = "2026-04-04T02:34:59.463Z" },
{ url = "https://files.pythonhosted.org/packages/43/3f/24ab240582eff51d9756f0cc1a8b3ccd318bf8bf9429ae024871aca057a1/habluetooth-6.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6e0ea6a4b93eb7f528588190e8154b75bacd882b50f718dc08ffdadbb3d8724a", size = 1241857, upload-time = "2026-04-04T02:35:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e5/3019d9cc58f0c2a77dc1fdce97202a2b008828900c00329e50036030c363/habluetooth-6.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:95076bfe54126e92afc5d61e82e7eddfeeef7bfac8fc6f126edf80dabc212f80", size = 1377691, upload-time = "2026-04-04T02:35:02.784Z" },
{ url = "https://files.pythonhosted.org/packages/17/f9/a38ead8303706e9c5c0d6c5d8d444b6a54143f8c26582c5183c2b2efb54f/habluetooth-6.0.0-cp314-cp314t-win32.whl", hash = "sha256:b458186b5c80f3031d5b30b19882cd3eb68969fea5e1b358b288518a11fc14fc", size = 973476, upload-time = "2026-04-04T02:35:04.514Z" },
{ url = "https://files.pythonhosted.org/packages/4e/57/e1d4be955c6b617a12fef18f988b3959c2afb26f214feef3a50e7b564dbd/habluetooth-6.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c7ef2e28e05f658c283700611a1faef95f09e6716596467939fdf4ed7cd4c413", size = 1147320, upload-time = "2026-04-04T02:35:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/fd/10/fbe581101510d5c1d04f045d40d332e2294f1aaf85b75c8d90f363994216/habluetooth-6.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a7e03e19ca1406d031bf3569849a3a41ed0d99f109dd58eddf77f30c525a0890", size = 577925, upload-time = "2026-04-19T22:28:22.461Z" },
{ url = "https://files.pythonhosted.org/packages/c3/5d/53ebb861788379f49c7f628f3400f39efbf7655c6d5c140d5956f4b20cd7/habluetooth-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81a6c739a4f31c89855ef41a555e3c1034e6bd3f85702a78469d32d350166d53", size = 679647, upload-time = "2026-04-19T22:28:24.003Z" },
{ url = "https://files.pythonhosted.org/packages/da/00/06affc9630c21da46d8aecafda55de05b55248b1a1ad7fa483b75251c7ec/habluetooth-6.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:729679ed08b1c589e89cb5d54d7579f0a484a377ca06ff028078e1607cbd8c98", size = 647972, upload-time = "2026-04-19T22:28:26.036Z" },
{ url = "https://files.pythonhosted.org/packages/ac/47/5b3c8ff9611bf6946828a1bf54112cbba2e491db9f837eb7f128d3d031b1/habluetooth-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76ebcc33b81d8990ff13d8fa7797402c422fa68618d70217136b5b6e4bd1697", size = 719028, upload-time = "2026-04-19T22:28:27.746Z" },
{ url = "https://files.pythonhosted.org/packages/9b/73/da31a80d5c0f547a50ae1b55302eafbbd659b53bcb00dd8801609f805fe0/habluetooth-6.1.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:7fd1aebff1f3888e731e6fa470cc13a8cf0450932f5202885793c8e5bcb9d17d", size = 717793, upload-time = "2026-04-19T22:11:46.296Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f2/821ae8cbfd84a98ec5d2ddab9b6e35ab88a1a66e261527a376038b91aa2f/habluetooth-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5bcbf145957d39358ae99c07ee04db5b303f4dd4c9cb3e210b14597726da47", size = 689008, upload-time = "2026-04-19T22:28:29.579Z" },
{ url = "https://files.pythonhosted.org/packages/05/51/22c96fffc0f8454d2ab79600c106e9fff00511241e71373c509d40f09e6f/habluetooth-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:e35ed0d81731f434ba0c8c8bdac73570ad5336b3f86b66a276338391da299da0", size = 652662, upload-time = "2026-04-19T22:28:31.446Z" },
{ url = "https://files.pythonhosted.org/packages/15/17/414ca5cf3972bca7c183a361725f7175cbfd113e7594725095a2d59189bb/habluetooth-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e7766ce743e42eea2820fbacebc688cd3b9ed90cdb61676f37c5d92ec7b458e", size = 725110, upload-time = "2026-04-19T22:28:33.16Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ab/5d89e71c8abbb73f2e7cfde63381341fd802aad3d315e3b24c465f37e665/habluetooth-6.1.0-cp314-cp314-win32.whl", hash = "sha256:415e5ba996bfbffc571a751a6dc5a13f55846be24fc07f7d6ac310a0bd84fc05", size = 470707, upload-time = "2026-04-19T22:28:34.645Z" },
{ url = "https://files.pythonhosted.org/packages/de/db/697c3e97aa0a298ecc12afc287d244001b019aef226e3a3781328ac33ce1/habluetooth-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4b31f6a827f12665d0ccd13b16b709740c66b060f4b7cdd294288a731b58eca1", size = 544957, upload-time = "2026-04-19T22:28:36.218Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a8/e6dc190a5008cd099961a2ded31e09061722b9d416ccfd4d185730da4ea3/habluetooth-6.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf41c1eb278968e73d00c4f229e3963f3595087f2568e7454645a4a34327442d", size = 1146040, upload-time = "2026-04-19T22:28:38.318Z" },
{ url = "https://files.pythonhosted.org/packages/62/91/deec7d3b9d32f3471e25b580f1182acde6799de599619de3e568f788f912/habluetooth-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b47141ca110f97c6f9d60673056faa8d49ee1d43700e81ce3cfc8a80f6414ff9", size = 1301043, upload-time = "2026-04-19T22:28:40.305Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d5/d5df26aa19237a16be3df52a447e24ea949e5273394088e9f3bc2b7e0764/habluetooth-6.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:553cb8cbfdd50a42abf68b92b1625a9beb0b5d19d78480199c14440ca787288a", size = 1220296, upload-time = "2026-04-19T22:28:42.05Z" },
{ url = "https://files.pythonhosted.org/packages/f9/20/907061cbe491dfb53ba1c23622460e317f50ad7935747045e69d690345b9/habluetooth-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a48b8a2fe8a903ffe656d94b6337b43dc1d3d3f0965550fff9a1294f0df3c0c0", size = 1362816, upload-time = "2026-04-19T22:28:43.808Z" },
{ url = "https://files.pythonhosted.org/packages/8a/51/a433154052d282ac753a5f5146fa13a3cfbf677412733a276c3ffbf41199/habluetooth-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c62c552f6778ddbac0310a153a08e5059aa876bf4b73d66073d660e140286f6c", size = 1320933, upload-time = "2026-04-19T22:28:45.318Z" },
{ url = "https://files.pythonhosted.org/packages/17/53/e2ff9e460f38f4bf95c45cc90ae5b8de874a8957ff6a28b9352e41198ed2/habluetooth-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3435d77d447cce135594cdde0d9579c7de507d3fb82d77d551eeb5ef9c8ee290", size = 1242264, upload-time = "2026-04-19T22:28:47.028Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c2/220dd1ccca28e312f4ca83453c0d72de09d1a9cd1b3460d0b4373714a72e/habluetooth-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5682c7a269acd41ee78cc7abb8b0225c70e39c22c1db888bcd26629d0a3abff", size = 1378101, upload-time = "2026-04-19T22:28:48.708Z" },
{ url = "https://files.pythonhosted.org/packages/60/1a/b28fc02284d3e0155843441fea353ec916b362e14d726a564b8fdea2a84e/habluetooth-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:aa5d791584572f08ff5e5141de0ed368909d62d34ce1c739e8d9ddaa346474fe", size = 973884, upload-time = "2026-04-19T22:28:50.634Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ff/0a3dd553a7d9dc67f29b0ac4627a43aeb69703bf3ffb4d2476ea9605a51a/habluetooth-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c625f1e1afac4bd7f834a3b29c742789d1199029d84bff183799e06a0334eb9b", size = 1147734, upload-time = "2026-04-19T22:28:52.331Z" },
]
[[package]]
@@ -1005,7 +1006,7 @@ wheels = [
[[package]]
name = "homeassistant"
version = "2026.4.2"
version = "2026.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiodns" },
@@ -1059,9 +1060,9 @@ dependencies = [
{ name = "yarl" },
{ name = "zeroconf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/47/031d06d33af9545e68f33b808dedd823ba98e06e40ce59f77d804df177b4/homeassistant-2026.4.2.tar.gz", hash = "sha256:791770614fc3e008c66c87cd6ee9815017dbb2bfcfd22d64d0a45a0b9fb58df8", size = 32357303, upload-time = "2026-04-11T18:56:55.596Z" }
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/c4/e5/b29b6d65baf0012a008359f50b25bf15e0cfaed49088371115da052456cf/homeassistant-2026.4.2-py3-none-any.whl", hash = "sha256:7ecd6e5de22515596eee05ba69c6147a4c95ba846b2b5440b87f926c45e9ebd9", size = 53530765, upload-time = "2026-04-11T18:56:49.946Z" },
{ 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]]
@@ -1103,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]]
@@ -1172,14 +1173,14 @@ wheels = [
[[package]]
name = "joserfc"
version = "1.6.3"
version = "1.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/90/b8cc8635c4ce2e5e8104bf26ef147f6e599478f6329107283cdc53aae97f/joserfc-1.6.3.tar.gz", hash = "sha256:c00c2830db969b836cba197e830e738dd9dda0955f1794e55d3c636f17f5c9a6", size = 229090, upload-time = "2026-02-25T15:33:38.167Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/4f/124b3301067b752f44f292f0b9a74e837dd75ff863ee39500a082fc4c733/joserfc-1.6.3-py3-none-any.whl", hash = "sha256:6beab3635358cbc565cb94fb4c53d0557e6d10a15b933e2134939351590bda9a", size = 70465, upload-time = "2026-02-25T15:33:36.997Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" },
]
[[package]]
@@ -1357,11 +1358,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.0"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
{ 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]]
@@ -1408,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]]
@@ -1864,7 +1865,7 @@ wheels = [
[[package]]
name = "pytest-homeassistant-custom-component"
version = "0.13.323"
version = "0.13.325"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohasupervisor" },
@@ -1895,9 +1896,9 @@ dependencies = [
{ name = "syrupy" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/a7/ee5fe414ba6ada3ebf27852082e5c42acdd4bf7c7340787583c7118ddf11/pytest_homeassistant_custom_component-0.13.323.tar.gz", hash = "sha256:ff0cdbfd39c26afdf021363f1a600367a29e96ff3d0ee68f5fba8cab9afeb0b6", size = 69948, upload-time = "2026-04-12T05:41:56.511Z" }
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/1b/55/798e8f648cd7efeb723aabf0fee33f620522459eba229496709952bb6c21/pytest_homeassistant_custom_component-0.13.323-py3-none-any.whl", hash = "sha256:0061c442358dc0ff94a5e027b07c8f47a14817f4c708343d67f1690784ea2f94", size = 75787, upload-time = "2026-04-12T05:41:55.202Z" },
{ 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]]
@@ -1987,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]]
@@ -2101,39 +2102,39 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.10"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
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/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
{ 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]]
@@ -2291,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]]
@@ -2354,28 +2355,28 @@ wheels = [
[[package]]
name = "uv"
version = "0.11.6"
version = "0.11.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
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/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
{ 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]]