1 Commits

Author SHA1 Message Date
Christiaan Goossens
6e56311176 Fix compatibility with Microsoft Entra ID (#48)
* Fixes necessary for Entra ID

* Better error

* Bump 0.6.1

* Also bump manifest

* Linting
2025-02-16 11:29:24 +01:00
4 changed files with 81 additions and 17 deletions

View File

@@ -19,5 +19,5 @@
"jinja2>=3.1.4",
"bcrypt>=4.2.0"
],
"version": "0.5.1"
"version": "0.6.1"
}

View File

@@ -51,6 +51,19 @@ class OIDCIdTokenSigningAlgorithmInvalid(OIDCTokenResponseInvalid):
"Raised when the id_token is signed with the wrong algorithm, adjust your config accordingly."
class HTTPClientError(aiohttp.ClientResponseError):
"Raised when the HTTP client encounters not OK (200) status code."
body: str
def __init__(self, *args, **kwargs):
self.body = kwargs.pop("body")
super().__init__(*args, **kwargs)
def __str__(self):
return f"{self.status} ({self.message}) with response body: {self.body}"
# pylint: disable=too-many-instance-attributes
class OIDCClient:
"""OIDC Client implementation for Python, including PKCE."""
@@ -105,6 +118,23 @@ class OIDCClient:
_LOGGER.debug("Closing HTTP session")
self.http_session.close()
async def http_raise_for_status(self, response: aiohttp.ClientResponse) -> None:
"""Raises an exception if the response is not OK."""
if not response.ok:
# reason should always be not None for a started response
assert response.reason is not None
body = await response.text()
raise HTTPClientError(
response.request_info,
response.history,
status=response.status,
message=response.reason,
headers=response.headers,
body=body,
)
def _base64url_encode(self, value: str) -> str:
"""Uses base64url encoding on a given string"""
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("utf-8")
@@ -145,15 +175,15 @@ class OIDCClient:
session = await self._get_http_session()
async with session.get(self.discovery_url) as response:
response.raise_for_status()
await self.http_raise_for_status(response)
return await response.json()
except aiohttp.ClientResponseError as e:
except HTTPClientError as e:
if e.status == 404:
_LOGGER.warning(
"Error: Discovery document not found at %s", self.discovery_url
)
else:
_LOGGER.warning("Error: %s - %s", e.status, e.message)
_LOGGER.warning("Error fetching discovery: %s", e)
raise OIDCDiscoveryInvalid from e
async def _get_jwks(self, jwks_uri):
@@ -162,10 +192,10 @@ class OIDCClient:
session = await self._get_http_session()
async with session.get(jwks_uri) as response:
response.raise_for_status()
await self.http_raise_for_status(response)
return await response.json()
except aiohttp.ClientResponseError as e:
_LOGGER.warning("Error fetching JWKS: %s - %s", e.status, e.message)
except HTTPClientError as e:
_LOGGER.warning("Error fetching JWKS: %s", e)
raise OIDCJWKSInvalid from e
async def _make_token_request(self, token_endpoint, query_params):
@@ -174,18 +204,20 @@ class OIDCClient:
session = await self._get_http_session()
async with session.post(token_endpoint, data=query_params) as response:
response.raise_for_status()
await self.http_raise_for_status(response)
return await response.json()
except aiohttp.ClientResponseError as e:
except HTTPClientError as e:
if e.status == 400:
_LOGGER.warning(
"Error: Token could not be obtained (Bad Request), "
+ "did you forget the client_secret?"
"Error: Token could not be obtained (%s, %s), "
+ "did you forget the client_secret? Server returned: %s",
e.status,
e.message,
e.body,
)
else:
_LOGGER.warning(
"Unexpected error exchanging token: %s - %s", e.status, e.message
)
_LOGGER.warning("Unexpected error exchanging token: %s", e)
raise OIDCTokenResponseInvalid from e
async def _parse_id_token(
@@ -257,6 +289,10 @@ class OIDCClient:
_LOGGER.warning("Could not find matching key with kid: %s", kid)
return None
# If signing_key does not have alg, set it to the one passed in the token
if "alg" not in signing_key:
signing_key["alg"] = alg
# Construct the JWK from the RSA key
jwk_obj = jwk.construct(signing_key)
@@ -459,5 +495,5 @@ class OIDCClient:
)
return data
except OIDCClientException as e:
_LOGGER.warning("Error completing token flow: %s", e)
_LOGGER.warning("Failed to complete token flow, returning None. (%s)", e)
return None

View File

@@ -1,2 +1,30 @@
# Other providers
Under construction.
## Microsoft Entra ID
> [!WARNING]
> Microsoft Entra ID does not support public clients that are not Single Page Applications (SPA's). Therefore, you will have to use a client secret.
1. Go to app registrations in Entra ID.
2. Create a new app, use the "Web" type for the redirect URI and fill in your URL: `<ha url>/auth/oidc/callback`. Note that you either have to use localhost, or HTTPS.
3. Copy the 'Application (client) ID' on the overview page of your app and use it as your `client_id`.
4. Create the discovery URL:
- If you selected 'own tenant only' use the 'Directory (tenant) ID' on the overview page of your app and create the discovery URL using: `https://login.microsoftonline.com/<tenant id>/v2.0/.well-known/openid-configuration`.
- If you selected any Azure AD account (would not recommend this) or also personal accounts, use `https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration`.
5. Go to Certificates & Secrets and create a client secret. Make sure to copy the 'Value' and not the Secret ID. Use this value for `client_secret` in the HA config.
- Make sure to renew this secret in time. It will expire in two years.
6. Go to API Permissions and click 'Add permission'. Add the `openid` and `profile` permissions from Microsoft Graph. You can remove `User.Read`.
Now configure Home Assistant with the following:
```
auth_oidc:
client_id: < client id from the 'Application (client) ID field' >
discovery_url: < discovery URL you made in step 4 >
client_secret: < client seret from step 5 >
features:
include_groups_scope: False
```
> [!CAUTION]
> Be careful! Configuring Entra ID wrong may leave your Home Assistant install open for anyone with a Microsoft account. Please use "Single tenant" account types only. Do not enable "Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant)" or personal account modes without enabling the mode to only allow specific accounts first!

View File

@@ -1,6 +1,6 @@
[project]
name = "hass-oidc-auth"
version = "0.5.1"
version = "0.6.1"
description = "OIDC component for Home Assistant"
authors = [
{ name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" }