From e2d55765fcd5de4fc236a0fe617587abba36dc6b Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Mon, 16 Jun 2025 09:30:08 +0200 Subject: [PATCH] Fix: Improvements to MusicKit auth workflow (#2230) --- music_assistant/helpers/auth.py | 29 ++- .../providers/apple_music/__init__.py | 96 +++++-- .../musickit_auth/musickit_wrapper.html | 237 +++++++++++------- 3 files changed, 243 insertions(+), 119 deletions(-) diff --git a/music_assistant/helpers/auth.py b/music_assistant/helpers/auth.py index 8a5ba50c..2dec70b1 100644 --- a/music_assistant/helpers/auth.py +++ b/music_assistant/helpers/auth.py @@ -11,6 +11,8 @@ from aiohttp.web import Request, Response from music_assistant_models.enums import EventType from music_assistant_models.errors import LoginFailed +from music_assistant.helpers.json import json_loads + if TYPE_CHECKING: from music_assistant.mass import MusicAssistant @@ -20,18 +22,19 @@ LOGGER = logging.getLogger(__name__) class AuthenticationHelper: """Context manager helper class for authentication with a forward and redirect URL.""" - def __init__(self, mass: MusicAssistant, session_id: str) -> None: + def __init__(self, mass: MusicAssistant, session_id: str, method: str = "GET") -> None: """ Initialize the Authentication Helper. Params: - - url: The URL the user needs to open for authentication. - session_id: a unique id for this auth session. + - method: the HTTP request method to expect, either "GET" or "POST" (default: GET). """ self.mass = mass self.session_id = session_id self._cb_path = f"/callback/{self.session_id}" self._callback_response: asyncio.Queue[dict[str, str]] = asyncio.Queue(1) + self._method = method @property def callback_url(self) -> str: @@ -40,7 +43,9 @@ class AuthenticationHelper: async def __aenter__(self) -> AuthenticationHelper: """Enter context manager.""" - self.mass.webserver.register_dynamic_route(self._cb_path, self._handle_callback, "GET") + self.mass.webserver.register_dynamic_route( + self._cb_path, self._handle_callback, self._method + ) return self async def __aexit__( @@ -50,11 +55,17 @@ class AuthenticationHelper: exc_tb: TracebackType | None, ) -> bool | None: """Exit context manager.""" - self.mass.webserver.unregister_dynamic_route(self._cb_path, "GET") + self.mass.webserver.unregister_dynamic_route(self._cb_path, self._method) return None async def authenticate(self, auth_url: str, timeout: int = 60) -> dict[str, str]: - """Start the auth process and return any query params if received on the callback.""" + """ + Start the auth process and return any query params if received on the callback. + + Params: + - url: The URL the user needs to open for authentication. + - timeout: duration in seconds helpers waits for callback (default: 60). + """ self.send_url(auth_url) LOGGER.debug("Waiting for authentication callback on %s", self.callback_url) return await self.wait_for_callback(timeout) @@ -75,6 +86,14 @@ class AuthenticationHelper: async def _handle_callback(self, request: Request) -> Response: """Handle callback response.""" params = dict(request.query) + if request.method == "POST" and request.can_read_body: + try: + raw_data = await request.read() + data = json_loads(raw_data) + params.update(data) + except Exception as err: + LOGGER.error("Failed to parse POST data: %s", err) + await self._callback_response.put(params) LOGGER.debug("Received callback with params: %s", params) return_html = """ diff --git a/music_assistant/providers/apple_music/__init__.py b/music_assistant/providers/apple_music/__init__.py index d57d9efd..2c89c740 100644 --- a/music_assistant/providers/apple_music/__init__.py +++ b/music_assistant/providers/apple_music/__init__.py @@ -1,4 +1,17 @@ -"""Apple Music musicprovider support for MusicAssistant.""" +""" +Apple Music musicprovider support for MusicAssistant. + +TODO MUSIC_APP_TOKEN expires after 6 months so should have a distribution mechanism outside + compulsory application updates. It is only a semi-private key in JWT format so code be refreshed + daily by a GitHub action and downloaded by the provider each initialise. +TODO Widevine keys can be obtained dynamically from Apple Music API rather than copied into Docker + build. This is undocumented but @maxlyth has a working example. +TODO MUSIC_USER_TOKEN must be refreshed (~min 180 days) and needs mechanism to prompt user to + re-authenticate in browser. +TODO Current provider ignores private tracks that are not available in the storefront catalog as + streamable url is derived from the catalog id. It is undecumented but @maxlyth has a working + example to get a streamable url from the library id. +""" from __future__ import annotations @@ -7,6 +20,7 @@ import json import os import pathlib import re +import time from typing import TYPE_CHECKING, Any import aiofiles @@ -82,6 +96,7 @@ UNKNOWN_PLAYLIST_NAME = "Unknown Apple Music Playlist" CONF_MUSIC_APP_TOKEN = "music_app_token" CONF_MUSIC_USER_TOKEN = "music_user_token" +CONF_MUSIC_USER_TOKEN_TIMESTAMP = "music_user_token_timestamp" async def setup( @@ -126,42 +141,67 @@ async def get_config_entries( default_app_token_valid = True # Action is to launch MusicKit flow - if action == "CONF_ACTION_AUTH": - # TODO: check the developer token is valid otherwise user is going to have bad experience - async with AuthenticationHelper(mass, values["session_id"]) as auth_helper: + if action == "CONF_ACTION_AUTH" and default_app_token_valid: + callback_method = "POST" + async with AuthenticationHelper(mass, values["session_id"], callback_method) as auth_helper: callback_url = auth_helper.callback_url flow_base_path = f"apple_music_auth/{values['session_id']}/" flow_timeout = 600 parent_file_path = pathlib.Path(__file__).parent.resolve() + base_url = f"{mass.webserver.base_url}/{flow_base_path}" + flow_base_url = f"{base_url}index.html" async def serve_mk_auth_page(request: web.Request) -> web.Response: auth_html_path = parent_file_path.joinpath("musickit_auth/musickit_wrapper.html") - return web.FileResponse(auth_html_path, headers={"content-type": "text/html"}) + return web.FileResponse( + auth_html_path, + headers={"content-type": "text/html"}, + ) async def serve_mk_auth_css(request: web.Request) -> web.Response: auth_css_path = parent_file_path.joinpath("musickit_auth/musickit_wrapper.css") - return web.FileResponse(auth_css_path, headers={"content-type": "text/css"}) + return web.FileResponse( + auth_css_path, + headers={ + "content-type": "text/css", + }, + ) async def serve_mk_glue(request: web.Request) -> web.Response: - return_html = f"const app_token='{values[CONF_MUSIC_APP_TOKEN]}';" - return_html += f"const user_token='{values[CONF_MUSIC_USER_TOKEN]}';" - return_html += f"const return_url='{callback_url}';" - return_html += f"const flow_timeout={flow_timeout - 10};" - return_html += f"const mass_buid='{mass.version}';" - return web.Response(body=return_html, headers={"content-type": "text/javascript"}) + return_html = f""" + const return_url='{callback_url}'; + const base_url='{base_url}'; + const app_token='{values[CONF_MUSIC_APP_TOKEN]}'; + const callback_method='{callback_method}'; + const user_token='{ + values[CONF_MUSIC_USER_TOKEN] + if validate_user_token(values[CONF_MUSIC_USER_TOKEN]) + else "" + }'; + const user_token_timestamp='{values[CONF_MUSIC_USER_TOKEN_TIMESTAMP]}'; + const flow_timeout={max([flow_timeout - 10, 60])}; + const flow_start_time={int(time.time())}; + const mass_version='{mass.version}'; + """ + return web.Response( + body=return_html, + headers={ + "content-type": "text/javascript", + }, + ) mass.webserver.register_dynamic_route( f"/{flow_base_path}index.html", serve_mk_auth_page ) mass.webserver.register_dynamic_route(f"/{flow_base_path}index.css", serve_mk_auth_css) mass.webserver.register_dynamic_route(f"/{flow_base_path}index.js", serve_mk_glue) - flow_base_url = f"{mass.webserver.base_url}/{flow_base_path}index.html" + try: - values[CONF_MUSIC_USER_TOKEN] = ( - await auth_helper.authenticate(flow_base_url, flow_timeout) - )["music-user-token"] + result = await auth_helper.authenticate(flow_base_url, flow_timeout) + values[CONF_MUSIC_USER_TOKEN] = result["music-user-token"] + values[CONF_MUSIC_USER_TOKEN_TIMESTAMP] = result["music-user-token-timestamp"] except KeyError: - # no music-user-token URL param was found so user probably cancelled the auth + # no music-user-token URL param was found so likely user cancelled the auth pass except Exception as error: raise LoginFailed(f"Failed to authenticate with Apple '{error}'.") @@ -186,9 +226,27 @@ async def get_config_entries( label="Music User Token", required=True, action="CONF_ACTION_AUTH", - description="You need to authenticate on Apple Music.", + description="Authenticate with Apple Music to retrieve a valid music user token.", action_label="Authenticate with Apple Music", - value=values.get(CONF_MUSIC_USER_TOKEN) if values else None, + value=values.get(CONF_MUSIC_USER_TOKEN) + if ( + values + and isinstance(values.get(CONF_MUSIC_USER_TOKEN_TIMESTAMP), int) + and ( + values.get(CONF_MUSIC_USER_TOKEN_TIMESTAMP) > (time.time() - (3600 * 24 * 150)) + ) + ) + else None, + ), + ConfigEntry( + key=CONF_MUSIC_USER_TOKEN_TIMESTAMP, + type=ConfigEntryType.INTEGER, + description="Timestamp music user token was updated.", + label="Music User Token Timestamp", + hidden=True, + required=True, + default_value=0, + value=values.get(CONF_MUSIC_USER_TOKEN_TIMESTAMP) if values else 0, ), ) diff --git a/music_assistant/providers/apple_music/musickit_auth/musickit_wrapper.html b/music_assistant/providers/apple_music/musickit_auth/musickit_wrapper.html index 5dc1dd21..a7a16c4b 100644 --- a/music_assistant/providers/apple_music/musickit_auth/musickit_wrapper.html +++ b/music_assistant/providers/apple_music/musickit_auth/musickit_wrapper.html @@ -3,87 +3,156 @@ - Music Assistant MusicKit Redirect + + + Apple Music Sign-in for Music Assistant + - - +
@@ -91,45 +160,27 @@
- - - - - - - - - - - - - + + + + + +

Apple Music

-
+

Music Assistant

@@ -138,39 +189,35 @@

Apple MusicKit is loading…

-

Sign - in with Apple to allow Music Assistant to access your Apple Music - account.

+

+ Sign in with Apple to allow Music Assistant to access your Apple Music account.

+ onclick="mkSignInButton();" disabled>Sign In

+ onclick="mkSignOutButton();" style="display: none">Sign Out

+ onclick="mkCloseButton();">Close

- -- 2.34.1