diff --git a/custom_components/auth_oidc/manifest.json b/custom_components/auth_oidc/manifest.json index b2b5881..a24b212 100644 --- a/custom_components/auth_oidc/manifest.json +++ b/custom_components/auth_oidc/manifest.json @@ -19,5 +19,5 @@ "jinja2>=3.1.4", "bcrypt>=4.2.0" ], - "version": "0.5.1" + "version": "0.6.1" } \ No newline at end of file diff --git a/custom_components/auth_oidc/oidc_client.py b/custom_components/auth_oidc/oidc_client.py index 7d12074..ab10b85 100644 --- a/custom_components/auth_oidc/oidc_client.py +++ b/custom_components/auth_oidc/oidc_client.py @@ -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 diff --git a/docs/provider-configurations/other.md b/docs/provider-configurations/other.md index 1b832e6..f3dba03 100644 --- a/docs/provider-configurations/other.md +++ b/docs/provider-configurations/other.md @@ -1,2 +1,30 @@ # Other providers -Under construction. \ No newline at end of file +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: `/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//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! \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d8d2f9d..1e84d20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }