34 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
Christiaan Goossens
7cc960e4db Bump to rc3 (#249) 2026-04-15 12:08:36 +02:00
Christiaan Goossens
07c1e3a4c4 Fix regression of storeToken parameter (#248)
* Try a different method to set ?storeToken

* Formatting

* Only insert storeToken on web client & fix tests
2026-04-15 12:07:19 +02:00
Christiaan Goossens
0ca300c385 Add tests for other signing methods (#246)
* Add tests for other signing methods #151

* Add doc for list source
2026-04-14 15:29:06 +02:00
Christiaan Goossens
a9483e2038 Change build script to align with HACS (#245)
* Change build script to align with HACS

* Fix path typo
2026-04-14 14:42:13 +02:00
Christiaan Goossens
6f1d2bcb3f Switch to creating releases by tag (#244) 2026-04-14 14:26:02 +02:00
Christiaan Goossens
67f58a39aa Better tag matching (#243)
* Better tag matching

* Split PR and release flows

* Undo PR archiving
2026-04-14 14:17:09 +02:00
Christiaan Goossens
ddb2952e64 Release with autogenerated zip files (#242)
* Try autobuilding

* Typo fix

* Entire components dir

* Directly upload zip
2026-04-14 13:55:09 +02:00
Christiaan Goossens
baf3ac6b5a Fixes for known bugs in v1.0.0-rc1 (#241)
* Fix #238 for same-site cookies

* Redirect in Python + bump to rc2
2026-04-14 09:43:58 +02:00
50 changed files with 1821 additions and 631 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.

21
.github/workflows/build-pr.yaml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Build pull request artifact
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: scripts/build
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
path: ./hass-oidc-auth.zip
archive: false

View File

@@ -15,7 +15,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v8.1.0
with: with:
enable-cache: true enable-cache: true
- name: Sync dependencies - name: Sync dependencies

27
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Build and create draft release
on:
push:
tags:
- v*.*.*
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Build
run: scripts/build
- name: Create or update draft release with ZIP
uses: softprops/action-gh-release@v3
with:
draft: true
fail_on_unmatched_files: true
generate_release_notes: true
files: ./hass-oidc-auth.zip

View File

@@ -19,7 +19,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v8.1.0
with: with:
enable-cache: true enable-cache: true
- name: Scan dependencies for vulnerabilities - name: Scan dependencies for vulnerabilities

View File

@@ -15,7 +15,7 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v8.1.0
with: with:
enable-cache: true enable-cache: true
- name: Sync dependencies - name: Sync dependencies

3
.gitignore vendored
View File

@@ -111,5 +111,6 @@ dmypy.json
.pytest_logs.log .pytest_logs.log
# Build NPM
node_modules node_modules
custom_components/auth_oidc/static/style.css

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) - If you want to, contributing financially through [Github Sponsors](https://github.com/sponsors/christiaangoossens)
## Code contributions ## 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 ### 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. 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 /> <br />
<div align="center"> <div align="center">
<a href="https://github.com/christiaangoossens/hass-oidc-auth/"> <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> </a>
<h3 align="center">OpenID Connect for Home Assistant</h3> <h3 align="center">OpenID Connect for Home Assistant</h3>
<p align="center"> <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 />
<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; &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; &middot;
<a href="./CONTRIBUTING.md">Contribution Guide</a> <a href="https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/faq.md">Frequently Asked Questions (FAQ)</a>
<br />
<br /> <br />
<a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions?discussions_q=is%3Aopen+category%3AAnnouncements+category%3APolls">Announcements & Polls</a> <a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions?discussions_q=is%3Aopen+category%3AAnnouncements+category%3APolls">Announcements & Polls</a>
&middot; &middot;
@@ -41,52 +40,64 @@
</p> </p>
</div> </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 The core values for this integration are:
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.
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 ## 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) [![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"> | | <img src="https://goauthentik.io/img/icon_top_brand_colour.svg" width="100"> | <img src="https://www.authelia.com/images/branding/logo-cropped.png" width="100"> | <img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"> |
|:-----------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------:| |:-----------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------:|
| [Authentik](./docs/provider-configurations/authentik.md) | [Authelia](./docs/provider-configurations/authelia.md) | [Pocket ID](./docs/provider-configurations/pocket-id.md) | | [authentik](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/authentik.md) | [Authelia](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/authelia.md) | [Pocket ID](https://github.com/christiaangoossens/hass-oidc-auth/blob/main/docs/provider-configurations/pocket-id.md) |
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 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).
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. ### 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 ## 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? ## Background
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). 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 ## 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 --> <!-- 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-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 [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-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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.components.http import StaticPathConfig
# Import and re-export config schema explictly # Import and re-export config schema explictly
# pylint: disable=useless-import-alias # pylint: disable=useless-import-alias
@@ -27,6 +28,7 @@ from .config import (
ROLES, ROLES,
NETWORK, NETWORK,
FEATURES_INCLUDE_GROUPS_SCOPE, FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_DEFAULT_REDIRECT,
FEATURES_FORCE_HTTPS, FEATURES_FORCE_HTTPS,
REQUIRED_SCOPES, REQUIRED_SCOPES,
) )
@@ -42,6 +44,7 @@ from .endpoints import (
OIDCDeviceSSE, OIDCDeviceSSE,
) )
from .tools.oidc_client import OIDCClient from .tools.oidc_client import OIDCClient
from .tools.types import OIDCWelcomeOptions
from .provider import OpenIDAuthProvider from .provider import OpenIDAuthProvider
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -87,24 +90,60 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry):
return False return False
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str): async def _register_oidc_provider(hass: HomeAssistant, my_config: dict):
"""Set up the OIDC provider with the given configuration.""" """Register the OIDC provider in Home Assistant's auth system."""
providers = OrderedDict()
# Use private APIs until there is a real auth platform # Use private APIs until there is a real auth platform
# pylint: disable=protected-access # pylint: disable=protected-access
providers = OrderedDict()
provider = OpenIDAuthProvider(hass, hass.auth._store, my_config) 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 providers[(provider.type, provider.id)] = provider
# Get current provider count # Add back any other providers that were already registered
has_other_auth_providers = len(hass.auth._providers) > 0 providers.update(existing_auth_providers)
providers.update(hass.auth._providers) _LOGGER.debug("Final auth providers: %s", list(providers.values()))
hass.auth._providers = providers hass.auth._providers = providers
# pylint: enable=protected-access # pylint: enable=protected-access
_LOGGER.info("Registered OIDC provider") _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 # Set the correct scopes
# Always use 'openid' & 'profile' as they are specified in the OIDC spec # 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) name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
force_https = features_config.get(FEATURES_FORCE_HTTPS, False) force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
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( 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(OIDCDeviceSSE(provider))
hass.http.register_view(OIDCRedirectView(oidc_client, provider, force_https)) 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") _LOGGER.info("Registered OIDC views")
# Inject OIDC code into the frontend for /auth/authorize for automatic redirect # Inject OIDC code into the frontend for /auth/authorize for automatic redirect
await OIDCInjectedAuthPage.inject(hass) await OIDCInjectedAuthPage.inject(
hass, provider, force_https, has_trusted_networks_provider_first
)
return True 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,9 +8,7 @@ from typing import Any, Dict
DEFAULT_TITLE = "OpenID Connect (SSO)" DEFAULT_TITLE = "OpenID Connect (SSO)"
DOMAIN = "auth_oidc" DOMAIN = "auth_oidc"
REPO_ROOT_URL = ( REPO_ROOT_URL = "https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.2"
"https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.0-rc1"
)
## === ## ===
## Config keys ## Config keys
@@ -29,6 +27,7 @@ FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
FEATURES_DISABLE_PKCE = "disable_rfc7636" FEATURES_DISABLE_PKCE = "disable_rfc7636"
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope" FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
FEATURES_FORCE_HTTPS = "force_https" FEATURES_FORCE_HTTPS = "force_https"
FEATURES_DEFAULT_REDIRECT = "default_redirect"
CLAIMS = "claims" CLAIMS = "claims"
CLAIMS_DISPLAY_NAME = "display_name" CLAIMS_DISPLAY_NAME = "display_name"
CLAIMS_USERNAME = "username" CLAIMS_USERNAME = "username"

View File

@@ -15,6 +15,7 @@ from .const import (
FEATURES_DISABLE_PKCE, FEATURES_DISABLE_PKCE,
FEATURES_INCLUDE_GROUPS_SCOPE, FEATURES_INCLUDE_GROUPS_SCOPE,
FEATURES_FORCE_HTTPS, FEATURES_FORCE_HTTPS,
FEATURES_DEFAULT_REDIRECT,
CLAIMS, CLAIMS,
CLAIMS_DISPLAY_NAME, CLAIMS_DISPLAY_NAME,
CLAIMS_USERNAME, CLAIMS_USERNAME,
@@ -75,6 +76,13 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce( vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce(
bool 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 # Determine which specific claims will be used from the id_token

View File

@@ -61,11 +61,9 @@ class OIDCFinishView(HomeAssistantView):
if "?" in redirect_uri: if "?" in redirect_uri:
separator = "&" separator = "&"
# Redirect to this new URL for login # Redirect to this new URL for login, make sure to skip OIDC to prevent loops
new_url = ( redirect_uri = f"{redirect_uri}{separator}skip_oidc_redirect=true"
redirect_uri + separator + "storeToken=true&skip_oidc_redirect=true" raise web.HTTPFound(location=redirect_uri)
)
raise web.HTTPFound(location=new_url)
# Check if we can link this device # Check if we can link this device
linked = await self.oidc_provider.async_link_state_to_code( linked = await self.oidc_provider.async_link_state_to_code(

View File

@@ -1,12 +1,19 @@
"""Injected authorization page, replacing the original""" """Injected authorization page, replacing the original"""
import base64
import logging import logging
from functools import partial from functools import partial
from homeassistant.components.http import HomeAssistantView, StaticPathConfig from urllib.parse import quote, unquote
from homeassistant.core import HomeAssistant
from aiohttp import web from aiohttp import web
from aiofiles import open as async_open from aiofiles import open as async_open
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" PATH = "/auth/authorize"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -18,7 +25,12 @@ async def read_file(path: str) -> str:
return await f.read() return await f.read()
async def frontend_injection(hass: HomeAssistant) -> 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.""" """Inject new frontend code into /auth/authorize."""
router = hass.http.app.router router = hass.http.app.router
frontend_path = None frontend_path = None
@@ -61,7 +73,7 @@ async def frontend_injection(hass: HomeAssistant) -> None:
frontend_code = await read_file(frontend_path) frontend_code = await read_file(frontend_path)
# Inject JS and register that route # Inject JS and register that route
injection_js = "<script src='/auth/oidc/static/injection.js?v=4'></script>" injection_js = "<script src='/auth/oidc/static/injection.js?v=7'></script>"
frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>") frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>")
await hass.http.async_register_static_paths( await hass.http.async_register_static_paths(
@@ -69,18 +81,17 @@ async def frontend_injection(hass: HomeAssistant) -> None:
StaticPathConfig( StaticPathConfig(
"/auth/oidc/static/injection.js", "/auth/oidc/static/injection.js",
hass.config.path("custom_components/auth_oidc/static/injection.js"), hass.config.path("custom_components/auth_oidc/static/injection.js"),
cache_headers=False, cache_headers=True,
), )
StaticPathConfig(
"/auth/oidc/static/style.css",
hass.config.path("custom_components/auth_oidc/static/style.css"),
cache_headers=False,
),
] ]
) )
# If everything is succesful, register a fake view that just returns the modified HTML # If everything is succesful, register a fake view that just returns the modified HTML
hass.http.register_view(OIDCInjectedAuthPage(frontend_code)) hass.http.register_view(
OIDCInjectedAuthPage(
frontend_code, provider, force_https, has_trusted_networks_provider_first
)
)
_LOGGER.info("Performed OIDC frontend injection") _LOGGER.info("Performed OIDC frontend injection")
@@ -91,18 +102,75 @@ class OIDCInjectedAuthPage(HomeAssistantView):
url = PATH url = PATH
name = "auth:oidc:authorize_page" name = "auth:oidc:authorize_page"
def __init__(self, html: str) -> None: def __init__(
self,
html: str,
provider: OpenIDAuthProvider,
force_https: bool,
has_trusted_networks_provider_first: bool,
) -> None:
"""Initialize the injected auth page.""" """Initialize the injected auth page."""
self.html = html self.html = html
self.provider = provider
self.force_https = force_https
self.has_trusted_networks_provider_first = has_trusted_networks_provider_first
@staticmethod @staticmethod
async def inject(hass: HomeAssistant) -> 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.""" """Inject the OIDC auth page into the frontend."""
try: try:
await frontend_injection(hass) await frontend_injection(
hass, provider, force_https, has_trusted_networks_provider_first
)
except Exception as e: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
_LOGGER.error("Failed to inject OIDC auth page: %s", e) _LOGGER.error("Failed to inject OIDC auth page: %s", e)
async def get(self, _) -> web.Response: def _should_do_oidc_redirect(self, req: web.Request) -> bool:
"""Return the screen""" """Check if we should redirect to the OIDC flow."""
# Set when we return from finish
if req.query.get("skip_oidc_redirect") == "true":
return False
# Set whenever you directly do /?skip_oidc_redirect=true,
# for example when you click the "other" button on the welcome screen
redirect_uri = req.query.get("redirect_uri")
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(url.encode("utf-8")).decode("ascii")
)
return get_url(
f"{WELCOME_PATH}?redirect_uri={encoded_current_url}",
self.force_https,
)
async def get(self, req: web.Request) -> web.Response:
"""Return the original page or redirect into the OIDC flow."""
if self._should_do_oidc_redirect(req):
raise web.HTTPFound(location=self._get_welcome_redirect_location(req))
return web.Response(text=self.html, content_type="text/html") return web.Response(text=self.html, content_type="text/html")

View File

@@ -2,11 +2,12 @@
import base64 import base64
import binascii import binascii
from urllib.parse import urlparse, parse_qs, unquote from urllib.parse import urlparse, parse_qs, unquote, urlencode
from aiohttp import web from aiohttp import web
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from ..tools.helpers import error_response, get_url, template_response from ..tools.helpers import error_response, get_url, template_response
from ..provider import OpenIDAuthProvider from ..provider import OpenIDAuthProvider
from ..tools.types import OIDCWelcomeOptions
PATH = "/auth/oidc/welcome" PATH = "/auth/oidc/welcome"
@@ -19,24 +20,54 @@ class OIDCWelcomeView(HomeAssistantView):
name = "auth:oidc:welcome" name = "auth:oidc:welcome"
def __init__( def __init__(
self, self, oidc_provider: OpenIDAuthProvider, options: OIDCWelcomeOptions
oidc_provider: OpenIDAuthProvider,
name: str,
force_https: bool,
has_other_auth_providers: bool,
) -> None: ) -> None:
self.oidc_provider = oidc_provider self.oidc_provider = oidc_provider
self.name = name self.name = options.get("name")
self.force_https = force_https self.force_https = options.get("force_https")
self.has_other_auth_providers = has_other_auth_providers 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) -> 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(
"utf-8"
)
def determine_if_mobile(self, redirect_uri: str) -> bool:
"""Determine if the client is a mobile client based on the redirect_uri."""
oauth2_url = urlparse(redirect_uri) oauth2_url = urlparse(redirect_uri)
client_id = parse_qs(oauth2_url.query).get("client_id") oauth2_query = parse_qs(oauth2_url.query)
client_id = oauth2_query.get("client_id")[0]
original_redirect_uri = oauth2_query.get("redirect_uri")[0]
# If the client_id starts with https://home-assistant.io/ we assume it's a mobile client # If the client_id starts with https://home-assistant.io/
return bool(client_id and client_id[0].startswith("https://home-assistant.io/")) # we assume it's a mobile client
# Android = https://home-assistant.io/Android,
# iOS = https://home-assistant.io/iOS
is_mobile = client_id.startswith("https://home-assistant.io/")
# Check if we appear to be signing in to the web version,
# for which we want to store tokens.
# We don't want to set storeTokens on sign-in to Google for instance
base_url = get_url("/", self.force_https)
is_web_client = original_redirect_uri.startswith(base_url)
if is_web_client:
# Adjust the original_redirect_uri to include the storeTokens parameter
separator = "?"
if "?" in original_redirect_uri:
separator = "&"
original_redirect_uri = f"{original_redirect_uri}{separator}storeToken=true"
oauth2_query.update({"redirect_uri": original_redirect_uri})
# Create new redirect_uri with the updated query parameters
new_oauth2_url = oauth2_url._replace(
query=urlencode(oauth2_query, doseq=True)
)
redirect_uri = new_oauth2_url.geturl()
return redirect_uri, is_mobile
async def get(self, req: web.Request) -> web.Response: async def get(self, req: web.Request) -> web.Response:
"""Receive response.""" """Receive response."""
@@ -44,23 +75,26 @@ class OIDCWelcomeView(HomeAssistantView):
# Get the query parameter with the redirect_uri # Get the query parameter with the redirect_uri
redirect_uri = req.query.get("redirect_uri") redirect_uri = req.query.get("redirect_uri")
# If set, determine if this is a mobile client based on the redirect_uri, # Do some processing on the redirect_uri to correct it
# otherwise assume it's not mobile # and determine if this is a mobile client.
if redirect_uri: if redirect_uri:
try: try:
# decodeURIComponent(btoa(...)) -> unquote first, then base64 decode redirect_uri, is_mobile = await self._process_url(redirect_uri)
redirect_uri = base64.b64decode( except (
unquote(redirect_uri), validate=True binascii.Error,
).decode("utf-8") UnicodeDecodeError,
is_mobile = self.determine_if_mobile(redirect_uri) ValueError,
except (binascii.Error, UnicodeDecodeError, ValueError): KeyError,
TypeError,
):
return await error_response( return await error_response(
"Invalid redirect_uri, please restart login." "Invalid redirect_uri, please restart login."
) )
else: else:
# Backwards compatibility with older versions that directly go to /auth/oidc/welcome # Backwards compatibility with older versions that directly go to /auth/oidc/welcome
# If not set, redirect back to the main page and assume that this is a web client # If not set, redirect back to the main page and assume that this is a web client
redirect_uri = get_url("/", self.force_https) redirect_uri = get_url("/?storeToken=true", self.force_https)
is_mobile = False is_mobile = False
# Create OIDC state with the redirect_uri so we can use it later in the flow # Create OIDC state with the redirect_uri so we can use it later in the flow
@@ -71,7 +105,9 @@ class OIDCWelcomeView(HomeAssistantView):
# If this is the only provider and we are on desktop, # If this is the only provider and we are on desktop,
# automatically go through the OIDC login # 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( raise web.HTTPFound(
location=get_url("/auth/oidc/redirect", self.force_https), location=get_url("/auth/oidc/redirect", self.force_https),
headers=cookie_header, headers=cookie_header,

View File

@@ -16,8 +16,7 @@
"requirements": [ "requirements": [
"aiofiles", "aiofiles",
"jinja2", "jinja2",
"bcrypt",
"joserfc" "joserfc"
], ],
"version": "1.0.0-rc1" "version": "1.0.2"
} }

View File

@@ -6,8 +6,12 @@ import logging
from typing import Dict, Optional from typing import Dict, Optional
import asyncio import asyncio
import bcrypt from ipaddress import (
from homeassistant.auth import EVENT_USER_ADDED ip_address,
IPv4Address,
IPv6Address,
)
from homeassistant.auth import EVENT_USER_ADDED, InvalidAuthError as HAInvalidAuthError
from homeassistant.auth.providers import ( from homeassistant.auth.providers import (
AUTH_PROVIDERS, AUTH_PROVIDERS,
AuthProvider, AuthProvider,
@@ -21,7 +25,6 @@ from homeassistant.auth.providers import (
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.components import http, person from homeassistant.components import http, person
from homeassistant.exceptions import HomeAssistantError
from .config.const import ( from .config.const import (
FEATURES, FEATURES,
@@ -32,6 +35,8 @@ from .config.const import (
from .stores.state_store import StateStore from .stores.state_store import StateStore
from .tools.types import UserDetails from .tools.types import UserDetails
type IPAddress = IPv4Address | IPv6Address
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PROVIDER_TYPE = "auth_oidc" PROVIDER_TYPE = "auth_oidc"
@@ -39,7 +44,7 @@ HASS_PROVIDER_TYPE = "homeassistant"
COOKIE_NAME = "auth_oidc_state" COOKIE_NAME = "auth_oidc_state"
class InvalidAuthError(HomeAssistantError): class InvalidAuthError(HAInvalidAuthError):
"""Raised when submitting invalid authentication.""" """Raised when submitting invalid authentication."""
@@ -60,8 +65,11 @@ class OpenIDAuthProvider(AuthProvider):
{ {
# Currently register as default, might be used when we have multiple OIDC providers # Currently register as default, might be used when we have multiple OIDC providers
CONF_ID: "default", CONF_ID: "default",
# Name displayed in the UI # Stable label for HA's native auth-picker row. Kept fixed so the
CONF_NAME: config.get("display_name", DEFAULT_TITLE), # 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 # Type
CONF_TYPE: PROVIDER_TYPE, CONF_TYPE: PROVIDER_TYPE,
}, },
@@ -115,6 +123,41 @@ class OpenIDAuthProvider(AuthProvider):
return None 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: async def async_create_state(self, redirect_uri: str, ip: str | None = None) -> str:
"""Create a new OIDC state and return the state id.""" """Create a new OIDC state and return the state id."""
if self._state_store is None: if self._state_store is None:
@@ -236,7 +279,7 @@ class OpenIDAuthProvider(AuthProvider):
# Keep cookie lifetime aligned with state lifetime in storage (5 minutes). # Keep cookie lifetime aligned with state lifetime in storage (5 minutes).
"set-cookie": f"{COOKIE_NAME}=" "set-cookie": f"{COOKIE_NAME}="
+ state_id + state_id
+ "; Path=/auth/; SameSite=Strict; HttpOnly; Max-Age=300" + "; Path=/auth/; SameSite=Lax; HttpOnly; Max-Age=300"
+ secure_flag, + secure_flag,
} }
@@ -367,14 +410,6 @@ class OpenIdLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
async def _finalize_user(self, state_id: str) -> AuthFlowResult: async def _finalize_user(self, state_id: str) -> AuthFlowResult:
# Verify a dummy hash to make it last a bit longer
# as security measure (limits the amount of attempts you have in 5 min)
# Similar to what the HomeAssistant auth provider does
dummy = b"$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO"
bcrypt.checkpw(b"foo", dummy)
# Actually look up the auth provider after,
# this doesn't take a lot of time (regardless of it's in there or not)
sub = await self._auth_provider.async_get_subject(state_id) sub = await self._auth_provider.async_get_subject(state_id)
if sub: if sub:
return await self.async_finish( return await self.async_finish(
@@ -396,11 +431,10 @@ class OpenIdLoginFlow(LoginFlow):
state_cookie = req.cookies.get(COOKIE_NAME) state_cookie = req.cookies.get(COOKIE_NAME)
if state_cookie: if state_cookie:
_LOGGER.debug("State cookie found on login: %s", state_cookie)
try: try:
return await self._finalize_user(state_cookie) return await self._finalize_user(state_cookie)
except InvalidAuthError: except InvalidAuthError:
pass return self.async_abort(reason="oidc_cookie_invalid")
# If no cookie is found, abort. # If no cookie is found, abort.
# User should either be redirected or start manually on the welcome # User should either be redirected or start manually on the welcome

View File

@@ -1,82 +1,80 @@
/** /**
* OIDC Frontend Redirect injection script * Frontend helpers for /auth/authorize: auto-select on aborted login,
* This script is injected because the 'hass-oidc-auth' custom component is active. * and route picker clicks on our provider through /auth/oidc/welcome.
*/ */
function attempt_oidc_redirect() { const OIDC_PROVIDER_NAME = "OpenID Connect (SSO)" // matches provider.py CONF_NAME
// Get URL parameters const OIDC_WELCOME_PATH = "/auth/oidc/welcome"
const urlParams = new URLSearchParams(window.location.search);
// Check if we have skip_oidc_redirect directly here let authFlowElement = null
if (urlParams.get('skip_oidc_redirect') === 'true') { let pickerIntercepted = false
// No console log because this is intended behavior
return; 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')
const originalUrl = urlParams.get('redirect_uri'); if (items.length === 0) return // not yet populated; retry on next mutation
if (!originalUrl) { for (const item of items) {
console.warn('[OIDC] No OAuth2 redirect_uri parameter found in the URL. Frontend redirect cancelled.'); if ((item.innerText || '').trim() !== OIDC_PROVIDER_NAME) continue
return; 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
} }
try {
// Parse the redirect URI
const redirectUrl = new URL(originalUrl);
// Check if redirect URI has a query parameter to stop OIDC injection
if (redirectUrl.searchParams.get('skip_oidc_redirect') === 'true') {
// No console log because this is intended behavior
return;
}
} catch (error) {
console.error('[OIDC] Invalid redirect_uri parameter:', error);
}
window.stop(); // Stop loading the current page before redirecting
// Redirect to the OIDC auth URL
const base64encodeUrl = btoa(window.location.href);
const oidcAuthUrl = '/auth/oidc/welcome?redirect_uri=' + encodeURIComponent(base64encodeUrl);
window.location.href = oidcAuthUrl;
} }
function click_alternative_provider_instead() { function update() {
setTimeout(() => { authFlowElement = document.querySelector('ha-auth-flow')
// Find ha-auth-flow if (!authFlowElement) return
const authFlowElement = document.querySelector('ha-auth-flow');
if (!authFlowElement) { const authProviderElement = document.querySelector('ha-pick-auth-provider')
console.warn("[OIDC] ha-auth-flow element not found. Not automatically selecting HA provider.");
return;
}
// Check if the text "Login aborted" is present on the page // Intercept picker clicks so the OIDC cookie is set before submit.
if (!authFlowElement.innerText.includes('Login aborted')) { interceptPickerRow(authProviderElement)
console.warn("[OIDC] 'Login aborted' text not found. Not automatically selecting HA provider.");
return;
}
// Find the ha-pick-auth-provider element // Auto-select on "Login aborted".
const authProviderElement = document.querySelector('ha-pick-auth-provider'); if (!authFlowElement.innerText.includes('Login aborted')) return
if (!authProviderElement) return
if (!authProviderElement) { const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item')
console.warn("[OIDC] ha-pick-auth-provider not found. Not automatically selecting HA provider."); if (!firstListItem) {
return; console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting OIDC provider.")
} return
}
// Click the first ha-list-item element inside the ha-pick-auth-provider firstListItem.click()
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();
}, 500);
} }
// Run OIDC injection upon load // Hide the content until ready
(() => { let ready = false
attempt_oidc_redirect(); document.querySelector(".content").style.display = "none"
click_alternative_provider_instead();
})(); const observer = new MutationObserver((mutationsList, observer) => {
update();
if (!ready) {
ready = Boolean(authFlowElement)
if (ready) {
document.querySelector(".content").style.display = ""
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
setTimeout(() => {
if (!ready) {
console.warn("[hass-oidc-auth]: Document was not ready after 300ms seconds, showing content anyway.")
}
// Force display the content
document.querySelector(".content").style.display = "";
update();
}, 300)

File diff suppressed because one or more lines are too long

View File

@@ -372,7 +372,7 @@ class OIDCClient:
tcp_connector_args["ssl"] = ssl_context tcp_connector_args["ssl"] = ssl_context
self.http_session = aiohttp.ClientSession( self.http_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(**tcp_connector_args) trust_env=True, connector=aiohttp.TCPConnector(**tcp_connector_args)
) )
return self.http_session return self.http_session

View File

@@ -39,3 +39,19 @@ class OIDCState(dict):
# IP address # IP address
ip_address: str | None 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title> <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 %} {% endblock %}
</head> </head>

View File

@@ -24,11 +24,8 @@
</div> </div>
<div class="rounded-lg border border-gray-300 bg-white p-6 text-left"> <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"> <h2 class="mb-2 text-lg font-semibold text-gray-800">Use a code from another device</h2>
<span class="text-lg font-semibold">Use a code from another device</span> <p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
</div>
<div class="border-t border-gray-200 pt-4">
<p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
6-digit code.</p> 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 class="mb-4 text-sm text-gray-600">Input that code here and click Approve to login on the other device.
</p> </p>
@@ -60,7 +57,6 @@
Approve login on the other device Approve login on the other device
</button> </button>
</form> </form>
</div>
</div> </div>
</div> </div>
{% endblock %} {% 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 # 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 ```yaml
auth_oidc: auth_oidc:
@@ -11,7 +14,7 @@ auth_oidc:
discovery_url: "" 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`. 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 ## 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) * [Kanidm](./provider-configurations/kanidm.md)
* [Microsoft Entra ID](./provider-configurations/microsoft-entra.md) * [Microsoft Entra ID](./provider-configurations/microsoft-entra.md)
* [Zitadel](./provider-configurations/zitadel.md) * [Zitadel](./provider-configurations/zitadel.md)
* [Keycloak](./provider-configurations/keycloak.md)
_Missing a provider? Submit your guide using a PR._ _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". 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 ### 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: 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 ### 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. 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 ```yaml
auth_oidc: 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. | | `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. | | `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. | `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 | | `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_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.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.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.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.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.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.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). | | `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.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. | | `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. | | `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 # 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] > [!NOTE]
> This configuration strictly requires a HTTPS redirect uri. > This configuration strictly requires a HTTPS redirect uri.
@@ -17,24 +33,11 @@ identity_providers:
public: true public: true
require_pkce: true require_pkce: true
pkce_challenge_method: 'S256' pkce_challenge_method: 'S256'
authorization_policy: 'two_factor'
redirect_uris: redirect_uris:
- 'https://hass.example.com/auth/oidc/callback' - 'https://hass.example.com/auth/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'groups'
id_token_signed_response_alg: 'RS256'
``` ```
Home Assistant `configuration.yaml` ### Confidential client configuration:
```yaml
auth_oidc:
client_id: "homeassistant"
discovery_url: "https://auth.example.com/.well-known/openid-configuration"
```
## Confidential client configuration:
Authelia `configuration.yml` Authelia `configuration.yml`
```yaml ```yaml
@@ -49,21 +52,43 @@ identity_providers:
public: false public: false
require_pkce: true require_pkce: true
pkce_challenge_method: 'S256' pkce_challenge_method: 'S256'
authorization_policy: 'two_factor'
redirect_uris: redirect_uris:
- 'https://hass.example.com/auth/oidc/callback' - 'https://hass.example.com/auth/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'groups'
id_token_signed_response_alg: 'RS256'
token_endpoint_auth_method: 'client_secret_post' token_endpoint_auth_method: 'client_secret_post'
``` ```
Home Assistant `configuration.yaml` ## Step 3. Home Assistant configuration
```yaml
auth_oidc: 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).
client_id: "homeassistant"
client_secret: "insecure_secret" 1. Open Home Assistant and go to **Settings -> Devices & Services**.
discovery_url: "https://auth.example.com/.well-known/openid-configuration" 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 > [!TIP]
Under construction. > 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` ## Step 1. Install the integration
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 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/.
## Home Assistant configuration After installing HACS, search for "OpenID Connect" in the HACS search box or click the button below:
> [!IMPORTANT] [![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)
> 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 this HACS addon, edit your `configuration.yaml` file and add: ## Step 2. Configure authentik
```yaml
auth_oidc:
client_id: "homeassistant"
client_secret: "client_secret"
discovery_url: "https://auth.example.com/application/o/home-assistant/.well-known/openid-configuration"
```
Restart Home Assistant and go to https://hass.example.com/auth/oidc/welcome 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 # 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` 1. Login to Pocket ID and go to `OIDC Clients`
2. Click on `Add OIDC Client` 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. 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 ### Confidential client 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)
```
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` 1. Login to Pocket ID and go to `OIDC Clients`
2. Click on `Add OIDC Client` 2. Click on `Add OIDC Client`
@@ -44,15 +48,38 @@ auth_oidc:
- `Client secret` - `Client secret`
- `OIDC Discovery URL` - `OIDC Discovery URL`
### Home Assistant configuration ## Step 3. 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) 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: The usage instructions have moved to [the main README](../README.md)
### 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.*

View File

@@ -1,6 +1,8 @@
{ {
"name": "OpenID Connect", "name": "OpenID Connect/SSO Authentication",
"hide_default_branch": true, "hide_default_branch": true,
"render_readme": true, "render_readme": true,
"homeassistant": "2025.11" "homeassistant": "2025.11",
"zip_release": true,
"filename": "hass-oidc-auth.zip"
} }

142
package-lock.json generated
View File

@@ -369,27 +369,27 @@
} }
}, },
"node_modules/@tailwindcss/cli": { "node_modules/@tailwindcss/cli": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.4.tgz",
"integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==", "integrity": "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@parcel/watcher": "^2.5.1", "@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.2.2", "@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.2", "@tailwindcss/oxide": "4.2.4",
"enhanced-resolve": "^5.19.0", "enhanced-resolve": "^5.19.0",
"mri": "^1.2.0", "mri": "^1.2.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"tailwindcss": "4.2.2" "tailwindcss": "4.2.4"
}, },
"bin": { "bin": {
"tailwindcss": "dist/index.mjs" "tailwindcss": "dist/index.mjs"
} }
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
@@ -398,36 +398,36 @@
"lightningcss": "1.32.0", "lightningcss": "1.32.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.2.2" "tailwindcss": "4.2.4"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2" "@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -441,9 +441,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -457,9 +457,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -473,9 +473,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -489,9 +489,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -505,9 +505,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -524,9 +524,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -543,9 +543,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -562,9 +562,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -581,9 +581,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@@ -668,9 +668,9 @@
"optional": true "optional": true
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -684,9 +684,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -709,13 +709,13 @@
} }
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
"tapable": "^2.3.0" "tapable": "^2.3.3"
}, },
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
@@ -1070,15 +1070,15 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.2", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.2", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"

View File

@@ -9,7 +9,6 @@ license = "MIT"
dependencies = [ dependencies = [
"aiofiles~=25.1", "aiofiles~=25.1",
"jinja2~=3.1", "jinja2~=3.1",
"bcrypt~=5.0",
"joserfc~=1.6.0", "joserfc~=1.6.0",
] ]
readme = "README.md" readme = "README.md"

10
scripts/build Executable file
View File

@@ -0,0 +1,10 @@
#! /bin/bash
# Build the plugin CSS
npm install --frozen-lockfile
npm run css
# Create zip from the custom_components/auth_oidc directory
# HACS wants only the contents of this dir in a zip
cd custom_components/auth_oidc/
zip -r ../../hass-oidc-auth.zip ./*

View File

@@ -2,11 +2,13 @@
import base64 import base64
import re import re
from collections import OrderedDict
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from homeassistant.auth import InvalidAuthError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component 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 ( from custom_components.auth_oidc.config.const import (
DISCOVERY_URL, DISCOVERY_URL,
CLIENT_ID, CLIENT_ID,
DEFAULT_TITLE,
DISPLAY_NAME,
FEATURES, FEATURES,
FEATURES_AUTOMATIC_PERSON_CREATION, FEATURES_AUTOMATIC_PERSON_CREATION,
FEATURES_AUTOMATIC_USER_LINKING, 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 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" 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: 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""" """Test successful setup"""
await setup( await setup(
hass, hass,
{ DEFAULT_CONFIG,
CLIENT_ID: "dummy",
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
},
True, True,
) )
@@ -55,6 +60,24 @@ async def test_setup_success_auth_provider_registration(hass: HomeAssistant):
assert auth_providers[0].support_mfa is False 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 @pytest.mark.asyncio
async def test_provider_ip_fallback_fails_closed_without_request_context( async def test_provider_ip_fallback_fails_closed_without_request_context(
hass: HomeAssistant, 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.""" """Provider should not invent a shared IP when request context is missing."""
await setup( await setup(
hass, hass,
{ DEFAULT_CONFIG,
CLIENT_ID: "dummy",
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
},
True, True,
) )
@@ -83,21 +103,154 @@ async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssis
"""Cookie header should include Secure when HTTPS is in use.""" """Cookie header should include Secure when HTTPS is in use."""
await setup( await setup(
hass, hass,
{ DEFAULT_CONFIG,
CLIENT_ID: "dummy",
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
},
True, True,
) )
provider = hass.auth.get_auth_providers(DOMAIN)[0] provider = hass.auth.get_auth_providers(DOMAIN)[0]
cookie_header = provider.get_cookie_header("state-id", secure=True)["set-cookie"] cookie_header = provider.get_cookie_header("state-id", secure=True)["set-cookie"]
assert "SameSite=Strict" in cookie_header assert "SameSite=Lax" in cookie_header
assert "HttpOnly" in cookie_header assert "HttpOnly" in cookie_header
assert "Secure" in cookie_header 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): async def login_user(hass: HomeAssistant, state_id: str):
"""Helper to login a user from the stored OIDC state.""" """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( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False, FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: False, FEATURES_AUTOMATIC_USER_LINKING: False,
@@ -198,8 +350,7 @@ async def test_login_with_linking(hass: HomeAssistant, hass_client):
await setup( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False, FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: True, FEATURES_AUTOMATIC_USER_LINKING: True,
@@ -233,8 +384,7 @@ async def test_login_with_person_create(hass: HomeAssistant, hass_client):
await setup( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: True, FEATURES_AUTOMATIC_PERSON_CREATION: True,
FEATURES_AUTOMATIC_USER_LINKING: False, FEATURES_AUTOMATIC_USER_LINKING: False,
@@ -267,8 +417,7 @@ async def test_login_without_person_create_does_not_create_person(
await setup( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False, FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: False, FEATURES_AUTOMATIC_USER_LINKING: False,
@@ -295,8 +444,7 @@ async def test_login_shows_form(hass: HomeAssistant):
await setup( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False, FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: False, FEATURES_AUTOMATIC_USER_LINKING: False,
@@ -319,8 +467,7 @@ async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
await setup( await setup(
hass, hass,
{ {
CLIENT_ID: "dummy", **DEFAULT_CONFIG,
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
FEATURES: { FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False, FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: False, FEATURES_AUTOMATIC_USER_LINKING: False,
@@ -342,5 +489,35 @@ async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
result = await flow.async_step_init({}) result = await flow.async_step_init({})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "oidc_cookie_invalid"
@pytest.mark.asyncio
async def test_login_with_no_cookie_aborts(hass: HomeAssistant):
"""Missing cookie should fail closed."""
await setup(
hass,
{
**DEFAULT_CONFIG,
FEATURES: {
FEATURES_AUTOMATIC_PERSON_CREATION: False,
FEATURES_AUTOMATIC_USER_LINKING: False,
},
},
True,
)
provider = hass.auth.get_auth_providers(DOMAIN)[0]
flow = await provider.async_login_flow({})
fake_request = SimpleNamespace(cookies={}, remote="127.0.0.1")
with patch(
"custom_components.auth_oidc.provider.http.current_request"
) as current_request:
current_request.get.return_value = fake_request
result = await flow.async_step_init({})
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_oidc_cookie_found" assert result["reason"] == "no_oidc_cookie_found"

View File

@@ -3,14 +3,17 @@
import base64 import base64
import asyncio import asyncio
import re import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, unquote, urlparse, urlencode from urllib.parse import parse_qs, unquote, urlparse, urlencode
import pytest import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from custom_components.auth_oidc import DOMAIN 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 ( from custom_components.auth_oidc.tools.oidc_client import (
OIDCDiscoveryClient, OIDCDiscoveryClient,
OIDCDiscoveryInvalid, OIDCDiscoveryInvalid,
@@ -103,10 +106,10 @@ async def verify_back_redirect(client, expected_redirect_uri: str):
"""Verify that POST to finish without body redirects back to the original redirect_uri.""" """Verify that POST to finish without body redirects back to the original redirect_uri."""
resp_finish_post = await client.post("/auth/oidc/finish", allow_redirects=False) resp_finish_post = await client.post("/auth/oidc/finish", allow_redirects=False)
assert resp_finish_post.status == 302 assert resp_finish_post.status == 302
assert (
resp_finish_post.headers["Location"] location = resp_finish_post.headers["Location"]
== unquote(expected_redirect_uri) + "&storeToken=true&skip_oidc_redirect=true" assert location.startswith(unquote(expected_redirect_uri))
) assert "skip_oidc_redirect=true" in location
async def listen_for_sse_events( async def listen_for_sse_events(
@@ -248,6 +251,47 @@ async def test_full_oidc_flow(hass: HomeAssistant, hass_client):
await verify_back_redirect(client, redirect_uri) 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( async def discovery_test_through_redirect(
hass_client, caplog, scenario: str, match_log_line: str hass_client, caplog, scenario: str, match_log_line: str
): ):

View File

@@ -23,6 +23,25 @@ from custom_components.auth_oidc.tools.oidc_client import (
http_raise_for_status, http_raise_for_status,
) )
# List from https://jose.authlib.org/en/guide/algorithms/#json-web-signature
ALL_ID_TOKEN_SIGNING_ALGORITHMS = (
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
"ES256K",
"Ed25519",
"Ed448",
)
def make_client(hass: HomeAssistant, **kwargs) -> OIDCClient: def make_client(hass: HomeAssistant, **kwargs) -> OIDCClient:
"""Build an OIDC client with explicit defaults for unit testing.""" """Build an OIDC client with explicit defaults for unit testing."""
@@ -67,6 +86,59 @@ def make_signed_hs256_jwt(secret: str, claims: dict) -> str:
return jwt.encode({"alg": "HS256"}, claims, jwk_obj) return jwt.encode({"alg": "HS256"}, claims, jwk_obj)
def build_real_signed_token(
algorithm: str, claims: dict, secret: str
) -> tuple[str, dict]:
"""Build a real signed token and matching JWKS payload for a given algorithm."""
if algorithm.startswith("HS"):
signing_key = jwk.import_key(
{
"kty": "oct",
"k": base64.urlsafe_b64encode(secret.encode()).decode().rstrip("="),
"alg": algorithm,
}
)
token = jwt.encode(
{"alg": algorithm}, claims, signing_key, algorithms=[algorithm]
)
return token, {"keys": []}
if algorithm in ("RS256", "RS384", "RS512", "PS256", "PS384", "PS512"):
key = jwk.generate_key(
"RSA", 2048, {"alg": algorithm, "use": "sig"}, private=True, auto_kid=True
)
elif algorithm in ("ES256", "ES384", "ES512", "ES256K"):
curve = {
"ES256": "P-256",
"ES384": "P-384",
"ES512": "P-521",
"ES256K": "secp256k1",
}[algorithm]
key = jwk.generate_key(
"EC", curve, {"alg": algorithm, "use": "sig"}, private=True, auto_kid=True
)
elif algorithm in ("Ed25519", "Ed448"):
key = jwk.generate_key(
"OKP",
algorithm,
{"alg": algorithm, "use": "sig"},
private=True,
auto_kid=True,
)
else:
raise ValueError(f"Unsupported test algorithm: {algorithm}")
kid = key.kid
token = jwt.encode(
{"alg": algorithm, "kid": kid},
claims,
key,
algorithms=[algorithm],
)
public_key = key.as_dict(private=False)
return token, {"keys": [public_key]}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_complete_token_flow_rejects_missing_state(hass: HomeAssistant): async def test_complete_token_flow_rejects_missing_state(hass: HomeAssistant):
"""Flow state must exist; missing state should fail closed.""" """Flow state must exist; missing state should fail closed."""
@@ -447,6 +519,62 @@ async def test_parse_id_token_rejects_invalid_registered_claims(hass: HomeAssist
assert parsed is None assert parsed is None
@pytest.mark.asyncio
@pytest.mark.parametrize("algorithm", ALL_ID_TOKEN_SIGNING_ALGORITHMS)
async def test_parse_id_token_validates_real_signed_tokens_and_decode_inputs(
hass: HomeAssistant, algorithm: str
):
"""Use real signatures and verify token/key/algorithm passed into joserfc."""
secret = "top-secret-value"
client_kwargs = {"id_token_signing_alg": algorithm}
if algorithm.startswith("HS"):
client_kwargs["client_secret"] = secret
client = make_client(hass, **client_kwargs)
client.discovery_document = {
"issuer": "https://issuer",
"jwks_uri": "https://issuer/jwks",
}
now = int(time.time())
claims = {
"sub": "subject-1",
"aud": "test-client",
"iss": "https://issuer",
"nbf": now,
"iat": now,
"exp": now + 3600,
}
token, jwks_payload = build_real_signed_token(algorithm, claims, secret)
with (
patch.object(client, "_fetch_jwks", new=AsyncMock(return_value=jwks_payload)),
patch(
"custom_components.auth_oidc.tools.oidc_client.jwt.decode",
wraps=jwt.decode,
) as decode_spy,
patch(
"custom_components.auth_oidc.tools.oidc_client.jwk.import_key",
wraps=jwk.import_key,
) as import_key_spy,
):
parsed = await client._parse_id_token(token)
assert parsed == claims
decode_spy.assert_called_once()
assert decode_spy.call_args.args[0] == token
assert decode_spy.call_args.kwargs["algorithms"] == [algorithm]
import_key_spy.assert_called()
imported_key_payload = import_key_spy.call_args.args[0]
assert imported_key_payload["alg"] == algorithm
if algorithm.startswith("HS"):
assert imported_key_payload["kty"] == "oct"
else:
assert imported_key_payload["kid"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_authorization_url_returns_none_when_discovery_fails( async def test_get_authorization_url_returns_none_when_discovery_fails(
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -2,8 +2,19 @@
import base64 import base64
import os import os
from collections import OrderedDict
from urllib.parse import parse_qs, quote, unquote, urlparse, urlencode
from unittest.mock import AsyncMock, MagicMock, patch 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 import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -16,14 +27,35 @@ from custom_components.auth_oidc.endpoints.injected_auth_page import (
frontend_injection, frontend_injection,
) )
WEB_CLIENT_ID = "https://example.com"
MOBILE_CLIENT_ID = "https://home-assistant.io/Android" 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: def create_redirect_uri(client_id: str) -> str:
"""Build a redirect URI that includes a client_id query parameter.""" """Build a redirect URI that includes a client_id query parameter."""
return f"http://example.com/auth/authorize?client_id={client_id}" params = {
"response_type": "code",
"redirect_uri": client_id,
"client_id": client_id,
"state": "example",
}
return f"http://example.com/auth/authorize?{urlencode(params)}"
def encode_redirect_uri(redirect_uri: str) -> str: def encode_redirect_uri(redirect_uri: str) -> str:
@@ -45,8 +77,26 @@ async def setup(
assert result assert result
async def setup_mock_authorize_route(hass: HomeAssistant) -> None:
"""Register a mock /auth/authorize page so frontend injection can hook into it."""
await async_setup_component(hass, HTTP_DOMAIN, {})
mock_html_path = os.path.join(os.path.dirname(__file__), "mocks", "auth_page.html")
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/auth/authorize",
mock_html_path,
cache_headers=False,
)
]
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_page_registration(hass: HomeAssistant, hass_client): async def test_welcome_page_registration(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Test that welcome page is present.""" """Test that welcome page is present."""
await setup(hass) await setup(hass)
@@ -57,7 +107,9 @@ async def test_welcome_page_registration(hass: HomeAssistant, hass_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redirect_page_registration(hass: HomeAssistant, hass_client): async def test_redirect_page_registration(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Test that redirect page can be reached.""" """Test that redirect page can be reached."""
await setup(hass) await setup(hass)
@@ -70,9 +122,31 @@ async def test_redirect_page_registration(hass: HomeAssistant, hass_client):
assert resp2.status == 302 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 @pytest.mark.asyncio
async def test_welcome_rejects_invalid_encoded_redirect_uri( async def test_welcome_rejects_invalid_encoded_redirect_uri(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Welcome should reject malformed base64 redirect_uri values.""" """Welcome should reject malformed base64 redirect_uri values."""
await setup(hass) await setup(hass)
@@ -87,12 +161,104 @@ async def test_welcome_rejects_invalid_encoded_redirect_uri(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_sets_strict_state_cookie_flags(hass: HomeAssistant, hass_client): @pytest.mark.parametrize(
"redirect_uri",
[
"http://example.com/auth/authorize?client_id=https://example.com",
"http://example.com/auth/authorize?redirect_uri=https://example.com",
],
)
async def test_welcome_rejects_redirect_uris_missing_required_query_params(
hass: HomeAssistant, hass_client: ClientSessionGenerator, redirect_uri: str
):
"""Welcome should reject redirect URIs that decode but are incomplete."""
await setup(hass)
client = await hass_client()
encoded = encode_redirect_uri(redirect_uri)
resp = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}",
allow_redirects=False,
)
assert resp.status == 400
assert "Invalid redirect_uri, please restart login." in await resp.text()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("client_id", "should_store_token", "is_mobile"),
[
("", True, False),
(MOBILE_CLIENT_ID, False, True),
("https://random.example", False, False),
],
)
async def test_welcome_only_adds_store_token_for_web_clients(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
client_id: str,
should_store_token: bool,
is_mobile: bool,
):
"""Welcome should only append storeToken for clients aligned with the base URL."""
await setup(hass)
captured_redirect_uri = {}
async def fake_create_state(state_redirect_uri: str, *_args):
captured_redirect_uri["value"] = state_redirect_uri
return "state-id"
with (
patch(
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_create_state",
new=AsyncMock(side_effect=fake_create_state),
),
patch(
"custom_components.auth_oidc.provider.OpenIDAuthProvider.async_generate_device_code",
new=AsyncMock(return_value="123456"),
),
):
client = await hass_client()
if client_id == "":
# If not present, set it to the root URL to
# emulate the normal website/Lovelace/dashboard
client_id = str(client.make_url("/?test=true"))
redirect_uri = create_redirect_uri(client_id)
encoded = encode_redirect_uri(redirect_uri)
resp = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}",
allow_redirects=False,
)
assert resp.status in (200, 302)
assert "value" in captured_redirect_uri
parsed_state_redirect = urlparse(captured_redirect_uri["value"])
state_redirect_query = parse_qs(parsed_state_redirect.query)
nested_redirect_uri = unquote(state_redirect_query["redirect_uri"][0])
if should_store_token:
assert "storeToken=true" in nested_redirect_uri
else:
assert "storeToken=true" not in nested_redirect_uri
if is_mobile:
assert "https://home-assistant.io/" in nested_redirect_uri
@pytest.mark.asyncio
async def test_welcome_sets_secure_state_cookie_flags(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Welcome should set secure cookie flags for the OIDC state cookie.""" """Welcome should set secure cookie flags for the OIDC state cookie."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp = await client.get( resp = await client.get(
@@ -105,14 +271,14 @@ async def test_welcome_sets_strict_state_cookie_flags(hass: HomeAssistant, hass_
set_cookie = resp.headers.get("Set-Cookie", "") set_cookie = resp.headers.get("Set-Cookie", "")
assert "Path=/auth/" in set_cookie assert "Path=/auth/" in set_cookie
assert "SameSite=Strict" in set_cookie assert "SameSite=Lax" in set_cookie
assert "HttpOnly" in set_cookie assert "HttpOnly" in set_cookie
assert "Max-Age=300" in set_cookie assert "Max-Age=300" in set_cookie
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_mobile_device_code_generation_failure( async def test_welcome_mobile_device_code_generation_failure(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Welcome should error if device code generation fails for mobile clients.""" """Welcome should error if device code generation fails for mobile clients."""
await setup(hass) await setup(hass)
@@ -137,13 +303,13 @@ async def test_welcome_mobile_device_code_generation_failure(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_shows_alternative_sign_in_link_when_other_providers_exist( async def test_welcome_shows_alternative_sign_in_link_when_other_providers_exist(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Welcome should render fallback auth link when other providers are present.""" """Welcome should render fallback auth link when other providers are present."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp = await client.get( resp = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -158,16 +324,18 @@ async def test_welcome_shows_alternative_sign_in_link_when_other_providers_exist
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_welcome_desktop_auto_redirects_without_other_providers( async def test_welcome_desktop_auto_redirects_without_other_providers(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Welcome should auto-redirect desktop clients when no other providers exist.""" """Welcome should auto-redirect desktop clients when no other providers exist."""
# pylint: disable=protected-access # 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) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp = await client.get( resp = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -179,7 +347,7 @@ async def test_welcome_desktop_auto_redirects_without_other_providers(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redirect_without_cookie_goes_to_welcome( async def test_redirect_without_cookie_goes_to_welcome(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Redirect endpoint should bounce to welcome when no state cookie exists.""" """Redirect endpoint should bounce to welcome when no state cookie exists."""
await setup(hass) await setup(hass)
@@ -187,18 +355,18 @@ async def test_redirect_without_cookie_goes_to_welcome(
client = await hass_client() client = await hass_client()
resp = await client.get("/auth/oidc/redirect", allow_redirects=False) resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
assert resp.status == 302 assert resp.status == 302
assert "/auth/oidc/welcome" in resp.headers["Location"] assert_redirects_to_welcome(resp)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redirect_shows_error_on_oidc_runtime_error( async def test_redirect_shows_error_on_oidc_runtime_error(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Redirect should show a configuration error when OIDC URL generation raises.""" """Redirect should show a configuration error when OIDC URL generation raises."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -220,13 +388,13 @@ async def test_redirect_shows_error_on_oidc_runtime_error(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redirect_shows_error_when_auth_url_empty( async def test_redirect_shows_error_when_auth_url_empty(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Redirect should show error page if OIDC returns no authorization URL.""" """Redirect should show error page if OIDC returns no authorization URL."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -247,7 +415,9 @@ async def test_redirect_shows_error_when_auth_url_empty(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_registration(hass: HomeAssistant, hass_client): async def test_callback_registration(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Test that callback page is reachable.""" """Test that callback page is reachable."""
await setup(hass) await setup(hass)
@@ -258,12 +428,14 @@ async def test_callback_registration(hass: HomeAssistant, hass_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_rejects_missing_code_or_state(hass: HomeAssistant, hass_client): async def test_callback_rejects_missing_code_or_state(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Callback must reject requests missing either code or state.""" """Callback must reject requests missing either code or state."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -287,12 +459,14 @@ async def test_callback_rejects_missing_code_or_state(hass: HomeAssistant, hass_
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_rejects_state_mismatch(hass: HomeAssistant, hass_client): async def test_callback_rejects_state_mismatch(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Callback must reject state mismatch to protect against CSRF.""" """Callback must reject state mismatch to protect against CSRF."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -310,13 +484,13 @@ async def test_callback_rejects_state_mismatch(hass: HomeAssistant, hass_client)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_rejects_when_user_details_fetch_fails( async def test_callback_rejects_when_user_details_fetch_fails(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""Callback should error when token exchange/userinfo retrieval fails.""" """Callback should error when token exchange/userinfo retrieval fails."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -340,12 +514,14 @@ async def test_callback_rejects_when_user_details_fetch_fails(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_rejects_invalid_role(hass: HomeAssistant, hass_client): async def test_callback_rejects_invalid_role(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Callback should reject users marked with invalid role.""" """Callback should reject users marked with invalid role."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -378,7 +554,10 @@ async def test_callback_rejects_invalid_role(hass: HomeAssistant, hass_client):
], ],
) )
async def test_finish_requires_state_cookie( async def test_finish_requires_state_cookie(
hass: HomeAssistant, hass_client, method: str, data: dict | None hass: HomeAssistant,
hass_client: ClientSessionGenerator,
method: str,
data: dict | None,
): ):
"""Finish endpoint should require the OIDC state cookie for both GET and POST.""" """Finish endpoint should require the OIDC state cookie for both GET and POST."""
await setup(hass) await setup(hass)
@@ -395,12 +574,14 @@ async def test_finish_requires_state_cookie(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_finish_post_rejects_invalid_state(hass: HomeAssistant, hass_client): async def test_finish_post_rejects_invalid_state(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Finish POST should error when the state cookie does not resolve to redirect_uri.""" """Finish POST should error when the state cookie does not resolve to redirect_uri."""
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
redirect_uri = create_redirect_uri(WEB_CLIENT_ID) redirect_uri = create_redirect_uri(client.make_url("/"))
encoded = encode_redirect_uri(redirect_uri) encoded = encode_redirect_uri(redirect_uri)
resp_welcome = await client.get( resp_welcome = await client.get(
f"/auth/oidc/welcome?redirect_uri={encoded}", f"/auth/oidc/welcome?redirect_uri={encoded}",
@@ -418,7 +599,9 @@ async def test_finish_post_rejects_invalid_state(hass: HomeAssistant, hass_clien
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_sse_requires_state_cookie(hass: HomeAssistant, hass_client): async def test_device_sse_requires_state_cookie(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""SSE endpoint should reject requests without state cookie.""" """SSE endpoint should reject requests without state cookie."""
await setup(hass) await setup(hass)
@@ -430,7 +613,7 @@ async def test_device_sse_requires_state_cookie(hass: HomeAssistant, hass_client
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_sse_emits_expired_for_unknown_state( async def test_device_sse_emits_expired_for_unknown_state(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""SSE should emit expired when the state can no longer be resolved.""" """SSE should emit expired when the state can no longer be resolved."""
await setup(hass) await setup(hass)
@@ -455,7 +638,9 @@ async def test_device_sse_emits_expired_for_unknown_state(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_sse_emits_timeout(hass: HomeAssistant, hass_client): async def test_device_sse_emits_timeout(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""SSE should emit timeout if the polling window is exceeded.""" """SSE should emit timeout if the polling window is exceeded."""
await setup(hass) await setup(hass)
@@ -493,7 +678,7 @@ async def test_device_sse_emits_timeout(hass: HomeAssistant, hass_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_sse_handles_runtime_error_and_returns_cleanly( async def test_device_sse_handles_runtime_error_and_returns_cleanly(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""SSE should swallow runtime errors from stream loop and finish response.""" """SSE should swallow runtime errors from stream loop and finish response."""
await setup(hass) await setup(hass)
@@ -523,7 +708,7 @@ async def test_device_sse_handles_runtime_error_and_returns_cleanly(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_device_sse_ignores_write_eof_connection_reset( async def test_device_sse_ignores_write_eof_connection_reset(
hass: HomeAssistant, hass_client hass: HomeAssistant, hass_client: ClientSessionGenerator
): ):
"""SSE should ignore ConnectionResetError while closing the stream.""" """SSE should ignore ConnectionResetError while closing the stream."""
await setup(hass) await setup(hass)
@@ -553,32 +738,20 @@ async def test_device_sse_ignores_write_eof_connection_reset(
# Test the frontend injection # Test the frontend injection
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_frontend_injection(hass: HomeAssistant, hass_client): async def test_frontend_injection(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Test that frontend injection works.""" """Test that frontend injection works."""
# Because there is no frontend in the test setup, # Because there is no frontend in the test setup,
# we'll have to fake /auth/authorize for the changes to register # we'll have to fake /auth/authorize for the changes to register.
await async_setup_component(hass, HTTP_DOMAIN, {}) await setup_mock_authorize_route(hass)
mock_html_path = os.path.join(os.path.dirname(__file__), "mocks", "auth_page.html")
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/auth/authorize",
mock_html_path,
cache_headers=False,
)
]
)
await setup(hass) await setup(hass)
client = await hass_client() client = await hass_client()
resp = await client.get("/auth/authorize", allow_redirects=False) resp = await client.get("/auth/authorize", allow_redirects=False)
assert resp.status == 200 await assert_normal_login_screen(resp)
text = await resp.text()
assert "<script src='/auth/oidc/static/injection.js" in text
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -605,8 +778,12 @@ async def test_frontend_injection_logs_and_returns_when_route_handler_is_unexpec
def __iter__(self): def __iter__(self):
return iter([FakeRoute()]) return iter([FakeRoute()])
provider = MagicMock()
with patch.object(hass.http.app.router, "resources", return_value=[FakeResource()]): with patch.object(hass.http.app.router, "resources", return_value=[FakeResource()]):
await frontend_injection(hass) await frontend_injection(
hass, provider, force_https=False, has_trusted_networks_provider_first=False
)
assert "Unexpected route handler type" in caplog.text assert "Unexpected route handler type" in caplog.text
assert ( assert (
@@ -625,6 +802,175 @@ async def test_injected_auth_page_inject_logs_errors(hass: HomeAssistant, caplog
"custom_components.auth_oidc.endpoints.injected_auth_page.frontend_injection", "custom_components.auth_oidc.endpoints.injected_auth_page.frontend_injection",
side_effect=RuntimeError("boom"), side_effect=RuntimeError("boom"),
): ):
await OIDCInjectedAuthPage.inject(hass) 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 assert "Failed to inject OIDC auth page: boom" in caplog.text
@pytest.mark.asyncio
async def test_injected_auth_page_redirects_to_welcome_when_not_skipped(
hass: HomeAssistant, hass_client: ClientSessionGenerator
):
"""Injected auth page should redirect into OIDC when skip flags are absent."""
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 resp.status == 302
location = resp.headers["Location"]
parsed_location = urlparse(location)
assert parsed_location.path == "/auth/oidc/welcome"
query = parse_qs(parsed_location.query)
assert "redirect_uri" in query
original_url = base64.b64decode(unquote(query["redirect_uri"][0]), validate=True)
original_url = original_url.decode("utf-8")
assert "/auth/authorize?redirect_uri=" in original_url
@pytest.mark.asyncio
@pytest.mark.parametrize(
"request_target",
[
"/auth/authorize?skip_oidc_redirect=true",
"/auth/authorize?redirect_uri=http%3A%2F%2Fexample.com%2Fauth%2Fauthorize%3Fskip_oidc_redirect%3Dtrue",
],
)
async def test_injected_auth_page_returns_original_html_when_skipped(
hass: HomeAssistant,
hass_client,
request_target: str,
):
"""Injected auth page should render HTML when redirect suppression is requested."""
await setup_mock_authorize_route(hass)
await setup(hass)
client = await hass_client()
response = await client.get(request_target, allow_redirects=False)
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")

199
uv.lock generated
View File

@@ -396,7 +396,7 @@ wheels = [
[[package]] [[package]]
name = "bleak" name = "bleak"
version = "3.0.1" version = "3.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "dbus-fast", marker = "sys_platform == 'linux'" }, { name = "dbus-fast", marker = "sys_platform == 'linux'" },
@@ -413,9 +413,9 @@ dependencies = [
{ name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" },
{ name = "winrt-windows-storage-streams", 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 = [ 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]] [[package]]
@@ -505,30 +505,30 @@ wheels = [
[[package]] [[package]]
name = "boto3" name = "boto3"
version = "1.42.88" version = "1.43.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
{ name = "jmespath" }, { name = "jmespath" },
{ name = "s3transfer" }, { 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 = [ 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]] [[package]]
name = "botocore" name = "botocore"
version = "1.42.88" version = "1.43.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "urllib3" }, { 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 = [ 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]] [[package]]
@@ -542,11 +542,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.4.22"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -890,7 +890,7 @@ wheels = [
[[package]] [[package]]
name = "habluetooth" name = "habluetooth"
version = "6.0.0" version = "6.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "async-interrupt" }, { name = "async-interrupt" },
@@ -902,26 +902,27 @@ dependencies = [
{ name = "btsocket" }, { name = "btsocket" },
{ name = "dbus-fast", marker = "sys_platform == 'linux'" }, { 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -958,7 +959,6 @@ version = "1.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "bcrypt" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "joserfc" }, { name = "joserfc" },
] ]
@@ -977,7 +977,6 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiofiles", specifier = "~=25.1" }, { name = "aiofiles", specifier = "~=25.1" },
{ name = "bcrypt", specifier = "~=5.0" },
{ name = "jinja2", specifier = "~=3.1" }, { name = "jinja2", specifier = "~=3.1" },
{ name = "joserfc", specifier = "~=1.6.0" }, { name = "joserfc", specifier = "~=1.6.0" },
] ]
@@ -1007,7 +1006,7 @@ wheels = [
[[package]] [[package]]
name = "homeassistant" name = "homeassistant"
version = "2026.4.2" version = "2026.4.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiodns" }, { name = "aiodns" },
@@ -1061,9 +1060,9 @@ dependencies = [
{ name = "yarl" }, { name = "yarl" },
{ name = "zeroconf" }, { 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 = [ 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]] [[package]]
@@ -1105,11 +1104,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.13"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -1174,14 +1173,14 @@ wheels = [
[[package]] [[package]]
name = "joserfc" name = "joserfc"
version = "1.6.3" version = "1.6.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { 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 = [ 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]] [[package]]
@@ -1359,11 +1358,11 @@ wheels = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -1410,11 +1409,11 @@ wheels = [
[[package]] [[package]]
name = "pip" name = "pip"
version = "26.0.1" version = "26.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -1866,7 +1865,7 @@ wheels = [
[[package]] [[package]]
name = "pytest-homeassistant-custom-component" name = "pytest-homeassistant-custom-component"
version = "0.13.323" version = "0.13.325"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohasupervisor" }, { name = "aiohasupervisor" },
@@ -1897,9 +1896,9 @@ dependencies = [
{ name = "syrupy" }, { name = "syrupy" },
{ name = "tqdm" }, { 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 = [ 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]] [[package]]
@@ -1989,11 +1988,11 @@ wheels = [
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2026.1.post1" version = "2026.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -2103,39 +2102,39 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.10" version = "0.15.12"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
name = "s3transfer" name = "s3transfer"
version = "0.16.0" version = "0.17.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { 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 = [ 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]] [[package]]
@@ -2293,11 +2292,11 @@ wheels = [
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.1" version = "2026.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -2356,28 +2355,28 @@ wheels = [
[[package]] [[package]]
name = "uv" name = "uv"
version = "0.11.6" version = "0.11.8"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]