├── .cruft.json ├── .devcontainer ├── devcontainer.json └── develop ├── .github ├── renovate.json5 └── workflows │ ├── cruft.yaml │ ├── hacs.yaml │ ├── hassfest.yaml │ ├── lint.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── .yaml-lint.yaml ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── openid_auth_provider │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── openid_auth_provider.py │ └── translations │ └── en.json ├── hacs.json ├── pyproject.toml ├── pytest.ini ├── requirements_dev.txt ├── resources └── login-screenshot.png ├── script └── run-mypy.sh └── tests ├── __init__.py ├── conftest.py ├── test_config_flow.py ├── test_init.py └── test_openid_auth_provider.py /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "http://github.com/allenporter/cookiecutter-home-assistant-custom-component", 3 | "commit": "5f0cfbcd5d41afd663b7fd3a7b62fe8cf68627b0", 4 | "checkout": null, 5 | "context": { 6 | "cookiecutter": { 7 | "full_name": "Allen Porter", 8 | "email": "allen.porter@gmail.com", 9 | "github_username": "allenporter", 10 | "project_name": "openid-auth-provider", 11 | "domain": "openid_auth_provider", 12 | "name": "OpenID Auth Provider", 13 | "description": "A Home Assistant Authentication Provider that can use Open ID", 14 | "version": "0.0.1", 15 | "_template": "http://github.com/allenporter/cookiecutter-home-assistant-custom-component", 16 | "_commit": "5f0cfbcd5d41afd663b7fd3a7b62fe8cf68627b0" 17 | } 18 | }, 19 | "directory": null 20 | } 21 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Home Assistant Custom Component Env", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye", 4 | "forwardPorts": [ 5 | 8123 6 | ], 7 | "portsAttributes": { 8 | "8123": { 9 | "label": "Home Assistant", 10 | "onAutoForward": "notify" 11 | } 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "ms-python.python", 17 | "github.vscode-pull-request-github", 18 | "ryanluker.vscode-coverage-gutters", 19 | "ms-python.vscode-pylance" 20 | ], 21 | "settings": { 22 | "files.eol": "\n", 23 | "editor.tabSize": 4, 24 | "python.pythonPath": "/usr/bin/python3", 25 | "python.analysis.autoSearchPaths": false, 26 | "python.formatting.provider": "black", 27 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 28 | "editor.formatOnPaste": false, 29 | "editor.formatOnSave": true, 30 | "editor.formatOnType": true, 31 | "files.trimTrailingWhitespace": true 32 | } 33 | } 34 | }, 35 | "remoteUser": "vscode", 36 | "features": { 37 | "ghcr.io/devcontainers/features/rust:1": {} 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.devcontainer/develop: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "assignees": ["allenporter"], 7 | "packageRules": [ 8 | { 9 | "description": "Minor updates are automatic", 10 | "automerge": true, 11 | "automergeType": "branch", 12 | "matchUpdateTypes": ["minor", "patch"] 13 | } 14 | ], 15 | "pip_requirements": { 16 | "fileMatch": ["requirements_dev.txt"] 17 | }, 18 | "pre-commit": {"enabled": true} 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/cruft.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update repository with Cruft 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | actions: write 7 | on: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | env: 12 | PYTHON_VERSION: 3.13 13 | 14 | jobs: 15 | update: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: true 19 | matrix: 20 | include: 21 | - add-paths: . 22 | body: Use this to merge the changes to this repository. 23 | branch: cruft/update 24 | commit-message: "chore: accept new Cruft update" 25 | title: New updates detected with Cruft 26 | - add-paths: .cruft.json 27 | body: Use this to reject the changes in this repository. 28 | branch: cruft/reject 29 | commit-message: "chore: reject new Cruft update" 30 | title: Reject new updates detected with Cruft 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ env.PYTHON_VERSION }} 37 | 38 | - name: Install Cruft 39 | run: pip3 install cruft 40 | 41 | - name: Check if update is available 42 | continue-on-error: false 43 | id: check 44 | run: | 45 | CHANGES=0 46 | if [ -f .cruft.json ]; then 47 | if ! cruft check; then 48 | CHANGES=1 49 | fi 50 | else 51 | echo "No .cruft.json file" 52 | fi 53 | 54 | echo "has_changes=$CHANGES" >> "$GITHUB_OUTPUT" 55 | 56 | - name: Run update if available 57 | if: steps.check.outputs.has_changes == '1' 58 | run: | 59 | git config --global user.email "allen.porter@gmail.com" 60 | git config --global user.name "Allen Porter" 61 | 62 | cruft update --skip-apply-ask --refresh-private-variables 63 | git restore --staged . 64 | 65 | 66 | - name: Create pull request 67 | if: steps.check.outputs.has_changes == '1' 68 | uses: peter-evans/create-pull-request@v6 69 | with: 70 | token: ${{ secrets.GITHUB_TOKEN }} 71 | add-paths: ${{ matrix.add-paths }} 72 | commit-message: ${{ matrix.commit-message }} 73 | branch: ${{ matrix.branch }} 74 | delete-branch: true 75 | branch-suffix: timestamp 76 | title: ${{ matrix.title }} 77 | body: | 78 | This is an autogenerated PR. ${{ matrix.body }} 79 | 80 | [Cruft](https://cruft.github.io/cruft/) has detected updates from the Cookiecutter repository. 81 | 82 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: hacs 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - renovate/** 9 | pull_request: 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | validate: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: HACS validation 19 | uses: hacs/action@main 20 | with: 21 | category: "integration" 22 | ignore: brands 23 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: hassfest 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - renovate/** 9 | pull_request: 10 | branches: 11 | - main 12 | - renovate/** 13 | schedule: 14 | - cron: "0 0 * * *" 15 | 16 | jobs: 17 | validate: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: home-assistant/actions/hassfest@master 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - renovate/** 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | env: 14 | PYTHON_VERSION: 3.13 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: chartboost/ruff-action@v1.0.0 25 | - uses: codespell-project/actions-codespell@v2.0 26 | with: 27 | check_hidden: false 28 | ignore_words_list: hass 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ env.PYTHON_VERSION }} 33 | cache: "pip" 34 | cache-dependency-path: "**/requirements_dev.txt" 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -r requirements_dev.txt 39 | - name: Static typing with mypy 40 | run: | 41 | mypy --install-types --non-interactive --no-warn-unused-ignores custom_components 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - renovate/** 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | env: 14 | PYTHON_VERSION: 3.13 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ env.PYTHON_VERSION }} 28 | cache: "pip" 29 | cache-dependency-path: "**/requirements_dev.txt" 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | if [ -f requirements_dev.txt ]; then pip install -r requirements_dev.txt; fi 34 | 35 | - name: Test with pytest 36 | run: | 37 | pytest 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | *.pyc 3 | venv 4 | .venv 5 | *.egg-info 6 | deps 7 | ls 8 | __pycache__ 9 | .python-version 10 | .coverage 11 | 12 | **/secrets.yaml 13 | config 14 | tmp 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.6.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | args: 10 | - --allow-multiple-documents 11 | - id: check-added-large-files 12 | - repo: https://github.com/psf/black 13 | rev: 24.4.2 14 | hooks: 15 | - id: black 16 | - repo: https://github.com/charliermarsh/ruff-pre-commit 17 | rev: v0.4.4 18 | hooks: 19 | - id: ruff 20 | args: 21 | - --fix 22 | - --exit-non-zero-on-fix 23 | - repo: local 24 | hooks: 25 | - id: mypy 26 | name: mypy 27 | entry: script/run-mypy.sh 28 | language: script 29 | types: [python] 30 | require_serial: true 31 | files: ^custom_components/ 32 | - repo: https://github.com/codespell-project/codespell 33 | rev: v2.2.6 34 | hooks: 35 | - id: codespell 36 | args: 37 | - --ignore-words-list=hass 38 | - repo: https://github.com/adrienverge/yamllint.git 39 | rev: v1.35.1 40 | hooks: 41 | - id: yamllint 42 | exclude: '^tests/tool/testdata/.*\.yaml$' 43 | args: 44 | - -c 45 | - ".yaml-lint.yaml" 46 | - repo: https://github.com/pre-commit/mirrors-prettier 47 | rev: v3.1.0 48 | hooks: 49 | - id: prettier 50 | - repo: https://github.com/asottile/setup-cfg-fmt 51 | rev: v1.20.0 52 | hooks: 53 | - id: setup-cfg-fmt 54 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | ignore = ["E501"] 3 | -------------------------------------------------------------------------------- /.yaml-lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ignore: | 3 | venv 4 | tests/testdata 5 | extends: default 6 | rules: 7 | truthy: 8 | allowed-values: ['true', 'false', 'on', 'yes'] 9 | comments: 10 | min-spaces-from-content: 1 11 | line-length: disable 12 | braces: 13 | min-spaces-inside: 0 14 | max-spaces-inside: 1 15 | brackets: 16 | min-spaces-inside: 0 17 | max-spaces-inside: 0 18 | indentation: 19 | spaces: 2 20 | indent-sequences: consistent 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # home-assistant-openid-auth-provider 2 | 3 | A Home Assistant Authentication Provider that can use Open ID 4 | 5 | This software is an unofficial custom component for Home Assistant. It is not developed, endorsed, or affiliated with the Home Assistant project. Use this software at your own risk. 6 | 7 | While efforts have been made to ensure the security and functionality of this software, it may introduce vulnerabilities, compatibility issues, or unexpected behavior. By installing and using this software, you accept full responsibility for any outcomes or consequences. 8 | 9 | If you have concerns about security, performance, or compatibility, please consider reviewing the code before installation and ensure it meets your standards. This software is provided "as is," without warranty of any kind. 10 | 11 | ## Development Status 12 | 13 | This custom component packages previous work done by others: 14 | 15 | - https://github.com/home-assistant/core/pull/32926 by elupus 16 | - https://github.com/home-assistant/frontend/pull/14471 by christiaangoossens 17 | 18 | The current status is that it also requires a Home Assistant Frontend PR to support external steps in the login flow which is [in progress](https://github.com/home-assistant/frontend/pull/23204) 19 | 20 | ## Pre-requisites 21 | 22 | You'll need an existing IDP set up. Here is an example setup for testing out [OpenID Connect with DexIDP](https://gist.github.com/bo0tzz/9531150b6aef0aafef5f739f7e903875). 23 | 24 | You'll need to create a new `Client ID` and `Client Secret` for Home Assistant and record those for use during setup. 25 | 26 | The `username` of the user in the IDP needs to match the username of a Home Assistant user you've created. 27 | 28 | ## Configuration 29 | 30 | 1. Install this custom component using HACS 31 | 1. Configure the _OpenID Auth Provider_ integration 32 | 1. Pick a name, set the URL for the _IDP_, and set the _Client ID_ and _Client Secret_ 33 | 1. At the bottom of the configuration flow is a list of _Emails_ or _Subjects_ which are allowed to login. You can add users from the IDP to the allow list. 34 | 35 | ## Usage 36 | 37 | The login page should now show an additional login option to use the IDP: 38 | 39 | 40 | 41 | ## Development 42 | 43 | 1. Prepare virtual environment 44 | 45 | ```bash 46 | $ uv venv 47 | $ source .venv/bin/activate 48 | $ uv pip install -r requirements_dev.txt 49 | ``` 50 | 51 | 1. Run tests 52 | 53 | ```bash 54 | $ py.test 55 | ``` 56 | 57 | 1. Prepare Home Assistant environment 58 | 59 | ```bash 60 | $ export PYTHONPATH="${PYTHONPATH}:${PWD}" # Allows loading custom_components 61 | $ hass --script ensure_config -c config 62 | ``` 63 | 64 | 1. Run Home Assistant 65 | 66 | ```bash 67 | $ hass -c config 68 | ``` 69 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components.""" 2 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/__init__.py: -------------------------------------------------------------------------------- 1 | """openid_auth_provider custom component.""" 2 | 3 | from __future__ import annotations 4 | import sys 5 | import logging 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import Platform 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.helpers.typing import ConfigType 11 | 12 | from .const import DOMAIN 13 | from . import openid_auth_provider 14 | 15 | _LOOGER = logging.getLogger(__name__) 16 | 17 | __all__ = [ 18 | "DOMAIN", 19 | ] 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | PLATFORMS: tuple[Platform] = () # type: ignore 24 | 25 | 26 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 27 | """Set up the config.""" 28 | await openid_auth_provider.async_setup(hass) 29 | 30 | # Currently auth providers are not real platforms and they assume 31 | # they are in a specific package. This is a hack to make it work for now 32 | # until home assistant is extended with a real with platform. 33 | sys.modules[f"homeassistant.auth.providers.{DOMAIN}"] = openid_auth_provider 34 | 35 | return True 36 | 37 | 38 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 39 | """Set up a config entry.""" 40 | await openid_auth_provider.async_setup_entry(hass, entry) 41 | 42 | await hass.config_entries.async_forward_entry_setups( 43 | entry, 44 | platforms=PLATFORMS, 45 | ) 46 | return True 47 | 48 | 49 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 50 | """Unload a config entry.""" 51 | return await hass.config_entries.async_unload_platforms( 52 | entry, 53 | PLATFORMS, 54 | ) 55 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for openid_auth_provider integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | from homeassistant.const import CONF_NAME 12 | from homeassistant.helpers import selector 13 | from homeassistant.helpers.schema_config_entry_flow import ( 14 | SchemaConfigFlowHandler, 15 | SchemaFlowFormStep, 16 | SchemaFlowError, 17 | SchemaCommonFlowHandler, 18 | ) 19 | 20 | from .const import ( 21 | DOMAIN, 22 | CONF_CONFIGURATION, 23 | CONF_CLIENT_ID, 24 | CONF_CLIENT_SECRET, 25 | CONF_EMAILS, 26 | CONF_SUBJECTS, 27 | DISCOVERY_SUFFIX, 28 | ) 29 | from .openid_auth_provider import async_get_configuration 30 | 31 | 32 | async def _validate_user_input( 33 | handler: SchemaCommonFlowHandler, user_input: dict[str, Any] 34 | ) -> dict[str, Any]: 35 | """Validate user input.""" 36 | session = async_get_clientsession(handler.parent_handler.hass) 37 | configuration_url = user_input[CONF_CONFIGURATION] 38 | if not configuration_url.endswith(DISCOVERY_SUFFIX): 39 | configuration_url = f"{configuration_url.rstrip('/')}{DISCOVERY_SUFFIX}" 40 | await async_get_configuration(session, user_input[CONF_CONFIGURATION]) 41 | 42 | if emails := user_input.get(CONF_EMAILS): 43 | user_input[CONF_EMAILS] = [email.strip() for email in emails] 44 | if subjects := user_input.get(CONF_SUBJECTS): 45 | user_input[CONF_SUBJECTS] = [subject.strip() for subject in subjects] 46 | if emails is None and subjects is None: 47 | raise SchemaFlowError("missing_email_or_subject") 48 | 49 | await handler.parent_handler.async_set_unique_id( # type: ignore[union-attr] 50 | unique_id=user_input[CONF_CLIENT_ID] 51 | ) 52 | handler.parent_handler._abort_if_unique_id_configured() # type: ignore[union-attr] 53 | 54 | return user_input 55 | 56 | 57 | CONFIG_FLOW = { 58 | "user": SchemaFlowFormStep( 59 | vol.Schema( 60 | { 61 | vol.Required(CONF_NAME): str, 62 | vol.Required(CONF_CONFIGURATION): selector.TextSelector( 63 | selector.TextSelectorConfig( 64 | type=selector.TextSelectorType.URL, 65 | ) 66 | ), 67 | vol.Required(CONF_CLIENT_ID): str, 68 | vol.Required(CONF_CLIENT_SECRET): str, 69 | vol.Optional(CONF_EMAILS): selector.TextSelector( 70 | selector.TextSelectorConfig( 71 | type=selector.TextSelectorType.EMAIL, 72 | multiple=True, 73 | ) 74 | ), 75 | vol.Optional(CONF_SUBJECTS): selector.TextSelector( 76 | selector.TextSelectorConfig( 77 | type=selector.TextSelectorType.TEXT, 78 | multiple=True, 79 | ) 80 | ), 81 | } 82 | ), 83 | validate_user_input=_validate_user_input, 84 | ) 85 | } 86 | 87 | OPTIONS_FLOW = { 88 | "init": CONFIG_FLOW["user"], 89 | } 90 | 91 | 92 | class OpenIDAuthProviderConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): 93 | """Handle a config flow for Switch as X.""" 94 | 95 | config_flow = CONFIG_FLOW 96 | options_flow = OPTIONS_FLOW 97 | 98 | VERSION = 1 99 | MINOR_VERSION = 1 100 | 101 | def async_config_entry_title(self, options: Mapping[str, Any]) -> str: 102 | """Return config entry title.""" 103 | return options[CONF_NAME] # type: ignore[no-any-return] 104 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/const.py: -------------------------------------------------------------------------------- 1 | """Constants for openid_auth_provider.""" 2 | 3 | DOMAIN = "openid_auth_provider" 4 | 5 | CONF_CLIENT_ID = "client_id" 6 | CONF_CLIENT_SECRET = "client_secret" 7 | CONF_CONFIGURATION = "configuration" 8 | CONF_EMAILS = "emails" 9 | CONF_SUBJECTS = "subjects" 10 | 11 | DISCOVERY_SUFFIX = "/.well-known/openid-configuration" 12 | AUTH_CALLBACK_PATH = "/auth/oidc/callback" 13 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "openid_auth_provider", 3 | "name": "OpenID Auth Provider", 4 | "codeowners": ["@allenporter"], 5 | "config_flow": true, 6 | "dependencies": ["auth", "http"], 7 | "documentation": "https://github.com/allenporter/home-assistant-openid-auth-provider", 8 | "integration_type": "service", 9 | "iot_class": "calculated", 10 | "issue_tracker": "https://github.com/allenporter/home-assistant-openid-auth-provider/issues", 11 | "requirements": ["python-jose>=3.3.0"], 12 | "version": "0.0.1" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/openid_auth_provider.py: -------------------------------------------------------------------------------- 1 | """OpenID based authentication provider.""" 2 | 3 | import logging 4 | from secrets import token_hex 5 | from typing import Any, Optional, cast 6 | from collections.abc import Mapping 7 | import secrets 8 | 9 | import aiohttp 10 | from aiohttp import web 11 | from aiohttp.client import ClientResponse 12 | from jose import jwt 13 | from jose.exceptions import JWTError 14 | import voluptuous as vol 15 | from yarl import URL 16 | 17 | from homeassistant.helpers.network import get_url 18 | from homeassistant.components import http 19 | from homeassistant.config_entries import ConfigEntry 20 | from homeassistant.core import HomeAssistant, callback 21 | from homeassistant.exceptions import HomeAssistantError 22 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 23 | from homeassistant.helpers.config_entry_oauth2_flow import ( 24 | LocalOAuth2Implementation, 25 | ) 26 | from homeassistant.auth.models import AuthFlowContext, AuthFlowResult 27 | from homeassistant.auth import auth_provider_from_config 28 | 29 | 30 | from homeassistant.auth.providers import ( 31 | AUTH_PROVIDER_SCHEMA, 32 | AUTH_PROVIDERS, 33 | AuthProvider, 34 | LoginFlow, 35 | ) 36 | from homeassistant.auth.models import Credentials, UserMeta 37 | 38 | from .const import ( 39 | CONF_CLIENT_ID, 40 | CONF_CLIENT_SECRET, 41 | CONF_CONFIGURATION, 42 | CONF_EMAILS, 43 | CONF_SUBJECTS, 44 | AUTH_CALLBACK_PATH, 45 | DOMAIN, 46 | ) 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | 51 | DATA_JWT_SECRET = "openid_jwt_secret" 52 | HEADER_FRONTEND_BASE = "HA-Frontend-Base" 53 | AUTH_PROVIDER_TYPE = DOMAIN 54 | 55 | WANTED_SCOPES = {"openid", "email", "profile"} 56 | 57 | 58 | CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( 59 | { 60 | vol.Required(CONF_CONFIGURATION): str, 61 | vol.Required(CONF_CLIENT_ID): str, 62 | vol.Required(CONF_CLIENT_SECRET): str, 63 | vol.Optional(CONF_EMAILS): [str], 64 | vol.Optional(CONF_SUBJECTS): [str], 65 | }, 66 | extra=vol.PREVENT_EXTRA, 67 | ) 68 | 69 | OPENID_CONFIGURATION_SCHEMA = vol.Schema( 70 | { 71 | vol.Required("issuer"): str, 72 | vol.Required("jwks_uri"): str, 73 | vol.Required("id_token_signing_alg_values_supported"): list, 74 | vol.Optional("scopes_supported"): vol.Contains("openid"), 75 | vol.Required("token_endpoint"): str, 76 | vol.Required("authorization_endpoint"): str, 77 | vol.Required("response_types_supported"): vol.Contains("code"), 78 | vol.Optional( 79 | "token_endpoint_auth_methods_supported", default=["client_secret_basic"] 80 | ): vol.Contains("client_secret_post"), 81 | vol.Optional( 82 | "grant_types_supported", default=["authorization_code", "implicit"] 83 | ): vol.Contains("authorization_code"), 84 | }, 85 | extra=vol.ALLOW_EXTRA, 86 | ) 87 | 88 | 89 | class InvalidAuthError(HomeAssistantError): 90 | """Raised when submitting invalid authentication.""" 91 | 92 | 93 | async def async_setup(hass: HomeAssistant) -> None: 94 | """Register the OpenID Auth Provider views.""" 95 | hass.http.register_view(AuthorizeCallbackView()) 96 | 97 | 98 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 99 | """Register the OpenID Auth Provider.""" 100 | _LOGGER.info("Registering OpenID Auth Provider") 101 | 102 | key = (AUTH_PROVIDER_TYPE, entry.unique_id) 103 | 104 | # Use private APIs until there is a real auth platform 105 | provider = await auth_provider_from_config( 106 | hass, 107 | hass.auth._store, 108 | { 109 | "type": AUTH_PROVIDER_TYPE, 110 | "id": entry.unique_id, 111 | "name": entry.title, 112 | **entry.options, 113 | }, 114 | ) 115 | hass.auth._providers[key] = provider 116 | 117 | def unsub() -> None: 118 | del hass.auth._providers[key] 119 | 120 | entry.async_on_unload(unsub) 121 | 122 | 123 | async def raise_for_status(response: ClientResponse) -> None: 124 | """Raise exception on data failure with logging.""" 125 | if response.status >= 400: 126 | standard = aiohttp.ClientResponseError( 127 | response.request_info, 128 | response.history, 129 | code=response.status, 130 | headers=response.headers, 131 | ) 132 | data = await response.text() 133 | _LOGGER.error("Request failed: %s", data) 134 | raise InvalidAuthError(data) from standard 135 | 136 | 137 | async def async_get_configuration( 138 | session: aiohttp.ClientSession, configuration_url: str 139 | ) -> dict[str, Any]: 140 | """Get discovery document for OpenID.""" 141 | async with session.get(configuration_url) as response: 142 | await raise_for_status(response) 143 | data = await response.json() 144 | return cast(dict[str, Any], OPENID_CONFIGURATION_SCHEMA(data)) 145 | 146 | 147 | class OpenIdLocalOAuth2Implementation(LocalOAuth2Implementation): 148 | """Local OAuth2 implementation for Toon.""" 149 | 150 | _nonce: Optional[str] = None 151 | _scope: str 152 | 153 | def __init__( 154 | self, 155 | hass: HomeAssistant, 156 | client_id: str, 157 | client_secret: str, 158 | configuration: dict[str, Any], 159 | ): 160 | """Initialize local auth implementation.""" 161 | super().__init__( 162 | hass, 163 | "auth", 164 | client_id, 165 | client_secret, 166 | configuration["authorization_endpoint"], 167 | configuration["token_endpoint"], 168 | ) 169 | 170 | self._scope = " ".join( 171 | sorted(WANTED_SCOPES.intersection(configuration["scopes_supported"])) 172 | ) 173 | 174 | @property 175 | def extra_authorize_data(self) -> dict: 176 | """Extra data that needs to be appended to the authorize url.""" 177 | return {"scope": self._scope, "nonce": self._nonce} 178 | 179 | async def async_generate_authorize_url_with_nonce( 180 | self, flow_id: str, nonce: str 181 | ) -> str: 182 | """Generate an authorize url with a given nonce.""" 183 | self._nonce = nonce 184 | url = await self.async_generate_authorize_url(flow_id) 185 | self._nonce = None 186 | return url 187 | 188 | async def async_generate_authorize_url(self, flow_id: str) -> str: 189 | """Generate a url for the user to authorize.""" 190 | redirect_uri = self.redirect_uri 191 | return str( 192 | URL(self.authorize_url) 193 | .with_query( 194 | { 195 | "response_type": "code", 196 | "client_id": self.client_id, 197 | "redirect_uri": redirect_uri, 198 | "state": encode_jwt( 199 | self.hass, 200 | { 201 | "flow_id": flow_id, 202 | "redirect_uri": redirect_uri, 203 | }, 204 | ), 205 | } 206 | ) 207 | .update_query(self.extra_authorize_data) 208 | ) 209 | 210 | @property 211 | def redirect_uri(self) -> str: 212 | """Return the redirect uri. 213 | 214 | This is similar to the oauth config flow, but doers not use "my" since 215 | the callback paths are different. 216 | """ 217 | if (req := http.current_request.get()) is None: 218 | raise RuntimeError("No current request in context") 219 | 220 | if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: 221 | base_url = get_url( 222 | self.hass, 223 | allow_external=False, 224 | allow_internal=False, 225 | require_current_request=True, 226 | ) 227 | base_url = f"{base_url.rstrip('/')}{AUTH_CALLBACK_PATH}" 228 | return base_url 229 | 230 | return f"{ha_host}{AUTH_CALLBACK_PATH}" 231 | 232 | 233 | @AUTH_PROVIDERS.register(DOMAIN) 234 | class OpenIdAuthProvider(AuthProvider): 235 | """Auth provider using openid connect as the authentication source.""" 236 | 237 | DEFAULT_TITLE = "OpenID Connect" 238 | 239 | async def async_get_configuration(self) -> dict[str, Any]: 240 | """Get discovery document for OpenID.""" 241 | session = async_get_clientsession(self.hass) 242 | return await async_get_configuration(session, self.config[CONF_CONFIGURATION]) 243 | 244 | async def async_get_jwks(self) -> dict[str, Any]: 245 | """Get the keys for id verification.""" 246 | session = async_get_clientsession(self.hass) 247 | async with session.get(self._configuration["jwks_uri"]) as response: 248 | await raise_for_status(response) 249 | data = await response.json() 250 | return cast(dict[str, Any], data) 251 | 252 | async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: 253 | """Return a flow to login.""" 254 | 255 | if not hasattr(self, "_configuration"): 256 | self._configuration = await self.async_get_configuration() 257 | 258 | if not hasattr(self, "_jwks"): 259 | self._jwks = await self.async_get_jwks() 260 | 261 | self._oauth2 = OpenIdLocalOAuth2Implementation( 262 | self.hass, 263 | self.config[CONF_CLIENT_ID], 264 | self.config[CONF_CLIENT_SECRET], 265 | self._configuration, 266 | ) 267 | return OpenIdLoginFlow(self) 268 | 269 | def _decode_id_token(self, token: dict[str, Any], nonce: str) -> dict[str, Any]: 270 | """Decode openid id_token.""" 271 | 272 | algorithms = self._configuration["id_token_signing_alg_values_supported"] 273 | issuer = self._configuration["issuer"] 274 | 275 | id_token = jwt.decode( 276 | token["id_token"], 277 | algorithms=algorithms, 278 | issuer=issuer, 279 | key=self._jwks, 280 | audience=self.config[CONF_CLIENT_ID], 281 | access_token=token["access_token"], 282 | ) 283 | if id_token.get("nonce") != nonce: 284 | raise InvalidAuthError("Nonce mismatch in id_token") 285 | 286 | return id_token 287 | 288 | def _authorize_id_token(self, id_token: dict[str, Any]) -> dict[str, Any]: 289 | """Authorize an id_token according to our internal database.""" 290 | 291 | if id_token["sub"] in self.config.get(CONF_SUBJECTS, []): 292 | return id_token 293 | 294 | if "email" in id_token and "email_verified" in id_token: 295 | if ( 296 | id_token["email"] in self.config.get(CONF_EMAILS, []) 297 | and id_token["email_verified"] 298 | ): 299 | return id_token 300 | 301 | raise InvalidAuthError(f"Subject {id_token['sub']} is not allowed") 302 | 303 | async def async_generate_authorize_url_with_nonce( 304 | self, flow_id: str, nonce: str 305 | ) -> str: 306 | """Generate an authorize url with a given nonce.""" 307 | return await self._oauth2.async_generate_authorize_url_with_nonce( 308 | flow_id, nonce 309 | ) 310 | 311 | async def async_authorize_external_data( 312 | self, external_data: dict[str, Any], nonce: str 313 | ) -> dict[str, Any]: 314 | """Authorize external data.""" 315 | token = await self._oauth2.async_resolve_external_data(external_data) 316 | id_token = self._decode_id_token(token, nonce) 317 | return self._authorize_id_token(id_token) 318 | 319 | @property 320 | def support_mfa(self) -> bool: 321 | """Return whether multi-factor auth supported by the auth provider.""" 322 | return False 323 | 324 | async def async_get_or_create_credentials( 325 | self, flow_result: Mapping[str, str] 326 | ) -> Credentials: 327 | """Get credentials based on the flow result.""" 328 | subject = flow_result["sub"] 329 | 330 | for credential in await self.async_credentials(): 331 | if credential.data["sub"] == subject: 332 | _LOGGER.info("Accepting credential for %s", subject) 333 | return credential 334 | 335 | _LOGGER.info("Creating credential for %s", subject) 336 | return self.async_create_credentials({**flow_result}) 337 | 338 | async def async_user_meta_for_credentials( 339 | self, credentials: Credentials 340 | ) -> UserMeta: 341 | """Return extra user metadata for credentials. 342 | Will be used to populate info when creating a new user. 343 | """ 344 | if "preferred_username" in credentials.data: 345 | name = credentials.data["preferred_username"] 346 | elif "given_name" in credentials.data: 347 | name = credentials.data["given_name"] 348 | elif "name" in credentials.data: 349 | name = credentials.data["name"] 350 | elif "email" in credentials.data: 351 | name = cast(str, credentials.data["email"]).split("@", 1)[0] 352 | else: 353 | name = credentials.data["sub"] 354 | _LOGGER.debug("Creating user meta for '%s'", name) 355 | return UserMeta(name=name, is_active=True) 356 | 357 | 358 | class OpenIdLoginFlow(LoginFlow): 359 | """Handler for the login flow.""" 360 | 361 | external_data: dict[str, str] 362 | _nonce: str 363 | 364 | async def async_step_init( 365 | self, user_input: Optional[dict[str, str]] = None 366 | ) -> AuthFlowResult: 367 | """Handle the step of the form.""" 368 | return await self.async_step_authenticate() 369 | 370 | async def async_step_authenticate( 371 | self, user_input: Optional[dict[str, str]] = None 372 | ) -> AuthFlowResult: 373 | """Authenticate user using external step.""" 374 | provider = cast(OpenIdAuthProvider, self._auth_provider) 375 | 376 | if user_input: 377 | _LOGGER.debug("Completing external step") 378 | self.external_data = user_input 379 | return self.async_external_step_done(next_step_id="authorize") 380 | 381 | self._nonce = token_hex() 382 | url = await provider.async_generate_authorize_url_with_nonce( 383 | self.flow_id, self._nonce 384 | ) 385 | return self.async_external_step(step_id="authenticate", url=url) 386 | 387 | async def async_step_authorize( 388 | self, user_input: Optional[dict[str, str]] = None 389 | ) -> AuthFlowResult: 390 | """Authorize user received from external step.""" 391 | provider = cast(OpenIdAuthProvider, self._auth_provider) 392 | try: 393 | result = await provider.async_authorize_external_data( 394 | self.external_data, self._nonce 395 | ) 396 | except InvalidAuthError as error: 397 | _LOGGER.error("Login failed: %s", str(error)) 398 | return self.async_abort(reason="invalid_auth") 399 | _LOGGER.info("Completed authorize flow") 400 | return await self.async_finish(result) 401 | 402 | 403 | @callback 404 | def encode_jwt(hass: HomeAssistant, data: dict) -> str: 405 | """JWT encode data.""" 406 | if (secret := hass.data.get(DATA_JWT_SECRET)) is None: 407 | secret = hass.data[DATA_JWT_SECRET] = secrets.token_hex() 408 | 409 | return jwt.encode(data, secret, algorithm="HS256") 410 | 411 | 412 | @callback 413 | def decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None: 414 | """JWT encode data.""" 415 | secret: str | None = hass.data.get(DATA_JWT_SECRET) 416 | 417 | if secret is None: 418 | return None 419 | 420 | try: 421 | return jwt.decode(encoded, secret, algorithms=["HS256"]) 422 | except JWTError: 423 | return None 424 | 425 | 426 | class AuthorizeCallbackView(http.HomeAssistantView): 427 | """OpenID Authorization Callback View.""" 428 | 429 | requires_auth = False 430 | url = AUTH_CALLBACK_PATH 431 | name = "auth:external:callback" 432 | 433 | async def get(self, request: web.Request) -> web.Response: 434 | if "state" not in request.query: 435 | return web.Response(text="Missing state parameter") 436 | 437 | hass = request.app[http.KEY_HASS] 438 | 439 | state = decode_jwt(hass, request.query["state"]) 440 | 441 | if state is None: 442 | _LOGGER.info("OIDC request contained invalid state") 443 | return web.Response( 444 | text=( 445 | "Invalid state. Is My Home Assistant configured " 446 | "to go to the right instance?" 447 | ), 448 | status=400, 449 | ) 450 | 451 | user_input: dict[str, Any] = {"state": state} 452 | 453 | if "code" in request.query: 454 | user_input["code"] = request.query["code"] 455 | elif "error" in request.query: 456 | user_input["error"] = request.query["error"] 457 | else: 458 | return web.Response(text="Missing code or error parameter") 459 | 460 | flow_mgr = hass.auth.login_flow 461 | 462 | await flow_mgr.async_configure(flow_id=state["flow_id"], user_input=user_input) 463 | _LOGGER.debug("Resumed OpenID login flow") 464 | return web.Response( 465 | headers={"content-type": "text/html"}, 466 | text="", 467 | ) 468 | -------------------------------------------------------------------------------- /custom_components/openid_auth_provider/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "OpenID Auth Provider", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Configure OpenID Auth Provider", 7 | "description": "You can use OpenID Connect to enable single sign-on (SSO) in Home Assistant as an additional Authentication Provider.\n\nThis software is an unofficial custom component for Home Assistant. It is not developed, endorsed, or affiliated with the Home Assistant project. Use this software at your own risk.\n\nWhile efforts have been made to ensure the security and functionality of this software, it may introduce vulnerabilities, compatibility issues, or unexpected behavior. By installing and using this software, you accept full responsibility for any outcomes or consequences.", 8 | "data": { 9 | "name": "Name", 10 | "configuration": "OpenID URL", 11 | "client_id": "Client ID", 12 | "client_secret": "Client Secret", 13 | "emails": "Emails", 14 | "subjects": "Subjects" 15 | }, 16 | "data_description": { 17 | "name": "The name of the OpenID Connect provider. This is the name that will be displayed in the Home Assistant UI.", 18 | "configuration": "The URL of the OpenID Connect provider. This is the URL that Home Assistant will use to authenticate users.", 19 | "client_id": "The client ID that Home Assistant will use to authenticate with the OpenID Connect provider.", 20 | "client_secret": "The client secret that Home Assistant will use to authenticate with the OpenID Connect provider.", 21 | "emails": "A list of email addresses, one per line, that are allowed to authenticate with Home Assistant.", 22 | "subjects": "A list of subjects, one per line, that are allowed to authenticate with Home Assistant." 23 | } 24 | } 25 | }, 26 | "error": { 27 | "missing_email_or_subject": "You must provide at least one email address or subject." 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OpenID Auth Provider", 3 | "homeassistant": "2024.04.00" 4 | } 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | exclude = [ 3 | "venv/", 4 | ] 5 | platform = "linux" 6 | show_error_codes = true 7 | follow_imports = "normal" 8 | local_partial_types = true 9 | strict_equality = true 10 | no_implicit_optional = true 11 | warn_incomplete_stub = true 12 | warn_redundant_casts = true 13 | warn_unused_configs = true 14 | warn_unused_ignores = true 15 | disable_error_code = [ 16 | "import-untyped", 17 | ] 18 | extra_checks = false 19 | check_untyped_defs = true 20 | disallow_incomplete_defs = true 21 | disallow_subclassing_any = true 22 | disallow_untyped_calls = true 23 | disallow_untyped_decorators = true 24 | disallow_untyped_defs = true 25 | warn_return_any = true 26 | warn_unreachable = true 27 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # Generic python dependencies 2 | black==24.10.0 3 | mypy==1.13.0 4 | pip==24.3.1 5 | pre-commit==4.0.1 6 | ruff==0.8.1 7 | 8 | # Home Assistant testing dependencies 9 | pytest-homeassistant-custom-component==0.13.186 10 | syrupy>=4.6.1 11 | 12 | # Component dependencies 13 | hassil>=2.0.4 14 | home-assistant-intents>=2024.4.3 15 | python-jose>=3.3.0 16 | -------------------------------------------------------------------------------- /resources/login-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allenporter/home-assistant-openid-auth-provider/cbcc24ec1cfa2546155a35f51929751c29515f0a/resources/login-screenshot.png -------------------------------------------------------------------------------- /script/run-mypy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | 5 | # other common virtualenvs 6 | my_path=$(git rev-parse --show-toplevel) 7 | 8 | for venv in venv .venv .; do 9 | if [ -f "${my_path}/${venv}/bin/activate" ]; then 10 | . "${my_path}/${venv}/bin/activate" 11 | break 12 | fi 13 | done 14 | 15 | mypy ${my_path} 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for openid_auth_provider.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for the custom component.""" 2 | 3 | from collections.abc import Generator, AsyncGenerator 4 | import logging 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | from homeassistant.const import Platform, CONF_NAME 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.setup import async_setup_component 12 | 13 | from pytest_homeassistant_custom_component.common import ( 14 | MockConfigEntry, 15 | ) 16 | 17 | from custom_components.openid_auth_provider.const import ( 18 | DOMAIN, 19 | CONF_CONFIGURATION, 20 | CONF_CLIENT_ID, 21 | CONF_CLIENT_SECRET, 22 | CONF_EMAILS, 23 | CONF_SUBJECTS, 24 | ) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | CONST_DESCRIPTION_URI = "https://openid.test/.well-known/openid-configuration" 29 | CONST_CLIENT_ID = "123client_id456" 30 | CONST_CLIENT_SECRET = "123client_secret456" 31 | CONST_SUBJECT = "248289761001" 32 | CONST_EMAIL = "john.doe@openid.test" 33 | 34 | 35 | CONST_JWKS_URI = "https://jwks.test/jwks" 36 | CONST_JWKS_KEY = "bla" 37 | CONST_JWKS = {"keys": [CONST_JWKS_KEY]} 38 | CONST_AUTHORIZATION_ENDPOINT = "https://openid.test/authorize" 39 | CONST_TOKEN_ENDPOINT = "https://openid.test/authorize" 40 | 41 | CONST_DESCRIPTION = { 42 | "issuer": "https://openid.test/", 43 | "jwks_uri": CONST_JWKS_URI, 44 | "authorization_endpoint": CONST_AUTHORIZATION_ENDPOINT, 45 | "token_endpoint": CONST_TOKEN_ENDPOINT, 46 | "token_endpoint_auth_methods_supported": "client_secret_post", 47 | "id_token_signing_alg_values_supported": ["RS256", "HS256"], 48 | "scopes_supported": ["openid", "email", "profile"], 49 | "response_types_supported": "code", 50 | } 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def auto_enable_custom_integrations( 55 | enable_custom_integrations: None, 56 | ) -> Generator[None, None, None]: 57 | """Enable custom integration.""" 58 | _ = enable_custom_integrations # unused 59 | yield 60 | 61 | 62 | @pytest.fixture(name="platforms") 63 | def mock_platforms() -> list[Platform]: 64 | """Fixture for platforms loaded by the integration.""" 65 | return [] 66 | 67 | 68 | @pytest.fixture(name="setup_integration") 69 | async def mock_setup_integration( 70 | hass: HomeAssistant, 71 | config_entry: MockConfigEntry, 72 | platforms: list[Platform], 73 | ) -> AsyncGenerator[ 74 | None, 75 | None, 76 | ]: 77 | """Set up the integration.""" 78 | 79 | with patch(f"custom_components.{DOMAIN}.PLATFORMS", platforms): 80 | assert await async_setup_component(hass, DOMAIN, {}) 81 | await hass.async_block_till_done() 82 | yield 83 | 84 | 85 | @pytest.fixture(name="emails") 86 | def mock_emails() -> list[str]: 87 | """Fixture for emails.""" 88 | return [CONST_EMAIL] 89 | 90 | 91 | @pytest.fixture(name="subjects") 92 | def mock_subjects() -> list[str]: 93 | """Fixture for subjects.""" 94 | return [CONST_SUBJECT] 95 | 96 | 97 | @pytest.fixture(name="config_entry") 98 | async def mock_config_entry( 99 | hass: HomeAssistant, 100 | emails: list[str], 101 | subjects: list[str], 102 | ) -> MockConfigEntry: 103 | """Fixture to create a configuration entry.""" 104 | config_entry = MockConfigEntry( 105 | data={}, 106 | domain=DOMAIN, 107 | options={ 108 | CONF_NAME: "Example", 109 | CONF_CONFIGURATION: CONST_DESCRIPTION_URI, 110 | CONF_CLIENT_ID: CONST_CLIENT_ID, 111 | CONF_CLIENT_SECRET: CONF_CLIENT_SECRET, 112 | CONF_SUBJECTS: subjects, 113 | CONF_EMAILS: emails, 114 | }, 115 | unique_id=CONST_CLIENT_ID, 116 | ) 117 | config_entry.add_to_hass(hass) 118 | assert await hass.config_entries.async_setup(config_entry.entry_id) 119 | await hass.async_block_till_done() 120 | return config_entry 121 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the config flow.""" 2 | 3 | from unittest.mock import patch 4 | 5 | 6 | from homeassistant import config_entries 7 | from homeassistant.data_entry_flow import FlowResultType 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.const import ( 10 | CONF_NAME, 11 | ) 12 | 13 | from custom_components.openid_auth_provider.const import ( 14 | DOMAIN, 15 | CONF_CONFIGURATION, 16 | CONF_CLIENT_ID, 17 | CONF_CLIENT_SECRET, 18 | CONF_EMAILS, 19 | ) 20 | 21 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 22 | 23 | from .conftest import CONST_DESCRIPTION_URI, CONST_DESCRIPTION 24 | 25 | 26 | async def test_config_flow( 27 | hass: HomeAssistant, 28 | aioclient_mock: AiohttpClientMocker, 29 | ) -> None: 30 | """Test completing the configuration flow.""" 31 | result = await hass.config_entries.flow.async_init( 32 | DOMAIN, context={"source": config_entries.SOURCE_USER} 33 | ) 34 | assert result.get("type") is FlowResultType.FORM 35 | assert result.get("errors") is None 36 | 37 | aioclient_mock.get( 38 | CONST_DESCRIPTION_URI, 39 | json=CONST_DESCRIPTION, 40 | ) 41 | 42 | with patch( 43 | f"custom_components.{DOMAIN}.async_setup_entry", return_value=True 44 | ) as mock_setup: 45 | result = await hass.config_entries.flow.async_configure( 46 | result["flow_id"], 47 | { 48 | CONF_NAME: "Example", 49 | CONF_CONFIGURATION: CONST_DESCRIPTION_URI, 50 | CONF_CLIENT_ID: "client-id", 51 | CONF_CLIENT_SECRET: "client-secret", 52 | CONF_EMAILS: ["user@dex.local", "user@dex.remote"], 53 | }, 54 | ) 55 | await hass.async_block_till_done() 56 | 57 | assert result.get("type") is FlowResultType.CREATE_ENTRY 58 | assert result.get("title") == "Example" 59 | assert result.get("context", {}).get("unique_id") == "client-id" 60 | assert result.get("data") == {} 61 | assert result.get("options") == { 62 | CONF_NAME: "Example", 63 | CONF_CONFIGURATION: CONST_DESCRIPTION_URI, 64 | CONF_CLIENT_ID: "client-id", 65 | CONF_CLIENT_SECRET: "client-secret", 66 | CONF_EMAILS: ["user@dex.local", "user@dex.remote"], 67 | } 68 | assert len(mock_setup.mock_calls) == 1 69 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for the openid_auth_provider component.""" 2 | 3 | from homeassistant.core import HomeAssistant 4 | 5 | 6 | from pytest_homeassistant_custom_component.common import ( 7 | MockConfigEntry, 8 | ) 9 | 10 | from .conftest import ( 11 | CONST_CLIENT_ID, 12 | ) 13 | 14 | 15 | async def test_no_config_entry( 16 | hass: HomeAssistant, 17 | ) -> None: 18 | """Test the auth provider is not enabled when no config entry is set up.""" 19 | manager = hass.auth 20 | assert manager.auth_providers == [] 21 | 22 | 23 | async def test_init( 24 | hass: HomeAssistant, 25 | config_entry: MockConfigEntry, 26 | ) -> None: 27 | """Test login flow with emails.""" 28 | manager = hass.auth 29 | 30 | assert {provider.id: provider.name for provider in manager.auth_providers} == { 31 | CONST_CLIENT_ID: "Example" 32 | } 33 | 34 | # Unload the config entry and verify the provider is unloaded 35 | await hass.config_entries.async_unload(config_entry.entry_id) 36 | assert [provider.id for provider in manager.auth_providers] == [] 37 | -------------------------------------------------------------------------------- /tests/test_openid_auth_provider.py: -------------------------------------------------------------------------------- 1 | """Test openid auth provider.""" 2 | 3 | from datetime import datetime, timezone 4 | from unittest.mock import patch 5 | from collections.abc import Generator 6 | 7 | import logging 8 | from jose import jwt 9 | import pytest 10 | from aiohttp.test_utils import TestClient 11 | 12 | from homeassistant import data_entry_flow 13 | from homeassistant.core import HomeAssistant 14 | 15 | from homeassistant.core_config import async_process_ha_core_config 16 | from homeassistant.auth import AuthManager 17 | 18 | from custom_components.openid_auth_provider import DOMAIN 19 | from custom_components.openid_auth_provider.openid_auth_provider import ( 20 | encode_jwt, 21 | ) 22 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 23 | from pytest_homeassistant_custom_component.typing import ClientSessionGenerator 24 | from pytest_homeassistant_custom_component.common import MockConfigEntry 25 | 26 | 27 | from .conftest import ( 28 | CONST_DESCRIPTION_URI, 29 | CONST_DESCRIPTION, 30 | CONST_CLIENT_ID, 31 | CONST_SUBJECT, 32 | CONST_EMAIL, 33 | CONST_JWKS_URI, 34 | CONST_JWKS, 35 | CONST_TOKEN_ENDPOINT, 36 | CONST_JWKS_KEY, 37 | CONST_AUTHORIZATION_ENDPOINT, 38 | ) 39 | 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | PROVIDER_MODULE = f"homeassistant.auth.providers.{DOMAIN}" 45 | CONST_ACCESS_TOKEN = "dummy_access_token" 46 | CONST_NONCE = "dummy_nonce" 47 | CONST_ID_TOKEN = { 48 | "iss": "https://openid.test/", 49 | "sub": CONST_SUBJECT, 50 | "aud": CONST_CLIENT_ID, 51 | "nonce": CONST_NONCE, 52 | "exp": datetime(2099, 1, 1, tzinfo=timezone.utc).timestamp(), 53 | "iat": datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp(), 54 | "name": "John Doe", 55 | "email": CONST_EMAIL, 56 | "email_verified": True, 57 | } 58 | REDIRECT_URL = "https://example.com/auth/oidc/callback" 59 | PROVIDER_KEY = (DOMAIN, CONST_CLIENT_ID) 60 | 61 | 62 | @pytest.fixture(name="openid_server") 63 | async def openid_server_fixture( 64 | hass: HomeAssistant, 65 | aioclient_mock: AiohttpClientMocker, 66 | ) -> None: 67 | """Mock openid server.""" 68 | aioclient_mock.get( 69 | CONST_DESCRIPTION_URI, 70 | json=CONST_DESCRIPTION, 71 | ) 72 | 73 | aioclient_mock.get( 74 | CONST_JWKS_URI, 75 | json=CONST_JWKS, 76 | ) 77 | 78 | aioclient_mock.post( 79 | CONST_TOKEN_ENDPOINT, 80 | json={ 81 | "access_token": CONST_ACCESS_TOKEN, 82 | "type": "bearer", 83 | "expires_in": 60, 84 | "id_token": jwt.encode( 85 | CONST_ID_TOKEN, CONST_JWKS_KEY, access_token=CONST_ACCESS_TOKEN 86 | ), 87 | }, 88 | ) 89 | 90 | 91 | @pytest.fixture(name="endpoints") 92 | async def endpoints_fixture(hass: HomeAssistant) -> None: 93 | """Initialize the needed endpoints and redirects.""" 94 | await async_process_ha_core_config( 95 | hass, 96 | {"internal_url": "http://example.com", "external_url": "http://external.com"}, 97 | ) 98 | 99 | 100 | @pytest.fixture(autouse=True) 101 | def token_fixture(hass: HomeAssistant) -> Generator[None, None, None]: 102 | with patch(f"{PROVIDER_MODULE}.token_hex") as token_hex: 103 | token_hex.return_value = CONST_NONCE 104 | yield 105 | 106 | 107 | def encode_redirect_jwt(hass: HomeAssistant, flow_id: str) -> str: 108 | return encode_jwt( 109 | hass, 110 | { 111 | "flow_id": flow_id, 112 | "redirect_uri": REDIRECT_URL, 113 | }, 114 | ) 115 | 116 | 117 | async def _run_external_flow( 118 | hass: HomeAssistant, manager: AuthManager, client: TestClient 119 | ) -> str: 120 | result = await manager.login_flow.async_init(PROVIDER_KEY) 121 | 122 | state = encode_redirect_jwt(hass, result["flow_id"]) 123 | _LOGGER.debug("flow_id=%s", result["flow_id"]) 124 | 125 | assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP 126 | assert result["url"] == ( 127 | f"{CONST_AUTHORIZATION_ENDPOINT}?response_type=code&client_id={ 128 | CONST_CLIENT_ID}" 129 | f"&redirect_uri={REDIRECT_URL}" 130 | f"&state={state}&scope=email+openid+profile&nonce={CONST_NONCE}" 131 | ) 132 | 133 | resp = await client.get(f"/auth/oidc/callback?code=abcd&state={state}") 134 | assert resp.status == 200 135 | assert resp.headers["content-type"] == "text/html; charset=utf-8" 136 | 137 | return result["flow_id"] 138 | 139 | 140 | @pytest.mark.usefixtures("current_request_with_host", "openid_server", "endpoints") 141 | async def test_login_flow_validates_email( 142 | hass: HomeAssistant, 143 | hass_client_no_auth: ClientSessionGenerator, 144 | config_entry: MockConfigEntry, 145 | ) -> None: 146 | """Test login flow with emails.""" 147 | manager = hass.auth 148 | 149 | client = await hass_client_no_auth() 150 | flow_id = await _run_external_flow(hass, manager, client) 151 | 152 | result = await manager.login_flow.async_configure(flow_id) 153 | 154 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 155 | assert result["data"]["email"] == CONST_EMAIL 156 | 157 | 158 | @pytest.mark.usefixtures("current_request_with_host", "openid_server", "endpoints") 159 | async def test_login_flow_validates_subject( 160 | hass: HomeAssistant, 161 | hass_client_no_auth: ClientSessionGenerator, 162 | config_entry: MockConfigEntry, 163 | ) -> None: 164 | """Test login flow with subjects.""" 165 | manager = hass.auth 166 | 167 | client = await hass_client_no_auth() 168 | flow_id = await _run_external_flow(hass, manager, client) 169 | 170 | result = await manager.login_flow.async_configure(flow_id) 171 | 172 | assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 173 | assert result["data"]["sub"] == CONST_SUBJECT 174 | 175 | 176 | @pytest.mark.usefixtures("current_request_with_host", "openid_server", "endpoints") 177 | @pytest.mark.parametrize(("emails", "subjects"), [([], [])]) 178 | async def test_login_flow_not_allowlisted( 179 | hass: HomeAssistant, 180 | hass_client_no_auth: ClientSessionGenerator, 181 | config_entry: MockConfigEntry, 182 | ) -> None: 183 | """Test login flow not in allowlist.""" 184 | manager = hass.auth 185 | 186 | client = await hass_client_no_auth() 187 | flow_id = await _run_external_flow(hass, manager, client) 188 | 189 | result = await manager.login_flow.async_configure(flow_id) 190 | 191 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 192 | 193 | 194 | @pytest.mark.usefixtures("current_request_with_host", "openid_server", "endpoints") 195 | async def test_login_flow_invalid_jwt( 196 | hass: HomeAssistant, 197 | hass_client_no_auth: ClientSessionGenerator, 198 | config_entry: MockConfigEntry, 199 | ) -> None: 200 | """Test login flow not in allowlist.""" 201 | manager = hass.auth 202 | 203 | client = await hass_client_no_auth() 204 | 205 | result = await manager.login_flow.async_init(PROVIDER_KEY) 206 | 207 | state = encode_redirect_jwt(hass, result["flow_id"]) 208 | _LOGGER.debug("flow_id=%s", result["flow_id"]) 209 | 210 | assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP 211 | assert result["url"] == ( 212 | f"{CONST_AUTHORIZATION_ENDPOINT}?response_type=code&client_id={ 213 | CONST_CLIENT_ID}" 214 | f"&redirect_uri={REDIRECT_URL}" 215 | f"&state={state}&scope=email+openid+profile&nonce={CONST_NONCE}" 216 | ) 217 | 218 | state += "invalid-data" 219 | resp = await client.get(f"/auth/oidc/callback?code=abcd&state={state}") 220 | assert resp.status == 400 221 | --------------------------------------------------------------------------------