Improve login with Home Assistant flow (#2847)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 19 Dec 2025 00:55:18 +0000 (01:55 +0100)
committerGitHub <noreply@github.com>
Fri, 19 Dec 2025 00:55:18 +0000 (01:55 +0100)
music_assistant/controllers/webserver/helpers/auth_middleware.py
music_assistant/controllers/webserver/helpers/auth_providers.py
music_assistant/controllers/webserver/websocket_client.py
music_assistant/providers/hass/__init__.py

index c2faff818457cf06ddd37e9f7989ee2f1e1df498..d28976845ef9b0897ea98cf50925c84676c3cbc8 100644 (file)
@@ -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
index 84f07933467331047a3ec68e4f0458d0026badc8..09b99b9b1c2c22195bfe8a7d4049a49b35c2d245 100644 (file)
@@ -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(
index 083e8f8f1c2f833efad2a31ceb42e908c53e020c..193d722a34ca3e242330dbcecad48bd9829bc48f 100644 (file)
@@ -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:
index 5f4b71568b94315db345d8705df5a55a0e5e59ee..59c6aedfa1e0c89364089f006380208003ab5624 100644 (file)
@@ -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