Compare commits
119 Commits
v0.5.0-alp
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5b89fa32 | ||
|
|
2a5d3e589f | ||
|
|
3ba65adc8b | ||
|
|
f90a7d5346 | ||
|
|
084e0e606e | ||
|
|
16c45544d3 | ||
|
|
556e9a0fbf | ||
|
|
a027c532fe | ||
|
|
681610241d | ||
|
|
02babe0022 | ||
|
|
7cc960e4db | ||
|
|
07c1e3a4c4 | ||
|
|
0ca300c385 | ||
|
|
a9483e2038 | ||
|
|
6f1d2bcb3f | ||
|
|
67f58a39aa | ||
|
|
ddb2952e64 | ||
|
|
baf3ac6b5a | ||
|
|
c7672f65d9 | ||
|
|
fd3643685d | ||
|
|
fdc93e2719 | ||
|
|
3d789be33b | ||
|
|
58f52e4720 | ||
|
|
a135be2d81 | ||
|
|
26f3590a69 | ||
|
|
a3e839d0a8 | ||
|
|
52c9bd2a50 | ||
|
|
94b0fe80fe | ||
|
|
51b5e5662a | ||
|
|
0e173cfe06 | ||
|
|
55375395ea | ||
|
|
a0e58448e7 | ||
|
|
c85002167f | ||
|
|
5050cb4d5e | ||
|
|
52ff6a899d | ||
|
|
620df3006b | ||
|
|
fb4b5785de | ||
|
|
dfd7f83d88 | ||
|
|
b146441a23 | ||
|
|
624b5ec189 | ||
|
|
9a8bca4c1f | ||
|
|
0eef84d092 | ||
|
|
5187ceffbd | ||
|
|
1a35f953da | ||
|
|
a29e0e6730 | ||
|
|
0f0679d46d | ||
|
|
d6b8f6bbb1 | ||
|
|
6f93a22c37 | ||
|
|
b2d07c28f0 | ||
|
|
eaed91016a | ||
|
|
307af07c0c | ||
|
|
1f95efd0aa | ||
|
|
04a693ccfe | ||
|
|
d663d28ece | ||
|
|
e2bcbf4aa7 | ||
|
|
50e78a5189 | ||
|
|
36d3255b0b | ||
|
|
bc5039b951 | ||
|
|
d197ebade1 | ||
|
|
ff13b1db8f | ||
|
|
6f45858909 | ||
|
|
da39443d95 | ||
|
|
8914dba95c | ||
|
|
0133446975 | ||
|
|
674c342a81 | ||
|
|
a8e0162d25 | ||
|
|
2b224b3002 | ||
|
|
6fc1cd2944 | ||
|
|
dde2a70a39 | ||
|
|
4e898087d4 | ||
|
|
75eb5378c8 | ||
|
|
979a55dd12 | ||
|
|
2aa083a88d | ||
|
|
4bf1923b8b | ||
|
|
92df670c3f | ||
|
|
744be2a4d8 | ||
|
|
404d2451df | ||
|
|
5714e844a7 | ||
|
|
d1da841e1f | ||
|
|
3b481cd282 | ||
|
|
9fb65084b1 | ||
|
|
19cbc6e3ad | ||
|
|
daa13fa94f | ||
|
|
db8a5575f8 | ||
|
|
513e8e33f7 | ||
|
|
b87dd35577 | ||
|
|
44794f7cfd | ||
|
|
b0532ec2ec | ||
|
|
4fca5d8de4 | ||
|
|
02a4937ce4 | ||
|
|
86663dd5e4 | ||
|
|
c13eb7c438 | ||
|
|
764326a9e1 | ||
|
|
601127d6b3 | ||
|
|
e22f960d69 | ||
|
|
054f0e4bca | ||
|
|
1e0310afc9 | ||
|
|
0888ea0400 | ||
|
|
27de2bcf71 | ||
|
|
2e85f4bd16 | ||
|
|
5651e9bff3 | ||
|
|
86c663700c | ||
|
|
b4d5d7f2bf | ||
|
|
cb4d72a148 | ||
|
|
be59c415a0 | ||
|
|
ccd5fb2459 | ||
|
|
fbc47d11ef | ||
|
|
881a6cb0be | ||
|
|
178cd4df49 | ||
|
|
de321c8817 | ||
|
|
aaa977781c | ||
|
|
1fc4e0f21a | ||
|
|
6e56311176 | ||
|
|
f24519787b | ||
|
|
d565380435 | ||
|
|
29a2545396 | ||
|
|
b39a65ff74 | ||
|
|
63f5f175ee | ||
|
|
bfad0418ad |
21
.github/workflows/build-pr.yaml
vendored
Normal 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
|
||||
5
.github/workflows/hacs.yaml
vendored
@@ -13,10 +13,9 @@ jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
uses: hacs/action@22.5.0
|
||||
with:
|
||||
category: "integration"
|
||||
ignore: brands
|
||||
|
||||
2
.github/workflows/hassfest.yaml
vendored
@@ -13,5 +13,5 @@ jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
16
.github/workflows/lint.yaml
vendored
@@ -9,12 +9,16 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install the latest version of rye
|
||||
uses: eifinger/setup-rye@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: rye sync
|
||||
- name: Lint (pylint/rye lint)
|
||||
run: rye run check
|
||||
run: scripts/sync
|
||||
- name: Lint (pylint/ruff lint)
|
||||
run: scripts/check
|
||||
|
||||
27
.github/workflows/release.yaml
vendored
Normal 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
|
||||
26
.github/workflows/security.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Security (pysentry)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 8 */3 * *"
|
||||
|
||||
jobs:
|
||||
vulnerability-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Scan dependencies for vulnerabilities
|
||||
run: uvx pysentry-rs .
|
||||
24
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Tests (pytest)
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: scripts/sync
|
||||
- name: Test
|
||||
run: scripts/test
|
||||
8
.gitignore
vendored
@@ -105,6 +105,12 @@ dmypy.json
|
||||
.pyre/
|
||||
|
||||
# End of https://www.gitignore.io/api/python
|
||||
config/
|
||||
/config/
|
||||
|
||||
.venv
|
||||
|
||||
.pytest_logs.log
|
||||
|
||||
# Build NPM
|
||||
node_modules
|
||||
custom_components/auth_oidc/static/style.css
|
||||
@@ -106,7 +106,7 @@ source-roots=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
#suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
|
||||
50
.pysentry.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
version = 1
|
||||
|
||||
[defaults]
|
||||
format = "human"
|
||||
severity = "low"
|
||||
fail_on = "medium"
|
||||
scope = "main"
|
||||
direct_only = false
|
||||
detailed = false
|
||||
include_withdrawn = false
|
||||
no_ci_detect = false
|
||||
|
||||
[sources]
|
||||
enabled = [
|
||||
"pypa",
|
||||
"pypi",
|
||||
"osv",
|
||||
]
|
||||
|
||||
[resolver]
|
||||
type = "uv"
|
||||
|
||||
[cache]
|
||||
enabled = true
|
||||
resolution_ttl = 24
|
||||
vulnerability_ttl = 48
|
||||
|
||||
[ignore]
|
||||
ids = []
|
||||
while_no_fix = []
|
||||
|
||||
[http]
|
||||
timeout = 120
|
||||
connect_timeout = 30
|
||||
max_retries = 3
|
||||
retry_initial_backoff = 1
|
||||
retry_max_backoff = 60
|
||||
show_progress = true
|
||||
|
||||
[maintenance]
|
||||
enabled = true
|
||||
forbid_archived = false
|
||||
forbid_deprecated = false
|
||||
forbid_quarantined = false
|
||||
forbid_unmaintained = false
|
||||
check_direct_only = false
|
||||
cache_ttl = 1
|
||||
|
||||
[notifications]
|
||||
enabled = true
|
||||
@@ -1 +1 @@
|
||||
3.13.1
|
||||
3.14.4
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@christiaangoossens.nl (email).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
68
CONTRIBUTING.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Contribution Guide
|
||||
Contibutions are very welcome!
|
||||
|
||||
## Non-code contributions
|
||||
If you are not a programmer, you can still contribute by:
|
||||
|
||||
- Adding discussion items over at the [Discussions page](https://github.com/christiaangoossens/hass-oidc-auth/discussions) if you have a question, feature idea or a setup you would like to show off.
|
||||
- Helping others in issues and discussion posts.
|
||||
- Voting on polls and providing input.
|
||||
- If you want to, contributing financially through [Github Sponsors](https://github.com/sponsors/christiaangoossens)
|
||||
|
||||
## Code contributions
|
||||
You may also submit Pull Requests (PRs) to add features yourself! You can find TODOs to work on in the [Issue Tracker](https://github.com/christiaangoossens/hass-oidc-auth/issues), the [Feature Requests](https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/ideas) and in the [FAQ](./docs/faq.md).
|
||||
|
||||
|
||||
Please note that workflows will be run on your pull request (linting, tests, security audit) and a pull request will only be merged when all checks pass and a review has been conducted (together with a manual test).
|
||||
|
||||
### Development
|
||||
This project uses the uv package manager for development. You can find installation instructions here: https://docs.astral.sh/uv/getting-started/installation/. Start by installing the dependencies using `uv sync` and then point your editor towards the environment created in the .venv directory.
|
||||
You can then run Home Assistant and put the `custom_components/auth_oidc` directory in your HA `config` folder.
|
||||
|
||||
#### Other useful commands
|
||||
Some useful scripts are in the `scripts` directory. If you run Linux (or WSL under Windows), you can run these directly:
|
||||
|
||||
- `scripts/check` will check your Python files for linting errors
|
||||
- `scripts/fix` will fix some formatting mistakes automatically
|
||||
- `scripts/test` will run the testing suite
|
||||
- `scripts/coverage-report` will run the testing suite and generate a code coverage report (and runs a webserver to serve the report)
|
||||
|
||||
You can also run these commands manually on Windows:
|
||||
|
||||
##### Compiling css
|
||||
|
||||
To compile tailwind css styles for the pages you need the NodeJS and NPM installed.
|
||||
|
||||
You can run the `npm run css` script to generate the css once and you can run the `npm run css:watch` to recompile the css every time the templates change
|
||||
|
||||
##### Check
|
||||
```
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
uv run pylint custom_components
|
||||
```
|
||||
|
||||
##### Fix
|
||||
```
|
||||
uv run ruff check --fix
|
||||
uv run ruff format
|
||||
```
|
||||
|
||||
### Docker Compose Development Environment
|
||||
You can also use the following Docker Compose configuration to automatically start up the latest HA release with the `auth_oidc` integration:
|
||||
|
||||
```
|
||||
services:
|
||||
homeassistant:
|
||||
container_name: homeassistant
|
||||
image: "ghcr.io/home-assistant/home-assistant:stable"
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- ./custom_components/auth_oidc:/config/custom_components/auth_oidc
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- 8123:8123
|
||||
```
|
||||
|
||||
# Found a security issue?
|
||||
Please see [SECURITY.md](./SECURITY.md) for more information on how to submit your security issue securely. You can find previously found vulnerablities and their corresponding security advisories at the [Security Advisories page](https://github.com/christiaangoossens/hass-oidc-auth/security/advisories).
|
||||
1
FUNDING.yml
Normal file
@@ -0,0 +1 @@
|
||||
github: christiaangoossens
|
||||
194
README.md
@@ -1,120 +1,114 @@
|
||||
# OIDC Auth for Home Assistant
|
||||
<!-- Based on the Best-README-template from https://github.com/christiaangoossens/hass-oidc-auth -->
|
||||
<a id="readme-top"></a>
|
||||
|
||||
> [!CAUTION]
|
||||
> This is an alpha release. I give no guarantees about code quality, error handling or security at this stage. Use at your own risk.
|
||||
<div align="center">
|
||||
|
||||
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.
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![MIT License][license-shield]][license-url]
|
||||
|
||||
### Background
|
||||
If you would like to read the background/open letter that lead to this component, please see 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.
|
||||
</div>
|
||||
|
||||
## How to use
|
||||
### Installation
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/">
|
||||
<img src="./docs/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
Add this repository to [HACS](https://hacs.xyz/).
|
||||
<h3 align="center">OpenID Connect for Home Assistant</h3>
|
||||
|
||||
<p align="center">
|
||||
OpenID Connect (OIDC) implementation for Home Assistant through a custom component/integration,<br/>with a strong focus on <b>security, stability and accessibility.</b>
|
||||
<br />
|
||||
<br />
|
||||
<a href="./docs/configuration.md">YAML Configuration Guide</a>
|
||||
·
|
||||
<a href="./CONTRIBUTING.md">Contribution Guide</a>
|
||||
·
|
||||
<a href="./docs/faq.md">Frequently Asked Questions (FAQ)</a>
|
||||
<br />
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions?discussions_q=is%3Aopen+category%3AAnnouncements+category%3APolls">Announcements & Polls</a>
|
||||
·
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/issues">Issues</a>
|
||||
·
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/q-a">Questions</a>
|
||||
·
|
||||
<a href="https://github.com/christiaangoossens/hass-oidc-auth/discussions/categories/ideas">Feature Requests</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
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.*
|
||||
|
||||
If you are deciding if this integration is the right fit for your setup, please see the [Frequently Asked Questions (FAQ)](./docs/faq.md) for more information.
|
||||
|
||||
|
||||
## Installation guide
|
||||
|
||||
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:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
|
||||
|
||||
Update your `configuration.yaml` file with
|
||||
Next, set up your OIDC provider. You can find setup guides for common providers here:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: ""
|
||||
discovery_url: ""
|
||||
```
|
||||
| <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) |
|
||||
|
||||
Register your client with your OIDC Provider (e.g. Authentik/Authelia) as a public client and get the client_id. Then, use the obtained client_id and discovery URLs to fill the fields in `configuration.yaml`.
|
||||
You can also find additional provider guides in the [the Provider Configurations folder](./docs/provider-configurations). If your provider isn't specified, you can use either a **public client** (recommended) or **confidential client** with the callback URL set to `<your HA URL>/auth/oidc/callback`.
|
||||
|
||||
For example:
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "someValueForTheClientId"
|
||||
discovery_url: "https://example.com/application/o/application/.well-known/openid-configuration"
|
||||
```
|
||||
Finally, choose your preferred configuration style (UI or YAML). After configuration, you should automatically be sent to the OIDC login page(s) if you open Home Assistant (web or app).
|
||||
|
||||
Afterwards, restart Home Assistant.
|
||||
### Configuration in the HA UI
|
||||
|
||||
You can find all possible configuration options below.
|
||||
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI.
|
||||
|
||||
### Login
|
||||
You should now be able to see a second option on your login screen ("OpenID Connect (SSO)"). It provides you with a single input field.
|
||||
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.
|
||||
|
||||
To start, go to one of to one of these URLs (you may also set these as application URLs in your OIDC Provider):
|
||||
- `/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)
|
||||
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.
|
||||
|
||||
So, for example, you may start at http://homeassistant.local:8123/auth/oidc/welcome.
|
||||

|
||||
|
||||
> [!TIP]
|
||||
> You can use a different device to login instead. Open the `/auth/oidc/welcome` link on device A and then type the obtained code into the normal HA login on device B (can also be the mobile app) to login.
|
||||
### Configuration by YAML
|
||||
|
||||
> [!TIP]
|
||||
> 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 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.
|
||||
Alternatively, you can configure the integration using YAML. You can find a full configuration guide for YAML here: [YAML Configuration Guide](./docs/configuration.md).
|
||||
|
||||
## Contributions
|
||||
Contributions are very welcome! If you program in Python or have worked with Home Assistant integrations before, please try to contribute. You can find more information in the [Contribution Guide](./CONTRIBUTING.md).
|
||||
|
||||
### Security issue?
|
||||
Please see [SECURITY.md](./SECURITY.md) for more information on how to submit your security issue securely. You can find previously found vulnerabilities and their corresponding security advisories at the [Security Advisories page](https://github.com/christiaangoossens/hass-oidc-auth/security/advisories).
|
||||
|
||||
## Background
|
||||
If you would like to read the background/open letter that led to this component, you can find it at https://github.com/orgs/home-assistant/discussions/48. It is currently one of the most upvoted feature requests for Home Assistant.
|
||||
|
||||
## License
|
||||
Distributed under the MIT license with no warranty. You are fully liable for configuring this integration correctly to keep your Home Assistant installation secure. Use at your own risk. The full license can be found in [LICENSE.md](./LICENSE.md)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Required | Default | Description |
|
||||
|-----------------------------|----------|----------|----------------------|---------------------------------------------------------------------------------------------------------|
|
||||
| `client_id` | `string` | Yes | | The Client ID as registered with your OpenID Connect provider. |
|
||||
| `client_secret` | `string` | No | | The Client Secret for enabling confidential client mode. |
|
||||
| `discovery_url` | `string` | Yes | | The OIDC well-known configuration URL. |
|
||||
| `display_name` | `string` | No | `"OpenID Connect (SSO)"` | The name to display on the login screen, both for the Home Assistant screen and the OIDC welcome screen. |
|
||||
| `id_token_signing_alg` | `string` | No | `RS256` | The signing algorithm that is used for your id_tokens.
|
||||
| `features.automatic_user_linking` | `boolean`| No | `false` | Automatically links users to existing Home Assistant users based on the OIDC username claim. Disabled by default for security. When disabled, OIDC users will get their own new user profile upon first login. |
|
||||
| `features.automatic_person_creation` | `boolean` | No | `true` | Automatically creates a person entry for new user profiles created by this integration. Recommended if you would like to assign presence detection to OIDC users. |
|
||||
| `features.disable_rfc7636` | `boolean`| No | `false` | Disables PKCE (RFC 7636) for OIDC providers that don't support it. You should not need this with most providers. |
|
||||
| `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name.
|
||||
| `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username.
|
||||
| `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). |
|
||||
| `roles.admin` | `string` | No | `admins` | Group name to require for users to get the 'admin' role in Home Assistant. Defaults to 'admins', the default group name for admins in Authentik. Doesn't do anything if no groups claim is found in your token. |
|
||||
| `roles.user` | `string` | No | | Group name to require for users to get the 'user' role in Home Assistant. Defaults to giving all users this role, unless configured. |
|
||||
|
||||
#### Example: Migrating from HA username/password users to OIDC users
|
||||
If you already have users created within Home Assistant and would like to re-use the current user profile for your OIDC login, you can (temporarily) enable `features.automatic_user_linking`, with the following config (example):
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "someValueForTheClientId"
|
||||
discovery_url: "https://example.com/application/o/application/.well-known/openid-configuration"
|
||||
features:
|
||||
automatic_user_linking: true
|
||||
```
|
||||
|
||||
Upon login, OIDC users will then automatically be linked to the HA user with the same username.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> It's recommended to only enable this temporarily as it may pose a security risk. Any OIDC user with a username corresponding to a user in Home Assistant can get access to that user, and it's existing rights (admin), even if MFA is currently enabled for that account. After you have migrated your users (and linked OIDC to all existing accounts) you can disable the feature and keep using the linked users.
|
||||
|
||||
## Development
|
||||
This project uses the Rye package manager for development. You can find installation instructions here: https://rye.astral.sh/guide/installation/.
|
||||
Start by installing the dependencies using `rye sync` and then point your editor towards the environment created in the `.venv` directory.
|
||||
|
||||
### Help wanted
|
||||
If you have any tips or would like to contribute, send me a message. You are also welcome to contribute a PR to fix any of the TODOs.
|
||||
|
||||
Currently, this is a pre-alpha, so I welcome issues but I cannot guarantee I can fix them (at least within a reasonable time). Please turn on watch for this repository to remain updated. When the component is in a beta stage, issues will likely get fixed more frequently.
|
||||
|
||||
### TODOs
|
||||
|
||||
- [X] Basic flow
|
||||
- [X] Implement a final link back to the main page from the finish page
|
||||
- [X] Improve welcome screen UI, should render a simple centered Tailwind UI instructing users that you should login externally to obtain a code.
|
||||
- [X] Improve finish screen UI, showing the code clearly with instructions to paste it into Home Assistant.
|
||||
- [X] Implement error handling on top of this proof of concept (discovery, JWKS, OIDC)
|
||||
- [X] Make id_token claim used for the group (admin/user) configurable
|
||||
- [X] Make id_token claim used for the username configurable
|
||||
- [X] Make id_token claim used for the name configurable
|
||||
- [ ] Add instructions on how to deploy this with Authentik & Authelia
|
||||
- [X] Configure Github Actions to automatically lint and build the package
|
||||
- [ ] Configure Dependabot for automatic updates
|
||||
- [ ] Configure tests
|
||||
- [ ] Consider use of setup UI instead of YAML (see https://github.com/christiaangoossens/hass-oidc-auth/discussions/6)
|
||||
|
||||
Currently waiting on HA feature additions:
|
||||
|
||||
- [ ] Update the HA frontend code to allow a redirection to be requested from an auth provider instead of manually opening welcome page (possibly after https://github.com/home-assistant/frontend/pull/23204)
|
||||
- [ ] Implement this redirection logic to open a new tab on desktop (#23204 uses popup)
|
||||
- [ ] Implement this redirection logic to open a Android Custom Tab (Android) / SFSafariViewController (iOS), instead of opening the link in the HA webview
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
|
||||
[contributors-url]: https://github.com/christiaangoossens/hass-oidc-auth/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/github/forks/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
|
||||
[forks-url]: https://github.com/christiaangoossens/hass-oidc-auth/network/members
|
||||
[stars-shield]: https://img.shields.io/github/stars/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
|
||||
[stars-url]: https://github.com/christiaangoossens/hass-oidc-auth/stargazers
|
||||
[issues-shield]: https://img.shields.io/github/issues/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
|
||||
[issues-url]: https://github.com/christiaangoossens/hass-oidc-auth/issues
|
||||
[license-shield]: https://img.shields.io/github/license/christiaangoossens/hass-oidc-auth.svg?style=for-the-badge
|
||||
[license-url]: https://github.com/christiaangoossens/hass-oidc-auth/blob/main/LICENSE.md
|
||||
|
||||
15
SECURITY.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Reporting Security Issues
|
||||
|
||||
With the nature of the integration, security issues and bugs are taken very seriously. I appreciate your efforts to responsibly disclose your findings and I will acknowledge your finding in the security advisory and release notes of the release that fixes your vulnerability. Together, we will keep the Home Assistant community safe.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/christiaangoossens/hass-oidc-auth/security/advisories/new) tab. **Do not make a public issue for your security vulnerability!**
|
||||
|
||||
I (@christiaangoossens) will review security advisories regularly and send you a response indicating next steps in handling your report. This might include fixing the vulnerability before disclosing its nature, or working together in a private branch on a fix.
|
||||
|
||||
Please note that this repository is maintained on a volunteer basis, I will try to respond quickly, but no guarantees.
|
||||
|
||||
If your bug has to do with a third party package, please have it fixed there first, such that we can include a fixed version in an update of hass-oidc-auth.
|
||||
If you found a security vulnerability in Home Assistant itself, please report it at https://www.home-assistant.io/security/
|
||||
|
||||
## Non qualifying vulnerabities
|
||||
Some vulnerabilities do not qualify for fixing in a security patch. The Home Assistant team has made a list of them over at https://www.home-assistant.io/security/#non-qualifying-vulnerabilities.
|
||||
@@ -1,14 +1,19 @@
|
||||
"""OIDC Integration for Home Assistant."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import OrderedDict
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
# Import and re-export config schema explictly
|
||||
# pylint: disable=useless-import-alias
|
||||
from .config import CONFIG_SCHEMA as CONFIG_SCHEMA
|
||||
|
||||
# Get all the constants for the config
|
||||
from .config import (
|
||||
CONFIG_SCHEMA as CONFIG_SCHEMA,
|
||||
DOMAIN,
|
||||
DEFAULT_TITLE,
|
||||
CLIENT_ID,
|
||||
@@ -16,28 +21,77 @@ from .config import (
|
||||
DISCOVERY_URL,
|
||||
DISPLAY_NAME,
|
||||
ID_TOKEN_SIGNING_ALGORITHM,
|
||||
GROUPS_SCOPE,
|
||||
ADDITIONAL_SCOPES,
|
||||
FEATURES,
|
||||
CLAIMS,
|
||||
ROLES,
|
||||
NETWORK,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE,
|
||||
FEATURES_DEFAULT_REDIRECT,
|
||||
FEATURES_FORCE_HTTPS,
|
||||
REQUIRED_SCOPES,
|
||||
)
|
||||
|
||||
# pylint: enable=useless-import-alias
|
||||
from .config import convert_ui_config_entry_to_internal_format
|
||||
|
||||
from .endpoints.welcome import OIDCWelcomeView
|
||||
from .endpoints.redirect import OIDCRedirectView
|
||||
from .endpoints.finish import OIDCFinishView
|
||||
from .endpoints.callback import OIDCCallbackView
|
||||
|
||||
from .oidc_client import OIDCClient
|
||||
from .endpoints import (
|
||||
OIDCWelcomeView,
|
||||
OIDCRedirectView,
|
||||
OIDCFinishView,
|
||||
OIDCCallbackView,
|
||||
OIDCInjectedAuthPage,
|
||||
OIDCDeviceSSE,
|
||||
)
|
||||
from .tools.oidc_client import OIDCClient
|
||||
from .tools.types import OIDCWelcomeOptions
|
||||
from .provider import OpenIDAuthProvider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config):
|
||||
"""Add the OIDC Auth Provider to the providers in Home Assistant"""
|
||||
"""Add the OIDC Auth Provider to the providers in Home Assistant (YAML config)."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
my_config = config[DOMAIN]
|
||||
|
||||
# Store YAML config for later access by config flow
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data[DOMAIN]["yaml_config"] = my_config
|
||||
|
||||
await _setup_oidc_provider(
|
||||
hass, my_config, config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up OIDC Authentication from a config entry (UI config)."""
|
||||
# Convert config entry data to the format expected by the existing setup
|
||||
config_data = entry.data.copy()
|
||||
|
||||
# Convert config entry format to internal format
|
||||
my_config = convert_ui_config_entry_to_internal_format(config_data)
|
||||
|
||||
# Get display name from config entry
|
||||
display_name = config_data.get("display_name", DEFAULT_TITLE)
|
||||
|
||||
await _setup_oidc_provider(hass, my_config, display_name)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
# OIDC auth providers cannot be easily unloaded as they are integrated
|
||||
# into Home Assistant's auth system. A restart is required.
|
||||
return False
|
||||
|
||||
|
||||
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||
"""Set up the OIDC provider with the given configuration."""
|
||||
providers = OrderedDict()
|
||||
|
||||
# Use private APIs until there is a real auth platform
|
||||
@@ -45,16 +99,39 @@ async def async_setup(hass: HomeAssistant, config):
|
||||
provider = OpenIDAuthProvider(hass, hass.auth._store, my_config)
|
||||
|
||||
providers[(provider.type, provider.id)] = provider
|
||||
|
||||
# Get current provider count
|
||||
has_other_auth_providers = len(hass.auth._providers) > 0
|
||||
|
||||
providers.update(hass.auth._providers)
|
||||
hass.auth._providers = providers
|
||||
# pylint: enable=protected-access
|
||||
|
||||
_LOGGER.info("Registered OIDC provider")
|
||||
|
||||
# We only use openid, profile & groups, never email
|
||||
scope = "openid profile groups"
|
||||
# Set the correct scopes
|
||||
# Always use 'openid' & 'profile' as they are specified in the OIDC spec
|
||||
# All servers should support this
|
||||
scope = REQUIRED_SCOPES
|
||||
|
||||
oidc_client = oidc_client = OIDCClient(
|
||||
# Include groups if requested (default is to include 'groups'
|
||||
# as a scope for Authelia & Authentik)
|
||||
features_config = my_config.get(FEATURES, {})
|
||||
include_groups_scope = features_config.get(FEATURES_INCLUDE_GROUPS_SCOPE, True)
|
||||
groups_scope = my_config.get(GROUPS_SCOPE, "groups")
|
||||
if include_groups_scope:
|
||||
scope += " " + groups_scope
|
||||
# Add additional scopes if configured
|
||||
additional_scopes = my_config.get(ADDITIONAL_SCOPES, [])
|
||||
if additional_scopes:
|
||||
# Ensure we have a space before adding additional scopes
|
||||
if scope:
|
||||
scope += " "
|
||||
scope += " ".join(additional_scopes)
|
||||
|
||||
# Create the OIDC client
|
||||
oidc_client = OIDCClient(
|
||||
hass=hass,
|
||||
discovery_url=my_config.get(DISCOVERY_URL),
|
||||
client_id=my_config.get(CLIENT_ID),
|
||||
scope=scope,
|
||||
@@ -63,16 +140,45 @@ async def async_setup(hass: HomeAssistant, config):
|
||||
features=my_config.get(FEATURES, {}),
|
||||
claims=my_config.get(CLAIMS, {}),
|
||||
roles=my_config.get(ROLES, {}),
|
||||
network=my_config.get(NETWORK, {}),
|
||||
)
|
||||
|
||||
# Register the views
|
||||
name = config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
||||
name = display_name
|
||||
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
|
||||
|
||||
hass.http.register_view(OIDCWelcomeView(name))
|
||||
hass.http.register_view(OIDCRedirectView(oidc_client))
|
||||
hass.http.register_view(OIDCCallbackView(oidc_client, provider))
|
||||
hass.http.register_view(OIDCFinishView())
|
||||
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,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
hass.http.register_view(
|
||||
OIDCWelcomeView(
|
||||
provider,
|
||||
OIDCWelcomeOptions(
|
||||
name=name,
|
||||
force_https=force_https,
|
||||
has_other_auth_providers=has_other_auth_providers,
|
||||
prefers_skipping=default_redirect,
|
||||
),
|
||||
)
|
||||
)
|
||||
hass.http.register_view(OIDCDeviceSSE(provider))
|
||||
hass.http.register_view(OIDCRedirectView(oidc_client, provider, force_https))
|
||||
hass.http.register_view(OIDCCallbackView(oidc_client, provider, force_https))
|
||||
hass.http.register_view(OIDCFinishView(provider))
|
||||
|
||||
_LOGGER.info("Registered OIDC views")
|
||||
|
||||
# Inject OIDC code into the frontend for /auth/authorize for automatic redirect
|
||||
await OIDCInjectedAuthPage.inject(hass, force_https)
|
||||
|
||||
return True
|
||||
|
||||
BIN
custom_components/auth_oidc/brand/icon.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
custom_components/auth_oidc/brand/icon@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
8
custom_components/auth_oidc/config/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Imports manager"""
|
||||
|
||||
from .const import * # noqa: F403
|
||||
from .schema import CONFIG_SCHEMA as CONFIG_SCHEMA
|
||||
from .ui_flow import (
|
||||
OIDCConfigFlow as OIDCConfigFlow,
|
||||
convert_ui_config_entry_to_internal_format as convert_ui_config_entry_to_internal_format,
|
||||
)
|
||||
92
custom_components/auth_oidc/config/const.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Config constants."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
## ===
|
||||
## General integration constants
|
||||
## ===
|
||||
|
||||
DEFAULT_TITLE = "OpenID Connect (SSO)"
|
||||
DOMAIN = "auth_oidc"
|
||||
REPO_ROOT_URL = "https://github.com/christiaangoossens/hass-oidc-auth/tree/v1.0.1"
|
||||
|
||||
## ===
|
||||
## Config keys
|
||||
## ===
|
||||
|
||||
CLIENT_ID = "client_id"
|
||||
CLIENT_SECRET = "client_secret"
|
||||
DISCOVERY_URL = "discovery_url"
|
||||
DISPLAY_NAME = "display_name"
|
||||
ID_TOKEN_SIGNING_ALGORITHM = "id_token_signing_alg"
|
||||
GROUPS_SCOPE = "groups_scope"
|
||||
ADDITIONAL_SCOPES = "additional_scopes"
|
||||
FEATURES = "features"
|
||||
FEATURES_AUTOMATIC_USER_LINKING = "automatic_user_linking"
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
|
||||
FEATURES_DISABLE_PKCE = "disable_rfc7636"
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE = "include_groups_scope"
|
||||
FEATURES_FORCE_HTTPS = "force_https"
|
||||
FEATURES_DEFAULT_REDIRECT = "default_redirect"
|
||||
CLAIMS = "claims"
|
||||
CLAIMS_DISPLAY_NAME = "display_name"
|
||||
CLAIMS_USERNAME = "username"
|
||||
CLAIMS_GROUPS = "groups"
|
||||
ROLES = "roles"
|
||||
ROLE_ADMINS = "admin"
|
||||
ROLE_USERS = "user"
|
||||
NETWORK = "network"
|
||||
NETWORK_TLS_VERIFY = "tls_verify"
|
||||
NETWORK_TLS_CA_PATH = "tls_ca_path"
|
||||
|
||||
## ===
|
||||
## Default configurations for providers
|
||||
## ===
|
||||
|
||||
REQUIRED_SCOPES = "openid profile"
|
||||
DEFAULT_ID_TOKEN_SIGNING_ALGORITHM = "RS256"
|
||||
|
||||
DEFAULT_GROUPS_SCOPE = "groups"
|
||||
DEFAULT_ADMIN_GROUP = "admins"
|
||||
|
||||
OIDC_PROVIDERS: Dict[str, Dict[str, Any]] = {
|
||||
"authentik": {
|
||||
"name": "Authentik",
|
||||
"discovery_url": "",
|
||||
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||
"supports_groups": True,
|
||||
"claims": {
|
||||
"display_name": "name",
|
||||
"username": "preferred_username",
|
||||
"groups": "groups",
|
||||
},
|
||||
},
|
||||
"authelia": {
|
||||
"name": "Authelia",
|
||||
"discovery_url": "",
|
||||
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||
"supports_groups": True,
|
||||
"claims": {
|
||||
"display_name": "name",
|
||||
"username": "preferred_username",
|
||||
"groups": "groups",
|
||||
},
|
||||
},
|
||||
"pocketid": {
|
||||
"name": "Pocket ID",
|
||||
"discovery_url": "",
|
||||
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||
"supports_groups": True,
|
||||
"claims": {
|
||||
"display_name": "name",
|
||||
"username": "preferred_username",
|
||||
"groups": "groups",
|
||||
},
|
||||
},
|
||||
"generic": {
|
||||
"name": "OpenID Connect (SSO)",
|
||||
"discovery_url": "",
|
||||
"supports_groups": False,
|
||||
"claims": {"display_name": "name", "username": "preferred_username"},
|
||||
},
|
||||
}
|
||||
35
custom_components/auth_oidc/config/provider_catalog.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Provider catalog and helpers for OIDC providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Any, Dict
|
||||
|
||||
from .const import OIDC_PROVIDERS, REPO_ROOT_URL
|
||||
|
||||
|
||||
def get_provider_config(key: str) -> Dict[str, Any]:
|
||||
"""Return provider configuration by key."""
|
||||
return OIDC_PROVIDERS.get(key, {})
|
||||
|
||||
|
||||
def get_provider_name(key: str | None) -> str:
|
||||
"""Return provider display name by key."""
|
||||
if not key:
|
||||
return "Unknown Provider"
|
||||
return OIDC_PROVIDERS.get(key, {}).get("name", "Unknown Provider")
|
||||
|
||||
|
||||
def get_provider_docs_url(key: str | None) -> str:
|
||||
"""Return documentation URL for a provider key."""
|
||||
base_url = REPO_ROOT_URL + "/docs/provider-configurations"
|
||||
|
||||
provider_docs = {
|
||||
"authentik": f"{base_url}/authentik.md",
|
||||
"authelia": f"{base_url}/authelia.md",
|
||||
"pocketid": f"{base_url}/pocket-id.md",
|
||||
"kanidm": f"{base_url}/kanidm.md",
|
||||
"microsoft": f"{base_url}/microsoft-entra.md",
|
||||
}
|
||||
|
||||
if key in provider_docs:
|
||||
return provider_docs[key]
|
||||
return REPO_ROOT_URL + "/docs/configuration.md"
|
||||
@@ -1,27 +1,35 @@
|
||||
"""Config schema and constants."""
|
||||
"""Config schema"""
|
||||
|
||||
import voluptuous as vol
|
||||
from .const import (
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
DISCOVERY_URL,
|
||||
DISPLAY_NAME,
|
||||
ID_TOKEN_SIGNING_ALGORITHM,
|
||||
GROUPS_SCOPE,
|
||||
ADDITIONAL_SCOPES,
|
||||
FEATURES,
|
||||
FEATURES_AUTOMATIC_USER_LINKING,
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION,
|
||||
FEATURES_DISABLE_PKCE,
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE,
|
||||
FEATURES_FORCE_HTTPS,
|
||||
FEATURES_DEFAULT_REDIRECT,
|
||||
CLAIMS,
|
||||
CLAIMS_DISPLAY_NAME,
|
||||
CLAIMS_USERNAME,
|
||||
CLAIMS_GROUPS,
|
||||
ROLES,
|
||||
ROLE_ADMINS,
|
||||
ROLE_USERS,
|
||||
NETWORK,
|
||||
NETWORK_TLS_VERIFY,
|
||||
NETWORK_TLS_CA_PATH,
|
||||
DOMAIN,
|
||||
DEFAULT_GROUPS_SCOPE,
|
||||
)
|
||||
|
||||
CLIENT_ID = "client_id"
|
||||
CLIENT_SECRET = "client_secret"
|
||||
DISCOVERY_URL = "discovery_url"
|
||||
DISPLAY_NAME = "display_name"
|
||||
ID_TOKEN_SIGNING_ALGORITHM = "id_token_signing_alg"
|
||||
FEATURES = "features"
|
||||
FEATURES_AUTOMATIC_USER_LINKING = "automatic_user_linking"
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION = "automatic_person_creation"
|
||||
FEATURES_DISABLE_PKCE = "disable_rfc7636"
|
||||
CLAIMS = "claims"
|
||||
CLAIMS_DISPLAY_NAME = "display_name"
|
||||
CLAIMS_USERNAME = "username"
|
||||
CLAIMS_GROUPS = "groups"
|
||||
ROLES = "roles"
|
||||
ROLE_ADMINS = "admin"
|
||||
ROLE_USERS = "user"
|
||||
|
||||
DEFAULT_TITLE = "OpenID Connect (SSO)"
|
||||
|
||||
DOMAIN = "auth_oidc"
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
@@ -37,6 +45,14 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
# Should we enforce a specific signing algorithm on the id tokens?
|
||||
# Defaults to RS256/RSA-pubkey
|
||||
vol.Optional(ID_TOKEN_SIGNING_ALGORITHM): vol.Coerce(str),
|
||||
# String value to allow changing the groups scope
|
||||
# Defaults to 'groups' which is used by Authelia and Authentik
|
||||
vol.Optional(GROUPS_SCOPE, default=DEFAULT_GROUPS_SCOPE): vol.Coerce(
|
||||
str
|
||||
),
|
||||
# Additional scopes to request from the OIDC provider
|
||||
# Optional, this field is unnecessary if you only use the openid and profile scopes.
|
||||
vol.Optional(ADDITIONAL_SCOPES, default=[]): vol.Coerce(list[str]),
|
||||
# Which features should be enabled/disabled?
|
||||
# Optional, defaults to sane/secure defaults
|
||||
vol.Optional(FEATURES): vol.Schema(
|
||||
@@ -52,6 +68,21 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
# Feature flag to disable PKCE to support OIDC servers that do not
|
||||
# allow additional parameters and don't support RFC 7636
|
||||
vol.Optional(FEATURES_DISABLE_PKCE): vol.Coerce(bool),
|
||||
# Boolean which activates and deactivates scope 'groups'
|
||||
vol.Optional(
|
||||
FEATURES_INCLUDE_GROUPS_SCOPE, default=True
|
||||
): vol.Coerce(bool),
|
||||
# Force HTTPS on all generated URLs (like redirect_uri)
|
||||
vol.Optional(FEATURES_FORCE_HTTPS, default=False): vol.Coerce(
|
||||
bool
|
||||
),
|
||||
# Welcome page will be skipped automatically if there are no
|
||||
# other auth providers.
|
||||
# This flag enables this behavior regardless of the amount
|
||||
# of other auth providers.
|
||||
vol.Optional(
|
||||
FEATURES_DEFAULT_REDIRECT, default=False
|
||||
): vol.Coerce(bool),
|
||||
}
|
||||
),
|
||||
# Determine which specific claims will be used from the id_token
|
||||
@@ -78,6 +109,17 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(ROLE_ADMINS): vol.Coerce(str),
|
||||
}
|
||||
),
|
||||
# Network options
|
||||
vol.Optional(NETWORK): vol.Schema(
|
||||
{
|
||||
# Verify x509 certificates provided when starting TLS connections
|
||||
vol.Optional(NETWORK_TLS_VERIFY, default=True): vol.Coerce(
|
||||
bool
|
||||
),
|
||||
# Load custom certificate chain for private CAs
|
||||
vol.Optional(NETWORK_TLS_CA_PATH): vol.Coerce(str),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
},
|
||||
839
custom_components/auth_oidc/config/ui_flow.py
Normal file
@@ -0,0 +1,839 @@
|
||||
"""Config flow for OIDC Authentication integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import aiohttp
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEFAULT_ADMIN_GROUP,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
DISCOVERY_URL,
|
||||
DISPLAY_NAME,
|
||||
FEATURES,
|
||||
CLAIMS,
|
||||
ROLES,
|
||||
DEFAULT_ID_TOKEN_SIGNING_ALGORITHM,
|
||||
)
|
||||
|
||||
from ..tools.oidc_client import (
|
||||
OIDCDiscoveryClient,
|
||||
OIDCDiscoveryInvalid,
|
||||
OIDCJWKSInvalid,
|
||||
)
|
||||
|
||||
from .provider_catalog import (
|
||||
OIDC_PROVIDERS,
|
||||
get_provider_name,
|
||||
get_provider_docs_url,
|
||||
)
|
||||
|
||||
from ..tools.validation import (
|
||||
validate_discovery_url,
|
||||
sanitize_client_secret,
|
||||
validate_client_id,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Configuration field names
|
||||
CONF_PROVIDER = "provider"
|
||||
CONF_CLIENT_ID = "client_id"
|
||||
CONF_CLIENT_SECRET = "client_secret"
|
||||
CONF_DISCOVERY_URL = "discovery_url"
|
||||
CONF_ENABLE_GROUPS = "enable_groups"
|
||||
CONF_ADMIN_GROUP = "admin_group"
|
||||
CONF_USER_GROUP = "user_group"
|
||||
CONF_ENABLE_USER_LINKING = "enable_user_linking"
|
||||
|
||||
# Cache settings
|
||||
DISCOVERY_CACHE_TTL = 300 # 5 minutes
|
||||
MAX_CACHE_SIZE = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowState:
|
||||
"""State tracking for the configuration flow."""
|
||||
|
||||
provider: str | None = None
|
||||
discovery_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientConfig:
|
||||
"""Client configuration settings."""
|
||||
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureConfig:
|
||||
"""Feature configuration settings."""
|
||||
|
||||
enable_groups: bool = False
|
||||
admin_group: str = DEFAULT_ADMIN_GROUP
|
||||
user_group: str | None = None
|
||||
enable_user_linking: bool = False
|
||||
|
||||
|
||||
class OIDCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OIDC Authentication."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def is_matching(self, other_flow):
|
||||
"""Check if this flow is the same as another flow."""
|
||||
self_state = getattr(self, "_flow_state", None)
|
||||
other_state = getattr(other_flow, "_flow_state", None)
|
||||
|
||||
if not self_state or not other_state:
|
||||
return False
|
||||
|
||||
self_discovery_url = self_state.discovery_url
|
||||
other_discovery_url = other_state.discovery_url
|
||||
|
||||
return (
|
||||
self_discovery_url
|
||||
and other_discovery_url
|
||||
and self_discovery_url.rstrip("/").lower()
|
||||
== other_discovery_url.rstrip("/").lower()
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the config flow."""
|
||||
self._flow_state = FlowState()
|
||||
self._client_config = ClientConfig()
|
||||
self._feature_config = FeatureConfig()
|
||||
self._discovery_cache = {}
|
||||
self._cache_timestamps = {}
|
||||
|
||||
@property
|
||||
def current_provider_config(self) -> dict[str, Any]:
|
||||
"""Get the configuration for the currently selected provider."""
|
||||
if not self._flow_state.provider:
|
||||
return {}
|
||||
return OIDC_PROVIDERS.get(self._flow_state.provider, {})
|
||||
|
||||
@property
|
||||
def current_provider_name(self) -> str:
|
||||
"""Get the name of the currently selected provider."""
|
||||
return get_provider_name(self._flow_state.provider)
|
||||
|
||||
def _cleanup_discovery_cache(self) -> None:
|
||||
"""Remove expired and excess cache entries."""
|
||||
current_time = time.time()
|
||||
|
||||
# Remove expired entries
|
||||
expired_keys = [
|
||||
key
|
||||
for key, timestamp in self._cache_timestamps.items()
|
||||
if current_time - timestamp > DISCOVERY_CACHE_TTL
|
||||
]
|
||||
for key in expired_keys:
|
||||
self._discovery_cache.pop(key, None)
|
||||
self._cache_timestamps.pop(key, None)
|
||||
|
||||
# Remove oldest entries if cache is too large
|
||||
if len(self._discovery_cache) > MAX_CACHE_SIZE:
|
||||
sorted_items = sorted(self._cache_timestamps.items(), key=lambda x: x[1])
|
||||
excess_count = len(self._discovery_cache) - MAX_CACHE_SIZE
|
||||
for key, _ in sorted_items[:excess_count]:
|
||||
self._discovery_cache.pop(key, None)
|
||||
self._cache_timestamps.pop(key, None)
|
||||
|
||||
def _is_cache_valid(self, cache_key: str) -> bool:
|
||||
"""Check if a cache entry is still valid."""
|
||||
if cache_key not in self._cache_timestamps:
|
||||
return False
|
||||
|
||||
age = time.time() - self._cache_timestamps[cache_key]
|
||||
return age <= DISCOVERY_CACHE_TTL
|
||||
|
||||
# =================
|
||||
# Step 1: Provider selection
|
||||
# =================
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step - provider selection."""
|
||||
# Check if OIDC is already configured (only one instance allowed)
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Check if YAML configuration exists
|
||||
if self.hass.data.get(DOMAIN, {}).get("yaml_config"):
|
||||
return self.async_abort(reason="yaml_configured")
|
||||
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._flow_state.provider = user_input[CONF_PROVIDER]
|
||||
|
||||
# If provider has a predefined discovery URL, prefill it but still
|
||||
# show the discovery URL step so the user can customize it.
|
||||
predefined = self.current_provider_config.get("discovery_url")
|
||||
if predefined:
|
||||
self._flow_state.discovery_url = predefined
|
||||
|
||||
# Always request discovery URL next (prefilled when available)
|
||||
return await self.async_step_discovery_url()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PROVIDER): vol.In(
|
||||
{key: provider["name"] for key, provider in OIDC_PROVIDERS.items()}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={},
|
||||
)
|
||||
|
||||
# =================
|
||||
# Step 2: Discovery URL
|
||||
# =================
|
||||
|
||||
async def async_step_discovery_url(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle discovery URL input for providers requiring URL configuration."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
discovery_url = user_input[CONF_DISCOVERY_URL].rstrip("/")
|
||||
|
||||
# Validate discovery URL format
|
||||
if not validate_discovery_url(discovery_url):
|
||||
errors["discovery_url"] = "invalid_url_format"
|
||||
else:
|
||||
self._flow_state.discovery_url = discovery_url
|
||||
return await self.async_step_validate_connection()
|
||||
|
||||
provider_name = self.current_provider_name
|
||||
provider_key = self._flow_state.provider
|
||||
|
||||
# Pre-populate with existing discovery URL if available
|
||||
default_url = (
|
||||
self._flow_state.discovery_url
|
||||
if self._flow_state.discovery_url
|
||||
else vol.UNDEFINED
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{vol.Required(CONF_DISCOVERY_URL, default=default_url): str}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_url",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"provider_name": provider_name,
|
||||
"documentation_url": get_provider_docs_url(provider_key),
|
||||
},
|
||||
)
|
||||
|
||||
# =================
|
||||
# Step 3: Discovery Validation
|
||||
# =================
|
||||
|
||||
async def _handle_validation_actions(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> FlowResult | None:
|
||||
"""Handle user actions from the validation form so they can fix errors."""
|
||||
action = user_input.get("action")
|
||||
|
||||
# Handle special actions first
|
||||
if action == "retry":
|
||||
return None # Continue with validation
|
||||
if action == "continue":
|
||||
return await self.async_step_client_config()
|
||||
|
||||
# Handle redirect actions
|
||||
action_handlers = {
|
||||
"fix_discovery": self.async_step_discovery_url,
|
||||
"change_provider": self.async_step_user,
|
||||
}
|
||||
|
||||
handler = action_handlers.get(action)
|
||||
return await handler() if handler else None
|
||||
|
||||
async def _perform_oidc_validation(self) -> tuple[dict, dict]:
|
||||
"""Perform the actual OIDC validation and return discovery doc and errors."""
|
||||
errors = {}
|
||||
discovery_doc = {}
|
||||
|
||||
try:
|
||||
http_session = aiohttp.ClientSession()
|
||||
discovery_client = OIDCDiscoveryClient(
|
||||
discovery_url=self._flow_state.discovery_url,
|
||||
http_session=http_session,
|
||||
verification_context={
|
||||
# Cannot be changed from the UI config currently
|
||||
"id_token_signing_alg": DEFAULT_ID_TOKEN_SIGNING_ALGORITHM,
|
||||
},
|
||||
)
|
||||
|
||||
# Clean up expired cache entries first
|
||||
self._cleanup_discovery_cache()
|
||||
|
||||
# Check if discovery document is already cached and valid
|
||||
cache_key = self._flow_state.discovery_url
|
||||
if cache_key in self._discovery_cache and self._is_cache_valid(cache_key):
|
||||
discovery_doc = self._discovery_cache[cache_key]
|
||||
|
||||
# Still validate JWKS if available since this might be a retry
|
||||
if "jwks_uri" in discovery_doc:
|
||||
await discovery_client.fetch_jwks(discovery_doc["jwks_uri"])
|
||||
else:
|
||||
# Perform discovery and JWKS validation
|
||||
discovery_doc = await discovery_client.fetch_discovery_document()
|
||||
|
||||
# Cache the discovery document with timestamp
|
||||
self._discovery_cache[cache_key] = discovery_doc
|
||||
self._cache_timestamps[cache_key] = time.time()
|
||||
|
||||
# Validate JWKS if available
|
||||
if "jwks_uri" in discovery_doc:
|
||||
await discovery_client.fetch_jwks(discovery_doc["jwks_uri"])
|
||||
|
||||
except OIDCDiscoveryInvalid as e:
|
||||
errors["base"] = "discovery_invalid"
|
||||
errors["detail_string"] = e.get_detail_string()
|
||||
except OIDCJWKSInvalid:
|
||||
errors["base"] = "jwks_invalid"
|
||||
except aiohttp.ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error during validation")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
await http_session.close()
|
||||
return discovery_doc, errors
|
||||
|
||||
def _get_action_options(self, has_errors: bool) -> dict[str, str]:
|
||||
"""Get action options based on validation state."""
|
||||
if has_errors:
|
||||
return {
|
||||
"retry": "Retry Validation",
|
||||
"fix_discovery": "Change Discovery URL",
|
||||
"change_provider": "Change Provider",
|
||||
}
|
||||
return {
|
||||
"continue": "Continue Setup",
|
||||
"fix_discovery": "Change Discovery URL",
|
||||
"change_provider": "Change Provider",
|
||||
}
|
||||
|
||||
def _build_discovery_success_details(self, discovery_doc: dict) -> str:
|
||||
"""Build success details from discovery document."""
|
||||
return (
|
||||
f"✅ Connected and verified succesfully!\n"
|
||||
f"_Discovered valid OIDC issuer: {discovery_doc['issuer']}_\n\n"
|
||||
)
|
||||
|
||||
def _build_error_details(self, errors: dict[str, str]) -> str:
|
||||
"""Build error details from validation errors."""
|
||||
|
||||
base = errors.get("base", "")
|
||||
detail_string = errors.get("detail_string", "")
|
||||
|
||||
error_messages = {
|
||||
"discovery_invalid": (
|
||||
"❌ **Discovery document could not be validated.**\n"
|
||||
"Please verify the discovery URL is correct and accessible.\n\n"
|
||||
f"_({detail_string})_"
|
||||
),
|
||||
"jwks_invalid": (
|
||||
"❌ **JWKS validation failed**\n"
|
||||
"The JSON Web Key Set could not be retrieved or validated."
|
||||
),
|
||||
"cannot_connect": (
|
||||
"❌ **Connection failed**\n"
|
||||
"Unable to connect to the OIDC provider. Check your network and URL."
|
||||
),
|
||||
}
|
||||
return error_messages.get(base, "")
|
||||
|
||||
async def _build_validation_form(
|
||||
self, errors: dict[str, str], discovery_doc: dict | None = None
|
||||
) -> FlowResult:
|
||||
"""Build the validation form with errors and action options."""
|
||||
action_options = self._get_action_options(bool(errors))
|
||||
data_schema = vol.Schema({vol.Required("action"): vol.In(action_options)})
|
||||
|
||||
# Build description with discovery details
|
||||
description_placeholders = {
|
||||
"discovery_url": self._flow_state.discovery_url,
|
||||
"provider_name": self.current_provider_name,
|
||||
"discovery_details": "",
|
||||
"documentation_url": get_provider_docs_url(self._flow_state.provider),
|
||||
}
|
||||
|
||||
# Add appropriate details based on validation state
|
||||
if discovery_doc and not errors:
|
||||
description_placeholders["discovery_details"] = (
|
||||
self._build_discovery_success_details(discovery_doc)
|
||||
)
|
||||
elif errors:
|
||||
description_placeholders["discovery_details"] = self._build_error_details(
|
||||
errors
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="validate_connection",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_validate_connection(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Validate the OIDC configuration by testing discovery and JWKS."""
|
||||
# Handle user actions from validation form
|
||||
if user_input is not None:
|
||||
action_result = await self._handle_validation_actions(user_input)
|
||||
if action_result is not None:
|
||||
return action_result
|
||||
|
||||
# Perform validation (either initial attempt or retry)
|
||||
discovery_doc, errors = await self._perform_oidc_validation()
|
||||
|
||||
# Always show validation form with results (success or error)
|
||||
return await self._build_validation_form(errors, discovery_doc)
|
||||
|
||||
# =================
|
||||
# Step 4: Configure client details (client_id & client_secret)
|
||||
# =================
|
||||
|
||||
async def _proceed_to_next_step_after_client_config(self) -> FlowResult:
|
||||
"""Proceed to next step after client config."""
|
||||
if self.current_provider_config.get("supports_groups", True):
|
||||
return await self.async_step_groups_config()
|
||||
return await self.async_step_user_linking()
|
||||
|
||||
async def async_step_client_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle client ID and client type selection."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
client_id = user_input[CONF_CLIENT_ID]
|
||||
|
||||
# Validate client ID
|
||||
if not validate_client_id(client_id):
|
||||
errors["client_id"] = "invalid_client_id"
|
||||
if not errors:
|
||||
self._client_config.client_id = client_id.strip()
|
||||
# Optional client secret determines confidential/public
|
||||
provided_secret = sanitize_client_secret(
|
||||
user_input.get(CONF_CLIENT_SECRET, "")
|
||||
)
|
||||
self._client_config.client_secret = provided_secret or None
|
||||
|
||||
if not errors:
|
||||
return await self._proceed_to_next_step_after_client_config()
|
||||
|
||||
provider_name = self.current_provider_name
|
||||
|
||||
# Pre-populate with existing values if available
|
||||
default_client_id = (
|
||||
self._client_config.client_id
|
||||
if self._client_config.client_id
|
||||
else vol.UNDEFINED
|
||||
)
|
||||
default_client_secret = (
|
||||
self._client_config.client_secret
|
||||
if self._client_config.client_secret
|
||||
else vol.UNDEFINED
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CLIENT_ID, default=default_client_id): str,
|
||||
vol.Optional(CONF_CLIENT_SECRET, default=default_client_secret): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="client_config",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"provider_name": provider_name,
|
||||
"discovery_url": self._flow_state.discovery_url,
|
||||
"documentation_url": get_provider_docs_url(self._flow_state.provider),
|
||||
},
|
||||
)
|
||||
|
||||
# =================
|
||||
# Step 5: Configure groups settings
|
||||
# =================
|
||||
|
||||
async def async_step_groups_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Configure groups and roles."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._feature_config.enable_groups = user_input.get(
|
||||
CONF_ENABLE_GROUPS, False
|
||||
)
|
||||
if self._feature_config.enable_groups:
|
||||
self._feature_config.admin_group = user_input.get(
|
||||
CONF_ADMIN_GROUP, "admins"
|
||||
)
|
||||
self._feature_config.user_group = user_input.get(CONF_USER_GROUP)
|
||||
|
||||
return await self.async_step_user_linking()
|
||||
|
||||
default_admin_group = self.current_provider_config.get(
|
||||
"default_admin_group", "admins"
|
||||
)
|
||||
|
||||
data_schema_dict = {vol.Optional(CONF_ENABLE_GROUPS, default=True): bool}
|
||||
|
||||
# Add group configuration fields if groups are enabled
|
||||
if user_input is None or user_input.get(CONF_ENABLE_GROUPS, True):
|
||||
data_schema_dict.update(
|
||||
{
|
||||
vol.Optional(CONF_ADMIN_GROUP, default=default_admin_group): str,
|
||||
vol.Optional(CONF_USER_GROUP): str,
|
||||
}
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(data_schema_dict)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="groups_config",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={"provider_name": self.current_provider_name},
|
||||
)
|
||||
|
||||
# =================
|
||||
# Step 6: Configure user linking
|
||||
# =================
|
||||
|
||||
async def async_step_user_linking(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Configure user linking options."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._feature_config.enable_user_linking = user_input.get(
|
||||
CONF_ENABLE_USER_LINKING, False
|
||||
)
|
||||
return await self.async_step_finalize()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{vol.Optional(CONF_ENABLE_USER_LINKING, default=False): bool}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user_linking",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={},
|
||||
)
|
||||
|
||||
# =================
|
||||
# Step 7: Finalize and create entry
|
||||
# =================
|
||||
|
||||
async def async_step_finalize(self) -> FlowResult:
|
||||
"""Finalize the configuration and create the config entry."""
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Build the configuration
|
||||
config_data = {
|
||||
"provider": self._flow_state.provider,
|
||||
"client_id": self._client_config.client_id,
|
||||
"discovery_url": self._flow_state.discovery_url,
|
||||
"display_name": f"{self.current_provider_name}",
|
||||
}
|
||||
|
||||
# Add optional fields
|
||||
if self._client_config.client_secret:
|
||||
config_data["client_secret"] = self._client_config.client_secret
|
||||
|
||||
# Configure features
|
||||
features = {
|
||||
"automatic_user_linking": self._feature_config.enable_user_linking,
|
||||
"automatic_person_creation": True,
|
||||
"include_groups_scope": self._feature_config.enable_groups,
|
||||
}
|
||||
config_data["features"] = features
|
||||
|
||||
# Configure claims using provider defaults
|
||||
claims = self.current_provider_config["claims"].copy()
|
||||
config_data["claims"] = claims
|
||||
|
||||
# Configure roles if groups are enabled
|
||||
if self._feature_config.enable_groups:
|
||||
roles = {}
|
||||
if self._feature_config.admin_group:
|
||||
roles["admin"] = self._feature_config.admin_group
|
||||
if self._feature_config.user_group:
|
||||
roles["user"] = self._feature_config.user_group
|
||||
config_data["roles"] = roles
|
||||
|
||||
title = f"{self.current_provider_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=config_data)
|
||||
|
||||
# =================
|
||||
# Allow reconfiguration of client ID and secret
|
||||
# =================
|
||||
|
||||
async def _validate_reconfigure_input(
|
||||
self, entry, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
"""Validate reconfigure input and return errors and data updates."""
|
||||
errors = {}
|
||||
|
||||
# Validate client ID
|
||||
client_id = user_input[CONF_CLIENT_ID].strip()
|
||||
if not validate_client_id(client_id):
|
||||
errors["client_id"] = "invalid_client_id"
|
||||
return errors, None
|
||||
|
||||
# Build updated data
|
||||
data_updates = {"client_id": client_id}
|
||||
|
||||
# The optional secret field is submitted explicitly when the form is used.
|
||||
# An empty value means the user wants to keep the existing secret.
|
||||
if CONF_CLIENT_SECRET in user_input:
|
||||
client_secret = user_input.get(CONF_CLIENT_SECRET, "").strip()
|
||||
|
||||
if client_secret:
|
||||
data_updates["client_secret"] = client_secret
|
||||
elif "client_secret" in entry.data:
|
||||
data_updates["client_secret"] = entry.data["client_secret"]
|
||||
|
||||
return errors, data_updates
|
||||
|
||||
def _build_reconfigure_schema(
|
||||
self, current_data: dict[str, Any], _user_input: dict[str, Any] | None
|
||||
) -> vol.Schema:
|
||||
"""Build the reconfigure form schema."""
|
||||
schema_dict = {
|
||||
vol.Required(
|
||||
CONF_CLIENT_ID, default=current_data.get("client_id", vol.UNDEFINED)
|
||||
): str,
|
||||
}
|
||||
|
||||
# Always allow updating or clearing the client secret
|
||||
schema_dict[vol.Optional(CONF_CLIENT_SECRET)] = str
|
||||
|
||||
return vol.Schema(schema_dict)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle reconfiguration of OIDC client credentials."""
|
||||
errors = {}
|
||||
entry = self._get_reconfigure_entry()
|
||||
if entry is None:
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
errors, data_updates = await self._validate_reconfigure_input(
|
||||
entry, user_input
|
||||
)
|
||||
|
||||
if not errors:
|
||||
# Update the config entry
|
||||
await self.async_set_unique_id(entry.unique_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
entry, data_updates=data_updates
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error during reconfiguration")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
# Show form
|
||||
current_data = entry.data
|
||||
data_schema = self._build_reconfigure_schema(current_data, user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"provider_name": get_provider_name(current_data.get("provider")),
|
||||
"discovery_url": current_data.get("discovery_url", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def _get_reconfigure_entry(self):
|
||||
"""Return the config entry being reconfigured if available.
|
||||
|
||||
Prefer the entry referenced by the flow context's entry_id. Fall back to the
|
||||
first existing entry for this domain when only a single instance is allowed.
|
||||
"""
|
||||
# Try from flow context (preferred)
|
||||
entry_id = None
|
||||
context = getattr(self, "context", None)
|
||||
if context and hasattr(context, "get"):
|
||||
entry_id = context.get("entry_id")
|
||||
|
||||
if entry_id:
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry and entry.domain == DOMAIN:
|
||||
return entry
|
||||
|
||||
# Fallback: this integration allows a single instance; use the first
|
||||
current = self._async_current_entries()
|
||||
if current:
|
||||
return current[0]
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return OIDCOptionsFlowHandler()
|
||||
|
||||
|
||||
class OIDCOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for OIDC Authentication."""
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
# Process the updated configuration
|
||||
updated_features = {
|
||||
"automatic_user_linking": user_input.get("enable_user_linking", False),
|
||||
"include_groups_scope": user_input.get("enable_groups", False),
|
||||
}
|
||||
|
||||
updated_roles = {}
|
||||
if user_input.get("enable_groups", False):
|
||||
if user_input.get("admin_group"):
|
||||
updated_roles["admin"] = user_input["admin_group"]
|
||||
if user_input.get("user_group"):
|
||||
updated_roles["user"] = user_input["user_group"]
|
||||
|
||||
# Update the config entry data
|
||||
new_data = self.config_entry.data.copy()
|
||||
new_data["features"] = {**new_data.get("features", {}), **updated_features}
|
||||
if updated_roles:
|
||||
new_data["roles"] = updated_roles
|
||||
elif "roles" in new_data:
|
||||
# Remove roles if groups are disabled
|
||||
if not user_input.get("enable_groups", False):
|
||||
del new_data["roles"]
|
||||
|
||||
# Update the config entry
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=new_data
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
current_config = self.config_entry.data
|
||||
current_features = current_config.get("features", {})
|
||||
current_roles = current_config.get("roles", {})
|
||||
|
||||
# Determine if this provider supports groups
|
||||
provider = current_config.get("provider", "authentik")
|
||||
provider_supports_groups = OIDC_PROVIDERS.get(provider, {}).get(
|
||||
"supports_groups", True
|
||||
)
|
||||
|
||||
# Build schema based on provider capabilities
|
||||
schema_dict = {
|
||||
vol.Optional(
|
||||
"enable_user_linking",
|
||||
default=current_features.get("automatic_user_linking", False),
|
||||
): bool
|
||||
}
|
||||
|
||||
# Add groups options if provider supports them
|
||||
if provider_supports_groups:
|
||||
enable_groups_default = current_features.get("include_groups_scope", False)
|
||||
schema_dict[
|
||||
vol.Optional("enable_groups", default=enable_groups_default)
|
||||
] = bool
|
||||
|
||||
# Add group name fields if groups are currently enabled or being enabled
|
||||
if enable_groups_default or (
|
||||
user_input and user_input.get("enable_groups", False)
|
||||
):
|
||||
schema_dict.update(
|
||||
{
|
||||
vol.Optional(
|
||||
"admin_group",
|
||||
default=current_roles.get("admin", DEFAULT_ADMIN_GROUP),
|
||||
): str,
|
||||
vol.Optional(
|
||||
"user_group", default=current_roles.get("user", "")
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
description_placeholders={
|
||||
"provider_name": get_provider_name(provider),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def convert_ui_config_entry_to_internal_format(config_data: dict) -> dict:
|
||||
"""Convert config entry data to internal configuration format."""
|
||||
my_config = {}
|
||||
|
||||
# Required fields
|
||||
my_config[CLIENT_ID] = config_data["client_id"]
|
||||
my_config[DISCOVERY_URL] = config_data["discovery_url"]
|
||||
|
||||
# Optional fields
|
||||
if "client_secret" in config_data:
|
||||
my_config[CLIENT_SECRET] = config_data["client_secret"]
|
||||
|
||||
if "display_name" in config_data:
|
||||
my_config[DISPLAY_NAME] = config_data["display_name"]
|
||||
|
||||
# Features configuration
|
||||
if "features" in config_data:
|
||||
my_config[FEATURES] = config_data["features"]
|
||||
|
||||
# Claims configuration
|
||||
if "claims" in config_data:
|
||||
my_config[CLAIMS] = config_data["claims"]
|
||||
|
||||
# Roles configuration
|
||||
if "roles" in config_data:
|
||||
my_config[ROLES] = config_data["roles"]
|
||||
|
||||
return my_config
|
||||
5
custom_components/auth_oidc/config_flow.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""UI config flow re-export"""
|
||||
|
||||
# pylint: disable=useless-import-alias
|
||||
# pylint: disable=unused-import
|
||||
from .config.ui_flow import OIDCConfigFlow as OIDCConfigFlow
|
||||
8
custom_components/auth_oidc/endpoints/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Imports manager"""
|
||||
|
||||
from .callback import OIDCCallbackView as OIDCCallbackView
|
||||
from .finish import OIDCFinishView as OIDCFinishView
|
||||
from .injected_auth_page import OIDCInjectedAuthPage as OIDCInjectedAuthPage
|
||||
from .redirect import OIDCRedirectView as OIDCRedirectView
|
||||
from .welcome import OIDCWelcomeView as OIDCWelcomeView
|
||||
from .device_sse import OIDCDeviceSSE as OIDCDeviceSSE
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from aiohttp import web
|
||||
from ..oidc_client import OIDCClient
|
||||
from ..tools.oidc_client import OIDCClient
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..helpers import get_url, get_view
|
||||
from ..tools.helpers import error_response, get_url, get_valid_state_id
|
||||
|
||||
PATH = "/auth/oidc/callback"
|
||||
|
||||
@@ -17,50 +17,61 @@ class OIDCCallbackView(HomeAssistantView):
|
||||
name = "auth:oidc:callback"
|
||||
|
||||
def __init__(
|
||||
self, oidc_client: OIDCClient, oidc_provider: OpenIDAuthProvider
|
||||
self,
|
||||
oidc_client: OIDCClient,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
) -> None:
|
||||
self.oidc_client = oidc_client
|
||||
self.oidc_provider = oidc_provider
|
||||
self.force_https = force_https
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
# Get the OIDC query parameters
|
||||
params = request.rel_url.query
|
||||
code = params.get("code")
|
||||
state = params.get("state")
|
||||
|
||||
if not (code and state):
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "Missing code or state parameter.",
|
||||
},
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
return await error_response("Missing code or state parameter.")
|
||||
|
||||
redirect_uri = get_url("/auth/oidc/callback")
|
||||
# Check if the states match
|
||||
if state != state_id:
|
||||
return await error_response(
|
||||
"State parameter does not match, possible CSRF attack."
|
||||
)
|
||||
|
||||
# Complete the OIDC flow to get user details
|
||||
redirect_uri = get_url("/auth/oidc/callback", self.force_https)
|
||||
user_details = await self.oidc_client.async_complete_token_flow(
|
||||
redirect_uri, code, state
|
||||
)
|
||||
if user_details is None:
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "Failed to get user details, "
|
||||
+ "see Home Assistant logs for more information.",
|
||||
},
|
||||
return await error_response(
|
||||
"Failed to get user details, see Home Assistant logs for more information.",
|
||||
status=500,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
if user_details.get("role") == "invalid":
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{
|
||||
"error": "User is not in the correct group to access Home Assistant, "
|
||||
+ "contact your administrator!",
|
||||
},
|
||||
return await error_response(
|
||||
"User is not in the correct group to access Home Assistant, "
|
||||
+ "contact your administrator!",
|
||||
status=403,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
code = await self.oidc_provider.async_save_user_info(user_details)
|
||||
return web.HTTPFound(get_url("/auth/oidc/finish?code=" + code))
|
||||
# Finalize on the state
|
||||
success = await self.oidc_provider.async_save_user_info(state_id, user_details)
|
||||
if not success:
|
||||
return await error_response(
|
||||
"Failed to save user information, session probably expired. Please sign in again.",
|
||||
status=500,
|
||||
)
|
||||
|
||||
raise web.HTTPFound(get_url("/auth/oidc/finish", self.force_https))
|
||||
|
||||
70
custom_components/auth_oidc/endpoints/device_sse.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""SSE handler for OIDC device authentication."""
|
||||
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import get_valid_state_id
|
||||
|
||||
PATH = "/auth/oidc/device-sse"
|
||||
|
||||
|
||||
class OIDCDeviceSSE(HomeAssistantView):
|
||||
"""OIDC Plugin SSE Handler."""
|
||||
|
||||
requires_auth = False
|
||||
url = PATH
|
||||
name = "auth:oidc:device-sse"
|
||||
|
||||
def __init__(self, oidc_provider: OpenIDAuthProvider) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
|
||||
async def get(self, req: web.Request) -> web.Response:
|
||||
"""Check for mobile sign-in completion with short server-side polling."""
|
||||
state_id = await get_valid_state_id(req, self.oidc_provider)
|
||||
if not state_id:
|
||||
raise web.HTTPBadRequest(text="Missing session cookie")
|
||||
|
||||
timeout_seconds = 300
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(req)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if (
|
||||
await self.oidc_provider.async_get_redirect_uri_for_state(state_id)
|
||||
is None
|
||||
):
|
||||
await response.write(b"event: expired\ndata: false\n\n")
|
||||
break
|
||||
|
||||
ready = await self.oidc_provider.async_is_state_ready(state_id)
|
||||
if ready:
|
||||
await response.write(b"event: ready\ndata: true\n\n")
|
||||
break
|
||||
|
||||
if asyncio.get_running_loop().time() - started_at >= timeout_seconds:
|
||||
await response.write(b"event: timeout\ndata: false\n\n")
|
||||
break
|
||||
|
||||
await response.write(b"event: waiting\ndata: false\n\n")
|
||||
await asyncio.sleep(0.5)
|
||||
except (ConnectionResetError, RuntimeError):
|
||||
# Client disconnected while listening for state changes.
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await response.write_eof()
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
|
||||
return response
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from aiohttp import web
|
||||
from ..helpers import get_view
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import (
|
||||
error_response,
|
||||
get_valid_state_id,
|
||||
template_response,
|
||||
)
|
||||
|
||||
PATH = "/auth/oidc/finish"
|
||||
|
||||
@@ -14,41 +19,60 @@ class OIDCFinishView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:finish"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Show the finish screen to allow the user to view their code."""
|
||||
"""Show the finish screen to pick between login & device code."""
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
code = request.query.get("code")
|
||||
|
||||
if not code:
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{"error": "Missing code to show the finish screen."},
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
view_html = await get_view("finish", {"code": code})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
return await template_response("finish", {})
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
# Get code from the message body
|
||||
data = await request.post()
|
||||
code = data.get("code")
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(request, self.oidc_provider)
|
||||
if not state_id:
|
||||
return await error_response("Missing state cookie, please restart login.")
|
||||
|
||||
if not code:
|
||||
return web.Response(text="No code received", status=500)
|
||||
|
||||
# Return redirect to the main page for sign in with a cookie
|
||||
return web.HTTPFound(
|
||||
location="/",
|
||||
headers={
|
||||
# Set a cookie to enable autologin on only the specific path used
|
||||
# for the POST request, with all strict parameters set
|
||||
# This cookie should not be read by any Javascript or any other paths.
|
||||
# It can be really short lifetime as we redirect immediately (5 seconds)
|
||||
"set-cookie": "auth_oidc_code="
|
||||
+ code
|
||||
+ "; Path=/auth/login_flow; SameSite=Strict; HttpOnly; Max-Age=5",
|
||||
},
|
||||
# Get redirect_uri from the state
|
||||
redirect_uri = await self.oidc_provider.async_get_redirect_uri_for_state(
|
||||
state_id
|
||||
)
|
||||
|
||||
if not redirect_uri:
|
||||
return await error_response("Invalid state, please restart login.")
|
||||
|
||||
# Get the message body
|
||||
data = await request.post()
|
||||
device_code = data.get("device_code")
|
||||
|
||||
# We are trying sign-in on this browser
|
||||
if not device_code:
|
||||
# Add to the URL correctly (also handle case where it's just the root)
|
||||
separator = "?"
|
||||
if "?" in redirect_uri:
|
||||
separator = "&"
|
||||
|
||||
# Redirect to this new URL for login, make sure to skip OIDC to prevent loops
|
||||
redirect_uri = f"{redirect_uri}{separator}skip_oidc_redirect=true"
|
||||
raise web.HTTPFound(location=redirect_uri)
|
||||
|
||||
# Check if we can link this device
|
||||
linked = await self.oidc_provider.async_link_state_to_code(
|
||||
state_id, device_code
|
||||
)
|
||||
|
||||
if not linked:
|
||||
return await error_response(
|
||||
"Failed to link state to device code, please restart login."
|
||||
)
|
||||
|
||||
return await template_response("device_success", {})
|
||||
|
||||
140
custom_components/auth_oidc/endpoints/injected_auth_page.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Injected authorization page, replacing the original"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from functools import partial
|
||||
from urllib.parse import quote, unquote
|
||||
from aiohttp import web
|
||||
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 ..tools.helpers import get_url
|
||||
|
||||
PATH = "/auth/authorize"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def read_file(path: str) -> str:
|
||||
"""Read a file from the static path."""
|
||||
async with async_open(path, mode="r") as f:
|
||||
return await f.read()
|
||||
|
||||
|
||||
async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
"""Inject new frontend code into /auth/authorize."""
|
||||
router = hass.http.app.router
|
||||
frontend_path = None
|
||||
|
||||
for resource in router.resources():
|
||||
if resource.canonical != "/auth/authorize":
|
||||
continue
|
||||
|
||||
# This path doesn't actually work, gives 404, effectively disabling the old matcher
|
||||
resource.add_prefix("/auth/oidc/unused")
|
||||
|
||||
# Now get the original frontend path from this resource to obtain the GET route
|
||||
routes = iter(resource)
|
||||
route = next(
|
||||
(r for r in routes if r.method == "GET"),
|
||||
None,
|
||||
)
|
||||
|
||||
if route is not None:
|
||||
if not route.handler or not isinstance(route.handler, partial):
|
||||
_LOGGER.warning(
|
||||
"Unexpected route handler type %s for /auth/authorize",
|
||||
type(route.handler),
|
||||
)
|
||||
continue
|
||||
|
||||
# The original frontend path is the first argument of the handler
|
||||
frontend_path = route.handler.args[0]
|
||||
break
|
||||
|
||||
# Get the path to the original frontend resource
|
||||
if frontend_path is None:
|
||||
_LOGGER.info(
|
||||
"Failed to find GET route for /auth/authorize, cannot inject OIDC frontend code"
|
||||
)
|
||||
return
|
||||
|
||||
# Inject our new script into the existing frontend code
|
||||
# First fetch the frontend path into memory
|
||||
frontend_code = await read_file(frontend_path)
|
||||
|
||||
# Inject JS and register that route
|
||||
injection_js = "<script src='/auth/oidc/static/injection.js?v=6'></script>"
|
||||
frontend_code = frontend_code.replace("</body>", f"{injection_js}</body>")
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
"/auth/oidc/static/injection.js",
|
||||
hass.config.path("custom_components/auth_oidc/static/injection.js"),
|
||||
cache_headers=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# If everything is succesful, register a fake view that just returns the modified HTML
|
||||
hass.http.register_view(OIDCInjectedAuthPage(frontend_code, force_https))
|
||||
_LOGGER.info("Performed OIDC frontend injection")
|
||||
|
||||
|
||||
class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
"""OIDC Plugin Injected Auth Page."""
|
||||
|
||||
requires_auth = False
|
||||
url = PATH
|
||||
name = "auth:oidc:authorize_page"
|
||||
|
||||
def __init__(self, html: str, force_https: bool) -> None:
|
||||
"""Initialize the injected auth page."""
|
||||
self.html = html
|
||||
self.force_https = force_https
|
||||
|
||||
@staticmethod
|
||||
async def inject(hass: HomeAssistant, force_https: bool) -> None:
|
||||
"""Inject the OIDC auth page into the frontend."""
|
||||
try:
|
||||
await frontend_injection(hass, force_https)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error("Failed to inject OIDC auth page: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _should_do_oidc_redirect(req: web.Request) -> bool:
|
||||
"""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
|
||||
|
||||
# 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."""
|
||||
encoded_current_url = quote(
|
||||
base64.b64encode(str(req.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")
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Redirect route to redirect the user to the external OIDC server,
|
||||
can either be linked to directly or accessed through the welcome page."""
|
||||
|
||||
from urllib.parse import quote
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
from ..oidc_client import OIDCClient
|
||||
from ..helpers import get_url, get_view
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.oidc_client import OIDCClient
|
||||
from ..tools.helpers import error_response, get_url, get_valid_state_id, get_view
|
||||
|
||||
PATH = "/auth/oidc/redirect"
|
||||
|
||||
@@ -17,24 +19,44 @@ class OIDCRedirectView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:redirect"
|
||||
|
||||
def __init__(self, oidc_client: OIDCClient) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
oidc_client: OIDCClient,
|
||||
oidc_provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
) -> None:
|
||||
self.oidc_client = oidc_client
|
||||
self.oidc_provider = oidc_provider
|
||||
self.force_https = force_https
|
||||
|
||||
async def get(self, _: web.Request) -> web.Response:
|
||||
async def get(self, req: web.Request) -> web.Response:
|
||||
"""Receive response."""
|
||||
|
||||
redirect_uri = get_url("/auth/oidc/callback")
|
||||
auth_url = await self.oidc_client.async_get_authorization_url(redirect_uri)
|
||||
# Get cookie to get the state_id
|
||||
state_id = await get_valid_state_id(req, self.oidc_provider)
|
||||
|
||||
if auth_url:
|
||||
return web.HTTPFound(auth_url)
|
||||
if not state_id:
|
||||
# Direct access to the redirect endpoint, go to welcome page instead
|
||||
welcome_url = get_url("/auth/oidc/welcome", self.force_https)
|
||||
raise web.HTTPFound(welcome_url)
|
||||
|
||||
view_html = await get_view(
|
||||
"error",
|
||||
{"error": "Integration is misconfigured, discovery could not be obtained."},
|
||||
try:
|
||||
redirect_uri = get_url("/auth/oidc/callback", self.force_https)
|
||||
auth_url = await self.oidc_client.async_get_authorization_url(
|
||||
redirect_uri, state_id
|
||||
)
|
||||
|
||||
if auth_url:
|
||||
view_html = await get_view("redirect", {"url": quote(auth_url)})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
return await error_response(
|
||||
"Integration is misconfigured, discovery could not be obtained.",
|
||||
status=500,
|
||||
)
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
async def post(self, req: web.Request) -> web.Response:
|
||||
"""POST"""
|
||||
return await self.get(request)
|
||||
return await self.get(req)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Welcome route to show the user the OIDC login button and give instructions."""
|
||||
|
||||
from ast import List
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import urlparse, parse_qs, unquote, urlencode
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from ..helpers import get_view
|
||||
from ..tools.helpers import error_response, get_url, template_response
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.types import OIDCWelcomeOptions
|
||||
|
||||
PATH = "/auth/oidc/welcome"
|
||||
|
||||
@@ -14,10 +20,125 @@ class OIDCWelcomeView(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:welcome"
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
def __init__(
|
||||
self, oidc_provider: OpenIDAuthProvider, options: OIDCWelcomeOptions
|
||||
) -> None:
|
||||
self.oidc_provider = oidc_provider
|
||||
self.name = options.get("name")
|
||||
self.force_https = options.get("force_https")
|
||||
self.has_other_auth_providers = options.get("has_other_auth_providers")
|
||||
self.prefers_skipping = options.get("prefers_skipping")
|
||||
|
||||
async def get(self, _: web.Request) -> web.Response:
|
||||
async def _process_url(self, redirect_uri: str) -> List[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"
|
||||
)
|
||||
|
||||
oauth2_url = urlparse(redirect_uri)
|
||||
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
|
||||
# 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:
|
||||
"""Receive response."""
|
||||
view_html = await get_view("welcome", {"name": self.name})
|
||||
return web.Response(text=view_html, content_type="text/html")
|
||||
|
||||
# Get the query parameter with the redirect_uri
|
||||
redirect_uri = req.query.get("redirect_uri")
|
||||
|
||||
# Do some processing on the redirect_uri to correct it
|
||||
# and determine if this is a mobile client.
|
||||
if redirect_uri:
|
||||
try:
|
||||
redirect_uri, is_mobile = await self._process_url(redirect_uri)
|
||||
except (
|
||||
binascii.Error,
|
||||
UnicodeDecodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
):
|
||||
return await error_response(
|
||||
"Invalid redirect_uri, please restart login."
|
||||
)
|
||||
|
||||
else:
|
||||
# Backwards compatibility with older versions that directly go to /auth/oidc/welcome
|
||||
# If not set, redirect back to the main page and assume that this is a web client
|
||||
redirect_uri = get_url("/?storeToken=true", self.force_https)
|
||||
is_mobile = False
|
||||
|
||||
# Create OIDC state with the redirect_uri so we can use it later in the flow
|
||||
state_id = await self.oidc_provider.async_create_state(redirect_uri)
|
||||
cookie_header = self.oidc_provider.get_cookie_header(
|
||||
state_id, secure=self.force_https or req.url.scheme == "https"
|
||||
)
|
||||
|
||||
# If this is the only provider and we are on desktop,
|
||||
# automatically go through the OIDC login
|
||||
if not is_mobile and (
|
||||
not self.has_other_auth_providers or self.prefers_skipping
|
||||
):
|
||||
raise web.HTTPFound(
|
||||
location=get_url("/auth/oidc/redirect", self.force_https),
|
||||
headers=cookie_header,
|
||||
)
|
||||
|
||||
# Otherwise display the screen with either mobile sign in or the buttons
|
||||
# First generate code if mobile
|
||||
code = None
|
||||
if is_mobile:
|
||||
# Create a code to login
|
||||
code = await self.oidc_provider.async_generate_device_code(state_id)
|
||||
if not code:
|
||||
return await error_response(
|
||||
"Failed to generate device code, please restart login.",
|
||||
status=500,
|
||||
)
|
||||
|
||||
# And add the other link if we have other auth providers
|
||||
other_link = None
|
||||
if self.has_other_auth_providers:
|
||||
other_link = get_url("/?skip_oidc_redirect=true", self.force_https)
|
||||
|
||||
# And display
|
||||
response = await template_response(
|
||||
"welcome",
|
||||
{
|
||||
"name": self.name,
|
||||
"other_link": other_link,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
response.headers.update(cookie_header)
|
||||
return response
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Helper functions for the integration."""
|
||||
|
||||
from homeassistant.components import http
|
||||
from .views.loader import AsyncTemplateRenderer
|
||||
|
||||
|
||||
def get_url(path: str) -> str:
|
||||
"""Returns the requested path appended to the current request base URL."""
|
||||
if (req := http.current_request.get()) is None:
|
||||
raise RuntimeError("No current request in context")
|
||||
|
||||
base_uri = str(req.url).split("/auth", 2)[0]
|
||||
return f"{base_uri}{path}"
|
||||
|
||||
|
||||
async def get_view(template: str, parameters: dict | None = None) -> str:
|
||||
"""Returns the generated HTML of the requested view."""
|
||||
if parameters is None:
|
||||
parameters = {}
|
||||
|
||||
renderer = AsyncTemplateRenderer()
|
||||
return await renderer.render_template(f"{template}.html", **parameters)
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"domain": "auth_oidc",
|
||||
"name": "OIDC Authentication",
|
||||
"name": "OpenID Connect/SSO Authentication",
|
||||
"codeowners": [
|
||||
"@christiaangoossens"
|
||||
],
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"auth",
|
||||
"http"
|
||||
@@ -14,10 +14,9 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/christiaangoossens/hass-oidc-auth/issues",
|
||||
"requirements": [
|
||||
"python-jose>=3.3.0",
|
||||
"aiofiles>=24.1.0",
|
||||
"jinja2>=3.1.4",
|
||||
"bcrypt>=4.2.0"
|
||||
"aiofiles",
|
||||
"jinja2",
|
||||
"joserfc"
|
||||
],
|
||||
"version": "0.4.1"
|
||||
"version": "1.0.1"
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
"""OIDC Client class"""
|
||||
|
||||
import urllib.parse
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
import aiohttp
|
||||
from jose import jwt, jwk
|
||||
|
||||
from .types import UserDetails
|
||||
from .config import (
|
||||
FEATURES_DISABLE_PKCE,
|
||||
CLAIMS_DISPLAY_NAME,
|
||||
CLAIMS_USERNAME,
|
||||
CLAIMS_GROUPS,
|
||||
ROLE_ADMINS,
|
||||
ROLE_USERS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCClientException(Exception):
|
||||
"Raised when the OIDC Client encounters an error"
|
||||
|
||||
|
||||
class OIDCDiscoveryInvalid(OIDCClientException):
|
||||
"Raised when the discovery document is not found, invalid or otherwise malformed."
|
||||
|
||||
|
||||
class OIDCTokenResponseInvalid(OIDCClientException):
|
||||
"Raised when the token request returns invalid."
|
||||
|
||||
|
||||
class OIDCJWKSInvalid(OIDCClientException):
|
||||
"Raised when the JWKS is invalid or cannot be obtained."
|
||||
|
||||
|
||||
class OIDCStateInvalid(OIDCClientException):
|
||||
"Raised when the state for your request cannot be matched against a stored state."
|
||||
|
||||
|
||||
class OIDCIdTokenSigningAlgorithmInvalid(OIDCTokenResponseInvalid):
|
||||
"Raised when the id_token is signed with the wrong algorithm, adjust your config accordingly."
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OIDCClient:
|
||||
"""OIDC Client implementation for Python, including PKCE."""
|
||||
|
||||
# Flows stores the state, code_verifier and nonce of all current flows.
|
||||
flows = {}
|
||||
|
||||
def __init__(self, discovery_url: str, client_id: str, scope: str, **kwargs: str):
|
||||
self.discovery_url = discovery_url
|
||||
self.discovery_document = None
|
||||
self.client_id = client_id
|
||||
self.scope = scope
|
||||
|
||||
# Optional parameters
|
||||
self.client_secret = kwargs.get("client_secret")
|
||||
|
||||
# Default id_token_signing_alg to RS256 if not specified
|
||||
self.id_token_signing_alg = kwargs.get("id_token_signing_alg")
|
||||
if self.id_token_signing_alg is None:
|
||||
self.id_token_signing_alg = "RS256"
|
||||
|
||||
features = kwargs.get("features")
|
||||
claims = kwargs.get("claims")
|
||||
roles = kwargs.get("roles")
|
||||
|
||||
self.disable_pkce: bool = features.get(FEATURES_DISABLE_PKCE)
|
||||
self.display_name_claim = claims.get(CLAIMS_DISPLAY_NAME, "name")
|
||||
self.username_claim = claims.get(CLAIMS_USERNAME, "preferred_username")
|
||||
self.groups_claim = claims.get(CLAIMS_GROUPS, "groups")
|
||||
self.user_role = roles.get(ROLE_USERS, None)
|
||||
self.admin_role = roles.get(ROLE_ADMINS, "admins")
|
||||
|
||||
def _base64url_encode(self, value: str) -> str:
|
||||
"""Uses base64url encoding on a given string"""
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("utf-8")
|
||||
|
||||
def _generate_random_url_string(self, length: int = 16) -> str:
|
||||
"""Generates a random URL safe string (base64_url encoded)"""
|
||||
return self._base64url_encode(os.urandom(length))
|
||||
|
||||
async def _fetch_discovery_document(self):
|
||||
"""Fetches discovery document from the given URL."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(self.discovery_url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document not found at %s", self.discovery_url
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Error: %s - %s", e.status, e.message)
|
||||
raise OIDCDiscoveryInvalid from e
|
||||
|
||||
async def _get_jwks(self, jwks_uri):
|
||||
"""Fetches JWKS from the given URL."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(jwks_uri) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
except aiohttp.ClientResponseError as e:
|
||||
_LOGGER.warning("Error fetching JWKS: %s - %s", e.status, e.message)
|
||||
raise OIDCJWKSInvalid from e
|
||||
|
||||
async def _make_token_request(self, token_endpoint, query_params):
|
||||
"""Performs the token POST call"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(token_endpoint, data=query_params) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 400:
|
||||
_LOGGER.warning(
|
||||
"Error: Token could not be obtained (Bad Request), "
|
||||
+ "did you forget the client_secret?"
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Unexpected error exchanging token: %s - %s", e.status, e.message
|
||||
)
|
||||
raise OIDCTokenResponseInvalid from e
|
||||
|
||||
async def _parse_id_token(
|
||||
self, id_token: str, access_token: str | None
|
||||
) -> Optional[dict]:
|
||||
"""Parses the ID token into a dict containing token contents."""
|
||||
if self.discovery_document is None:
|
||||
self.discovery_document = await self._fetch_discovery_document()
|
||||
|
||||
jwks_uri = self.discovery_document["jwks_uri"]
|
||||
jwks_data = await self._get_jwks(jwks_uri)
|
||||
|
||||
try:
|
||||
# Obtain the id_token header
|
||||
unverified_header = jwt.get_unverified_header(id_token)
|
||||
if not unverified_header:
|
||||
_LOGGER.warning("Could not get header from received id_token.")
|
||||
return None
|
||||
|
||||
# Obtain the signing algorithm from the header of the id_token
|
||||
alg = unverified_header.get("alg")
|
||||
if alg != self.id_token_signing_alg:
|
||||
# Verify that it matches our requested algorithm
|
||||
_LOGGER.warning(
|
||||
"ID Token received signed with the wrong algorithm: %s, expected %s",
|
||||
alg,
|
||||
self.id_token_signing_alg,
|
||||
)
|
||||
raise OIDCIdTokenSigningAlgorithmInvalid()
|
||||
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.8
|
||||
# If the JWT alg Header Parameter uses a MAC based algorithm
|
||||
# such as HS256, HS384, or HS512, the octets of the UTF-8 [RFC3629]
|
||||
# representation of the client_secret corresponding to the client_id
|
||||
# contained in the aud (audience) Claim are used as the key to
|
||||
# validate the signature.
|
||||
if alg.startswith("HS"):
|
||||
if not self.client_secret:
|
||||
_LOGGER.warning(
|
||||
"ID Token signed with HMAC algorithm, but no client_secret provided."
|
||||
)
|
||||
raise OIDCIdTokenSigningAlgorithmInvalid()
|
||||
|
||||
jwk_obj = jwk.construct(
|
||||
{
|
||||
"kty": "oct",
|
||||
"k": base64.urlsafe_b64encode(
|
||||
self.client_secret.encode()
|
||||
).decode(),
|
||||
"alg": alg,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# TODO: Deal with cases where kid is not specified (just take the first key?)
|
||||
# Obtain the kid (Key ID) from the header of the id_token
|
||||
kid = unverified_header.get("kid")
|
||||
if not kid:
|
||||
_LOGGER.warning("JWT does not have kid (Key ID)")
|
||||
return None
|
||||
|
||||
# Get the correct key
|
||||
signing_key = None
|
||||
for key in jwks_data["keys"]:
|
||||
if key["kid"] == kid:
|
||||
signing_key = key
|
||||
break
|
||||
|
||||
if not signing_key:
|
||||
_LOGGER.warning("Could not find matching key with kid: %s", kid)
|
||||
return None
|
||||
|
||||
# Construct the JWK from the RSA key
|
||||
jwk_obj = jwk.construct(signing_key)
|
||||
|
||||
# Verify the token
|
||||
decoded_token = jwt.decode(
|
||||
id_token,
|
||||
jwk_obj,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.6
|
||||
# The Client MUST validate the signature of all other ID Tokens
|
||||
# according to JWS [JWS] using the algorithm specified in the JWT
|
||||
# alg Header Parameter.
|
||||
algorithms=[self.id_token_signing_alg],
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.3
|
||||
# The Client MUST validate that the aud (audience) Claim contains
|
||||
# its client_id value registered at the Issuer identified by the
|
||||
# iss (issuer) Claim as an audience.
|
||||
audience=self.client_id,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.2
|
||||
# The Issuer Identifier for the OpenID Provider MUST exactly
|
||||
# match the value of the iss (issuer) Claim.
|
||||
issuer=self.discovery_document["issuer"],
|
||||
access_token=access_token,
|
||||
options={
|
||||
# Verify everything if present
|
||||
"verify_signature": True,
|
||||
"verify_aud": True,
|
||||
"verify_iat": True,
|
||||
"verify_exp": True,
|
||||
"verify_nbf": True,
|
||||
"verify_iss": True,
|
||||
"verify_sub": True,
|
||||
"verify_jti": True,
|
||||
"verify_at_hash": True,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.3
|
||||
"require_aud": True,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.10
|
||||
"require_iat": True,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.9
|
||||
"require_exp": True,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.2
|
||||
"require_iss": True,
|
||||
# We need the sub as it's used to identify the user
|
||||
"require_sub": True,
|
||||
# Other values, not required.
|
||||
"require_nbf": False,
|
||||
"require_jti": False,
|
||||
"require_at_hash": False,
|
||||
"leeway": 5,
|
||||
},
|
||||
)
|
||||
return decoded_token
|
||||
|
||||
except jwt.JWTError as e:
|
||||
_LOGGER.warning("JWT Verification failed: %s", e)
|
||||
return None
|
||||
|
||||
async def async_get_authorization_url(self, redirect_uri: str) -> Optional[str]:
|
||||
"""Generates the authorization URL for the OIDC flow."""
|
||||
try:
|
||||
if self.discovery_document is None:
|
||||
self.discovery_document = await self._fetch_discovery_document()
|
||||
|
||||
auth_endpoint = self.discovery_document["authorization_endpoint"]
|
||||
|
||||
# Generate random nonce & state
|
||||
nonce = self._generate_random_url_string()
|
||||
state = self._generate_random_url_string()
|
||||
|
||||
# Generate PKCE (RFC 7636) parameters
|
||||
code_verifier = self._generate_random_url_string(32)
|
||||
code_challenge = self._base64url_encode(
|
||||
hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
||||
)
|
||||
|
||||
# Save all of them for later verification
|
||||
self.flows[state] = {"code_verifier": code_verifier, "nonce": nonce}
|
||||
|
||||
# Construct the params
|
||||
query_params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": self.scope,
|
||||
"state": state,
|
||||
# Nonce is always set in accordance with OpenID Connect Core 1.0
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
# We always want to use PKCE (RFC 7636), unless it's disabled for compatibility.
|
||||
# PKCE is the recommended method of securing the authorization code grant
|
||||
# for public clients as much as possible.
|
||||
# (see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-7.5.1)
|
||||
if not self.disable_pkce:
|
||||
query_params["code_challenge"] = code_challenge
|
||||
query_params["code_challenge_method"] = "S256"
|
||||
|
||||
url = f"{auth_endpoint}?{urllib.parse.urlencode(query_params)}"
|
||||
return url
|
||||
except OIDCClientException as e:
|
||||
_LOGGER.warning("Error generating authorization URL: %s", e)
|
||||
return None
|
||||
|
||||
async def async_complete_token_flow(
|
||||
self, redirect_uri: str, code: str, state: str
|
||||
) -> Optional[UserDetails]:
|
||||
"""Completes the OIDC token flow to obtain a user's details."""
|
||||
|
||||
try:
|
||||
if state not in self.flows:
|
||||
raise OIDCStateInvalid
|
||||
|
||||
flow = self.flows[state]
|
||||
|
||||
if self.discovery_document is None:
|
||||
self.discovery_document = await self._fetch_discovery_document()
|
||||
|
||||
token_endpoint = self.discovery_document["token_endpoint"]
|
||||
|
||||
# Construct the params
|
||||
query_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
|
||||
# Send the client secret if we have one
|
||||
if self.client_secret is not None:
|
||||
query_params["client_secret"] = self.client_secret
|
||||
|
||||
# If we disable PKCE, don't send the code verifier
|
||||
if not self.disable_pkce:
|
||||
query_params["code_verifier"] = flow["code_verifier"]
|
||||
|
||||
# Exchange the code for a token
|
||||
token_response = await self._make_token_request(
|
||||
token_endpoint, query_params
|
||||
)
|
||||
|
||||
id_token = token_response.get("id_token")
|
||||
access_token = token_response.get("access_token")
|
||||
|
||||
# Parse the id token to obtain the relevant details
|
||||
# Access token is supplied to check at_hash if present
|
||||
id_token = await self._parse_id_token(id_token, access_token)
|
||||
|
||||
if id_token is None:
|
||||
_LOGGER.warning("ID token could not be parsed!")
|
||||
return None
|
||||
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.11
|
||||
# If a nonce value was sent in the Authentication Request,
|
||||
# a nonce Claim MUST be present and its value checked to verify
|
||||
# that it is the same value as the one that was sent in the Authentication Request.
|
||||
if id_token.get("nonce") != flow["nonce"]:
|
||||
_LOGGER.warning("Nonce mismatch!")
|
||||
return None
|
||||
|
||||
# TODO: If the configured claims are not present in id_token, we should fetch userinfo
|
||||
|
||||
# Get and parse groups (to check if it's an array)
|
||||
groups = id_token.get(self.groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
_LOGGER.warning("Groups claim is not a list, using empty list instead.")
|
||||
groups = []
|
||||
|
||||
# Assign role if user has the required groups
|
||||
role = "invalid"
|
||||
if self.user_role in groups or self.user_role is None:
|
||||
role = "system-users"
|
||||
|
||||
if self.admin_role in groups:
|
||||
role = "system-admin"
|
||||
|
||||
# Create a user details dict based on the contents of the id_token & userinfo
|
||||
data: UserDetails = {
|
||||
# Subject Identifier. A locally unique and never reassigned identifier within the
|
||||
# Issuer for the End-User, which is intended to be consumed by the Client
|
||||
# Only unique per issuer, so we combine it with the issuer and hash it.
|
||||
# This might allow multiple OIDC providers to be used with this integration.
|
||||
"sub": hashlib.sha256(
|
||||
f"{self.discovery_document['issuer']}.{id_token.get('sub')}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest(),
|
||||
# Display name, configurable
|
||||
"display_name": id_token.get(self.display_name_claim),
|
||||
# Username, configurable
|
||||
"username": id_token.get(self.username_claim),
|
||||
# Role
|
||||
"role": role,
|
||||
}
|
||||
|
||||
# Log which details were obtained for debugging
|
||||
# Also log the original subject identifier such that you can look it up in your provider
|
||||
_LOGGER.debug(
|
||||
"Obtained user details from OIDC provider: %s (issuer subject: %s)",
|
||||
data,
|
||||
id_token.get("sub"),
|
||||
)
|
||||
return data
|
||||
except OIDCClientException as e:
|
||||
_LOGGER.warning("Error completing token flow: %s", e)
|
||||
return None
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
|
||||
from typing import Dict, Optional
|
||||
import asyncio
|
||||
import bcrypt
|
||||
from homeassistant.auth import EVENT_USER_ADDED
|
||||
from homeassistant.auth.providers import (
|
||||
AUTH_PROVIDERS,
|
||||
@@ -22,21 +21,21 @@ from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.components import http, person
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import voluptuous as vol
|
||||
|
||||
from .config import (
|
||||
from .config.const import (
|
||||
FEATURES,
|
||||
FEATURES_AUTOMATIC_USER_LINKING,
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION,
|
||||
DEFAULT_TITLE,
|
||||
)
|
||||
from .stores.code_store import CodeStore
|
||||
from .types import UserDetails
|
||||
from .stores.state_store import StateStore
|
||||
from .tools.types import UserDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE = "auth_oidc"
|
||||
HASS_PROVIDER_TYPE = "homeassistant"
|
||||
COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
@@ -68,7 +67,7 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
)
|
||||
|
||||
self._user_meta: dict[UserDetails] = {}
|
||||
self._code_store: CodeStore | None = None
|
||||
self._state_store: StateStore | None = None
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
features = config.get(
|
||||
@@ -89,29 +88,120 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
|
||||
# Init the code store first
|
||||
# Init the store first
|
||||
# Use the same technique as the HomeAssistant auth provider for storage
|
||||
# (/auth/providers/homeassistant.py#L392)
|
||||
async with self._init_lock:
|
||||
if self._code_store is not None:
|
||||
if self._state_store is not None:
|
||||
return
|
||||
|
||||
store = CodeStore(self.hass)
|
||||
store = StateStore(self.hass)
|
||||
await store.async_load()
|
||||
self._code_store = store
|
||||
self._state_store = store
|
||||
self._user_meta = {}
|
||||
|
||||
# Listen for user creation events
|
||||
self.hass.bus.async_listen(EVENT_USER_ADDED, self.async_user_created)
|
||||
|
||||
async def async_get_subject(self, code: str) -> Optional[str]:
|
||||
"""Retrieve user from the code, return subject and save meta
|
||||
for later use with this provider instance."""
|
||||
if self._code_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._code_store is not None
|
||||
def _resolve_ip(self, ip: str | None = None) -> str | None:
|
||||
"""Resolve client IP from explicit input or current request context."""
|
||||
if ip:
|
||||
return ip
|
||||
|
||||
user_data = await self._code_store.receive_userinfo_for_code(code)
|
||||
req = http.current_request.get()
|
||||
if req and req.remote:
|
||||
return req.remote
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_state(self, redirect_uri: str, ip: str | None = None) -> str:
|
||||
"""Create a new OIDC state and return the state id."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_create_state_from_url(
|
||||
redirect_uri, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_generate_device_code(self, state_id: str) -> Optional[str]:
|
||||
"""Generate a device code for the state, used for device login."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_generate_code_for_state(state_id)
|
||||
|
||||
async def async_save_user_info(
|
||||
self, state_id: str, user_info: dict[str, dict | str]
|
||||
) -> bool:
|
||||
"""Save user info to the given state."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_add_userinfo_to_state(state_id, user_info)
|
||||
|
||||
async def async_get_redirect_uri_for_state(
|
||||
self, state_id: str, ip: str | None = None
|
||||
) -> Optional[str]:
|
||||
"""Get the redirect_uri for the given state."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_get_redirect_uri_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_is_state_valid(self, state_id: str, ip: str | None = None) -> bool:
|
||||
"""Check if a state exists, belongs to this IP, and is not expired."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return (
|
||||
await self._state_store.async_get_redirect_uri_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
async def async_is_state_ready(self, state_id: str, ip: str | None = None) -> bool:
|
||||
"""Check if the state has received the user info from the OIDC callback."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_is_state_ready(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_link_state_to_code(
|
||||
self, state_id: str, code: str, ip: str | None = None
|
||||
) -> bool:
|
||||
"""Link two states together by copying the user info from one to the other."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
return await self._state_store.async_link_state_to_code(
|
||||
state_id, code, self._resolve_ip(ip)
|
||||
)
|
||||
|
||||
async def async_get_subject(
|
||||
self, state_id: str, ip: str | None = None
|
||||
) -> Optional[str]:
|
||||
"""Retrieve user from the state_id, return subject and save meta
|
||||
for later use with this provider instance."""
|
||||
if self._state_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._state_store is not None
|
||||
|
||||
# This also deletes the state as we are using it for sign-in
|
||||
user_data = await self._state_store.async_receive_userinfo_for_state(
|
||||
state_id, self._resolve_ip(ip)
|
||||
)
|
||||
if user_data is None:
|
||||
return None
|
||||
|
||||
@@ -119,14 +209,6 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
self._user_meta[sub] = user_data
|
||||
return sub
|
||||
|
||||
async def async_save_user_info(self, user_info: dict[str, dict | str]) -> str:
|
||||
"""Save user info and return a code."""
|
||||
if self._code_store is None:
|
||||
await self.async_initialize()
|
||||
assert self._code_store is not None
|
||||
|
||||
return await self._code_store.async_generate_code_for_userinfo(user_info)
|
||||
|
||||
async def _async_find_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Find a user by username."""
|
||||
users = await self.store.async_get_users()
|
||||
@@ -145,6 +227,18 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
|
||||
return None
|
||||
|
||||
def get_cookie_header(self, state_id: str, secure: bool = False):
|
||||
"""Get the cookie header to set the state_id cookie."""
|
||||
secure_flag = "; Secure" if secure else ""
|
||||
return {
|
||||
# Set a cookie for the other pages to know the state_id
|
||||
# Keep cookie lifetime aligned with state lifetime in storage (5 minutes).
|
||||
"set-cookie": f"{COOKIE_NAME}="
|
||||
+ state_id
|
||||
+ "; Path=/auth/; SameSite=Lax; HttpOnly; Max-Age=300"
|
||||
+ secure_flag,
|
||||
}
|
||||
|
||||
# ====
|
||||
# Handler for user created and related functions (person creation)
|
||||
# ====
|
||||
@@ -177,9 +271,9 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
# If person creation is enabled, add a person for this user
|
||||
if self.create_persons:
|
||||
user_meta = await self.async_user_meta_for_credentials(credential)
|
||||
await self.async_create_person(user, user_meta.name)
|
||||
await self._async_create_person(user, user_meta.name)
|
||||
|
||||
async def async_create_person(self, user: User, name: str) -> None:
|
||||
async def _async_create_person(self, user: User, name: str) -> None:
|
||||
"""Create a person for the user."""
|
||||
_LOGGER.info("Automatically creating person for new user %s", user.id)
|
||||
|
||||
@@ -194,7 +288,7 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Requested automatic person creation, but person creation failed."
|
||||
"Requested automatic person creation, but person creation failed"
|
||||
)
|
||||
# pylint: enable=broad-exception-caught
|
||||
|
||||
@@ -271,16 +365,8 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
class OpenIdLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def _finalize_user(self, code: 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(code)
|
||||
async def _finalize_user(self, state_id: str) -> AuthFlowResult:
|
||||
sub = await self._auth_provider.async_get_subject(state_id)
|
||||
if sub:
|
||||
return await self.async_finish(
|
||||
{
|
||||
@@ -290,53 +376,22 @@ class OpenIdLoginFlow(LoginFlow):
|
||||
|
||||
raise InvalidAuthError
|
||||
|
||||
def _show_login_form(
|
||||
self, errors: Optional[dict[str, str]] = None
|
||||
) -> AuthFlowResult:
|
||||
if errors is None:
|
||||
errors = {}
|
||||
|
||||
# Show the login form
|
||||
# Abuses the MFA form, as it works better for our usecase
|
||||
# UI suggestions are welcome (make a PR!)
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("code"): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> AuthFlowResult:
|
||||
"""Handle the step of the form."""
|
||||
|
||||
# Try to use the user input first
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._finalize_user(user_input["code"])
|
||||
except InvalidAuthError:
|
||||
return self._show_login_form({"base": "invalid_auth"})
|
||||
|
||||
# If not available, check the cookie
|
||||
# Check if the cookie is present to login
|
||||
req = http.current_request.get()
|
||||
code_cookie = req.cookies.get("auth_oidc_code")
|
||||
if req and req.cookies:
|
||||
state_cookie = req.cookies.get(COOKIE_NAME)
|
||||
|
||||
if code_cookie:
|
||||
_LOGGER.debug("Code cookie found on login: %s", code_cookie)
|
||||
try:
|
||||
return await self._finalize_user(code_cookie)
|
||||
except InvalidAuthError:
|
||||
pass
|
||||
if state_cookie:
|
||||
try:
|
||||
return await self._finalize_user(state_cookie)
|
||||
except InvalidAuthError:
|
||||
return self.async_abort(reason="oidc_cookie_invalid")
|
||||
|
||||
# If none are available, just show the form
|
||||
return self._show_login_form()
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> AuthFlowResult:
|
||||
# This is a dummy step function just to use the nicer MFA UI instead
|
||||
return await self.async_step_init(user_input)
|
||||
# If no cookie is found, abort.
|
||||
# User should either be redirected or start manually on the welcome
|
||||
return self.async_abort(reason="no_oidc_cookie_found")
|
||||
|
||||
61
custom_components/auth_oidc/static/injection.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* hass-oidc-auth - UX script to automatically select the Home Assistant auth provider when the "Login aborted" message is shown.
|
||||
*/
|
||||
|
||||
let authFlowElement = null
|
||||
|
||||
function update() {
|
||||
// Find ha-auth-flow
|
||||
authFlowElement = document.querySelector('ha-auth-flow');
|
||||
|
||||
if (!authFlowElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the text "Login aborted" is present on the page
|
||||
if (!authFlowElement.innerText.includes('Login aborted')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the ha-pick-auth-provider element
|
||||
const authProviderElement = document.querySelector('ha-pick-auth-provider');
|
||||
|
||||
if (!authProviderElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Click the first ha-list-item element inside the ha-pick-auth-provider
|
||||
const firstListItem = authProviderElement.shadowRoot?.querySelector('ha-list-item');
|
||||
if (!firstListItem) {
|
||||
console.warn("[OIDC] No ha-list-item found inside ha-pick-auth-provider. Not automatically selecting HA provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
firstListItem.click();
|
||||
}
|
||||
|
||||
// Hide the content until ready
|
||||
let ready = false
|
||||
document.querySelector(".content").style.display = "none"
|
||||
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
update();
|
||||
|
||||
if (!ready) {
|
||||
ready = Boolean(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 = "";
|
||||
}, 300)
|
||||
3
custom_components/auth_oidc/static/input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../views/templates";
|
||||
0
custom_components/auth_oidc/stores/__init__.py
Normal file
@@ -1,78 +0,0 @@
|
||||
"""Code Store, stores the codes and their associated authenticated user temporarily."""
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, Optional
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..types import UserDetails
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_provider.auth_oidc.codes"
|
||||
|
||||
|
||||
class CodeStore:
|
||||
"""Holds the codes and associated data"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = Store[dict[str, UserDetails]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._data: dict[str, dict[str, dict | str]] | None = None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
if (data := await self._store.async_load()) is None:
|
||||
data = cast(dict[str, UserDetails], {})
|
||||
self._data = data
|
||||
|
||||
async def async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._data is not None:
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
"""Generate a random six-digit code."""
|
||||
return "".join(random.choices(string.digits, k=6))
|
||||
|
||||
async def async_generate_code_for_userinfo(self, user_info: UserDetails) -> str:
|
||||
"""Generates a one time code and adds it to the database for 5 minutes."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
code = self._generate_code()
|
||||
expiration = datetime.utcnow() + timedelta(minutes=5)
|
||||
|
||||
self._data[code] = {
|
||||
"user_info": user_info,
|
||||
"code": code,
|
||||
"expiration": expiration.isoformat(),
|
||||
}
|
||||
|
||||
await self.async_save()
|
||||
return code
|
||||
|
||||
async def receive_userinfo_for_code(self, code: str) -> Optional[UserDetails]:
|
||||
"""Retrieve user info based on the code."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
user_data = self._data.get(code)
|
||||
|
||||
if user_data:
|
||||
# We should now wipe it from the database, as it's one time use code
|
||||
self._data.pop(code)
|
||||
await self.async_save()
|
||||
|
||||
if (
|
||||
user_data
|
||||
and datetime.fromisoformat(user_data["expiration"]) > datetime.utcnow()
|
||||
):
|
||||
return user_data["user_info"]
|
||||
|
||||
return None
|
||||
191
custom_components/auth_oidc/stores/state_store.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""State Store, store authentication states (redirect_uri)."""
|
||||
|
||||
import secrets
|
||||
import random
|
||||
import string
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast, Optional
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..tools.types import OIDCState, UserDetails
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_provider.auth_oidc.states"
|
||||
MAX_DEVICE_CODE_ATTEMPTS = 10
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Holds the authentication states and associated data"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = Store[dict[str, OIDCState]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._data: dict[str, OIDCState] | None = None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
if (data := await self._store.async_load()) is None:
|
||||
data = cast(dict[str, OIDCState], {})
|
||||
self._data = data
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._data is not None:
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
def _generate_id(self) -> str:
|
||||
"""Generate a random identifier."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
"""Generate a random six-digit code."""
|
||||
return "".join(random.choices(string.digits, k=6))
|
||||
|
||||
def _is_expired(self, state: OIDCState) -> bool:
|
||||
"""Check if a state is expired."""
|
||||
return datetime.fromisoformat(state["expiration"]) < datetime.now(timezone.utc)
|
||||
|
||||
def _is_valid(self, state: OIDCState, ip: str | None) -> bool:
|
||||
"""Check if a state is valid"""
|
||||
return (
|
||||
not self._is_expired(state)
|
||||
and bool(state["redirect_uri"])
|
||||
and ip is not None
|
||||
and state["ip_address"] == ip
|
||||
)
|
||||
|
||||
async def async_create_state_from_url(self, redirect_uri: str, ip: str) -> str:
|
||||
"""Generates a the OIDC state adds it to the database for 5 minutes."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state_id = self._generate_id()
|
||||
expiration = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
|
||||
self._data[state_id] = {
|
||||
"id": state_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"device_code": None,
|
||||
"device_code_attempts": 0,
|
||||
"user_details": None,
|
||||
"expiration": expiration.isoformat(),
|
||||
"ip_address": ip,
|
||||
}
|
||||
|
||||
await self._async_save()
|
||||
return state_id
|
||||
|
||||
async def async_generate_code_for_state(self, state_id: str) -> Optional[str]:
|
||||
"""Generates a one time code for the state to link device clients."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
try:
|
||||
code = self._generate_code()
|
||||
self._data[state_id]["device_code"] = code
|
||||
await self._async_save()
|
||||
return code
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
async def async_add_userinfo_to_state(
|
||||
self, state_id: str, user_info: UserDetails
|
||||
) -> bool:
|
||||
"""Add userinfo to existing state to complete login"""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
try:
|
||||
self._data[state_id]["user_details"] = user_info
|
||||
await self._async_save()
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
async def async_get_redirect_uri_for_state(
|
||||
self, state_id: str, ip: str
|
||||
) -> Optional[str]:
|
||||
"""Get the redirect_uri for a given state_id."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state = self._data.get(state_id)
|
||||
if state and self._is_valid(state, ip):
|
||||
return state["redirect_uri"]
|
||||
|
||||
return None
|
||||
|
||||
async def async_is_state_ready(self, state_id: str, ip: str) -> bool:
|
||||
"""Check if the state has received the user info from the OIDC callback."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state = self._data.get(state_id)
|
||||
return (
|
||||
state is not None
|
||||
and state["user_details"] is not None
|
||||
and self._is_valid(state, ip)
|
||||
)
|
||||
|
||||
async def async_link_state_to_code(
|
||||
self, state_id: str, code: str, ip: str | None
|
||||
) -> bool:
|
||||
"""Link a state to a device code, used for mobile sign-in."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
state_data = self._data.get(state_id)
|
||||
if (
|
||||
state_data
|
||||
and self._is_valid(state_data, ip)
|
||||
and state_data["user_details"] is not None
|
||||
):
|
||||
attempts = state_data.get("device_code_attempts", 0)
|
||||
if attempts >= MAX_DEVICE_CODE_ATTEMPTS:
|
||||
return False
|
||||
|
||||
# Find the state with the matching device code and link it
|
||||
for state in self._data.values():
|
||||
if state["device_code"] == code and not self._is_expired(state):
|
||||
# Set user details on the device state to allow it to complete login
|
||||
state["user_details"] = state_data["user_details"]
|
||||
|
||||
# Delete the 'donor' state as it's one time use
|
||||
self._data.pop(state_id)
|
||||
|
||||
# Save and return true
|
||||
await self._async_save()
|
||||
return True
|
||||
|
||||
state_data["device_code_attempts"] = attempts + 1
|
||||
await self._async_save()
|
||||
|
||||
return False
|
||||
|
||||
async def async_receive_userinfo_for_state(
|
||||
self, state_id: str, ip: str
|
||||
) -> Optional[OIDCState]:
|
||||
"""Retrieve user info based on the state_id."""
|
||||
if self._data is None:
|
||||
raise RuntimeError("Data not loaded")
|
||||
|
||||
user_data = self._data.get(state_id)
|
||||
|
||||
if user_data:
|
||||
# We should now wipe it from the database, as it's one time use
|
||||
self._data.pop(state_id)
|
||||
await self._async_save()
|
||||
|
||||
if user_data and self._is_valid(user_data, ip):
|
||||
return user_data["user_details"]
|
||||
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
"""Get the internal data for testing purposes."""
|
||||
return self._data
|
||||
104
custom_components/auth_oidc/strings.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose OIDC Provider",
|
||||
"description": "Select your OpenID Connect identity provider to get started with the setup.",
|
||||
"data": {
|
||||
"provider": "Provider"
|
||||
}
|
||||
},
|
||||
"discovery_url": {
|
||||
"title": "Provider Configuration",
|
||||
"description": "Enter the discovery URL for {provider_name}. This is typically found in your provider's documentation and usually ends with '/.well-known/openid-configuration'.\n\nNeed detailed setup instructions? See the [provider guide]({documentation_url}).",
|
||||
"data": {
|
||||
"discovery_url": "Discovery URL"
|
||||
}
|
||||
},
|
||||
"client_config": {
|
||||
"title": "Client Configuration",
|
||||
"description": "Configure your OIDC client. You can find these details in your {provider_name} application settings.\n\n**Discovery URL:** {discovery_url}\n\n**Setup Instructions:**\n1. Register a new application in your OIDC provider\n2. Set the application type to 'Public Client' (recommended for most users)\n3. Add redirect URLs for Home Assistant\n4. Copy the Client ID below\n\n**Note:** If your provider requires a client secret, check 'Use Confidential Client' and provide your client secret below.\n\n**Need detailed setup instructions?** Check the [setup guide]({documentation_url}) for step-by-step instructions.",
|
||||
"data": {
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client Secret (optional; required by some providers)"
|
||||
}
|
||||
},
|
||||
"client_secret": {
|
||||
"title": "Client Secret Configuration",
|
||||
"description": "Since you selected 'Confidential Client', please provide your client secret.\n\n**Provider:** {provider_name}\n**Client ID:** {client_id}\n**Discovery URL:** {discovery_url}\n\n**Security Note:** The client secret will be stored securely in Home Assistant's configuration. Never share your client secret with others.",
|
||||
"data": {
|
||||
"client_secret": "Client Secret"
|
||||
}
|
||||
},
|
||||
"validate_connection": {
|
||||
"title": "Connection Validation",
|
||||
"description": "Testing connection to your {provider_name} OIDC provider...\n\n**Discovery URL:** {discovery_url}\n**Client ID:** {client_id}\n\n{discovery_details}\n\n**What to do next:**\n- **Continue Setup:** Proceed with the configuration (when validation succeeds)\n- **Retry Validation:** Test the connection again with current settings\n- **Modify Client Settings:** Go back to change Client ID or secret\n- **Modify Discovery URL:** Go back to change the discovery URL\n- **Change Provider:** Start over with a different provider\n\n**Need Help?** Check the [setup documentation]({documentation_url}) for detailed configuration instructions.",
|
||||
"data": {
|
||||
"action": "Choose an action"
|
||||
}
|
||||
},
|
||||
"groups_config": {
|
||||
"title": "Groups & Role Configuration",
|
||||
"description": "Configure how user groups from {provider_name} should be mapped to Home Assistant roles.\n\n**Groups Support:** Groups allow you to automatically assign admin or user roles based on group membership in your identity provider.\n\n**Admin Group:** Users in this group will have administrator access\n**User Group:** Users in this group will have standard user access (leave empty to allow all authenticated users)",
|
||||
"data": {
|
||||
"enable_groups": "Enable group-based role assignment",
|
||||
"admin_group": "Admin group name",
|
||||
"user_group": "User group name (optional)"
|
||||
}
|
||||
},
|
||||
"user_linking": {
|
||||
"title": "User Linking Options",
|
||||
"description": "Configure how OIDC users are linked to existing Home Assistant users.\n\n**⚠️ Important Security Information:**\n\n**User Linking Disabled (Recommended):** New OIDC accounts are created for each user. This is the most secure option.\n\n**User Linking Enabled:** OIDC users can be linked to existing Home Assistant users by username. **This has security implications:**\n- If someone can guess or obtain a Home Assistant username, they might gain access to that account\n- Only enable this if you're migrating from local Home Assistant accounts to OIDC\n- You can disable this later if needed",
|
||||
"data": {
|
||||
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)"
|
||||
}
|
||||
},
|
||||
"finalize": {
|
||||
"title": "Setup Complete",
|
||||
"description": "Your OIDC authentication is now configured and ready to use.\n\n**Next Steps:**\n1. Save this configuration\n2. Restart Home Assistant if prompted\n3. The OIDC login option will appear on your login screen\n\n**Advanced Configuration:**\nAdvanced options like custom networking settings, specific claim configurations, or custom scopes are only available through YAML configuration. See the documentation for details.",
|
||||
"data": {}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure OIDC Authentication",
|
||||
"description": "Update your OIDC client credentials for {provider_name}.\n\n**Discovery URL:** {discovery_url}\n\n**What you can change:**\n- **Client ID**: Update your application's client identifier\n- **Client Type**: Switch between Public and Confidential client types\n- **Client Secret**: Update or add a client secret (for confidential clients)\n\n**Note:** Changes will be validated against your OIDC provider before being saved. Your existing settings will be preserved if validation fails.\n\n**Security:** For confidential clients, leave the client secret field empty to keep your existing secret unchanged.",
|
||||
"data": {
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client Secret (leave empty to keep current)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the OIDC provider. Please check your network connection and discovery URL.",
|
||||
"discovery_invalid": "The discovery document could not be retrieved or is invalid. Please verify the discovery URL is correct.",
|
||||
"jwks_invalid": "Failed to retrieve or validate the JWKS (JSON Web Key Set). Please check your provider configuration.",
|
||||
"invalid_client_credentials": "The client ID or client secret appears to be invalid. Please check your OIDC application settings and ensure the credentials are correct.",
|
||||
"client_secret_required": "Client secret is required when using confidential client mode.",
|
||||
"invalid_url_format": "The discovery URL must be a valid HTTP or HTTPS URL.",
|
||||
"invalid_client_id": "Client ID cannot be empty and must contain valid characters.",
|
||||
"no_url_available": "Unable to determine Home Assistant URL for OAuth redirect. Please check your network configuration.",
|
||||
"auth_url_failed": "Failed to generate authorization URL for OAuth test.",
|
||||
"unknown": "An unexpected error occurred. Please check the logs for more details."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This OIDC provider is already configured.",
|
||||
"cannot_connect": "Unable to connect to the OIDC provider.",
|
||||
"invalid_discovery": "Invalid discovery document received from the provider.",
|
||||
"reconfigure_successful": "OIDC Authentication has been successfully reconfigured with the updated client credentials.",
|
||||
"single_instance_allowed": "OIDC Authentication only supports a single configuration. You already have OIDC configured (either through YAML or the UI). To modify your existing configuration, go to Settings > Devices & Services > OIDC Authentication and click 'Configure'. To replace your configuration, first remove the existing one."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "OIDC Authentication Options",
|
||||
"description": "Update configuration options for your {provider_name} OIDC authentication.\n\n**User Linking:** Control how OIDC users are linked to existing Home Assistant accounts (⚠️ security implications).\n\n**Groups Configuration:** Configure role assignment based on group membership from your identity provider.\n\n**Note:** Changes take effect immediately but may require users to log out and back in.",
|
||||
"data": {
|
||||
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)",
|
||||
"enable_groups": "Enable group-based role assignment",
|
||||
"admin_group": "Admin group name",
|
||||
"user_group": "User group name (optional - leave empty to allow all authenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
custom_components/auth_oidc/tools/__init__.py
Normal file
69
custom_components/auth_oidc/tools/helpers.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Helper functions for the integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import http
|
||||
from aiohttp import web
|
||||
|
||||
from ..views.loader import AsyncTemplateRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..provider import OpenIDAuthProvider
|
||||
|
||||
STATE_COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
def get_url(path: str, force_https: bool) -> str:
|
||||
"""Returns the requested path appended to the current request base URL."""
|
||||
if (req := http.current_request.get()) is None:
|
||||
raise RuntimeError("No current request in context")
|
||||
|
||||
base_uri = str(req.url).split("/auth", 2)[0]
|
||||
if force_https:
|
||||
base_uri = base_uri.replace("http://", "https://")
|
||||
return f"{base_uri}{path}"
|
||||
|
||||
|
||||
async def get_view(template: str, parameters: dict | None = None) -> str:
|
||||
"""Returns the generated HTML of the requested view."""
|
||||
if parameters is None:
|
||||
parameters = {}
|
||||
|
||||
renderer = AsyncTemplateRenderer()
|
||||
return await renderer.render_template(f"{template}.html", **parameters)
|
||||
|
||||
|
||||
def get_state_id(request: web.Request) -> str | None:
|
||||
"""Return the current OIDC state cookie, if present."""
|
||||
return request.cookies.get(STATE_COOKIE_NAME)
|
||||
|
||||
|
||||
async def get_valid_state_id(
|
||||
request: web.Request, oidc_provider: "OpenIDAuthProvider"
|
||||
) -> str | None:
|
||||
"""Return state id only when cookie exists and state is still valid."""
|
||||
state_id = get_state_id(request)
|
||||
if not state_id:
|
||||
return None
|
||||
|
||||
if not await oidc_provider.async_is_state_valid(state_id):
|
||||
return None
|
||||
|
||||
return state_id
|
||||
|
||||
|
||||
def html_response(html: str, status: int = 200) -> web.Response:
|
||||
"""Return an HTML response with the standard content type."""
|
||||
return web.Response(text=html, content_type="text/html", status=status)
|
||||
|
||||
|
||||
async def template_response(
|
||||
template: str, parameters: dict | None = None
|
||||
) -> web.Response:
|
||||
"""Render a template and return it as an HTML response."""
|
||||
return html_response(await get_view(template, parameters))
|
||||
|
||||
|
||||
async def error_response(message: str, status: int = 400) -> web.Response:
|
||||
"""Render the shared error view."""
|
||||
return html_response(await get_view("error", {"error": message}), status=status)
|
||||
706
custom_components/auth_oidc/tools/oidc_client.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""OIDC Client class"""
|
||||
|
||||
import urllib.parse
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from functools import partial
|
||||
import aiohttp
|
||||
from joserfc import jwt, jwk, jws, errors as joserfc_errors
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .types import UserDetails
|
||||
from ..config.const import (
|
||||
FEATURES_DISABLE_PKCE,
|
||||
CLAIMS_DISPLAY_NAME,
|
||||
CLAIMS_USERNAME,
|
||||
CLAIMS_GROUPS,
|
||||
ROLE_ADMINS,
|
||||
ROLE_USERS,
|
||||
NETWORK_TLS_VERIFY,
|
||||
NETWORK_TLS_CA_PATH,
|
||||
DEFAULT_ID_TOKEN_SIGNING_ALGORITHM,
|
||||
)
|
||||
from .validation import validate_url
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCClientException(Exception):
|
||||
"Raised when the OIDC Client encounters an error"
|
||||
|
||||
|
||||
class OIDCDiscoveryInvalid(OIDCClientException):
|
||||
"Raised when the discovery document is not found, invalid or otherwise malformed."
|
||||
|
||||
type: Optional[str]
|
||||
details: Optional[dict]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.message = "OIDC Discovery document is invalid"
|
||||
self.type = kwargs.pop("type", None)
|
||||
self.details = kwargs.pop("details", None)
|
||||
super().__init__(self.message)
|
||||
|
||||
def get_detail_string(self) -> str:
|
||||
"""Returns a detailed string for logging purposes."""
|
||||
string = []
|
||||
|
||||
if self.type:
|
||||
string.append(f"type: {self.type}")
|
||||
|
||||
if self.details:
|
||||
for key, value in self.details.items():
|
||||
string.append(f"{key}: {value}")
|
||||
|
||||
return ", ".join(string)
|
||||
|
||||
|
||||
class OIDCTokenResponseInvalid(OIDCClientException):
|
||||
"Raised when the token request returns invalid."
|
||||
|
||||
|
||||
class OIDCJWKSInvalid(OIDCClientException):
|
||||
"Raised when the JWKS is invalid or cannot be obtained."
|
||||
|
||||
|
||||
class OIDCStateInvalid(OIDCClientException):
|
||||
"Raised when the state for your request cannot be matched against a stored state."
|
||||
|
||||
|
||||
class OIDCUserinfoInvalid(OIDCClientException):
|
||||
"Raised when the user info is invalid or cannot be obtained."
|
||||
|
||||
|
||||
class OIDCIdTokenSigningAlgorithmInvalid(OIDCTokenResponseInvalid):
|
||||
"Raised when the id_token is signed with the wrong algorithm, adjust your config accordingly."
|
||||
|
||||
|
||||
class HTTPClientError(aiohttp.ClientResponseError):
|
||||
"Raised when the HTTP client encounters not OK (200) status code."
|
||||
|
||||
body: str
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.body = kwargs.pop("body")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.status} ({self.message}) with response body: {self.body}"
|
||||
|
||||
|
||||
async def http_raise_for_status(response: aiohttp.ClientResponse) -> None:
|
||||
"""Raises an exception if the response is not OK."""
|
||||
if not response.ok:
|
||||
# reason should always be not None for a started response
|
||||
assert response.reason is not None
|
||||
body = await response.text()
|
||||
|
||||
raise HTTPClientError(
|
||||
response.request_info,
|
||||
response.history,
|
||||
status=response.status,
|
||||
message=response.reason,
|
||||
headers=response.headers,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
class OIDCDiscoveryClient:
|
||||
"""OIDC Discovery Client implementation for Python"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
discovery_url: str,
|
||||
http_session: aiohttp.ClientSession,
|
||||
verification_context: dict,
|
||||
):
|
||||
self.discovery_url = discovery_url
|
||||
self.http_session = http_session
|
||||
self.verification_context = verification_context
|
||||
|
||||
async def _fetch_discovery_document(self):
|
||||
"""Fetches discovery document from the given URL."""
|
||||
try:
|
||||
async with self.http_session.get(self.discovery_url) as response:
|
||||
await http_raise_for_status(response)
|
||||
return await response.json()
|
||||
except HTTPClientError as e:
|
||||
if e.status == 404:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document not found at %s", self.discovery_url
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Error fetching discovery: %s", e)
|
||||
raise OIDCDiscoveryInvalid(type="fetch_error") from e
|
||||
|
||||
async def _fetch_jwks(self, jwks_uri):
|
||||
"""Fetches JWKS from the given URL."""
|
||||
try:
|
||||
async with self.http_session.get(jwks_uri) as response:
|
||||
await http_raise_for_status(response)
|
||||
return await response.json()
|
||||
except HTTPClientError as e:
|
||||
_LOGGER.warning("Error fetching JWKS: %s", e)
|
||||
raise OIDCJWKSInvalid from e
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
async def _validate_discovery_document(self, document):
|
||||
"""Validates the discovery document."""
|
||||
|
||||
# Verify that required endpoints are present
|
||||
required_endpoints = [
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"jwks_uri",
|
||||
]
|
||||
|
||||
for endpoint in required_endpoints:
|
||||
if endpoint not in document:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s is missing required endpoint: %s",
|
||||
self.discovery_url,
|
||||
endpoint,
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="missing_endpoint", details={"endpoint": endpoint}
|
||||
)
|
||||
if validate_url(document[endpoint]) is False:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s has invalid URL in endpoint: %s (%s)",
|
||||
self.discovery_url,
|
||||
endpoint,
|
||||
document[endpoint],
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="invalid_endpoint",
|
||||
details={"endpoint": endpoint, "url": document[endpoint]},
|
||||
)
|
||||
|
||||
# Verify optional response_modes_supported
|
||||
if "response_modes_supported" in document:
|
||||
if "query" not in document["response_modes_supported"]:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not support required 'query' "
|
||||
"response mode, only supports: %s",
|
||||
self.discovery_url,
|
||||
document["response_modes_supported"],
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="does_not_support_response_mode",
|
||||
details={"modes": document["response_modes_supported"]},
|
||||
)
|
||||
|
||||
# If grant_types_supported is set, should support 'authorization_code'
|
||||
if "grant_types_supported" in document:
|
||||
if "authorization_code" not in document["grant_types_supported"]:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not support required "
|
||||
"'authorization_code' grant type, only supports: %s",
|
||||
self.discovery_url,
|
||||
document["grant_types_supported"],
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="does_not_support_grant_type",
|
||||
details={
|
||||
"required": "authorization_code",
|
||||
"supported": document["grant_types_supported"],
|
||||
},
|
||||
)
|
||||
|
||||
# If response_types_supported is set, should support 'code'
|
||||
if "response_types_supported" in document:
|
||||
if "code" not in document["response_types_supported"]:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not support required "
|
||||
"'code' response type, only supports: %s",
|
||||
self.discovery_url,
|
||||
document["response_types_supported"],
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="does_not_support_response_type",
|
||||
details={
|
||||
"required": "code",
|
||||
"supported": document["response_types_supported"],
|
||||
},
|
||||
)
|
||||
|
||||
# If code_challenge_methods_supported is present, check that it contains S256
|
||||
if "code_challenge_methods_supported" in document:
|
||||
if "S256" not in document["code_challenge_methods_supported"]:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not support required "
|
||||
"'S256' code challenge method, only supports: %s",
|
||||
self.discovery_url,
|
||||
document["code_challenge_methods_supported"],
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="does_not_support_required_code_challenge_method",
|
||||
details={
|
||||
"required": "S256",
|
||||
"supported": document["code_challenge_methods_supported"],
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the id_token_signing_alg_values_supported field is present and filled
|
||||
signing_values = document.get("id_token_signing_alg_values_supported", None)
|
||||
if signing_values is None:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not have "
|
||||
"'id_token_signing_alg_values_supported' field",
|
||||
self.discovery_url,
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(type="missing_id_token_signing_alg_values")
|
||||
|
||||
# Verify that the requested id_token_signing_alg is supported
|
||||
requested_alg = self.verification_context.get("id_token_signing_alg", None)
|
||||
if requested_alg is not None and requested_alg not in signing_values:
|
||||
_LOGGER.warning(
|
||||
"Error: Discovery document %s does not support requested "
|
||||
"id_token_signing_alg '%s', only supports: %s",
|
||||
self.discovery_url,
|
||||
requested_alg,
|
||||
signing_values,
|
||||
)
|
||||
raise OIDCDiscoveryInvalid(
|
||||
type="does_not_support_id_token_signing_alg",
|
||||
details={"requested": requested_alg, "supported": signing_values},
|
||||
)
|
||||
|
||||
async def fetch_discovery_document(self):
|
||||
"""Fetches discovery document."""
|
||||
document = await self._fetch_discovery_document()
|
||||
await self._validate_discovery_document(document)
|
||||
return document
|
||||
|
||||
async def fetch_jwks(self, jwks_uri: str | None = None):
|
||||
"""Fetches JWKS."""
|
||||
if jwks_uri is None:
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
jwks_uri = discovery_document["jwks_uri"]
|
||||
return await self._fetch_jwks(jwks_uri)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OIDCClient:
|
||||
"""OIDC Client implementation for Python, including PKCE."""
|
||||
|
||||
# HTTP session to be used
|
||||
http_session: aiohttp.ClientSession = None
|
||||
|
||||
# OIDC Discovery tool to be used
|
||||
discovery_class: OIDCDiscoveryClient = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
discovery_url: str,
|
||||
client_id: str,
|
||||
scope: str,
|
||||
**kwargs: str,
|
||||
):
|
||||
self.hass = hass
|
||||
self.discovery_url = discovery_url
|
||||
self.discovery_document = None
|
||||
self.client_id = client_id
|
||||
self.scope = scope
|
||||
|
||||
# Stores code_verifier and nonce for active authorization flows.
|
||||
self.flows: dict[str, dict[str, str]] = {}
|
||||
|
||||
# Optional parameters
|
||||
self.client_secret = kwargs.get("client_secret")
|
||||
|
||||
# Default id_token_signing_alg to RS256 if not specified
|
||||
self.id_token_signing_alg = kwargs.get("id_token_signing_alg")
|
||||
if self.id_token_signing_alg is None:
|
||||
self.id_token_signing_alg = DEFAULT_ID_TOKEN_SIGNING_ALGORITHM
|
||||
|
||||
features = kwargs.get("features")
|
||||
claims = kwargs.get("claims")
|
||||
roles = kwargs.get("roles")
|
||||
network = kwargs.get("network")
|
||||
|
||||
self.disable_pkce = features.get(FEATURES_DISABLE_PKCE, False)
|
||||
self.display_name_claim = claims.get(CLAIMS_DISPLAY_NAME, "name")
|
||||
self.username_claim = claims.get(CLAIMS_USERNAME, "preferred_username")
|
||||
self.groups_claim = claims.get(CLAIMS_GROUPS, "groups")
|
||||
self.user_role = roles.get(ROLE_USERS, None)
|
||||
self.admin_role = roles.get(ROLE_ADMINS, "admins")
|
||||
self.tls_verify = network.get(NETWORK_TLS_VERIFY, True)
|
||||
self.tls_ca_path = network.get(NETWORK_TLS_CA_PATH)
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup the HTTP session."""
|
||||
|
||||
# HA never seems to run this, but it's good practice to close the session
|
||||
if self.http_session:
|
||||
_LOGGER.debug("Closing HTTP session")
|
||||
self.http_session.close()
|
||||
|
||||
def _base64url_encode(self, value: str) -> str:
|
||||
"""Uses base64url encoding on a given string"""
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("utf-8")
|
||||
|
||||
def _generate_random_url_string(self, length: int = 16) -> str:
|
||||
"""Generates a random URL safe string (base64_url encoded)"""
|
||||
return self._base64url_encode(os.urandom(length))
|
||||
|
||||
async def _get_http_session(self) -> aiohttp.ClientSession:
|
||||
"""Create or get the existing client session with custom networking/TLS options"""
|
||||
if self.http_session is not None:
|
||||
return self.http_session
|
||||
|
||||
_LOGGER.debug(
|
||||
"Creating HTTP session provider with options: "
|
||||
+ "verify certificates: %r, custom CA file: %s",
|
||||
self.tls_verify,
|
||||
self.tls_ca_path,
|
||||
)
|
||||
|
||||
tcp_connector_args = {"verify_ssl": self.tls_verify}
|
||||
|
||||
if self.tls_ca_path:
|
||||
# Move to hass' executor to prevent blocking code inside non-blocking method
|
||||
ssl_context = await self.hass.loop.run_in_executor(
|
||||
None, partial(ssl.create_default_context, cafile=self.tls_ca_path)
|
||||
)
|
||||
tcp_connector_args["ssl"] = ssl_context
|
||||
|
||||
self.http_session = aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(**tcp_connector_args)
|
||||
)
|
||||
return self.http_session
|
||||
|
||||
async def _make_token_request(self, token_endpoint, query_params):
|
||||
"""Performs the token POST call"""
|
||||
try:
|
||||
session = await self._get_http_session()
|
||||
|
||||
async with session.post(token_endpoint, data=query_params) as response:
|
||||
await http_raise_for_status(response)
|
||||
return await response.json()
|
||||
except HTTPClientError as e:
|
||||
if e.status == 400:
|
||||
_LOGGER.warning(
|
||||
"Error: Token could not be obtained (%s, %s), "
|
||||
+ "did you forget the client_secret? Server returned: %s",
|
||||
e.status,
|
||||
e.message,
|
||||
e.body,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Unexpected error exchanging token: %s", e)
|
||||
|
||||
raise OIDCTokenResponseInvalid from e
|
||||
|
||||
async def _get_userinfo(self, userinfo_uri, access_token):
|
||||
"""Fetches userinfo from the given URL."""
|
||||
try:
|
||||
session = await self._get_http_session()
|
||||
headers = {"Authorization": "Bearer " + access_token}
|
||||
|
||||
async with session.get(userinfo_uri, headers=headers) as response:
|
||||
await http_raise_for_status(response)
|
||||
return await response.json()
|
||||
except HTTPClientError as e:
|
||||
_LOGGER.warning("Error fetching userinfo: %s", e)
|
||||
raise OIDCUserinfoInvalid from e
|
||||
|
||||
async def _fetch_discovery_document(self):
|
||||
"""Fetches discovery document."""
|
||||
if self.discovery_document is not None:
|
||||
return self.discovery_document
|
||||
|
||||
if self.discovery_class is None:
|
||||
session = await self._get_http_session()
|
||||
self.discovery_class = OIDCDiscoveryClient(
|
||||
discovery_url=self.discovery_url,
|
||||
http_session=session,
|
||||
verification_context={
|
||||
"id_token_signing_alg": self.id_token_signing_alg,
|
||||
},
|
||||
)
|
||||
|
||||
self.discovery_document = await self.discovery_class.fetch_discovery_document()
|
||||
return self.discovery_document
|
||||
|
||||
async def _fetch_jwks(self, jwks_uri: str):
|
||||
"""Fetches JWKS."""
|
||||
return await self.discovery_class.fetch_jwks(jwks_uri)
|
||||
|
||||
async def _parse_id_token(self, id_token: str) -> Optional[dict]:
|
||||
"""Parses the ID token into a dict containing token contents."""
|
||||
if self.discovery_document is None:
|
||||
self.discovery_document = await self._fetch_discovery_document()
|
||||
|
||||
jwks_uri = self.discovery_document["jwks_uri"]
|
||||
jwks_data = await self._fetch_jwks(jwks_uri)
|
||||
|
||||
try:
|
||||
# Obtain the id_token header
|
||||
token_obj = jws.extract_compact(id_token.encode())
|
||||
unverified_header = token_obj.protected
|
||||
if not unverified_header:
|
||||
_LOGGER.warning("Could not get header from received id_token.")
|
||||
return None
|
||||
|
||||
# Obtain the signing algorithm from the header of the id_token
|
||||
alg = unverified_header.get("alg")
|
||||
if alg != self.id_token_signing_alg:
|
||||
# Verify that it matches our requested algorithm
|
||||
_LOGGER.warning(
|
||||
"ID Token received signed with the wrong algorithm: %s, expected %s",
|
||||
alg,
|
||||
self.id_token_signing_alg,
|
||||
)
|
||||
raise OIDCIdTokenSigningAlgorithmInvalid()
|
||||
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.8
|
||||
# If the JWT alg Header Parameter uses a MAC based algorithm
|
||||
# such as HS256, HS384, or HS512, the octets of the UTF-8 [RFC3629]
|
||||
# representation of the client_secret corresponding to the client_id
|
||||
# contained in the aud (audience) Claim are used as the key to
|
||||
# validate the signature.
|
||||
if alg.startswith("HS"):
|
||||
if not self.client_secret:
|
||||
_LOGGER.warning(
|
||||
"ID Token signed with HMAC algorithm, but no client_secret provided."
|
||||
)
|
||||
raise OIDCIdTokenSigningAlgorithmInvalid()
|
||||
|
||||
jwk_obj = jwk.import_key(
|
||||
{
|
||||
"kty": "oct",
|
||||
"k": base64.urlsafe_b64encode(
|
||||
self.client_secret.encode()
|
||||
).decode(),
|
||||
"alg": alg,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# TODO: Deal with cases where kid is not specified (just take the first key?)
|
||||
# Obtain the kid (Key ID) from the header of the id_token
|
||||
kid = unverified_header.get("kid")
|
||||
if not kid:
|
||||
_LOGGER.warning("JWT does not have kid (Key ID)")
|
||||
return None
|
||||
|
||||
# Get the correct key
|
||||
signing_key = None
|
||||
for key in jwks_data["keys"]:
|
||||
if key["kid"] == kid:
|
||||
signing_key = key
|
||||
break
|
||||
|
||||
if not signing_key:
|
||||
_LOGGER.warning("Could not find matching key with kid: %s", kid)
|
||||
return None
|
||||
|
||||
# If signing_key does not have alg, set it to the one passed in the token
|
||||
if "alg" not in signing_key:
|
||||
signing_key["alg"] = alg
|
||||
|
||||
# Construct the JWK from the RSA key
|
||||
jwk_obj = jwk.import_key(signing_key)
|
||||
|
||||
# Decode the token, decode does not verify it
|
||||
decoded_token = jwt.decode(
|
||||
id_token,
|
||||
jwk_obj,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.6
|
||||
# The Client MUST validate the signature of all other ID Tokens
|
||||
# according to JWS [JWS] using the algorithm specified in the JWT
|
||||
# alg Header Parameter.
|
||||
algorithms=[self.id_token_signing_alg],
|
||||
)
|
||||
|
||||
# Create Claims Registry for validation
|
||||
id_token_validator = jwt.JWTClaimsRegistry(
|
||||
leeway=5,
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.3
|
||||
# The Client MUST validate that the aud (audience) Claim contains
|
||||
# its client_id value registered at the Issuer identified by the
|
||||
# iss (issuer) Claim as an audience.
|
||||
aud={"essential": True, "value": self.client_id},
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.2
|
||||
# The Issuer Identifier for the OpenID Provider MUST exactly
|
||||
# match the value of the iss (issuer) Claim.
|
||||
iss={"essential": True, "value": self.discovery_document["issuer"]},
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.9
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.10
|
||||
# No need to specify exp, nbf, iat, they are in here by default
|
||||
sub={"essential": True},
|
||||
)
|
||||
|
||||
id_token_validator.validate(decoded_token.claims)
|
||||
return decoded_token.claims
|
||||
|
||||
except joserfc_errors.JoseError as e:
|
||||
_LOGGER.warning("JWT verification failed: %s", e)
|
||||
return None
|
||||
|
||||
async def async_get_authorization_url(
|
||||
self, redirect_uri: str, state: str
|
||||
) -> Optional[str]:
|
||||
"""Generates the authorization URL for the OIDC flow."""
|
||||
try:
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
auth_endpoint = discovery_document["authorization_endpoint"]
|
||||
|
||||
# Generate random nonce & state
|
||||
nonce = self._generate_random_url_string()
|
||||
|
||||
# Generate PKCE (RFC 7636) parameters
|
||||
code_verifier = self._generate_random_url_string(32)
|
||||
code_challenge = self._base64url_encode(
|
||||
hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
||||
)
|
||||
|
||||
# Save all of them for later verification
|
||||
self.flows[state] = {"code_verifier": code_verifier, "nonce": nonce}
|
||||
|
||||
# Construct the params
|
||||
query_params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": self.scope,
|
||||
"state": state,
|
||||
# Nonce is always set in accordance with OpenID Connect Core 1.0
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
# We always want to use PKCE (RFC 7636), unless it's disabled for compatibility.
|
||||
# PKCE is the recommended method of securing the authorization code grant
|
||||
# for public clients as much as possible.
|
||||
# (see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-7.5.1)
|
||||
if not self.disable_pkce:
|
||||
query_params["code_challenge"] = code_challenge
|
||||
query_params["code_challenge_method"] = "S256"
|
||||
|
||||
url = f"{auth_endpoint}?{urllib.parse.urlencode(query_params)}"
|
||||
return url
|
||||
except OIDCClientException as e:
|
||||
_LOGGER.warning("Error generating authorization URL: %s", e)
|
||||
return None
|
||||
|
||||
async def parse_user_details(self, id_token: str, access_token: str) -> UserDetails:
|
||||
"""Parses the ID token and/or userinfo into user details."""
|
||||
|
||||
# Fetch userinfo if there is an userinfo_endpoint available
|
||||
# and use the data to supply the missing values in id_token
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
if "userinfo_endpoint" in discovery_document:
|
||||
userinfo_endpoint = discovery_document["userinfo_endpoint"]
|
||||
userinfo = await self._get_userinfo(userinfo_endpoint, access_token)
|
||||
|
||||
# Replace missing claims in the id_token with their userinfo version
|
||||
for claim in (
|
||||
self.groups_claim,
|
||||
self.display_name_claim,
|
||||
self.username_claim,
|
||||
):
|
||||
if claim not in id_token and claim in userinfo:
|
||||
id_token[claim] = userinfo[claim]
|
||||
|
||||
# Get and parse groups (to check if it's an array)
|
||||
groups = id_token.get(self.groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
_LOGGER.warning("Groups claim is not a list, using empty list instead.")
|
||||
groups = []
|
||||
|
||||
# Assign role if user has the required groups
|
||||
role = "invalid"
|
||||
if self.user_role in groups or self.user_role is None:
|
||||
role = "system-users"
|
||||
|
||||
if self.admin_role in groups:
|
||||
role = "system-admin"
|
||||
|
||||
# Create a user details dict based on the contents of the id_token & userinfo
|
||||
return {
|
||||
# Subject Identifier. A locally unique and never reassigned identifier within the
|
||||
# Issuer for the End-User, which is intended to be consumed by the Client
|
||||
# Only unique per issuer, so we combine it with the issuer and hash it.
|
||||
# This might allow multiple OIDC providers to be used with this integration.
|
||||
"sub": hashlib.sha256(
|
||||
f"{discovery_document['issuer']}.{id_token.get('sub')}".encode("utf-8")
|
||||
).hexdigest(),
|
||||
# Display name, configurable
|
||||
"display_name": id_token.get(self.display_name_claim),
|
||||
# Username, configurable
|
||||
"username": id_token.get(self.username_claim),
|
||||
# Role
|
||||
"role": role,
|
||||
}
|
||||
|
||||
async def async_complete_token_flow(
|
||||
self, redirect_uri: str, code: str, state: str
|
||||
) -> Optional[UserDetails]:
|
||||
"""Completes the OIDC token flow to obtain a user's details."""
|
||||
|
||||
try:
|
||||
flow = self.flows.pop(state, None)
|
||||
if flow is None:
|
||||
raise OIDCStateInvalid
|
||||
|
||||
discovery_document = await self._fetch_discovery_document()
|
||||
token_endpoint = discovery_document["token_endpoint"]
|
||||
|
||||
# Construct the params
|
||||
query_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
|
||||
# Send the client secret if we have one
|
||||
if self.client_secret is not None:
|
||||
query_params["client_secret"] = self.client_secret
|
||||
|
||||
# If we disable PKCE, don't send the code verifier
|
||||
if not self.disable_pkce:
|
||||
query_params["code_verifier"] = flow["code_verifier"]
|
||||
|
||||
# Exchange the code for a token
|
||||
token_response = await self._make_token_request(
|
||||
token_endpoint, query_params
|
||||
)
|
||||
|
||||
id_token = token_response.get("id_token")
|
||||
|
||||
# Parse the id token to obtain the relevant details
|
||||
id_token = await self._parse_id_token(id_token)
|
||||
|
||||
if id_token is None:
|
||||
_LOGGER.warning("ID token could not be parsed!")
|
||||
return None
|
||||
|
||||
# OpenID Connect Core 1.0 Section 3.1.3.7.11
|
||||
# If a nonce value was sent in the Authentication Request,
|
||||
# a nonce Claim MUST be present and its value checked to verify
|
||||
# that it is the same value as the one that was sent in the Authentication Request.
|
||||
if id_token.get("nonce") != flow["nonce"]:
|
||||
_LOGGER.warning("Nonce mismatch!")
|
||||
return None
|
||||
|
||||
access_token = token_response.get("access_token")
|
||||
data = await self.parse_user_details(id_token, access_token)
|
||||
|
||||
# Log which details were obtained for debugging
|
||||
# Also log the original subject identifier such that you can look it up in your provider
|
||||
_LOGGER.debug(
|
||||
"Obtained user details from OIDC provider: %s (issuer subject: %s)",
|
||||
data,
|
||||
id_token.get("sub"),
|
||||
)
|
||||
return data
|
||||
except OIDCClientException as e:
|
||||
_LOGGER.warning("Failed to complete token flow, returning None. (%s)", e)
|
||||
return None
|
||||
57
custom_components/auth_oidc/tools/types.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Generic data types"""
|
||||
|
||||
# Dict class to give a type to the user details
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class UserDetails(dict):
|
||||
"""User details representation"""
|
||||
|
||||
# User subject, persistent identifier
|
||||
sub: str
|
||||
# Full name of the user for display purposes
|
||||
display_name: str
|
||||
# Preferred username for the user, will be used when first generating the account
|
||||
# or to link the account on first login
|
||||
username: str
|
||||
# Home Assistant role to assign to this user
|
||||
role: Literal["system-admin", "system-users", "invalid"]
|
||||
|
||||
|
||||
class OIDCState(dict):
|
||||
"""OIDC State representation"""
|
||||
|
||||
# ID of this state
|
||||
id: str
|
||||
|
||||
# User friendly device code
|
||||
device_code: str | None
|
||||
|
||||
# The redirect_uri associated with this state,
|
||||
# to be able to redirect the user back after authentication
|
||||
redirect_uri: str
|
||||
|
||||
# User details, if available
|
||||
user_details: UserDetails | None
|
||||
|
||||
# Expiration time of this state, in ISO format
|
||||
expiration: str
|
||||
|
||||
# IP address
|
||||
ip_address: str | None
|
||||
|
||||
|
||||
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
|
||||
37
custom_components/auth_oidc/tools/validation.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Validation and sanitization helpers for config flow inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
"""Validate that a URL is properly formatted."""
|
||||
try:
|
||||
parsed = urlparse(url.strip())
|
||||
return bool(parsed.scheme in ("http", "https") and parsed.netloc)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_discovery_url(url: str) -> bool:
|
||||
"""Validate that a URL is properly formatted for OIDC discovery."""
|
||||
try:
|
||||
parsed = urlparse(url.strip())
|
||||
return bool(
|
||||
parsed.scheme in ("http", "https")
|
||||
and parsed.netloc
|
||||
and parsed.path.endswith("/.well-known/openid-configuration")
|
||||
)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_client_secret(secret: str) -> str:
|
||||
"""Sanitize client secret input."""
|
||||
return secret.strip() if secret else ""
|
||||
|
||||
|
||||
def validate_client_id(client_id: str) -> bool:
|
||||
"""Validate client ID format."""
|
||||
return bool(client_id and client_id.strip())
|
||||
94
custom_components/auth_oidc/translations/en.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose OIDC Provider",
|
||||
"description": "Select your OpenID Connect identity provider to get started with the setup.\n\nIf you want to use a provider that isn't listed, try the Generic OpenID Connect provider or use the advanced YAML configuration instead.",
|
||||
"data": {
|
||||
"provider": "Provider"
|
||||
}
|
||||
},
|
||||
"discovery_url": {
|
||||
"title": "Provider Configuration",
|
||||
"description": "Enter the discovery URL for {provider_name}. This is typically found in your provider's admin interface and ends with '/.well-known/openid-configuration'.\n\nNeed detailed setup instructions? See the [provider guide]({documentation_url}).",
|
||||
"data": {
|
||||
"discovery_url": "Discovery URL"
|
||||
}
|
||||
},
|
||||
"validate_connection": {
|
||||
"title": "Connection Validation",
|
||||
"description": "Testing connection to your {provider_name} OIDC provider...\n\n**Discovery URL:** {discovery_url}\n\n{discovery_details}\n\n**What to do next:**\n- **Continue Setup:** Proceed with the configuration (when validation succeeds)\n- **Retry Validation:** Test the connection again with current settings\n- **Modify Discovery URL:** Go back to change the discovery URL\n- **Change Provider:** Start over with a different provider\n\n**Need Help?** Check the [setup documentation]({documentation_url}) for detailed configuration instructions.",
|
||||
"data": {
|
||||
"action": "Choose an action"
|
||||
}
|
||||
},
|
||||
"client_config": {
|
||||
"title": "Client Configuration",
|
||||
"description": "Configure your OIDC client. You can find these details in your {provider_name} application settings.\n\n**Discovery URL:** {discovery_url}\n\n**Setup Instructions:**\n1. Register a new application in your OIDC provider\n2. Set the application type to 'Public Client' (recommended for most users)\n3. Add redirect URLs for Home Assistant\n4. Copy the Client ID below\n\n**Note:** If your provider requires a client secret, check 'Use Confidential Client' and provide your client secret below.\n\n**Need detailed setup instructions?** Check the [setup guide]({documentation_url}) for step-by-step instructions.",
|
||||
"data": {
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client Secret (optional; required by some providers)"
|
||||
}
|
||||
},
|
||||
"groups_config": {
|
||||
"title": "Groups & Role Configuration",
|
||||
"description": "Configure how user groups from {provider_name} should be mapped to Home Assistant roles.\n\n**Groups Support:** Groups allow you to automatically assign admin or user roles based on group membership in your identity provider.\n\n**Admin Group:** Users in this group will have administrator access\n**User Group:** Users in this group will have standard user access (leave empty to allow all authenticated users)",
|
||||
"data": {
|
||||
"enable_groups": "Enable group-based role assignment",
|
||||
"admin_group": "Admin group name",
|
||||
"user_group": "User group name (optional)"
|
||||
}
|
||||
},
|
||||
"user_linking": {
|
||||
"title": "User Linking Options",
|
||||
"description": "Configure how OIDC users are linked to existing Home Assistant users.\n\n**⚠️ Important Security Information:**\n\n**User Linking Disabled (Recommended):** New OIDC accounts are created for each user. This is the most secure option.\n\n**User Linking Enabled:** OIDC users can be linked to existing Home Assistant users by username. **This has security implications:**\n- If someone can guess or obtain a Home Assistant username, they might gain access to that account\n- Only enable this if you're migrating from local Home Assistant accounts to OIDC\n- You can disable this later if needed",
|
||||
"data": {
|
||||
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)"
|
||||
}
|
||||
},
|
||||
"finalize": {
|
||||
"title": "Setup Complete",
|
||||
"description": "Your OIDC authentication is now configured and ready to use.\n\n**Next Steps:**\n1. Save this configuration\n2. Restart Home Assistant if prompted\n3. The OIDC login option will appear on your login screen\n\n**Advanced Configuration:**\nAdvanced options like custom networking settings, specific claim configurations, or custom scopes are only available through YAML configuration. See the documentation for details.",
|
||||
"data": {}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure OIDC Authentication",
|
||||
"description": "Update your OIDC client credentials for {provider_name}.\n\n**Discovery URL:** {discovery_url}\n\n**What you can change:**\n- **Client ID**: Update your application's client identifier\n- **Client Type**: Switch between Public and Confidential client types\n- **Client Secret**: Update or add a client secret (for confidential clients)\n\n**Note:** Changes will be validated against your OIDC provider before being saved. Your existing settings will be preserved if validation fails.\n\n**Security:** For confidential clients, leave the client secret field empty to keep your existing secret unchanged.",
|
||||
"data": {
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client Secret (leave empty to keep current)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the OIDC provider. Please check your network connection and discovery URL.",
|
||||
"discovery_invalid": "The discovery document could not be retrieved or is invalid. Please verify the discovery URL is correct.",
|
||||
"jwks_invalid": "Failed to retrieve or validate the JWKS (JSON Web Key Set). Please check your provider configuration.",
|
||||
"invalid_url_format": "The discovery URL must be a valid HTTP or HTTPS URL and should end with '/.well-known/openid-configuration'",
|
||||
"invalid_client_id": "Client ID cannot be empty and must contain valid characters.",
|
||||
"unknown": "An unexpected error occurred. Please check the logs for more details."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This OIDC provider is already configured.",
|
||||
"cannot_connect": "Unable to connect to the OIDC provider.",
|
||||
"invalid_discovery": "Invalid discovery document received from the provider.",
|
||||
"reconfigure_successful": "OIDC Authentication has been successfully reconfigured with the updated client credentials.",
|
||||
"single_instance_allowed": "OIDC Authentication only supports a single configuration. You already have OIDC configured in the UI. To modify your existing configuration, go to Settings > Devices & Services > OIDC Authentication and click 'Configure'. To replace your configuration, first remove the existing one.",
|
||||
"yaml_configured": "You are currently using YAML configuration for this integration. To switch to UI configuration, please remove the YAML configuration first. Note that some advanced options configured via YAML may not be available in the UI."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "OIDC Authentication Options",
|
||||
"description": "Update configuration options for your {provider_name} OIDC authentication.\n\n**User Linking:** Control how OIDC users are linked to existing Home Assistant accounts (⚠️ security implications).\n\n**Groups Configuration:** Configure role assignment based on group membership from your identity provider.\n\n**Note:** Changes take effect immediately but may require users to log out and back in.",
|
||||
"data": {
|
||||
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)",
|
||||
"enable_groups": "Enable group-based role assignment",
|
||||
"admin_group": "Admin group name",
|
||||
"user_group": "User group name (optional - leave empty to allow all authenticated users)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Generic data types"""
|
||||
|
||||
# Dict class to give a type to the user details
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class UserDetails(dict):
|
||||
"""User details representation"""
|
||||
|
||||
# User subject, persistent identifier
|
||||
sub: str
|
||||
# Full name of the user for display purposes
|
||||
display_name: str
|
||||
# Preferred username for the user, will be used when first generating the account
|
||||
# or to link the account on first login
|
||||
username: str
|
||||
# Home Assistant role to assign to this user
|
||||
role: Literal["system-admin", "system-users", "invalid"]
|
||||
0
custom_components/auth_oidc/views/__init__.py
Normal file
@@ -40,7 +40,7 @@ class AsyncTemplateRenderer:
|
||||
) as f:
|
||||
content = await f.read()
|
||||
templates[filename] = content
|
||||
except (OSError, IOError) as e:
|
||||
except (OSError, IOError) as e: # pragma: no cover
|
||||
_LOGGER.warning("Error reading template file %s: %s", filename, e)
|
||||
|
||||
async def render_template(self, template_name: str, **kwargs: Any) -> str:
|
||||
@@ -54,7 +54,9 @@ class AsyncTemplateRenderer:
|
||||
if template_name not in templates:
|
||||
raise ValueError(f"Template '{template_name}' not found.")
|
||||
|
||||
env = Environment(loader=DictLoader(templates), enable_async=True)
|
||||
env = Environment(
|
||||
loader=DictLoader(templates), enable_async=True, autoescape=True
|
||||
)
|
||||
template = env.get_template(template_name)
|
||||
|
||||
# Render template
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/auth/oidc/static/style.css?v=2">
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Done!{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="text-center">
|
||||
<p id="mobile-success-message" class="mb-4">You have successfully logged in on your mobile device. It should continue the login soon. <br/><br/>You have been logged out on this device.</p>
|
||||
<div class="my-6">
|
||||
<a id="restart-login-button" href='/auth/oidc/redirect'
|
||||
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">Restart</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,28 +4,59 @@
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="text-center">
|
||||
<div class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800">I want to login to this browser</h2>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4 text-center">Logged in!</h1>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-gray-300 bg-gray-50 p-6 text-left">
|
||||
<h2 class="mb-2 text-lg font-semibold text-gray-800">Continue on this device</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">Tap Continue to login to Home Assistant on this device.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="code" value="{{ code }}">
|
||||
<button type="submit"
|
||||
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">
|
||||
Login to Home Assistant in this browser
|
||||
<button
|
||||
id="continue-on-this-device"
|
||||
type="submit"
|
||||
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg
|
||||
shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400
|
||||
focus:ring-opacity-75 hover:cursor-pointer"
|
||||
>
|
||||
Continue on this device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr class="my-12">
|
||||
|
||||
<div class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800">I am on a mobile device</h2>
|
||||
<p class="mb-4">Your one-time code is: <b class="text-blue-600 text-xl">{{ code }}</b></p>
|
||||
<p class="mb-4 text-sm">You have 5 minutes to use this code on any device.<br />The code can only
|
||||
be used once.</p>
|
||||
<p class="mb-4 text-sm">Please type the code into your app manually. If you don't see a code input, select
|
||||
'Login with
|
||||
OpenID Connect (SSO)' first.</p>
|
||||
<div class="rounded-lg border border-gray-300 bg-white p-6 text-left">
|
||||
<h2 class="mb-2 text-lg font-semibold text-gray-800">Use a code from another device</h2>
|
||||
<p class="mb-2 text-sm text-gray-600">On your other device, open the Home Assistant app. You will see a
|
||||
6-digit code.</p>
|
||||
<p class="mb-4 text-sm text-gray-600">Input that code here and click Approve to login on the other device.
|
||||
</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input
|
||||
type="tel"
|
||||
id="device-code-input"
|
||||
name="device_code"
|
||||
required
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
placeholder="123456"
|
||||
class="mb-2 w-full rounded-md border border-gray-300 px-5 py-3 text-center text-base
|
||||
tracking-[0.15em] text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-400
|
||||
focus:ring-opacity-75"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
id="approve-login-button"
|
||||
type="submit"
|
||||
class="w-full py-2 px-4 bg-white text-blue-600
|
||||
font-semibold rounded-lg border border-blue-500 shadow-md hover:bg-gray-100
|
||||
hover:text-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400
|
||||
focus:ring-opacity-75 hover:cursor-pointer"
|
||||
>
|
||||
Approve login on the other device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
custom_components/auth_oidc/views/templates/redirect.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}OIDC Redirect{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="text-center">
|
||||
<div role="status" id="loader" class="items-center justify-center flex">
|
||||
<svg aria-hidden="true" class="w-10 h-10 text-gray-200 animate-spin fill-blue-600" viewBox="0 0 100 101"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
</svg>
|
||||
<span class="sr-only">Redirecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Redirect after loading the page to show the redirect visual
|
||||
setTimeout(() => {
|
||||
auth_url = decodeURIComponent("{{ url }}");
|
||||
window.location.href = auth_url;
|
||||
}, 0);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -12,41 +12,53 @@
|
||||
dashboard</a></p>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-4">Home Assistant</h1>
|
||||
<p class="mb-4">You have been invited to login to Home Assistant.<br />Start the login process below.</p>
|
||||
|
||||
<div>
|
||||
<button id="oidc-login-btn"
|
||||
class="w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75">
|
||||
Login with {{ name }}
|
||||
</button>
|
||||
|
||||
<div role="status" id="loader" class="items-center justify-center flex hidden">
|
||||
<svg aria-hidden="true" class="w-10 h-10 text-gray-200 animate-spin fill-blue-600" viewBox="0 0 100 101"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
</svg>
|
||||
<span class="sr-only">Redirecting...</span>
|
||||
{% if code %}
|
||||
<div>
|
||||
<p id="device-instructions">Please login to Home Assistant on another device and enter this code when asked:</p>
|
||||
<div class="mt-4 text-3xl tracking-wide font-bold bg-gray-100 border border-gray-300 rounded-lg py-4 px-6 inline-block" id="device-code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-600">
|
||||
The login will continue automatically when you complete the login on your other device. Please keep the app open.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const source = new EventSource('/auth/oidc/device-sse');
|
||||
|
||||
<p class="mt-6 text-sm">After login, you will be granted a one-time code to login to any device. You may complete
|
||||
this login on your desktop or any mobile browser and then use the token for any desktop or the Home Assistant
|
||||
app.</p>
|
||||
source.addEventListener('ready', function () {
|
||||
source.close();
|
||||
|
||||
// Perform a POST request to the finish endpoint to complete the login.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/auth/oidc/finish';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
|
||||
source.addEventListener('error', function () {
|
||||
source.close();
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div>
|
||||
<a id="login-button" href="/auth/oidc/redirect" class="
|
||||
w-full py-2 px-4 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75
|
||||
hover:cursor-pointer
|
||||
">
|
||||
Login with {{ name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if other_link %}
|
||||
<p class=" mt-4 text-sm text-center">
|
||||
<a id="alternative-sign-in-link" href="{{ other_link }}" class="text-gray-600 hover:underline">Use alternative sign-in method</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
// Hide the login button and show the loader when clicked
|
||||
document.getElementById('oidc-login-btn').addEventListener('click', function () {
|
||||
this.classList.add('hidden');
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
window.location.href = '/auth/oidc/redirect';
|
||||
});
|
||||
|
||||
// Show the direct login button if we already have a token
|
||||
if (localStorage.getItem('hassTokens')) {
|
||||
document.getElementById('signed-in').classList.remove('hidden');
|
||||
|
||||
184
docs/configuration.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# UI Configuration
|
||||
|
||||
If you want to use the (limited) UI configuration method, please see [the README](../README.md).
|
||||
|
||||
# YAML Configuration
|
||||
|
||||
You can configure this integration using YAML in your `configuration.yaml` file. All features of the integration will always be available within the YAML configuration.
|
||||
|
||||
By default, only two fields are required:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: ""
|
||||
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 its discovery URL (ending in `.well-known/openid-configuration`).
|
||||
You don't have to configure other settings in most cases, as they have secure defaults set. If your provider requires manually configuring the callback URL, use `<your HA URL>/auth/oidc/callback`.
|
||||
|
||||
## Provider Configurations
|
||||
Here are some documentation links for specific providers that you may want to follow:
|
||||
|
||||
* [Authentik](./provider-configurations/authentik.md)
|
||||
* [Authelia](./provider-configurations/authelia.md)
|
||||
* [Pocket ID](./provider-configurations/pocket-id.md)
|
||||
* [Kanidm](./provider-configurations/kanidm.md)
|
||||
* [Microsoft Entra ID](./provider-configurations/microsoft-entra.md)
|
||||
* [Zitadel](./provider-configurations/zitadel.md)
|
||||
|
||||
_Missing a provider? Submit your guide using a PR._
|
||||
|
||||
## Common Configurations
|
||||
### Configuring Client Secret
|
||||
If you want to configure Home Assistant as a **confidential client**, you should provide the client secret as well. An example configuration might look like this:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: ""
|
||||
client_secret: !secret oidc_client_secret
|
||||
discovery_url: ""
|
||||
```
|
||||
|
||||
You should use the Home Assistant secrets helper (`!secret`) to make sure you store secrets securely. See https://www.home-assistant.io/docs/configuration/secrets/ for more information.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Most users will not experience any benefits from using a confidential client, as using properly configured redirect URLs + PKCE already provides enough security in a home setting and using a client secret introduces the risk of it getting lost/stolen/put on the internet. Do not use a confidential setup if you don't know what you are doing.
|
||||
|
||||
### Configuring roles & scopes or OIDC settings
|
||||
|
||||
If your provider isn't listed above, you might want to configure OIDC settings yourself. Here's an example configuration for that use case:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: ""
|
||||
discovery_url: ""
|
||||
id_token_signing_alg: <HS256, RS256, ES256, ...>
|
||||
groups_scope: <groups scope>
|
||||
claims:
|
||||
display_name: <display name claim from your provider>
|
||||
username: <username claim from your provider>
|
||||
groups: <groups claim from your provider>
|
||||
roles:
|
||||
admin: <group name to use for admins>
|
||||
user: <group name to use for users>
|
||||
```
|
||||
|
||||
If you configure the user role, OIDC users that have neither configured group name will be rejected! If you configure the admin role, users with that role will receive administrator rights in Home Assistant automatically upon login.
|
||||
|
||||
### Configuring a display name for your OIDC provider
|
||||
If you would like to change the default name on the OIDC welcome screen and Home Assistant login screens from `OpenID Connect (SSO)` to your own display name, you can set the `display_name` configuration property.
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: ""
|
||||
discovery_url: ""
|
||||
display_name: "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
|
||||
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:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
features:
|
||||
force_https: true
|
||||
```
|
||||
|
||||
### Disabling registration for new users
|
||||
This integration does not allow disabling registration for new users, as there is no way to abort registration that late in the process while providing a good user experience.
|
||||
You can however set both roles to groups that only contain certain users or to a non-existent group.
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
roles:
|
||||
user: "non_existent"
|
||||
admin: "admins"
|
||||
```
|
||||
|
||||
Note that if you put both on non-existent groups, no users will be able to login.
|
||||
|
||||
### Migrating from HA username/password users to OIDC users
|
||||
If you already have users created within Home Assistant and would like to re-use the current user profile for your OIDC login, you can (temporarily) enable `features.automatic_user_linking`, with the following config (example):
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "someValueForTheClientId"
|
||||
discovery_url: "https://example.com/application/o/application/.well-known/openid-configuration"
|
||||
features:
|
||||
automatic_user_linking: true
|
||||
```
|
||||
|
||||
Upon login, OIDC users will then automatically be linked to the HA user with the same username. It's recommended to **only enable this temporarily** as it may pose a security risk. You should disable it after linking all your users, as existing links will still work if you disable it, but no new links will be created.
|
||||
|
||||
> [!CAUTION]
|
||||
> Any OIDC user with a username corresponding to a user in Home Assistant can get access to that user and all its rights/configuration.
|
||||
|
||||
> [!CAUTION]
|
||||
> MFA is ignored when using this setting, thus bypassing any MFA configuration the user has originally configured, as long as the username is an exact match. This is dangerous if you are not aware of it!
|
||||
|
||||
|
||||
|
||||
### Using a private certificate authority
|
||||
If you use a private certificate authority to secure your OIDC provider, you must configure the root certificates of your private certificate authority. Otherwise you will get an error (`[SSL: CERTIFICATE_VERIFY_FAILED]`) when connecting to the OIDC provider.
|
||||
|
||||
You can either make the CA known to the entire operating system or configure only this component to use the CA. If you want to only use your private CA with this integration, you can specify it via `network.tls_ca_path`:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
network:
|
||||
tls_ca_path: /path/to/private-ca.pem
|
||||
```
|
||||
|
||||
If you want to deactivate the validation of all TLS certificates for test purposes, you can do this via `network.tls_verify: false`:
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
network:
|
||||
tls_verify: false
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not disable `tls_verify` in a production setting or when your Home Assistant installation is exposed outside of your network. If disabled, man-in-the-middle attacks can be used to change the provider configuration to allow fake tokens to be used.
|
||||
|
||||
## All configuration Options
|
||||
|
||||
Here's a table of all options that you can set:
|
||||
|
||||
| Option | Type | Required | Default | Description |
|
||||
|-----------------------------|----------|----------|----------------------|---------------------------------------------------------------------------------------------------------|
|
||||
| `client_id` | `string` | Yes | | The Client ID as registered with your OpenID Connect provider. |
|
||||
| `client_secret` | `string` | No | | The Client Secret for enabling confidential client mode. |
|
||||
| `discovery_url` | `string` | Yes | | The OIDC well-known configuration URL. |
|
||||
| `display_name` | `string` | No | `"OpenID Connect (SSO)"` | The name to display on the login screen, both for the Home Assistant screen and the OIDC welcome screen. |
|
||||
| `id_token_signing_alg` | `string` | No | `RS256` | The signing algorithm that is used for your id_tokens.
|
||||
| `groups_scope` | `string` | No | `groups` | Override the default groups scope with another scope of your choice. |
|
||||
| `additional_scopes`|`list of strings`| No | `empty list` | Add additional scopes to request for custom identity provider configurations in addition to the automatic `openid` and `profile` scopes and the `groups_scope` configuration option |
|
||||
| `features.automatic_user_linking` | `boolean`| No | `false` | Automatically links users to existing Home Assistant users based on the OIDC username claim. Disabled by default for security. When disabled, OIDC users will get their own new user profile upon first login. |
|
||||
| `features.automatic_person_creation` | `boolean` | No | `true` | Automatically creates a person entry for new user profiles created by this integration. Recommended if you would like to assign presence detection to OIDC users. |
|
||||
| `features.disable_rfc7636` | `boolean`| No | `false` | Disables PKCE (RFC 7636) for OIDC providers that don't support it. You should not need this with most providers. |
|
||||
| `features.include_groups_scope` | `boolean` | No | `true` | Include the 'groups' scope in the OIDC request. Set to `false` to exclude it. |
|
||||
| `features.force_https` | `boolean` | No | `false` | Set to `true` to force all URLs generated to use `https` instead of automatically determining based on the request scheme or `X-Forwarded-Proto`. |
|
||||
| `features.default_redirect` | `boolean` | No | `false` | Set to `true` to always skip the welcome screen (on desktop), regardless of if there are any other auth providers registered. |
|
||||
| `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name.
|
||||
| `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username.
|
||||
| `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). |
|
||||
| `roles.admin` | `string` | No | `admins` | Group name to require for users to get the 'admin' role in Home Assistant. Defaults to 'admins', the default group name for admins in Authentik. Doesn't do anything if no groups claim is found in your token. |
|
||||
| `roles.user` | `string` | No | | Group name to require for users to get the 'user' role in Home Assistant. Defaults to giving all users this role, unless configured. |
|
||||
| `network.tls_verify` | `boolean` | No | `true` | Verify TLS certificate. You may want to set this to `false` when testing locally. |
|
||||
| `network.tls_ca_path` | `string` | No | | Path to file containing a private certificate authority chain. |
|
||||
59
docs/faq.md
Normal file
@@ -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.
|
||||
BIN
docs/logo.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
94
docs/provider-configurations/authelia.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Authelia
|
||||
|
||||
> [!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:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
|
||||
|
||||
## Step 2. Configure Authelia
|
||||
|
||||
You can choose between configuring Authelia as a public or confidential client.
|
||||
|
||||
### Public client configuration
|
||||
|
||||
> [!NOTE]
|
||||
> This configuration strictly requires a HTTPS redirect uri.
|
||||
|
||||
Authelia `configuration.yml`
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
|
||||
## See: https://www.authelia.com/c/oidc
|
||||
clients:
|
||||
- client_id: 'homeassistant'
|
||||
client_name: 'Home Assistant'
|
||||
public: true
|
||||
require_pkce: true
|
||||
pkce_challenge_method: 'S256'
|
||||
redirect_uris:
|
||||
- 'https://hass.example.com/auth/oidc/callback'
|
||||
```
|
||||
|
||||
### Confidential client configuration:
|
||||
|
||||
Authelia `configuration.yml`
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
|
||||
## See: https://www.authelia.com/c/oidc
|
||||
clients:
|
||||
- client_id: 'homeassistant'
|
||||
client_name: 'Home Assistant'
|
||||
client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' # The digest of 'insecure_secret'.
|
||||
public: false
|
||||
require_pkce: true
|
||||
pkce_challenge_method: 'S256'
|
||||
redirect_uris:
|
||||
- 'https://hass.example.com/auth/oidc/callback'
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
```
|
||||
|
||||
## 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**.
|
||||
|
||||

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

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

|
||||
|
||||
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
|
||||
|
||||
|
||||

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

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

|
||||
|
||||
## 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.
|
||||
63
docs/provider-configurations/authentik.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# authentik
|
||||
|
||||
> [!TIP]
|
||||
> This guide describes configuring authentik using the UI method. You can also configure authentik by hand with YAML. Instructions for configuring any provider using YAML can be found here: [YAML Configuration Guide](../configuration.md).
|
||||
|
||||
|
||||
## 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:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=christiaangoossens&repository=hass-oidc-auth&category=Integration)
|
||||
|
||||
## Step 2. Configure authentik
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
|
||||
2. Navigate to **Applications > Applications** and click **Create with Provider** to create an application and provider pair. (Alternatively you can first create a provider separately, then create the application and connect it with the provider.)
|
||||
|
||||
- **Application**: provide a descriptive name, an optional group for the type of application, the policy engine mode, and optional UI settings.
|
||||
- Choose a **Provider Type**: select **OAuth2/OpenID Connect** as the provider type.
|
||||
- **Configure the Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and the following required configurations.
|
||||
- Note the **Client ID**, **Client Secret**, and **slug** values because they will be required later.
|
||||
- Set a `Strict` redirect URI to `https://<your HA URL>/auth/oidc/callback`.
|
||||
- Select any available signing key (to use the RS256 `id_token_signing_alg`)
|
||||
- Configure Bindings (optional): you can create a binding (policy, group, or user) to manage the listing and access to applications on a user's **My applications** page.
|
||||
|
||||
## Step 3. Home Assistant configuration
|
||||
|
||||
The recommended setup method for beginners is through the "Integrations" panel within the Home Assistant UI. You can also use YAML setup, for which you can find the configuration guide here: [YAML Configuration Guide](../configuration.md).
|
||||
|
||||
1. Open Home Assistant and go to **Settings -> Devices & Services**.
|
||||
2. Click Add Integration and select **OpenID Connect/SSO Authentication**.
|
||||
|
||||

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

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

|
||||
|
||||
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
|
||||
|
||||
|
||||

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

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

|
||||
|
||||
## 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.
|
||||
145
docs/provider-configurations/kanidm.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Kanidm
|
||||
|
||||
## Public client configuration
|
||||
|
||||
[Home Assistant](https://github.com/home-assistant/core) `/var/lib/hass/configuration.yaml`
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "homeassistant"
|
||||
discovery_url: "https://idm.example.org/oauth2/openid/homeassistant/.well-known/openid-configuration"
|
||||
features:
|
||||
automatic_person_creation: true
|
||||
id_token_signing_alg: "ES256"
|
||||
roles:
|
||||
admin: "homeassistant_admins@idm.example.org"
|
||||
user: "idm_all_persons@idm.example.org"
|
||||
```
|
||||
|
||||
[Kanidm](https://github.com/kanidm/kanidm)
|
||||
|
||||
1. Create your Kanidm account, if you don't have one already:
|
||||
|
||||
```shell
|
||||
kanidm person create "your_username" "Your Username" --name "idm_admin"
|
||||
```
|
||||
|
||||
2. Create a new Kanidm group for your HomeAssistant administrators (`homeassistant_admins`), and add your regular account to it:
|
||||
|
||||
```shell
|
||||
kanidm group create "homeassistant_admins" --name "idm_admin"
|
||||
kanidm group add-members "homeassistant_admins" "your_username" --name "idm_admin"
|
||||
```
|
||||
|
||||
3. Create a new OAuth2 application configuration in Kanidm (`homeassistant`), configure the redirect URL, and scope access:
|
||||
|
||||
```shell
|
||||
kanidm system oauth2 create-public "homeassistant" "Home Assistant" "https://hass.example.org/auth/oidc/welcome" --name "idm_admin"
|
||||
kanidm system oauth2 add-redirect-url "homeassistant" "https://hass.example.org/auth/oidc/callback" --name "idm_admin"
|
||||
kanidm system oauth2 update-scope-map "homeassistant" "homeassistant_users" "email" "groups" "openid" "profile" --name "idm_admin"
|
||||
```
|
||||
|
||||
[Kanidm Provision](https://github.com/oddlama/kanidm-provision) `state.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"groups": {
|
||||
"homeassistant_admins": {
|
||||
"members": ["your_username"]
|
||||
}
|
||||
},
|
||||
"persons": {
|
||||
"your_username": {
|
||||
"displayName": "Your Username"
|
||||
},
|
||||
},
|
||||
"systems": {
|
||||
"oauth2": {
|
||||
"homeassistant": {
|
||||
"displayName": "Home Assistant",
|
||||
"originLanding": "https://hass.example.org/auth/oidc/welcome",
|
||||
"originUrl": "https://hass.example.org/auth/oidc/callback",
|
||||
"public": true,
|
||||
"scopeMaps": {
|
||||
"homeassistant_users": ["email", "groups", "openid", "profile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Confidential client configuration
|
||||
|
||||
[Home Assistant](https://github.com/home-assistant/core) `/var/lib/hass/configuration.yaml`
|
||||
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: "homeassistant"
|
||||
client_secret: !secret oidc_client_secret
|
||||
discovery_url: "https://idm.example.org/oauth2/openid/homeassistant/.well-known/openid-configuration"
|
||||
features:
|
||||
automatic_person_creation: true
|
||||
id_token_signing_alg: "ES256"
|
||||
roles:
|
||||
admin: "homeassistant_admins@idm.example.org"
|
||||
user: "idm_all_persons@idm.example.org"
|
||||
```
|
||||
|
||||
[Kanidm](https://github.com/kanidm/kanidm)
|
||||
|
||||
1. Create your Kanidm account, if you don't have one already:
|
||||
|
||||
```shell
|
||||
kanidm person create "your_username" "Your Username" --name "idm_admin"
|
||||
```
|
||||
|
||||
2. Create a new Kanidm group for your HomeAssistant administrators (`homeassistant_admins`), and add your regular account to it:
|
||||
|
||||
```shell
|
||||
kanidm group create "homeassistant_admins" --name "idm_admin"
|
||||
kanidm group add-members "homeassistant_admins" "your_username" --name "idm_admin"
|
||||
```
|
||||
|
||||
3. Create a new OAuth2 application configuration in Kanidm (`homeassistant`), configure the redirect URL, and scope access:
|
||||
|
||||
```shell
|
||||
kanidm system oauth2 create "homeassistant" "Home Assistant" "https://hass.example.org/auth/oidc/welcome" --name "idm_admin"
|
||||
kanidm system oauth2 add-redirect-url "homeassistant" "https://hass.example.org/auth/oidc/callback" --name "idm_admin"
|
||||
kanidm system oauth2 update-scope-map "homeassistant" "homeassistant_users" "email" "groups" "openid" "profile" --name "idm_admin"
|
||||
```
|
||||
|
||||
4. Get the `homeassistant` OAuth2 client secret from Kanidm:
|
||||
|
||||
```shell
|
||||
kanidm system oauth2 show-basic-secret "homeassistant" --name "idm_admin" | xargs echo 'oidc_client_secret: {}' | tee --append "/var/lib/hass/secrets.yaml"
|
||||
```
|
||||
|
||||
[Kanidm Provision](https://github.com/oddlama/kanidm-provision) `state.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"groups": {
|
||||
"homeassistant_admins": {
|
||||
"members": ["your_username"]
|
||||
}
|
||||
},
|
||||
"persons": {
|
||||
"your_username": {
|
||||
"displayName": "Your Username"
|
||||
},
|
||||
},
|
||||
"systems": {
|
||||
"oauth2": {
|
||||
"homeassistant": {
|
||||
"displayName": "Home Assistant",
|
||||
"originLanding": "https://hass.example.org/auth/oidc/welcome",
|
||||
"originUrl": "https://hass.example.org/auth/oidc/callback",
|
||||
"scopeMaps": {
|
||||
"homeassistant_users": ["email", "groups", "openid", "profile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
51
docs/provider-configurations/microsoft-entra.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Microsoft Entra ID
|
||||
> [!WARNING]
|
||||
> Microsoft Entra ID does not support public clients that are not Single Page Applications (SPA's). Therefore, you will have to use a client secret.
|
||||
## Basic configuration
|
||||
1. Go to app registrations in Entra ID.
|
||||
2. Create a new app, use the "Web" type for the redirect URI and fill in your URL: `<ha url>/auth/oidc/callback`. Note that you either have to use localhost, or HTTPS.
|
||||
3. Copy the 'Application (client) ID' on the overview page of your app and use it as your `client_id`.
|
||||
4. Create the discovery URL:
|
||||
- If you selected 'own tenant only' use the 'Directory (tenant) ID' on the overview page of your app and create the discovery URL using: `https://login.microsoftonline.com/<tenant id>/v2.0/.well-known/openid-configuration`.
|
||||
- If you selected any Azure AD account (would not recommend this) or also personal accounts, use `https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration`.
|
||||
5. Go to Certificates & Secrets and create a client secret. Make sure to copy the 'Value' and not the Secret ID. Use this value for `client_secret` in the HA config.
|
||||
- Make sure to renew this secret in time. It will expire in two years.
|
||||
6. Go to API Permissions and click 'Add permission'. Add the `openid` and `profile` permissions from Microsoft Graph. You can remove `User.Read`.
|
||||
|
||||
Now configure Home Assistant with the following:
|
||||
|
||||
```
|
||||
auth_oidc:
|
||||
client_id: < client id from the 'Application (client) ID field' >
|
||||
discovery_url: < discovery URL you made in step 4 >
|
||||
client_secret: < client seret from step 5 >
|
||||
features:
|
||||
include_groups_scope: False
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> Be careful! Configuring Entra ID wrong may leave your Home Assistant install open for anyone with a Microsoft account. Please use "Single tenant" account types only. Do not enable "Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant)" or personal account modes without enabling the mode to only allow specific accounts first!
|
||||
|
||||
## Configuring user roles
|
||||
If you like to configure the Home Assistant users roles based on your Entra ID settings, you have to create 2 roles within your Entra ID app registration.
|
||||
Go to "App registrations" and select app roles. Create two new roles for admins and users, giving them sensible names and values (the example uses `users` and `admins`), that you will need later in your HA configuration.
|
||||
|
||||
<img width="1205" height="965" alt="Entra-HA-Roles" src="https://github.com/user-attachments/assets/568a1526-0607-4f88-945f-7c4f1fcc0ac2" />
|
||||
|
||||
Then you need to create the users and assign them a role of your choice.
|
||||
Go to "Enterprise apps" chose your app registration again and select "Users and groups" within the manage section. Add users, or groups from your tenant or AD-sync and assign them a role, from the ones you created before.
|
||||
|
||||
<img width="1112" height="570" alt="Entra-HA-Users" src="https://github.com/user-attachments/assets/13a49cee-798b-4b53-8fee-d2792ccd7763" />
|
||||
|
||||
Last thing to do is to include
|
||||
```
|
||||
claims:
|
||||
groups: "roles"
|
||||
roles:
|
||||
admin: "admins"
|
||||
user: "users"
|
||||
```
|
||||
in your auth_oidc config, where the roles values correspond to the ones you chose in your Entra ID roles.
|
||||
Make sure, you keep the "include_groups_scope: False" from the basic configuration, as the claim needed for Entra ID is "roles".
|
||||
|
||||
Newly created users will get the role assigned in Entra ID, but there is no update to user roles. A user created with user role in HA will not get the admin role, if you change the assignment later on in Entra ID.
|
||||
85
docs/provider-configurations/pocket-id.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Pocket ID
|
||||
|
||||
> [!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:
|
||||
|
||||
[](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
|
||||
|
||||
1. Login to Pocket ID and go to `OIDC Clients`
|
||||
|
||||
2. Click on `Add OIDC Client`
|
||||
|
||||
3. Fill the following details:
|
||||
- Name: `Home Assistant`
|
||||
- Callback URLs: `<your-homeassistant-url>/auth/oidc/callback` (for example: https://hass.example.com/auth/oidc/callback)
|
||||
- Click on `Public Client` (PKCE will be automatically marked when doing this)
|
||||
|
||||
4. Click on `Save`
|
||||
|
||||
5. Click on `Show more details` and note down your `Client ID` and `OIDC Discovery URL` since you will need them later.
|
||||
|
||||
### Confidential client configuration:
|
||||
|
||||
1. Login to Pocket ID and go to `OIDC Clients`
|
||||
|
||||
2. Click on `Add OIDC Client`
|
||||
|
||||
3. Fill the following details:
|
||||
- Name: `Home Assistant`
|
||||
- Callback URLs: `<your-homeassistant-url>/auth/oidc/callback` (for example: https://hass.example.com/auth/oidc/callback)
|
||||
|
||||
4. Click on `Save`
|
||||
|
||||
5. Click on `Show more details` and note down your:
|
||||
- `Client ID`
|
||||
- `Client secret`
|
||||
- `OIDC Discovery URL`
|
||||
|
||||
## 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**.
|
||||
|
||||

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

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

|
||||
|
||||
6. If your discovery URL is tested succesfully, you will see something like this and you can continue with the **Submit** button to continue.
|
||||
|
||||
|
||||

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

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

|
||||
|
||||
## 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.
|
||||
27
docs/provider-configurations/zitadel.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Zitadel
|
||||
|
||||
## Zitadel configuration
|
||||
|
||||
1. From the Zitadel home screen, go to `Projects` and click `Create New Project`
|
||||
2. Enter "Home Assistant" or your preferred name
|
||||
3. Click on `New` to create a new Application
|
||||
4. Enter "Home Assistant" or your preferred name
|
||||
5. Select `Web` and `Continue`
|
||||
6. Select `CODE` (not `PKCE`) and `Continue`
|
||||
7. Enter https://hass.example.com/auth/oidc/callback as the Redirect URI, and click `Continue`
|
||||
8. Click `Create`. A pop-up will dispay the `ClientId` and `ClientSecret`
|
||||
|
||||
## Home Assistant configuration
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For HTTPS configuration make sure to have a public valid SSL certificate (i.e. LetsEncrypt), if not, use HTTP instead (more insecure) or add your Zitadel CA certificate to `network.tls_ca_path`.
|
||||
|
||||
After installing this HACS addon, edit your `configuration.yaml` file and add:
|
||||
```yaml
|
||||
auth_oidc:
|
||||
client_id: <ClientID from above>
|
||||
client_secret: <ClientSecret from above>
|
||||
discovery_url: "https://auth.example.com/.well-known/openid-configuration"
|
||||
```
|
||||
|
||||
Restart Home Assistant and go to https://hass.example.com/auth/oidc/welcome
|
||||
BIN
docs/ui-config-steps/client-details.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/ui-config-steps/discovery-url-failure.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/ui-config-steps/discovery-url-success.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/ui-config-steps/discovery-url.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/ui-config-steps/ui-configuration.gif
Normal file
|
After Width: | Height: | Size: 1018 KiB |
BIN
docs/ui-config-steps/ui-reconfigure.gif
Normal file
|
After Width: | Height: | Size: 221 KiB |
3
docs/usage.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Usage Guide
|
||||
|
||||
The usage instructions have moved to [the main README](../README.md)
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"name": "OpenID Connect",
|
||||
"name": "OpenID Connect/SSO Authentication",
|
||||
"hide_default_branch": true,
|
||||
"render_readme": true,
|
||||
"homeassistant": "2024.12"
|
||||
"homeassistant": "2025.11",
|
||||
"zip_release": true,
|
||||
"filename": "hass-oidc-auth.zip"
|
||||
}
|
||||
1092
package-lock.json
generated
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hass-oidc-auth",
|
||||
"scripts": {
|
||||
"css": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --minify",
|
||||
"css:watch": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --watch --minify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.14",
|
||||
"tailwindcss": "^4.1.14"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,42 @@
|
||||
[project]
|
||||
name = "hass-oidc-auth"
|
||||
version = "0.4.1"
|
||||
version = "1.0.0"
|
||||
description = "OIDC component for Home Assistant"
|
||||
authors = [
|
||||
{ name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" }
|
||||
]
|
||||
license = "MIT"
|
||||
dependencies = [
|
||||
"python-jose>=3.3.0",
|
||||
"aiofiles>=24.1.0",
|
||||
"jinja2>=3.1.4",
|
||||
"bcrypt>=4.2.0",
|
||||
"aiofiles~=25.1",
|
||||
"jinja2~=3.1",
|
||||
"joserfc~=1.6.0",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.13"
|
||||
requires-python = "~=3.14.4"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"homeassistant~=2026.4",
|
||||
"pylint~=4.0",
|
||||
"pytest~=9.0.0",
|
||||
"pytest-asyncio~=1.3.0",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-homeassistant-custom-component~=0.13.308",
|
||||
"ruff~=0.12",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.rye]
|
||||
[tool.uv]
|
||||
managed = true
|
||||
dev-dependencies = [
|
||||
"homeassistant~=2024.12",
|
||||
"pylint~=3.3",
|
||||
override-dependencies = [
|
||||
"orjson>=3.11.8,<3.12.0",
|
||||
"pyjwt>=2.12.0,<2.13.0",
|
||||
"pillow>=12.2.0,<12.3.0",
|
||||
"pytest>=9.0.3,<9.1.0",
|
||||
"uv>=0.11.6,<0.12.0",
|
||||
]
|
||||
|
||||
[tool.hatch.metadata]
|
||||
@@ -32,11 +45,10 @@ allow-direct-references = true
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["custom_components/auth_oidc"]
|
||||
|
||||
[tool.rye.scripts]
|
||||
check = { chain = ["check-lint", "check-fmt", "check-pylint" ] }
|
||||
"check-lint" = "rye lint"
|
||||
"check-fmt" = "rye fmt --check"
|
||||
"check-pylint" = "pylint custom_components"
|
||||
fix = { chain = ["fix-lint", "fix-fmt" ] }
|
||||
"fix-lint" = "rye lint --fix"
|
||||
"fix-fmt" = "rye fmt"
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "--cov=custom_components --cov-fail-under=0"
|
||||
log_level = "DEBUG"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
49
renovate.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"schedule": [
|
||||
"every weekend"
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"groupName": "vulnerabilityAlerts",
|
||||
"enabled": true,
|
||||
"schedule": [
|
||||
"after 6am and before 6pm"
|
||||
],
|
||||
"prCreation": "immediate"
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all GitHub Actions updates",
|
||||
"matchDatasources": [
|
||||
"github-actions",
|
||||
"github-tags",
|
||||
"github-runners"
|
||||
],
|
||||
"groupName": "Github Actions Updates",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Version updates for Home Assistant packages",
|
||||
"groupName": "Home Assistant Update",
|
||||
"matchPackageNames": [
|
||||
"homeassistant",
|
||||
"jinja2",
|
||||
"bcrypt"
|
||||
],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Version updates for other Python packages",
|
||||
"matchDatasources": [
|
||||
"pypi"
|
||||
],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
acme==3.0.1
|
||||
# via hass-nabucasa
|
||||
aiodns==3.2.0
|
||||
# via homeassistant
|
||||
aiofiles==24.1.0
|
||||
# via hass-oidc-auth
|
||||
aiohappyeyeballs==2.4.4
|
||||
# via aiohttp
|
||||
aiohasupervisor==0.2.1
|
||||
# via homeassistant
|
||||
aiohttp==3.11.11
|
||||
# via aiohasupervisor
|
||||
# via aiohttp-cors
|
||||
# via aiohttp-fast-zlib
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
# via snitun
|
||||
aiohttp-cors==0.7.0
|
||||
# via homeassistant
|
||||
aiohttp-fast-zlib==0.2.0
|
||||
# via homeassistant
|
||||
aiooui==0.1.7
|
||||
# via bluetooth-adapters
|
||||
aiosignal==1.3.2
|
||||
# via aiohttp
|
||||
aiozoneinfo==0.2.1
|
||||
# via homeassistant
|
||||
anyio==4.7.0
|
||||
# via httpx
|
||||
astral==2.2
|
||||
# via homeassistant
|
||||
astroid==3.3.8
|
||||
# via pylint
|
||||
async-interrupt==1.2.0
|
||||
# via habluetooth
|
||||
# via homeassistant
|
||||
async-timeout==5.0.1
|
||||
# via snitun
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
attrs==24.2.0
|
||||
# via aiohttp
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
# via snitun
|
||||
audioop-lts==0.2.1
|
||||
# via homeassistant
|
||||
# via standard-aifc
|
||||
awesomeversion==24.6.0
|
||||
# via homeassistant
|
||||
bcrypt==4.2.0
|
||||
# via hass-oidc-auth
|
||||
# via homeassistant
|
||||
bleak==0.22.3
|
||||
# via bleak-retry-connector
|
||||
# via bluetooth-adapters
|
||||
# via habluetooth
|
||||
bleak-retry-connector==3.6.0
|
||||
# via habluetooth
|
||||
bluetooth-adapters==0.20.2
|
||||
# via bleak-retry-connector
|
||||
# via bluetooth-auto-recovery
|
||||
# via habluetooth
|
||||
bluetooth-auto-recovery==1.4.2
|
||||
# via habluetooth
|
||||
bluetooth-data-tools==1.20.0
|
||||
# via habluetooth
|
||||
boto3==1.35.87
|
||||
# via pycognito
|
||||
botocore==1.35.87
|
||||
# via boto3
|
||||
# via s3transfer
|
||||
btsocket==0.3.0
|
||||
# via bluetooth-auto-recovery
|
||||
certifi==2024.12.14
|
||||
# via homeassistant
|
||||
# via httpcore
|
||||
# via httpx
|
||||
# via requests
|
||||
cffi==1.17.1
|
||||
# via cryptography
|
||||
# via pycares
|
||||
charset-normalizer==3.4.0
|
||||
# via requests
|
||||
ciso8601==2.3.1
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
cryptography==43.0.1
|
||||
# via acme
|
||||
# via bluetooth-data-tools
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
# via josepy
|
||||
# via pyjwt
|
||||
# via pyopenssl
|
||||
# via securetar
|
||||
# via snitun
|
||||
dbus-fast==2.24.4
|
||||
# via bleak
|
||||
# via bleak-retry-connector
|
||||
# via bluetooth-adapters
|
||||
dill==0.3.9
|
||||
# via pylint
|
||||
ecdsa==0.19.0
|
||||
# via python-jose
|
||||
envs==1.4
|
||||
# via pycognito
|
||||
fnv-hash-fast==1.0.2
|
||||
# via homeassistant
|
||||
fnvhash==0.1.0
|
||||
# via fnv-hash-fast
|
||||
frozenlist==1.5.0
|
||||
# via aiohttp
|
||||
# via aiosignal
|
||||
h11==0.14.0
|
||||
# via httpcore
|
||||
habluetooth==3.6.0
|
||||
# via home-assistant-bluetooth
|
||||
hass-nabucasa==0.86.0
|
||||
# via homeassistant
|
||||
home-assistant-bluetooth==1.13.0
|
||||
# via homeassistant
|
||||
homeassistant==2024.12.5
|
||||
httpcore==1.0.7
|
||||
# via httpx
|
||||
httpx==0.27.2
|
||||
# via homeassistant
|
||||
idna==3.10
|
||||
# via anyio
|
||||
# via httpx
|
||||
# via requests
|
||||
# via yarl
|
||||
ifaddr==0.2.0
|
||||
# via homeassistant
|
||||
isort==5.13.2
|
||||
# via pylint
|
||||
jinja2==3.1.4
|
||||
# via hass-oidc-auth
|
||||
# via homeassistant
|
||||
jmespath==1.0.1
|
||||
# via boto3
|
||||
# via botocore
|
||||
josepy==1.14.0
|
||||
# via acme
|
||||
lru-dict==1.3.0
|
||||
# via homeassistant
|
||||
markupsafe==3.0.2
|
||||
# via jinja2
|
||||
mashumaro==3.15
|
||||
# via aiohasupervisor
|
||||
# via webrtc-models
|
||||
mccabe==0.7.0
|
||||
# via pylint
|
||||
multidict==6.1.0
|
||||
# via aiohttp
|
||||
# via yarl
|
||||
orjson==3.10.12
|
||||
# via aiohasupervisor
|
||||
# via homeassistant
|
||||
# via webrtc-models
|
||||
packaging==24.2
|
||||
# via homeassistant
|
||||
pillow==11.0.0
|
||||
# via homeassistant
|
||||
platformdirs==4.3.6
|
||||
# via pylint
|
||||
propcache==0.2.1
|
||||
# via aiohttp
|
||||
# via homeassistant
|
||||
# via yarl
|
||||
psutil==6.1.1
|
||||
# via psutil-home-assistant
|
||||
psutil-home-assistant==0.0.1
|
||||
# via homeassistant
|
||||
pyasn1==0.6.1
|
||||
# via python-jose
|
||||
# via rsa
|
||||
pycares==4.5.0
|
||||
# via aiodns
|
||||
pycognito==2024.5.1
|
||||
# via hass-nabucasa
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pyjwt==2.10.1
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
# via pycognito
|
||||
pylint==3.3.3
|
||||
pyopenssl==24.2.1
|
||||
# via acme
|
||||
# via homeassistant
|
||||
# via josepy
|
||||
pyrfc3339==2.0.1
|
||||
# via acme
|
||||
pyric==0.1.6.3
|
||||
# via bluetooth-auto-recovery
|
||||
python-dateutil==2.9.0.post0
|
||||
# via botocore
|
||||
python-jose==3.3.0
|
||||
# via hass-oidc-auth
|
||||
python-slugify==8.0.4
|
||||
# via homeassistant
|
||||
pytz==2024.2
|
||||
# via acme
|
||||
# via astral
|
||||
pyyaml==6.0.2
|
||||
# via homeassistant
|
||||
requests==2.32.3
|
||||
# via acme
|
||||
# via homeassistant
|
||||
# via pycognito
|
||||
rsa==4.9
|
||||
# via python-jose
|
||||
s3transfer==0.10.4
|
||||
# via boto3
|
||||
securetar==2024.11.0
|
||||
# via homeassistant
|
||||
setuptools==75.6.0
|
||||
# via acme
|
||||
six==1.17.0
|
||||
# via ecdsa
|
||||
# via python-dateutil
|
||||
sniffio==1.3.1
|
||||
# via anyio
|
||||
# via httpx
|
||||
snitun==0.39.1
|
||||
# via hass-nabucasa
|
||||
sqlalchemy==2.0.36
|
||||
# via homeassistant
|
||||
standard-aifc==3.13.0
|
||||
# via homeassistant
|
||||
standard-chunk==3.13.0
|
||||
# via standard-aifc
|
||||
standard-telnetlib==3.13.0
|
||||
# via homeassistant
|
||||
text-unidecode==1.3
|
||||
# via python-slugify
|
||||
tomlkit==0.13.2
|
||||
# via pylint
|
||||
typing-extensions==4.12.2
|
||||
# via homeassistant
|
||||
# via mashumaro
|
||||
# via sqlalchemy
|
||||
tzdata==2024.2
|
||||
# via aiozoneinfo
|
||||
uart-devices==0.1.0
|
||||
# via bluetooth-adapters
|
||||
ulid-transform==1.0.2
|
||||
# via homeassistant
|
||||
urllib3==1.26.20
|
||||
# via botocore
|
||||
# via homeassistant
|
||||
# via requests
|
||||
usb-devices==0.4.5
|
||||
# via bluetooth-adapters
|
||||
# via bluetooth-auto-recovery
|
||||
uv==0.5.4
|
||||
# via homeassistant
|
||||
voluptuous==0.15.2
|
||||
# via homeassistant
|
||||
# via voluptuous-openapi
|
||||
# via voluptuous-serialize
|
||||
voluptuous-openapi==0.0.5
|
||||
# via homeassistant
|
||||
voluptuous-serialize==2.6.0
|
||||
# via homeassistant
|
||||
webrtc-models==0.3.0
|
||||
# via hass-nabucasa
|
||||
# via homeassistant
|
||||
yarl==1.18.3
|
||||
# via aiohasupervisor
|
||||
# via aiohttp
|
||||
# via homeassistant
|
||||
@@ -1,31 +0,0 @@
|
||||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
aiofiles==24.1.0
|
||||
# via hass-oidc-auth
|
||||
bcrypt==4.2.1
|
||||
# via hass-oidc-auth
|
||||
ecdsa==0.19.0
|
||||
# via python-jose
|
||||
jinja2==3.1.5
|
||||
# via hass-oidc-auth
|
||||
markupsafe==3.0.2
|
||||
# via jinja2
|
||||
pyasn1==0.6.1
|
||||
# via python-jose
|
||||
# via rsa
|
||||
python-jose==3.3.0
|
||||
# via hass-oidc-auth
|
||||
rsa==4.9
|
||||
# via python-jose
|
||||
six==1.17.0
|
||||
# via ecdsa
|
||||
10
scripts/build
Executable 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 ./*
|
||||
4
scripts/check
Executable file
@@ -0,0 +1,4 @@
|
||||
#! /bin/bash
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
uv run pylint custom_components --allow-reexport-from-package true
|
||||
3
scripts/coverage-report
Executable file
@@ -0,0 +1,3 @@
|
||||
#! /bin/bash
|
||||
uv run pytest --cov-report html tests/
|
||||
uv run python -m http.server 8000 -d htmlcov
|
||||
3
scripts/fix
Executable file
@@ -0,0 +1,3 @@
|
||||
#! /bin/bash
|
||||
uv run ruff check --fix
|
||||
uv run ruff format
|
||||
2
scripts/security-check
Executable file
@@ -0,0 +1,2 @@
|
||||
#! /bin/bash
|
||||
uvx pysentry-rs .
|
||||
2
scripts/sync
Executable file
@@ -0,0 +1,2 @@
|
||||
#! /bin/bash
|
||||
uv sync --locked
|
||||
2
scripts/test
Executable file
@@ -0,0 +1,2 @@
|
||||
#! /bin/bash
|
||||
uv run pytest --cov-report term:skip-covered tests/
|
||||
0
tests/__init__.py
Normal file
8
tests/conftest.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Fixtures for testing."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||
yield
|
||||
0
tests/mocks/__init__.py
Normal file
14
tests/mocks/auth_page.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
Test page
|
||||
</body>
|
||||
|
||||
</html>
|
||||
197
tests/mocks/oidc_server.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""A simple mock OIDC server for testing purposes."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import RSAKey, KeySet
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://oidc.example.com"
|
||||
SUBJECT = "testuser"
|
||||
|
||||
|
||||
class MockOIDCServer:
|
||||
"""A simple mock OIDC server for testing purposes."""
|
||||
|
||||
_code_storage = {}
|
||||
_scenario = {}
|
||||
|
||||
def __init__(self, scenario: str | None = None):
|
||||
"""Initialize the mock OIDC server."""
|
||||
# Create a JWK private key
|
||||
self._jwk = RSAKey.generate_key(
|
||||
2048, {"alg": "RS256", "use": "sig"}, private=True, auto_kid=True
|
||||
)
|
||||
|
||||
if scenario:
|
||||
# Load scenario JSON file from disk
|
||||
scenario_path = os.path.join(
|
||||
os.path.dirname(__file__), "scenarios", f"{scenario}.json"
|
||||
)
|
||||
with open(scenario_path, "r", encoding="utf-8") as f:
|
||||
self._scenario = json.load(f)
|
||||
|
||||
# Log it
|
||||
_LOGGER.debug("Loaded scenario: %s", self._scenario)
|
||||
|
||||
def get_random_code(self):
|
||||
"""Return a random authorization code."""
|
||||
return "".join(str(random.randint(0, 9)) for _ in range(6))
|
||||
|
||||
@staticmethod
|
||||
def get_discovery_url():
|
||||
"""Return the discovery URL for the given base URL."""
|
||||
return f"{BASE_URL}/.well-known/openid-configuration"
|
||||
|
||||
@staticmethod
|
||||
def get_authorize_url():
|
||||
"""Return the authorization URL for the given base URL."""
|
||||
return f"{BASE_URL}/authorize"
|
||||
|
||||
def process_request(self, url: str, method: str, body: dict) -> tuple[dict, int]:
|
||||
"""Process a request to the mock OIDC server."""
|
||||
_LOGGER.debug("Received %s request to %s in OIDC mock server", method, url)
|
||||
|
||||
if url == self.get_discovery_url() and method == "GET":
|
||||
response = self._get_discovery_document()
|
||||
elif url.startswith(self.get_authorize_url()) and method == "GET":
|
||||
response = self._get_authorize_response(url)
|
||||
elif url == f"{BASE_URL}/token" and method == "POST":
|
||||
response = self._get_token_response(body)
|
||||
elif url == f"{BASE_URL}/jwks" and method == "GET":
|
||||
response = self._get_jwks_response()
|
||||
else:
|
||||
response = {"error": "Unknown endpoint"}, 404
|
||||
|
||||
_LOGGER.debug("Responding with: %s", response)
|
||||
return response
|
||||
|
||||
def _get_discovery_document(self) -> tuple[dict, int]:
|
||||
"""Return a mock discovery document."""
|
||||
|
||||
if "discovery" in self._scenario:
|
||||
return self._scenario["discovery"], 200
|
||||
|
||||
return {
|
||||
"issuer": BASE_URL,
|
||||
"authorization_endpoint": self.get_authorize_url(),
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"userinfo_endpoint": f"{BASE_URL}/userinfo",
|
||||
"jwks_uri": f"{BASE_URL}/jwks",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
}, 200
|
||||
|
||||
def _get_authorize_response(self, url: str) -> tuple[dict, int]:
|
||||
"""Return a mock authorization response."""
|
||||
# Parse the url
|
||||
parsed_url = urlparse(url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
code = self.get_random_code()
|
||||
self._code_storage[code] = query_params
|
||||
|
||||
return {"code": code, "state": "xyz"}, 200
|
||||
|
||||
def _get_token_response(self, body: dict) -> tuple[dict, int]:
|
||||
"""Return a mock token response."""
|
||||
|
||||
if body.get("code") in self._code_storage:
|
||||
# TODO: Verify PKCE?
|
||||
return {
|
||||
"access_token": "exampleAccessToken",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": self._create_id_token(body.get("code")),
|
||||
}, 200
|
||||
else:
|
||||
return {"error": "invalid_request"}, 400
|
||||
|
||||
def _create_id_token(self, code: str) -> str:
|
||||
"""Create a mock ID token."""
|
||||
# Get the query params
|
||||
if code not in self._code_storage:
|
||||
raise ValueError("Invalid code")
|
||||
query_params = self._code_storage[code]
|
||||
_LOGGER.debug("Creating ID token with query params: %s", query_params)
|
||||
|
||||
# Get username
|
||||
if "username" in self._scenario:
|
||||
username = self._scenario["username"]
|
||||
else:
|
||||
username = "testuser"
|
||||
|
||||
# Create a simple signed JWT with our JWK
|
||||
header = {"alg": self._jwk.alg, "kid": self._jwk.kid}
|
||||
claims = {
|
||||
"iss": BASE_URL,
|
||||
"sub": SUBJECT,
|
||||
"aud": query_params.get("client_id", [""])[0],
|
||||
"nonce": query_params.get("nonce", [""])[0],
|
||||
"name": "Test Name",
|
||||
"preferred_username": username,
|
||||
}
|
||||
|
||||
now = int(time.time())
|
||||
claims["nbf"] = now
|
||||
claims["iat"] = now
|
||||
claims["exp"] = now + 3600 # 1 hour expiry
|
||||
|
||||
return jwt.encode(header, claims, self._jwk)
|
||||
|
||||
def _get_jwks_response(self) -> tuple[dict, int]:
|
||||
"""Return a mock JWKS response."""
|
||||
private_key = self._jwk
|
||||
public_key_dict = private_key.as_dict(private=False)
|
||||
public_key = RSAKey.import_key(
|
||||
public_key_dict, {"use": "sig", "alg": "RS256", "kid": private_key.kid}
|
||||
)
|
||||
|
||||
key_set = KeySet([public_key])
|
||||
|
||||
return key_set.as_dict(), 200
|
||||
|
||||
@staticmethod
|
||||
def get_final_subject():
|
||||
"""Return the subject that's returned to HA."""
|
||||
return hashlib.sha256(f"{BASE_URL}.{SUBJECT}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mock_oidc_responses(scenario: str | None = None):
|
||||
"""Mock OIDC responses for testing."""
|
||||
|
||||
mock_oidc_server = MockOIDCServer(scenario)
|
||||
|
||||
def make_mock_response(json_data, status):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.__aenter__.return_value = mock_response
|
||||
mock_response.__aexit__.return_value = None
|
||||
mock_response.json = AsyncMock(return_value=json_data)
|
||||
mock_response.status = status
|
||||
return mock_response
|
||||
|
||||
def default_handler(method, url, *args, **kwargs):
|
||||
_LOGGER.debug("Mocked %s request to %s", method, url)
|
||||
body = kwargs.get("data") or kwargs.get("json") or None
|
||||
response = mock_oidc_server.process_request(url, method, body)
|
||||
return make_mock_response(response[0], response[1])
|
||||
|
||||
def get_side_effect(url, *args, **kwargs):
|
||||
return default_handler("GET", url, *args, **kwargs)
|
||||
|
||||
def post_side_effect(url, *args, **kwargs):
|
||||
return default_handler("POST", url, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession.get", side_effect=get_side_effect) as get_patch,
|
||||
patch("aiohttp.ClientSession.post", side_effect=post_side_effect) as post_patch,
|
||||
):
|
||||
yield (get_patch, post_patch, default_handler)
|
||||
5
tests/mocks/scenarios/empty.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"discovery": {
|
||||
|
||||
}
|
||||
}
|
||||
10
tests/mocks/scenarios/invalid_code_challenge_types.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "https://mock-oidc-server.local/jwks",
|
||||
"response_types_supported": ["code"],
|
||||
"code_challenge_methods_supported": ["plain"]
|
||||
}
|
||||
}
|
||||
10
tests/mocks/scenarios/invalid_grant_types.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "https://mock-oidc-server.local/jwks",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["refresh_token"]
|
||||
}
|
||||
}
|
||||
8
tests/mocks/scenarios/invalid_id_token_signing_alg.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "https://mock-oidc-server.local/jwks"
|
||||
}
|
||||
}
|
||||
9
tests/mocks/scenarios/invalid_response_modes.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "https://mock-oidc-server.local/jwks",
|
||||
"response_modes_supported": ["post"]
|
||||
}
|
||||
}
|
||||
9
tests/mocks/scenarios/invalid_response_types.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "https://mock-oidc-server.local/jwks",
|
||||
"response_types_supported": ["token"]
|
||||
}
|
||||
}
|
||||
8
tests/mocks/scenarios/invalid_url.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token",
|
||||
"jwks_uri": "/jwks"
|
||||
}
|
||||
}
|
||||
7
tests/mocks/scenarios/missing_jwks.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"discovery": {
|
||||
"issuer": "https://mock-oidc-server.local",
|
||||
"authorization_endpoint": "https://mock-oidc-server.local/authorize",
|
||||
"token_endpoint": "https://mock-oidc-server.local/token"
|
||||
}
|
||||
}
|
||||