From: Marcel van der Veldt Date: Fri, 19 Dec 2025 00:55:18 +0000 (+0100) Subject: Improve login with Home Assistant flow (#2847) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=b4e4f075c487e5f3590a31aac084c1ef0ec143d8;p=music-assistant-server.git Improve login with Home Assistant flow (#2847) --- diff --git a/music_assistant/controllers/webserver/helpers/auth_middleware.py b/music_assistant/controllers/webserver/helpers/auth_middleware.py index c2faff81..d2897684 100644 --- a/music_assistant/controllers/webserver/helpers/auth_middleware.py +++ b/music_assistant/controllers/webserver/helpers/auth_middleware.py @@ -10,7 +10,7 @@ from music_assistant_models.auth import AuthProviderType, User, UserRole from music_assistant.constants import HOMEASSISTANT_SYSTEM_USER -from .auth_providers import get_ha_user_role +from .auth_providers import get_ha_user_details, get_ha_user_role if TYPE_CHECKING: from music_assistant import MusicAssistant @@ -51,11 +51,16 @@ async def get_authenticated_user(request: web.Request) -> User | None: if not user: user = await mass.webserver.auth.get_user_by_username(ingress_username) if not user: + # New user - fetch details from HA + ha_username, ha_display_name, avatar_url = await get_ha_user_details( + mass, ingress_user_id + ) role = await get_ha_user_role(mass, ingress_user_id) user = await mass.webserver.auth.create_user( - username=ingress_username, + username=ha_username or ingress_username, role=role, - display_name=ingress_display_name, + display_name=ha_display_name or ingress_display_name, + avatar_url=avatar_url, ) # Link to Home Assistant provider (or create the link if user already existed) @@ -63,6 +68,18 @@ async def get_authenticated_user(request: web.Request) -> User | None: user, AuthProviderType.HOME_ASSISTANT, ingress_user_id ) + # Update user with HA details if missing (HA is source of truth) + if not user.display_name or not user.avatar_url: + _, ha_display_name, avatar_url = await get_ha_user_details(mass, ingress_user_id) + update_display_name = ha_display_name if not user.display_name else None + update_avatar_url = avatar_url if not user.avatar_url else None + if update_display_name or update_avatar_url: + user = await mass.webserver.auth.update_user( + user, + display_name=update_display_name, + avatar_url=update_avatar_url, + ) + # Store in request context request[USER_CONTEXT_KEY] = user return user diff --git a/music_assistant/controllers/webserver/helpers/auth_providers.py b/music_assistant/controllers/webserver/helpers/auth_providers.py index 84f07933..09b99b9b 100644 --- a/music_assistant/controllers/webserver/helpers/auth_providers.py +++ b/music_assistant/controllers/webserver/helpers/auth_providers.py @@ -27,6 +27,9 @@ if TYPE_CHECKING: from music_assistant.providers.hass import HomeAssistantProvider +LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth") + + def normalize_username(username: str) -> str: """ Normalize username to lowercase for case-insensitive comparison. @@ -37,7 +40,25 @@ def normalize_username(username: str) -> str: return username.strip().lower() -LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth") +async def get_ha_user_details( + mass: MusicAssistant, ha_user_id: str +) -> tuple[str | None, str | None, str | None]: + """ + Get user username, display name and avatar URL from Home Assistant. + + Uses the existing HA provider connection (which has admin access) to fetch + user details from config/auth/list and the person entity. + + :param mass: MusicAssistant instance. + :param ha_user_id: Home Assistant user ID. + :return: Tuple of (username, display_name, avatar_url) or all None if not found. + """ + hass_prov = mass.get_provider("hass") + if not hass_prov or not hass_prov.available: + return None, None, None + + hass_prov = cast("HomeAssistantProvider", hass_prov) + return await hass_prov.get_user_details(ha_user_id) async def get_ha_user_role( @@ -617,48 +638,46 @@ class HomeAssistantOAuthProvider(LoginProvider): ), ) - async def _fetch_ha_user_via_websocket( - self, ha_url: str, access_token: str - ) -> tuple[str | None, str | None, str | None]: + async def _fetch_ha_user_id_via_websocket(self, ha_url: str, access_token: str) -> str | None: """ - Fetch user information from Home Assistant via WebSocket. + Fetch the HA user ID from Home Assistant via WebSocket using OAuth token. :param ha_url: Home Assistant URL. :param access_token: Access token for WebSocket authentication. - :return: Tuple of (user_id, username, display_name) or (None, None, None) if fetch fails. + :return: The HA user ID or None if fetch fails. """ ws_url = get_websocket_url(ha_url) try: # Use context manager to automatically handle connect/disconnect async with HomeAssistantClient(ws_url, access_token, self.mass.http_session) as client: - # Use the auth/current_user command to get user details + # Use the auth/current_user command to get user ID result = await client.send_command("auth/current_user") - - if result: - # Extract user_id, username and display name from response - user_id = result.get("id") - username = result.get("name") or result.get("username") - display_name = result.get("name") - if user_id and username: - return user_id, username, display_name - - self.logger.warning("auth/current_user returned no user data") - return None, None, None - + if result and (user_id := result.get("id")): + return str(user_id) + self.logger.warning("auth/current_user returned no user data or missing id") + return None except BaseHassClientError as ws_error: self.logger.error("Failed to fetch HA user via WebSocket: %s", ws_error) - return None, None, None + return None async def _get_or_create_user( - self, username: str, display_name: str | None, ha_user_id: str + self, + username: str, + display_name: str | None, + ha_user_id: str, + avatar_url: str | None = None, ) -> User | None: """ Get or create a user for Home Assistant OAuth authentication. + Updates existing users with display_name and avatar_url from HA on each OAuth login + (HA is considered the source of truth for these fields). + :param username: Username from Home Assistant. :param display_name: Display name from Home Assistant. :param ha_user_id: Home Assistant user ID. + :param avatar_url: Avatar URL from Home Assistant person entity. :return: User object or None if creation failed. """ # Check if user already linked to HA @@ -666,6 +685,13 @@ class HomeAssistantOAuthProvider(LoginProvider): AuthProviderType.HOME_ASSISTANT, ha_user_id ) if user: + # Update user with HA details if available (HA is source of truth) + if display_name or avatar_url: + user = await self.auth_manager.update_user( + user, + display_name=display_name, + avatar_url=avatar_url, + ) return user username = normalize_username(username) @@ -689,6 +715,15 @@ class HomeAssistantOAuthProvider(LoginProvider): await self.auth_manager.link_user_to_provider( existing_user, AuthProviderType.HOME_ASSISTANT, ha_user_id ) + + # Update user with HA details if available (HA is source of truth) + if display_name or avatar_url: + existing_user = await self.auth_manager.update_user( + existing_user, + display_name=display_name, + avatar_url=avatar_url, + ) + return existing_user # New HA user - check if self-registration allowed @@ -703,6 +738,7 @@ class HomeAssistantOAuthProvider(LoginProvider): username=username, role=role, display_name=display_name or username, + avatar_url=avatar_url, ) # Link to Home Assistant @@ -755,20 +791,24 @@ class HomeAssistantOAuthProvider(LoginProvider): if not access_token: return AuthResult(success=False, error="No access token received from HA") - # Fetch user information from HA via WebSocket (includes the real user ID) - ha_user_id, username, display_name = await self._fetch_ha_user_via_websocket( - ha_url, access_token - ) - - # If we couldn't get user info from WebSocket, fail authentication - if not ha_user_id or not username: + # Get the HA user ID from the OAuth token via WebSocket + ha_user_id = await self._fetch_ha_user_id_via_websocket(ha_url, access_token) + if not ha_user_id: return AuthResult( success=False, - error="Failed to get user info from Home Assistant", + error="Failed to get user ID from Home Assistant", ) + # Get username, display name and avatar from HA provider (has admin access) + username, display_name, avatar_url = await get_ha_user_details(self.mass, ha_user_id) + + # Fall back to HA user ID as username if not found + if not username: + self.logger.warning("Could not get username from HA, using user ID as fallback") + username = ha_user_id + # Get or create user - user = await self._get_or_create_user(username, display_name, ha_user_id) + user = await self._get_or_create_user(username, display_name, ha_user_id, avatar_url) if not user: return AuthResult( diff --git a/music_assistant/controllers/webserver/websocket_client.py b/music_assistant/controllers/webserver/websocket_client.py index 083e8f8f..193d722a 100644 --- a/music_assistant/controllers/webserver/websocket_client.py +++ b/music_assistant/controllers/webserver/websocket_client.py @@ -28,7 +28,7 @@ from music_assistant.constants import HOMEASSISTANT_SYSTEM_USER, VERBOSE_LOG_LEV from music_assistant.helpers.api import APICommandHandler, parse_arguments from .helpers.auth_middleware import is_request_from_ingress, set_current_token, set_current_user -from .helpers.auth_providers import get_ha_user_role +from .helpers.auth_providers import get_ha_user_details, get_ha_user_role if TYPE_CHECKING: from music_assistant_models.event import MassEvent @@ -372,13 +372,17 @@ class WebsocketClientHandler: user = await self.webserver.auth.get_user_by_username(ingress_username) if not user: + # New user - fetch details from HA + ha_username, ha_display_name, avatar_url = await get_ha_user_details( + self.mass, ingress_user_id + ) # Auto-create user for Ingress (they're already authenticated by HA) - # Determine role based on HA admin status role = await get_ha_user_role(self.mass, ingress_user_id) user = await self.webserver.auth.create_user( - username=ingress_username, + username=ha_username or ingress_username, role=role, - display_name=ingress_display_name, + display_name=ha_display_name or ingress_display_name, + avatar_url=avatar_url, ) # Link to Home Assistant provider (or create the link if user already existed) @@ -386,6 +390,20 @@ class WebsocketClientHandler: user, AuthProviderType.HOME_ASSISTANT, ingress_user_id ) + # Update user with HA details if missing (HA is source of truth) + if not user.display_name or not user.avatar_url: + _, ha_display_name, avatar_url = await get_ha_user_details( + self.mass, ingress_user_id + ) + update_display_name = ha_display_name if not user.display_name else None + update_avatar_url = avatar_url if not user.avatar_url else None + if update_display_name or update_avatar_url: + user = await self.webserver.auth.update_user( + user, + display_name=update_display_name, + avatar_url=update_avatar_url, + ) + self._authenticated_user = user self._logger.debug("Ingress user authenticated: %s", user.username) else: diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 5f4b7156..59c6aedf 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -518,3 +518,61 @@ class HomeAssistantProvider(PluginProvider): if player_control.supports_mute and entity_platform == "media_player": player_control.volume_muted = attributes.get("volume_muted") self.mass.players.update_player_control(entity_id) + + async def get_user_details(self, ha_user_id: str) -> tuple[str | None, str | None, str | None]: + """ + Get user username, display name and avatar URL from Home Assistant. + + Looks up the user in config/auth/list for username, and the person entity + for display name and picture URL. + + :param ha_user_id: Home Assistant user ID. + :return: Tuple of (username, display_name, avatar_url) or all None if not found. + """ + try: + username: str | None = None + display_name: str | None = None + avatar_url: str | None = None + + # Get username from config/auth/list (admin endpoint, we have admin access) + try: + users = await self.hass.send_command("config/auth/list") + for user in users or []: + if user.get("id") == ha_user_id: + username = user.get("username") + # Also get name as fallback display name + if not display_name: + display_name = user.get("name") + break + except Exception as err: + self.logger.debug("Failed to get HA user list: %s", err) + + # Get external URL for building avatar URL + ha_url: str | None = None + try: + network_urls = await self.hass.send_command("network/url") + if network_urls: + ha_url = network_urls.get("external") or network_urls.get("internal") + except Exception as err: + self.logger.debug("Failed to get HA network URLs: %s", err) + + # Find person linked to this HA user ID for display name and avatar + try: + persons = await self.hass.send_command("person/list") + # person/list returns {storage: [...], config: [...]} + all_persons = (persons.get("storage") or []) + (persons.get("config") or []) + for person in all_persons: + if person.get("user_id") == ha_user_id: + # Person name takes priority for display name + if person_name := person.get("name"): + display_name = person_name + if (person_picture := person.get("picture")) and ha_url: + avatar_url = f"{ha_url.rstrip('/')}{person_picture}" + break + except Exception as err: + self.logger.debug("Failed to get HA person details: %s", err) + + return username, display_name, avatar_url + except Exception as err: + self.logger.debug("Failed to get HA user details: %s", err) + return None, None, None