From 00da053f50ce688fc805c45f25695fc4b9027f4c Mon Sep 17 00:00:00 2001 From: Christiaan Goossens <9487666+christiaangoossens@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:24:48 +0100 Subject: [PATCH] Add configurable group names for roles (#17) --- README.md | 2 ++ custom_components/auth_oidc/__init__.py | 2 ++ custom_components/auth_oidc/config.py | 15 ++++++++++++ .../auth_oidc/endpoints/callback.py | 10 ++++++++ .../auth_oidc/endpoints/finish.py | 4 ++-- custom_components/auth_oidc/oidc_client.py | 23 +++++++++++++++++-- custom_components/auth_oidc/provider.py | 7 ++---- custom_components/auth_oidc/types.py | 8 ++++--- 8 files changed, 59 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dee161b..b8dbd1f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ With the default configuration, [a person entry](https://www.home-assistant.io/i | `claims.display_name` | `string` | No | `name` | The claim to use to obtain the display name. | `claims.username` | `string` | No | `preferred_username` | The claim to use to obtain the username. | `claims.groups` | `string` | No | `groups` | The claim to use to obtain the user's group(s). | +| `roles.admin` | `string` | No | `admins` | Group name to require for users to get the 'admin' role in Home Assistant. Defaults to 'admins', the default group name for admins in Authentik. Doesn't do anything if no groups claim is found in your token. | +| `roles.user` | `string` | No | | Group name to require for users to get the 'user' role in Home Assistant. Defaults to giving all users this role, unless configured. | #### Example: Migrating from HA username/password users to OIDC users If you already have users created within Home Assistant and would like to re-use the current user profile for your OIDC login, you can (temporarily) enable `features.automatic_user_linking`, with the following config (example): diff --git a/custom_components/auth_oidc/__init__.py b/custom_components/auth_oidc/__init__.py index 18b02eb..2407908 100644 --- a/custom_components/auth_oidc/__init__.py +++ b/custom_components/auth_oidc/__init__.py @@ -18,6 +18,7 @@ ID_TOKEN_SIGNING_ALGORITHM, FEATURES, CLAIMS, + ROLES, ) # pylint: enable=useless-import-alias @@ -61,6 +62,7 @@ async def async_setup(hass: HomeAssistant, config): id_token_signing_alg=my_config.get(ID_TOKEN_SIGNING_ALGORITHM), features=my_config.get(FEATURES, {}), claims=my_config.get(CLAIMS, {}), + roles=my_config.get(ROLES, {}), ) # Register the views diff --git a/custom_components/auth_oidc/config.py b/custom_components/auth_oidc/config.py index 9b9c263..5e15e4e 100644 --- a/custom_components/auth_oidc/config.py +++ b/custom_components/auth_oidc/config.py @@ -15,6 +15,9 @@ CLAIMS_DISPLAY_NAME = "display_name" CLAIMS_USERNAME = "username" CLAIMS_GROUPS = "groups" +ROLES = "roles" +ROLE_ADMINS = "admin" +ROLE_USERS = "user" DEFAULT_TITLE = "OpenID Connect (SSO)" @@ -63,6 +66,18 @@ vol.Optional(CLAIMS_GROUPS): vol.Coerce(str), } ), + # Determine which specific group values will be mapped to which roles + # Optional, defaults user = null, admin = 'admins' + # If user role is set, users that do not have either will be rejected! + vol.Optional(ROLES): vol.Schema( + { + # Which group name should we use to assign the user role? + vol.Optional(ROLE_USERS): vol.Coerce(str), + # What group name should we use to assign the admin role? + # Defaults to admins + vol.Optional(ROLE_ADMINS): vol.Coerce(str), + } + ), } ) }, diff --git a/custom_components/auth_oidc/endpoints/callback.py b/custom_components/auth_oidc/endpoints/callback.py index 5192d19..e12d196 100644 --- a/custom_components/auth_oidc/endpoints/callback.py +++ b/custom_components/auth_oidc/endpoints/callback.py @@ -52,5 +52,15 @@ async def get(self, request: web.Request) -> web.Response: ) return web.Response(text=view_html, content_type="text/html") + if user_details.get("role") == "invalid": + view_html = await get_view( + "error", + { + "error": "User is not in the correct group to access Home Assistant, " + + "contact your administrator!", + }, + ) + return web.Response(text=view_html, content_type="text/html") + code = await self.oidc_provider.async_save_user_info(user_details) return web.HTTPFound(get_url("/auth/oidc/finish?code=" + code)) diff --git a/custom_components/auth_oidc/endpoints/finish.py b/custom_components/auth_oidc/endpoints/finish.py index adfd24a..7736658 100644 --- a/custom_components/auth_oidc/endpoints/finish.py +++ b/custom_components/auth_oidc/endpoints/finish.py @@ -46,9 +46,9 @@ async def post(self, request: web.Request) -> web.Response: # Set a cookie to enable autologin on only the specific path used # for the POST request, with all strict parameters set # This cookie should not be read by any Javascript or any other paths. - # It can be really short lifetime as we redirect immediately (15 seconds) + # It can be really short lifetime as we redirect immediately (5 seconds) "set-cookie": "auth_oidc_code=" + code - + "; Path=/auth/login_flow; SameSite=Strict; HttpOnly; Max-Age=15", + + "; Path=/auth/login_flow; SameSite=Strict; HttpOnly; Max-Age=5", }, ) diff --git a/custom_components/auth_oidc/oidc_client.py b/custom_components/auth_oidc/oidc_client.py index 33798e0..77e682b 100644 --- a/custom_components/auth_oidc/oidc_client.py +++ b/custom_components/auth_oidc/oidc_client.py @@ -15,6 +15,8 @@ CLAIMS_DISPLAY_NAME, CLAIMS_USERNAME, CLAIMS_GROUPS, + ROLE_ADMINS, + ROLE_USERS, ) _LOGGER = logging.getLogger(__name__) @@ -67,11 +69,14 @@ def __init__(self, discovery_url: str, client_id: str, scope: str, **kwargs: str features = kwargs.get("features") claims = kwargs.get("claims") + roles = kwargs.get("roles") self.disable_pkce: bool = features.get(FEATURES_DISABLE_PKCE) self.display_name_claim = claims.get(CLAIMS_DISPLAY_NAME, "name") self.username_claim = claims.get(CLAIMS_USERNAME, "preferred_username") self.groups_claim = claims.get(CLAIMS_GROUPS, "groups") + self.user_role = roles.get(ROLE_USERS, None) + self.admin_role = roles.get(ROLE_ADMINS, "admins") def _base64url_encode(self, value: str) -> str: """Uses base64url encoding on a given string""" @@ -356,6 +361,20 @@ async def async_complete_token_flow( # 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 @@ -371,8 +390,8 @@ async def async_complete_token_flow( "display_name": id_token.get(self.display_name_claim), # Username, configurable "username": id_token.get(self.username_claim), - # Groups, configurable - "groups": id_token.get(self.groups_claim), + # Role + "role": role, } # Log which details were obtained for debugging diff --git a/custom_components/auth_oidc/provider.py b/custom_components/auth_oidc/provider.py index ae6acaa..fecbaac 100644 --- a/custom_components/auth_oidc/provider.py +++ b/custom_components/auth_oidc/provider.py @@ -259,14 +259,11 @@ async def async_user_meta_for_credentials( sub = credentials.data["sub"] meta = self._user_meta.get(sub, {}) - groups = meta.get("groups") or [] - - # TODO: Allow setting which group is for admins - group = "system-admin" if "admins" in groups else "system-users" + role = meta.get("role") return UserMeta( name=meta.get("display_name"), is_active=True, - group=group, + group=role, local_only=False, ) diff --git a/custom_components/auth_oidc/types.py b/custom_components/auth_oidc/types.py index 4a7419f..ef55c63 100644 --- a/custom_components/auth_oidc/types.py +++ b/custom_components/auth_oidc/types.py @@ -1,7 +1,9 @@ """Generic data types""" - # Dict class to give a type to the user details +from typing import Literal + + class UserDetails(dict): """User details representation""" @@ -12,5 +14,5 @@ class UserDetails(dict): # Preferred username for the user, will be used when first generating the account # or to link the account on first login username: str - # Groups that the user has, if any are sent from the OIDC provider - groups: list[str] + # Home Assistant role to assign to this user + role: Literal["system-admin", "system-users", "invalid"]