Compare commits
1 Commits
v0.6.0-alp
...
v0.6.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e56311176 |
@@ -19,5 +19,5 @@
|
||||
"jinja2>=3.1.4",
|
||||
"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."
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,2 +1,30 @@
|
||||
# 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]
|
||||
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" }
|
||||
|
||||
Reference in New Issue
Block a user