From 46387d1620f03c567bc72646529c38648dabba1e Mon Sep 17 00:00:00 2001 From: Marco Kirchner Date: Tue, 6 Dec 2022 16:33:53 +0000 Subject: [PATCH] initial commit --- .devcontainer/README.md | 60 +++++ .devcontainer/configuration.yaml | 9 + .devcontainer/devcontainer.json | 30 +++ .gitattributes | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 17 ++ .github/ISSUE_TEMPLATE/issue.md | 42 ++++ .github/workflows/cron.yaml | 21 ++ .github/workflows/pull.yml | 55 +++++ .github/workflows/push.yml | 58 +++++ .gitignore | 6 + .vscode/launch.json | 35 +++ .vscode/settings.json | 8 + .vscode/tasks.json | 29 +++ CONTRIBUTING.md | 61 ++++++ LICENSE | 21 ++ README.md | 115 ++++++++++ custom_components/jwt_cookie/__init__.py | 243 +++++++++++++++++++++ custom_components/jwt_cookie/manifest.json | 18 ++ hacs.json | 5 + integrations/caddy.md | 82 +++++++ jwt.png | Bin 0 -> 12136 bytes requirements_dev.txt | 1 + requirements_test.txt | 1 + setup.cfg | 35 +++ tests/README.md | 24 ++ tests/__init__.py | 1 + 26 files changed, 978 insertions(+) create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue.md create mode 100644 .github/workflows/cron.yaml create mode 100644 .github/workflows/pull.yml create mode 100644 .github/workflows/push.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 custom_components/jwt_cookie/__init__.py create mode 100644 custom_components/jwt_cookie/manifest.json create mode 100644 hacs.json create mode 100644 integrations/caddy.md create mode 100644 jwt.png create mode 100644 requirements_dev.txt create mode 100644 requirements_test.txt create mode 100644 setup.cfg create mode 100644 tests/README.md create mode 100644 tests/__init__.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..e304a9a --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debugger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..53b8c09 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,9 @@ +default_config: + +logger: + default: info + logs: + custom_components.integration_blueprint: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0761dae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "Blueprint integration development", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6bcce42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..bbd0345 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your logs here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..8d51d0f --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,21 @@ +name: Cron actions + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..d895c86 --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,55 @@ +name: Pull actions + +on: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.integration_blueprint \ + -o console_output_style=count \ + -p no:sugar \ + tests diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..d0ff7bf --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,58 @@ +name: Push actions + +on: + push: + branches: + - master + - dev + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.integration_blueprint \ + -o console_output_style=count \ + -p no:sugar \ + tests \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492cda3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +pythonenv* +venv +.venv +.coverage +.idea diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..555a62b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] + } + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3d535d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "/usr/local/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7ab4ba8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d85a589 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee2aa67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Marco Kirchner @BigBoot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9201406 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# hass-jwt_cookie + +[![GitHub Release][releases-shield]][releases] +[![License][license-shield]](LICENSE) +[![hacs][hacsbadge]][hacs] +![Project Maintenance][maintenance-shield] + +Create JWT Cookies every time you log in to your HomeAssistant instance. + +![jwt][jwtimg] + +## Why? + +I wanted to reverse proxy a few of my internally reachable services and make them available through my publicly accessible HomeAssistant installation. +After looking at the available solutions I was not satisfied with any of them, here's some of the solutions I evaluated and why I disliked them. + +- BasicAuth using user:password in the url + ❌ Doesn't work in the Android/iOS App + ❌ Makes the login details available in cleartext in the url + +- Authelia + ❌ User Management Separate from HomeAssistant + ❌ No SSO + ❌ Doesn't work in the Android/iOS App? + +- LDAP+Authelia+HomeAssistant LDAP+Some LDAP GUI + ❌ Very Complex + ❌ No True SSO (You'll have to log in to Home Assistant **AND** Authelia separately) + ❌ Doesn't work in the Android/iOS App? + +- Various other similar combination of solutions like Authentik/Keycloak/... all suffer from the same fundamental problems as Authelia + +So I decided to create this intergration and combine it with a reverse proxy supporting jwt auth. This ticks all of my requirements: +- ✅ Works everywhere (including the iOS/Android apps) +- ✅ True SSO +- ✅ Users are managed in HomeAssistant +- ✅ No cleartext login/passwords +- ✅ Is easily extensible to new services +- ✅ Reasonably safe + +**NOTE:** By itself this integration only provides the creation of a json cookie, the actual authentication will still need to be configured in the reverse proxy, see [intergrations](#intergrations) for more details. + +## Installation + +### HACS (Recommended) + +Installation is via the [Home Assistant Community Store +(HACS)](https://hacs.xyz/), which is the best place to get third-party +integrations for Home Assistant. Once you have HACS set up, simply click the button below or +follow the [instructions for adding a custom +repository](https://hacs.xyz/docs/faq/custom_repositories) and then +the integration will be available to install like any other. + +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bigboot&repository=hass-jwt_cookie&category=integration) + +### Manual + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `jwt_cookie`. +4. Download _all_ the files from the `custom_components/jwt_cookie/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant + +Using your HA configuration directory (folder) as a starting point you should now also have this: + +```text +custom_components/jwt_cookie/__init__.py +custom_components/jwt_cookie/manifest.json +``` + +## Configuration + +To use this component in your installation, add the following to your configuration.yaml file: + +### Example configuration.yaml entry (uncomment and change if needed) + +```yaml +jwt_cookie: + # cookie_name: # defaults to jwt_access_token + # audience: # defaults to homeassistant + # issuer: # defaults to homeassistant + # http_only: # defaults to true + # secure: # defaults to false + # domain: # defaults to the current domain, to include subdomains + # set this to the domain name with a leading `.` + # i.e. .my.hass.domain + # public_key_file: # defaults to /config/jwt_cookie.pem + # private_key_file: # defaults to null + # if not set no private key will be stored + # this means a new private/public key pair + # will be generated every time ha restarts +``` + +## Intergrations + +- [Caddy](/integrations/caddy.md) +- Traefik (Open for contributions, probably requires commercial edition) +- Nginx (Open for contributions, probably requires commercial edition) +- HAProxy (Open for contributions) + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[integration_blueprint]: https://github.com/custom-components/integration_blueprint +[hacs]: https://github.com/custom-components/hacs +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[jwtimg]: jwt.png +[license-shield]: https://img.shields.io/github/license/custom-components/blueprint.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40BigBoot-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/custom-components/blueprint.svg?style=for-the-badge +[releases]: https://github.com/custom-components/integration_blueprint/releases diff --git a/custom_components/jwt_cookie/__init__.py b/custom_components/jwt_cookie/__init__.py new file mode 100644 index 0000000..3ddc7a1 --- /dev/null +++ b/custom_components/jwt_cookie/__init__.py @@ -0,0 +1,243 @@ +import gc +import jwt +import logging +from http import HTTPStatus +from os import path +from typing import Union +from multidict import MultiDictProxy +from yarl import URL +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import load_pem_private_key + +import voluptuous as vol + +from aiohttp import web +from homeassistant.components.http.view import HomeAssistantView + +from homeassistant.helpers import config_validation as cv +from homeassistant.auth.models import User +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION +from homeassistant.util import dt +from homeassistant.data_entry_flow import FlowResultType, FlowResult +from homeassistant.components.auth import ( + DOMAIN as AUTH_DOMAIN, + RetrieveResultType, + TokenView, +) +from homeassistant.components.auth.login_flow import LoginFlowResourceView +from homeassistant.core import HomeAssistant + + +DOMAIN = "jwt_cookie" +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional("cookie_name", default="jwt_access_token"): cv.string, + vol.Optional("audience", default="homeassistant"): cv.string, + vol.Optional("issuer", default="homeassistant"): cv.string, + vol.Optional("http_only", default=True): cv.boolean, + vol.Optional("secure", default=True): cv.boolean, + vol.Optional("domain"): cv.string, + vol.Optional("public_key_file"): cv.path, + vol.Optional("private_key_file"): cv.path, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config): + """Load configuration and register custom views""" + domain_config = config[DOMAIN] + + # Because we start after auth, we have access to store_result + store_result = hass.data[AUTH_DOMAIN] + retrieve_auth = next( + filter(lambda obj: isinstance(obj, TokenView), gc.get_objects()) + )._retrieve_auth + + # Remove old Views + for route in hass.http.app.router._resources: + if route.canonical == "/auth/login_flow/{flow_id}": + _LOGGER.debug("Removed original login_flow route") + hass.http.app.router._resources.remove(route) + elif route.canonical == "/auth/token": + _LOGGER.debug("Removed original token route") + hass.http.app.router._resources.remove(route) + + private_key_path = domain_config.get("private_key_file", None) + if private_key_path and path.isfile(private_key_path): + _LOGGER.debug(f"Loading signing key from {private_key_path}") + with open(private_key_path, "rb") as pem_file: + private_key = load_pem_private_key(pem_file.read(), password=None) + else: + _LOGGER.debug("Generating signing key") + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + + public_key = private_key.public_key() + + domain_config["private_key"] = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + domain_config["public_key"] = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + public_key_path_default = path.join(hass.config.config_dir, "jwt_cookie.pem") + public_key_path = domain_config.get("public_key_file", public_key_path_default) + + with open(public_key_path, "wb") as pem_file: + pem_file.write(domain_config["public_key"]) + + if private_key_path: + with open(private_key_path, "wb") as pem_file: + pem_file.write(domain_config["private_key"]) + + _LOGGER.debug("Add new routes") + hass.http.register_view( + JWTCookieLoginFlowResourceView( + hass.auth.login_flow, store_result, domain_config + ) + ) + hass.http.register_view(JWTCookieTokenView(retrieve_auth, domain_config)) + hass.http.register_view(JWTCookieRedirectView) + + return True + + +def create_jwt_cookie(response: web.Response, user: User, config): + """Create a new JWT Cookie and append it to the response""" + now = dt.utcnow() + token = jwt.encode( + { + "sub": user.id, + "name": user.name, + "roles": [ + *(["admin"] if user.is_admin else []), + "user", + ], + "aud": [config["audience"]], + "iss": config["issuer"], + "exp": now + ACCESS_TOKEN_EXPIRATION, + "iat": now, + }, + config["private_key"], + algorithm="ES256", + ) + + response.set_cookie( + config["cookie_name"], + token, + httponly=config["http_only"], + max_age=ACCESS_TOKEN_EXPIRATION.total_seconds(), + secure=config["secure"], + domain=config.get("domain"), + ) + + +class JWTCookieRedirectView(HomeAssistantView): + """Helper view for redirecting after oauth2""" + + url = "/auth/jwt_cookie" + name = "api:auth:redirect" + requires_auth = False + + async def get(self, request: web.Request) -> web.Response: + """Redirect based on query parameters""" + + if "state" in request.query and "code" in request.query: + url = URL.build( + scheme=request.url.scheme, + authority=request.url.authority, + path="/auth/jwt_cookie", + ) + return web.HTTPTemporaryRedirect(request.query.getone("state")) + + if "redirect_url" in request.query: + return_url = URL.build( + scheme=request.url.scheme, + authority=request.url.authority, + path="/auth/jwt_cookie", + ) + url = URL.build( + scheme=request.url.scheme, + authority=request.url.authority, + path="/auth/authorize", + ).with_query( + redirect_uri=str(return_url), + state=request.query.getone("redirect_url"), + client_id=str( + URL.build( + scheme=request.url.scheme, authority=request.url.authority + ) + ), + ) + return web.HTTPTemporaryRedirect(url) + + return self.json( + { + "error": "invalid_request", + "error_description": "either redirect_url or state and code is required", + }, + status_code=HTTPStatus.BAD_REQUEST, + ) + + +class JWTCookieLoginFlowResourceView(LoginFlowResourceView): + """Wrapper around LoginFlowResourceView to create a JWT Cookie after successful login""" + + def __init__(self, flow_mgr, store_result, config) -> None: + super().__init__(flow_mgr, store_result) + self.config = config + + async def _async_flow_result_to_response( + self, + request: web.Request, + client_id: str, + result: FlowResult, + ) -> web.Response: + response = await super()._async_flow_result_to_response( + request, client_id, dict(result) + ) + + if result["type"] == FlowResultType.CREATE_ENTRY and response.status == 200: + hass: HomeAssistant = request.app["hass"] + user = await hass.auth.async_get_user_by_credentials(result.pop("result")) + + if user is not None: + create_jwt_cookie(response, user, self.config) + + return response + + +class JWTCookieTokenView(TokenView): + """Wrapper around TokenView to create a JWT Cookie after refreshing a token""" + + def __init__(self, retrieve_auth: RetrieveResultType, config) -> None: + super().__init__(retrieve_auth) + self.config = config + + async def _async_handle_refresh_token( + self, + hass: HomeAssistant, + data: MultiDictProxy[str], + remote_addr: Union[str, None], + ) -> web.Response: + response = await super()._async_handle_refresh_token(hass, data, remote_addr) + + if response.status == 200: + token = await hass.auth.async_get_refresh_token_by_token( + data.get("refresh_token") + ) + create_jwt_cookie(response, token.user, self.config) + + return response diff --git a/custom_components/jwt_cookie/manifest.json b/custom_components/jwt_cookie/manifest.json new file mode 100644 index 0000000..473ffce --- /dev/null +++ b/custom_components/jwt_cookie/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "jwt_cookie", + "name": "jwt_cookie", + "documentation": "", + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [ + "auth" + ], + "requirements": [ + "python-jose==3.3.0" + ], + "codeowners": [ + "BigBoot" + ], + "version": "0.1" +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..60f1a38 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "jwt_cookie", + "hacs": "0.0.1", + "render_readme": true +} \ No newline at end of file diff --git a/integrations/caddy.md b/integrations/caddy.md new file mode 100644 index 0000000..660d68c --- /dev/null +++ b/integrations/caddy.md @@ -0,0 +1,82 @@ +# Set Up SSO using Caddy and jwt_cookie + +Note: In the following guide `my.home.assistant` will be used for demonstrative purposes, replace this with your own domain. + +What you will end up after following this guide +- HomeAssistant reachable on `my.home.assistant` with automatic HTTPS certificates managed by Caddy +- Internal services reachable on `svc1.my.home.assistant` etc. +- Seamless SSO for HomeAssistant and services + +## Assumptions & Requirements +This guide assumes the following: +- You do have access to manage the DNS entries for `my.home.assistant` (i.e. you own the domain) +- You are using HassOS or Supervised (to install the caddy addon) +- You already set up your DNS so `my.home.assistants` points to your HomeAssitant instance +- You already set up your DNS so `*.my.home.assistants` points to your HomeAssitant instance +- Your HomeAssistant is publicly reachable on Port 80 & 443 +- You already have HACS running + +Note that none of those are hard requirements but for the sake of simplicity this is the only setup we will be looking at. + +## 1. Install jwt_cookie: +- Add the jwt_cookie HACS repository: [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bigboot&repository=hass-jwt_cookie&category=integration) +- Install the jwt_cookie intergration in HACS + +## 2. Install Caddy2 +- Add the Caddy2 addon repository: + [![Add Caddy2 addon repository](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Feinschmidt%2Fhassio-addons) +- Install the Caddy2 addon: [![Install the Caddy2 addon](https://my.home-assistant.io/badges/supervisor_store.svg)](https://my.home-assistant.io/redirect/supervisor_store/) +- Enable AutoStart & Watchdog for the Caddy2 addon +- Configure the Caddy2 addon: + set `config_path` to `/config/Caddyfile` + set `custom_binary_path` to `/config/caddy` +- [Download Caddy with the caddy-security plugin](https://caddyserver.com/download?package=github.com%2Fgreenpau%2Fcaddy-security) and save it as `/config/caddy` + + +## 3. Configure Caddy, and HomeAssistant +- Create the file `/config/Caddyfile` with the following content: +```Caddyfile +{ + security { + # Set up a policy called homeassistant + authorization policy homeassistant { + set token sources cookie + crypto key verify from file /config/jwt_cookie.pem + set auth url https://my.home.assistant/auth/jwt_cookie + crypto key token name jwt_access_token + allow roles user + } + } +} + +# reverse proxy to home assistant without authentication +my.home.assistant { + reverse_proxy localhost:8123 +} + +# reverse proxy svc1 with enabled authentication +svc1.my.home.assistant { + route { + authorize with homeassistant + reverse_proxy + } +} +``` +- Create or edit your `/config/configuration.yaml`: +```yaml +http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - ::1 + cors_allowed_origins: + - https://my.home.assistant + +jwt_cookie: + domain: ".my.home.assistant" + private_key_file: /config/jwt_cookie.key +``` + +## 4. Restart HomeAssistant +That's it, everything should be working now, if something is not working as expected check your HomeAssistant and Caddy2 logs. + diff --git a/jwt.png b/jwt.png new file mode 100644 index 0000000000000000000000000000000000000000..0ab60de5205dc3db8059fb87b604f80d1fd07cf8 GIT binary patch literal 12136 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