Fix compatibility with Microsoft Entra ID (#48)
* Fixes necessary for Entra ID * Better error * Bump 0.6.1 * Also bump manifest * Linting
This commit is contained in:
committed by
GitHub
parent
f24519787b
commit
6e56311176
@@ -19,5 +19,5 @@
|
|||||||
"jinja2>=3.1.4",
|
"jinja2>=3.1.4",
|
||||||
"bcrypt>=4.2.0"
|
"bcrypt>=4.2.0"
|
||||||
],
|
],
|
||||||
"version": "0.5.1"
|
"version": "0.6.1"
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,19 @@ class OIDCIdTokenSigningAlgorithmInvalid(OIDCTokenResponseInvalid):
|
|||||||
"Raised when the id_token is signed with the wrong algorithm, adjust your config accordingly."
|
"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
|
# pylint: disable=too-many-instance-attributes
|
||||||
class OIDCClient:
|
class OIDCClient:
|
||||||
"""OIDC Client implementation for Python, including PKCE."""
|
"""OIDC Client implementation for Python, including PKCE."""
|
||||||
@@ -105,6 +118,23 @@ class OIDCClient:
|
|||||||
_LOGGER.debug("Closing HTTP session")
|
_LOGGER.debug("Closing HTTP session")
|
||||||
self.http_session.close()
|
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:
|
def _base64url_encode(self, value: str) -> str:
|
||||||
"""Uses base64url encoding on a given string"""
|
"""Uses base64url encoding on a given string"""
|
||||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("utf-8")
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("utf-8")
|
||||||
@@ -145,15 +175,15 @@ class OIDCClient:
|
|||||||
session = await self._get_http_session()
|
session = await self._get_http_session()
|
||||||
|
|
||||||
async with session.get(self.discovery_url) as response:
|
async with session.get(self.discovery_url) as response:
|
||||||
response.raise_for_status()
|
await self.http_raise_for_status(response)
|
||||||
return await response.json()
|
return await response.json()
|
||||||
except aiohttp.ClientResponseError as e:
|
except HTTPClientError as e:
|
||||||
if e.status == 404:
|
if e.status == 404:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Error: Discovery document not found at %s", self.discovery_url
|
"Error: Discovery document not found at %s", self.discovery_url
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("Error: %s - %s", e.status, e.message)
|
_LOGGER.warning("Error fetching discovery: %s", e)
|
||||||
raise OIDCDiscoveryInvalid from e
|
raise OIDCDiscoveryInvalid from e
|
||||||
|
|
||||||
async def _get_jwks(self, jwks_uri):
|
async def _get_jwks(self, jwks_uri):
|
||||||
@@ -162,10 +192,10 @@ class OIDCClient:
|
|||||||
session = await self._get_http_session()
|
session = await self._get_http_session()
|
||||||
|
|
||||||
async with session.get(jwks_uri) as response:
|
async with session.get(jwks_uri) as response:
|
||||||
response.raise_for_status()
|
await self.http_raise_for_status(response)
|
||||||
return await response.json()
|
return await response.json()
|
||||||
except aiohttp.ClientResponseError as e:
|
except HTTPClientError as e:
|
||||||
_LOGGER.warning("Error fetching JWKS: %s - %s", e.status, e.message)
|
_LOGGER.warning("Error fetching JWKS: %s", e)
|
||||||
raise OIDCJWKSInvalid from e
|
raise OIDCJWKSInvalid from e
|
||||||
|
|
||||||
async def _make_token_request(self, token_endpoint, query_params):
|
async def _make_token_request(self, token_endpoint, query_params):
|
||||||
@@ -174,18 +204,20 @@ class OIDCClient:
|
|||||||
session = await self._get_http_session()
|
session = await self._get_http_session()
|
||||||
|
|
||||||
async with session.post(token_endpoint, data=query_params) as response:
|
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()
|
return await response.json()
|
||||||
except aiohttp.ClientResponseError as e:
|
except HTTPClientError as e:
|
||||||
if e.status == 400:
|
if e.status == 400:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Error: Token could not be obtained (Bad Request), "
|
"Error: Token could not be obtained (%s, %s), "
|
||||||
+ "did you forget the client_secret?"
|
+ "did you forget the client_secret? Server returned: %s",
|
||||||
|
e.status,
|
||||||
|
e.message,
|
||||||
|
e.body,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning("Unexpected error exchanging token: %s", e)
|
||||||
"Unexpected error exchanging token: %s - %s", e.status, e.message
|
|
||||||
)
|
|
||||||
raise OIDCTokenResponseInvalid from e
|
raise OIDCTokenResponseInvalid from e
|
||||||
|
|
||||||
async def _parse_id_token(
|
async def _parse_id_token(
|
||||||
@@ -257,6 +289,10 @@ class OIDCClient:
|
|||||||
_LOGGER.warning("Could not find matching key with kid: %s", kid)
|
_LOGGER.warning("Could not find matching key with kid: %s", kid)
|
||||||
return None
|
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
|
# Construct the JWK from the RSA key
|
||||||
jwk_obj = jwk.construct(signing_key)
|
jwk_obj = jwk.construct(signing_key)
|
||||||
|
|
||||||
@@ -459,5 +495,5 @@ class OIDCClient:
|
|||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
except OIDCClientException as e:
|
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
|
return None
|
||||||
|
|||||||
@@ -1,2 +1,30 @@
|
|||||||
# Other providers
|
# Other providers
|
||||||
Under construction.
|
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!
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "hass-oidc-auth"
|
name = "hass-oidc-auth"
|
||||||
version = "0.5.1"
|
version = "0.6.1"
|
||||||
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user