10 Commits

Author SHA1 Message Date
Christiaan Goossens
4e77b321fd Bump to 0.6.5 2026-02-06 12:41:03 +01:00
Christiaan Goossens
b688cc872f Detect misconfiguration on downgrade 2026-02-06 11:54:04 +01:00
Christiaan Goossens
2dea5c6b58 Reset min required HA 2026-02-06 11:44:41 +01:00
Christiaan Goossens
5465c1d213 Run correct workflows 2026-02-06 11:42:14 +01:00
Christiaan Goossens
759ea57bc8 Bump versions & dep maintenance 2026-02-06 11:40:37 +01:00
Andrew Garrett
a0e833ba69 Enable Jinja2 autoescaping (#200)
- Enable Jinja2 autoescape by default in the template environment.
- Use json.dumps to safely inject sso_name into JavaScript context.
- Fix linting issue (line too long) in injected_auth_page.py.
- Update tests to verify escaping and safe injection.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: werdnum <271070+werdnum@users.noreply.github.com>
2026-02-06 11:38:01 +01:00
Tricked
9bf2372b7e Use tailwind cli to compile css instead of tailwind cdn (#132)
* implement feature
* use npm instead of cli
2026-02-06 11:36:16 +01:00
Christiaan Goossens
653c716ea8 Fix 500 on redirect path (#201)
* Fix 500 on redirect path

Co-authored-by: anntnzrb <anntnzrb@proton.me>
2026-02-06 11:30:27 +01:00
Christiaan Goossens
f53c16b20e Fix manifest json requirements (#152) 2026-02-06 11:26:02 +01:00
Christiaan Goossens
d54046245f Migrate to joserfc, remove python-jose (#150) 2026-02-06 11:25:49 +01:00
18 changed files with 2105 additions and 945 deletions

View File

@@ -5,6 +5,7 @@ on:
push: push:
branches: branches:
- main - main
- release/*
pull_request: pull_request:
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *"
@@ -18,4 +19,3 @@ jobs:
uses: hacs/action@22.5.0 uses: hacs/action@22.5.0
with: with:
category: "integration" category: "integration"

View File

@@ -5,6 +5,7 @@ on:
push: push:
branches: branches:
- main - main
- release/*
pull_request: pull_request:
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *"

View File

@@ -5,6 +5,7 @@ on:
push: push:
pull_request: pull_request:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

5
.gitignore vendored
View File

@@ -108,3 +108,8 @@ dmypy.json
config/ config/
.venv .venv
.pytest_logs.log
node_modules

View File

@@ -1 +1 @@
3.13.7 3.14.2

View File

@@ -24,6 +24,12 @@ Some useful scripts are in the `scripts` directory. If you run Linux (or WSL und
You can also run these commands manually on Windows: You can also run these commands manually on Windows:
##### Compiling css
To compile tailwind css styles for the pages you need the NodeJS and NPM installed.
You can run the `npm run css` script to generate the css once and you can run the `npm run css:watch` to recompile the css every time the templates change
##### Check ##### Check
``` ```
uv run ruff check uv run ruff check

View File

@@ -4,6 +4,7 @@ import logging
from typing import OrderedDict from typing import OrderedDict
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.components.http import StaticPathConfig
# Import and re-export config schema explictly # Import and re-export config schema explictly
# pylint: disable=useless-import-alias # pylint: disable=useless-import-alias
@@ -41,6 +42,13 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config): async def async_setup(hass: HomeAssistant, config):
"""Add the OIDC Auth Provider to the providers in Home Assistant""" """Add the OIDC Auth Provider to the providers in Home Assistant"""
if DOMAIN not in config:
_LOGGER.warning(
"Setup was triggered, but no configuration was found. "
+ "Did you downgrade from 0.7+ without deleting the OIDC UI configuration?"
)
return False
my_config = config[DOMAIN] my_config = config[DOMAIN]
providers = OrderedDict() providers = OrderedDict()
@@ -99,6 +107,16 @@ async def async_setup(hass: HomeAssistant, config):
hass.http.register_view(OIDCCallbackView(oidc_client, provider, force_https)) hass.http.register_view(OIDCCallbackView(oidc_client, provider, force_https))
hass.http.register_view(OIDCFinishView()) hass.http.register_view(OIDCFinishView())
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/auth/oidc/static/style.css",
hass.config.path("custom_components/auth_oidc/static/style.css"),
cache_headers=False,
),
]
)
_LOGGER.info("Registered OIDC views") _LOGGER.info("Registered OIDC views")
return True return True

View File

@@ -24,11 +24,14 @@ class OIDCRedirectView(HomeAssistantView):
async def get(self, _: web.Request) -> web.Response: async def get(self, _: web.Request) -> web.Response:
"""Receive response.""" """Receive response."""
try:
redirect_uri = get_url("/auth/oidc/callback", self.force_https) redirect_uri = get_url("/auth/oidc/callback", self.force_https)
auth_url = await self.oidc_client.async_get_authorization_url(redirect_uri) auth_url = await self.oidc_client.async_get_authorization_url(redirect_uri)
if auth_url: if auth_url:
return web.HTTPFound(auth_url) raise web.HTTPFound(auth_url)
except RuntimeError:
pass
view_html = await get_view( view_html = await get_view(
"error", "error",

View File

@@ -9,15 +9,15 @@
"auth", "auth",
"http" "http"
], ],
"documentation": "https://github.com/christiaangoossens/hass-oidc-auth/blob/v0.6.3-alpha/docs/configuration.md", "documentation": "https://github.com/christiaangoossens/hass-oidc-auth/blob/v0.6.4-alpha/docs/configuration.md",
"integration_type": "service", "integration_type": "service",
"iot_class": "calculated", "iot_class": "calculated",
"issue_tracker": "https://github.com/christiaangoossens/hass-oidc-auth/issues", "issue_tracker": "https://github.com/christiaangoossens/hass-oidc-auth/issues",
"requirements": [ "requirements": [
"python-jose>=3.3.0", "aiofiles",
"aiofiles>=24.1.0", "jinja2",
"jinja2>=3.1.4", "bcrypt",
"bcrypt>=4.2.0" "joserfc"
], ],
"version": "0.6.3" "version": "0.6.5"
} }

View File

@@ -9,7 +9,7 @@ import ssl
from typing import Optional from typing import Optional
from functools import partial from functools import partial
import aiohttp import aiohttp
from jose import jwt, jwk from joserfc import jwt, jwk, jws, errors as joserfc_errors
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .types import UserDetails from .types import UserDetails
@@ -237,9 +237,7 @@ class OIDCClient:
_LOGGER.warning("Error fetching userinfo: %s", e) _LOGGER.warning("Error fetching userinfo: %s", e)
raise OIDCUserinfoInvalid from e raise OIDCUserinfoInvalid from e
async def _parse_id_token( async def _parse_id_token(self, id_token: str) -> Optional[dict]:
self, id_token: str, access_token: str | None
) -> Optional[dict]:
"""Parses the ID token into a dict containing token contents.""" """Parses the ID token into a dict containing token contents."""
if self.discovery_document is None: if self.discovery_document is None:
self.discovery_document = await self._fetch_discovery_document() self.discovery_document = await self._fetch_discovery_document()
@@ -249,7 +247,8 @@ class OIDCClient:
try: try:
# Obtain the id_token header # Obtain the id_token header
unverified_header = jwt.get_unverified_header(id_token) token_obj = jws.extract_compact(id_token.encode())
unverified_header = token_obj.protected
if not unverified_header: if not unverified_header:
_LOGGER.warning("Could not get header from received id_token.") _LOGGER.warning("Could not get header from received id_token.")
return None return None
@@ -278,7 +277,7 @@ class OIDCClient:
) )
raise OIDCIdTokenSigningAlgorithmInvalid() raise OIDCIdTokenSigningAlgorithmInvalid()
jwk_obj = jwk.construct( jwk_obj = jwk.import_key(
{ {
"kty": "oct", "kty": "oct",
"k": base64.urlsafe_b64encode( "k": base64.urlsafe_b64encode(
@@ -311,9 +310,9 @@ class OIDCClient:
signing_key["alg"] = alg signing_key["alg"] = alg
# Construct the JWK from the RSA key # Construct the JWK from the RSA key
jwk_obj = jwk.construct(signing_key) jwk_obj = jwk.import_key(signing_key)
# Verify the token # Decode the token, decode does not verify it
decoded_token = jwt.decode( decoded_token = jwt.decode(
id_token, id_token,
jwk_obj, jwk_obj,
@@ -322,48 +321,31 @@ class OIDCClient:
# according to JWS [JWS] using the algorithm specified in the JWT # according to JWS [JWS] using the algorithm specified in the JWT
# alg Header Parameter. # alg Header Parameter.
algorithms=[self.id_token_signing_alg], algorithms=[self.id_token_signing_alg],
)
# Create Claims Registry for validation
id_token_validator = jwt.JWTClaimsRegistry(
leeway=5,
# OpenID Connect Core 1.0 Section 3.1.3.7.3 # OpenID Connect Core 1.0 Section 3.1.3.7.3
# The Client MUST validate that the aud (audience) Claim contains # The Client MUST validate that the aud (audience) Claim contains
# its client_id value registered at the Issuer identified by the # its client_id value registered at the Issuer identified by the
# iss (issuer) Claim as an audience. # iss (issuer) Claim as an audience.
audience=self.client_id, aud={"essential": True, "value": self.client_id},
# OpenID Connect Core 1.0 Section 3.1.3.7.2 # OpenID Connect Core 1.0 Section 3.1.3.7.2
# The Issuer Identifier for the OpenID Provider MUST exactly # The Issuer Identifier for the OpenID Provider MUST exactly
# match the value of the iss (issuer) Claim. # match the value of the iss (issuer) Claim.
issuer=self.discovery_document["issuer"], iss={"essential": True, "value": self.discovery_document["issuer"]},
access_token=access_token,
options={
# Verify everything if present
"verify_signature": True,
"verify_aud": True,
"verify_iat": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iss": True,
"verify_sub": True,
"verify_jti": True,
"verify_at_hash": True,
# OpenID Connect Core 1.0 Section 3.1.3.7.3
"require_aud": True,
# OpenID Connect Core 1.0 Section 3.1.3.7.10
"require_iat": True,
# OpenID Connect Core 1.0 Section 3.1.3.7.9 # OpenID Connect Core 1.0 Section 3.1.3.7.9
"require_exp": True, # OpenID Connect Core 1.0 Section 3.1.3.7.10
# OpenID Connect Core 1.0 Section 3.1.3.7.2 # No need to specify exp, nbf, iat, they are in here by default
"require_iss": True, sub={"essential": True},
# We need the sub as it's used to identify the user
"require_sub": True,
# Other values, not required.
"require_nbf": False,
"require_jti": False,
"require_at_hash": False,
"leeway": 5,
},
) )
return decoded_token
except jwt.JWTError as e: id_token_validator.validate(decoded_token.claims)
_LOGGER.warning("JWT Verification failed: %s", e) return decoded_token.claims
except joserfc_errors.JoseError as e:
_LOGGER.warning("JWT verification failed: %s", e)
return None return None
async def async_get_authorization_url(self, redirect_uri: str) -> Optional[str]: async def async_get_authorization_url(self, redirect_uri: str) -> Optional[str]:
@@ -501,11 +483,9 @@ class OIDCClient:
) )
id_token = token_response.get("id_token") id_token = token_response.get("id_token")
access_token = token_response.get("access_token")
# Parse the id token to obtain the relevant details # Parse the id token to obtain the relevant details
# Access token is supplied to check at_hash if present id_token = await self._parse_id_token(id_token)
id_token = await self._parse_id_token(id_token, access_token)
if id_token is None: if id_token is None:
_LOGGER.warning("ID token could not be parsed!") _LOGGER.warning("ID token could not be parsed!")
@@ -519,6 +499,7 @@ class OIDCClient:
_LOGGER.warning("Nonce mismatch!") _LOGGER.warning("Nonce mismatch!")
return None return None
access_token = token_response.get("access_token")
data = await self.parse_user_details(id_token, access_token) data = await self.parse_user_details(id_token, access_token)
# Log which details were obtained for debugging # Log which details were obtained for debugging

View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@source "../views/templates";

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,9 @@ class AsyncTemplateRenderer:
if template_name not in templates: if template_name not in templates:
raise ValueError(f"Template '{template_name}' not found.") raise ValueError(f"Template '{template_name}' not found.")
env = Environment(loader=DictLoader(templates), enable_async=True) env = Environment(
loader=DictLoader(templates), enable_async=True, autoescape=True
)
template = env.get_template(template_name) template = env.get_template(template_name)
# Render template # Render template

View File

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

1123
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

11
package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "hass-oidc-auth",
"scripts": {
"css": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --minify",
"css:watch": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --watch --minify"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.14",
"tailwindcss": "^4.1.14"
}
}

View File

@@ -1,19 +1,26 @@
[project] [project]
name = "hass-oidc-auth" name = "hass-oidc-auth"
version = "0.6.3" version = "0.6.4"
description = "OIDC component for Home Assistant" description = "OIDC component for Home Assistant"
authors = [ authors = [
{ name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" } { name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" }
] ]
license = "MIT" license = "MIT"
dependencies = [ dependencies = [
"python-jose>=3.3.0", "aiofiles~=25.1",
"aiofiles>=24.1.0", "jinja2~=3.1",
"jinja2>=3.1.4", "bcrypt~=5.0",
"bcrypt>=4.2.0", "joserfc~=1.6.0",
] ]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.13.2" requires-python = "~=3.14.2"
[dependency-groups]
dev = [
"homeassistant~=2026.1",
"pylint~=4.0",
"ruff~=0.12",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -21,14 +28,12 @@ build-backend = "hatchling.build"
[tool.uv] [tool.uv]
managed = true managed = true
dev-dependencies = [
"homeassistant~=2025.8",
"pylint~=3.3",
"ruff>=0.12.11",
]
[tool.hatch.metadata] [tool.hatch.metadata]
allow-direct-references = true allow-direct-references = true
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["custom_components/auth_oidc"] packages = ["custom_components/auth_oidc"]
[tool.ruff]
target-version = "py313"

1697
uv.lock generated

File diff suppressed because it is too large Load Diff