Compare commits
3 Commits
v0.6.0-alp
...
v0.6.2-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa977781c | ||
|
|
1fc4e0f21a | ||
|
|
6e56311176 |
@@ -19,5 +19,5 @@
|
||||
"jinja2>=3.1.4",
|
||||
"bcrypt>=4.2.0"
|
||||
],
|
||||
"version": "0.5.1"
|
||||
"version": "0.6.2"
|
||||
}
|
||||
@@ -47,10 +47,27 @@ class OIDCStateInvalid(OIDCClientException):
|
||||
"Raised when the state for your request cannot be matched against a stored state."
|
||||
|
||||
|
||||
class OIDCUserinfoInvalid(OIDCClientException):
|
||||
"Raised when the user info is invalid or cannot be obtained."
|
||||
|
||||
|
||||
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 +122,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 +179,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 +196,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,20 +208,35 @@ 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 _get_userinfo(self, userinfo_uri, access_token):
|
||||
"""Fetches userinfo from the given URL."""
|
||||
try:
|
||||
session = await self._get_http_session()
|
||||
headers = {"Authorization": "Bearer " + access_token}
|
||||
|
||||
async with session.get(userinfo_uri, headers=headers) as response:
|
||||
await self.http_raise_for_status(response)
|
||||
return await response.json()
|
||||
except HTTPClientError as e:
|
||||
_LOGGER.warning("Error fetching userinfo: %s", e)
|
||||
raise OIDCUserinfoInvalid from e
|
||||
|
||||
async def _parse_id_token(
|
||||
self, id_token: str, access_token: str | None
|
||||
) -> Optional[dict]:
|
||||
@@ -257,6 +306,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)
|
||||
|
||||
@@ -359,6 +412,57 @@ class OIDCClient:
|
||||
_LOGGER.warning("Error generating authorization URL: %s", e)
|
||||
return None
|
||||
|
||||
async def parse_user_details(self, id_token: str, access_token: str) -> UserDetails:
|
||||
"""Parses the ID token and/or userinfo into user details."""
|
||||
|
||||
# Fetch userinfo if there is an userinfo_endpoint available
|
||||
# and use the data to supply the missing values in id_token
|
||||
if "userinfo_endpoint" in self.discovery_document:
|
||||
userinfo_endpoint = self.discovery_document["userinfo_endpoint"]
|
||||
userinfo = await self._get_userinfo(userinfo_endpoint, access_token)
|
||||
|
||||
# Replace missing claims in the id_token with their userinfo version
|
||||
for claim in (
|
||||
self.groups_claim,
|
||||
self.display_name_claim,
|
||||
self.username_claim,
|
||||
):
|
||||
if claim not in id_token and claim in userinfo:
|
||||
id_token[claim] = userinfo[claim]
|
||||
|
||||
# Get and parse groups (to check if it's an array)
|
||||
groups = id_token.get(self.groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
_LOGGER.warning("Groups claim is not a list, using empty list instead.")
|
||||
groups = []
|
||||
|
||||
# Assign role if user has the required groups
|
||||
role = "invalid"
|
||||
if self.user_role in groups or self.user_role is None:
|
||||
role = "system-users"
|
||||
|
||||
if self.admin_role in groups:
|
||||
role = "system-admin"
|
||||
|
||||
# Create a user details dict based on the contents of the id_token & userinfo
|
||||
return {
|
||||
# Subject Identifier. A locally unique and never reassigned identifier within the
|
||||
# Issuer for the End-User, which is intended to be consumed by the Client
|
||||
# Only unique per issuer, so we combine it with the issuer and hash it.
|
||||
# This might allow multiple OIDC providers to be used with this integration.
|
||||
"sub": hashlib.sha256(
|
||||
f"{self.discovery_document['issuer']}.{id_token.get('sub')}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest(),
|
||||
# Display name, configurable
|
||||
"display_name": id_token.get(self.display_name_claim),
|
||||
# Username, configurable
|
||||
"username": id_token.get(self.username_claim),
|
||||
# Role
|
||||
"role": role,
|
||||
}
|
||||
|
||||
async def async_complete_token_flow(
|
||||
self, redirect_uri: str, code: str, state: str
|
||||
) -> Optional[UserDetails]:
|
||||
@@ -415,40 +519,7 @@ class OIDCClient:
|
||||
_LOGGER.warning("Nonce mismatch!")
|
||||
return None
|
||||
|
||||
# TODO: If the configured claims are not present in id_token, we should fetch userinfo
|
||||
|
||||
# Get and parse groups (to check if it's an array)
|
||||
groups = id_token.get(self.groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
_LOGGER.warning("Groups claim is not a list, using empty list instead.")
|
||||
groups = []
|
||||
|
||||
# Assign role if user has the required groups
|
||||
role = "invalid"
|
||||
if self.user_role in groups or self.user_role is None:
|
||||
role = "system-users"
|
||||
|
||||
if self.admin_role in groups:
|
||||
role = "system-admin"
|
||||
|
||||
# Create a user details dict based on the contents of the id_token & userinfo
|
||||
data: UserDetails = {
|
||||
# Subject Identifier. A locally unique and never reassigned identifier within the
|
||||
# Issuer for the End-User, which is intended to be consumed by the Client
|
||||
# Only unique per issuer, so we combine it with the issuer and hash it.
|
||||
# This might allow multiple OIDC providers to be used with this integration.
|
||||
"sub": hashlib.sha256(
|
||||
f"{self.discovery_document['issuer']}.{id_token.get('sub')}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest(),
|
||||
# Display name, configurable
|
||||
"display_name": id_token.get(self.display_name_claim),
|
||||
# Username, configurable
|
||||
"username": id_token.get(self.username_claim),
|
||||
# Role
|
||||
"role": role,
|
||||
}
|
||||
data = await self.parse_user_details(id_token, access_token)
|
||||
|
||||
# Log which details were obtained for debugging
|
||||
# Also log the original subject identifier such that you can look it up in your provider
|
||||
@@ -459,5 +530,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.2"
|
||||
description = "OIDC component for Home Assistant"
|
||||
authors = [
|
||||
{ name = "Christiaan Goossens", email = "contact@christiaangoossens.nl" }
|
||||
|
||||
Reference in New Issue
Block a user