Compare commits
10 Commits
v0.6.3-alp
...
v0.6.5-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e77b321fd | ||
|
|
b688cc872f | ||
|
|
2dea5c6b58 | ||
|
|
5465c1d213 | ||
|
|
759ea57bc8 | ||
|
|
a0e833ba69 | ||
|
|
9bf2372b7e | ||
|
|
653c716ea8 | ||
|
|
f53c16b20e | ||
|
|
d54046245f |
2
.github/workflows/hacs.yaml
vendored
2
.github/workflows/hacs.yaml
vendored
@@ -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"
|
||||||
|
|
||||||
1
.github/workflows/hassfest.yaml
vendored
1
.github/workflows/hassfest.yaml
vendored
@@ -5,6 +5,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- release/*
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * *"
|
- cron: "0 0 * * *"
|
||||||
|
|||||||
1
.github/workflows/lint.yaml
vendored
1
.github/workflows/lint.yaml
vendored
@@ -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
5
.gitignore
vendored
@@ -108,3 +108,8 @@ dmypy.json
|
|||||||
config/
|
config/
|
||||||
|
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
.pytest_logs.log
|
||||||
|
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.13.7
|
3.14.2
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
3
custom_components/auth_oidc/static/input.css
Normal file
3
custom_components/auth_oidc/static/input.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@source "../views/templates";
|
||||||
2
custom_components/auth_oidc/static/style.css
Normal file
2
custom_components/auth_oidc/static/style.css
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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
1123
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "hass-oidc-auth",
|
||||||
|
"scripts": {
|
||||||
|
"css": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --minify",
|
||||||
|
"css:watch": "tailwindcss -i ./custom_components/auth_oidc/static/input.css -o ./custom_components/auth_oidc/static/style.css --watch --minify"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.14",
|
||||||
|
"tailwindcss": "^4.1.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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"
|
||||||
Reference in New Issue
Block a user