If you're wondering what's the gif in the title, that's a demo of authentication flow for Apple Music that I've tested in of the apps I've made, MoovinGroovin.
This post describes my journey of isolating the authentication from MusicKitJS, and packaging it into a passportJS strategy.
And in case the cover is cropped, this is it:
TL;DR
The package is available as passport-apple-music NPM package.
Background
Over the past few months, I've made MoovinGroovin, a web service that creates playlists from the songs you listened when working out with Strava turned on.
MoovinGroovin is integrated with Spotify, and I got a request from a user to add support for Apple Music.
Apple Music has a rest API (Apple Music API). The API has an endpoint to get recently-played resources which is what the app needed. But to access those on a per-user basis, you need a music user token. And these user tokens are made available only when the user logins in to Apple using MusicKitJS, and authenticates the app for Apple Music.
The MoovinGroovin app handled all other 3rd party integrations on the backend using passportJS.
But, there was no passport strategy for Apple Music. And handling Apple Music on the frontend as an exception felt like a bad design choice.
And so my goal was to extract the MusicKitJS auth flow and package it into a passport strategy.
Analysing MusicKitJS to understand auth flow
Note: To keep this article on the topic, I've written the tips for reverse-engineering minified JS code in a separate post:
7 Tips for reverse engineering minified TypeScript/JavaScript
Juro Oravec γ» Mar 20 '21
When you trigger authentication using MusicKitJS, it opens a separate window with login page. This seems like a standard OAuth flow. Based on OAuth, I was expecting that I could ditch MusicKitJS by:
- Generating the auth URL ourselves.
- Passing in the callback URL (redirect URI) query param to the auth URL, which would point back to our server.
- After user authenticates (or fails to do so) on the Apple login page, a request would be sent to the callback URL with either a
code
orerror
query param.code
in this case, would've been the music user token we need.
Or so I thought..
Generating auth URL
And so I searched and found how MusicKitJS generates the auth URL. And I found that the query params of the auth URL contain the encoded info of your developer token, name and icon, as well as some analytics data.
You can see the query params in the comments in the screenshots below:
As you can see, there's no magic to the auth URL. And, if you have the developer token, you can construct it yourself too.
Great! π¦ So now I could just use that URL and call it a day...
Controlling the auth flow
Except...
Except when I tried to open the URL, I got an error message and an error in the console. π
Quick debug revealed that the "invalid URL" was because the login page tries to do:
new URL(document.referrer);
As can be seen in the last line here:
And if I open the page directly, well, there's no referrer! π€¦ π€¦
Cool, no problem, I thought. But in fact it was a problem.
First I tried to solve this by using an iframe
and opening the auth URL inside the iframe
. However, I was still getting empty referrer.
So I had to stick with opening the auth URL in a separate window.
OK. Fine. But then it should work, right?
...I guess you already know the answer. π
This time, the auth URL loaded, I could enter my credentials, verify with 2FA, and confirm. But, then the page froze in a loading state.
So I did it again, and it froze again. I've checked if I got any response, and I did not.
So I tried again. Still nothing. π
Once again diving deep into debugging the JS of the login page, it turned out that the login page sends messages using postMessage
. And when it posts a message, it's waiting for a response with addEventListener
. And if there's no response coming, it is stuck in the loading state.
Since I launched the window with the auth URL without handling any messages, it got stuck.
And the referrer is expected to respond to the sent messages. If this is not upheld, the JS on the auth URL hangs in a waiting state, and the user token is never received.
With an event-based communication like this, there could have been a lot going on that I could miss if I tried to handle it instead of MusicKitJS.
That meant that I really did not want to - I could not - bypass MusicKitJS. I had to use it to handle the communication with the login page, so that I could then get the user token from MusicKitJS.
This meant that since we cannot bypass MusicKitJS, we have to work with the auth URL it generates. So we cannot pass the callback URL, or other additional query params to the auth URL. π
To stick with the standard and to support passportJS features, I decided that, in the end, when we trigger the authentication for Apple Music, we need 2 windows:
The first is the one we control, which triggers the authorize
method on MusicKitJS, waits for login, and processes the response (user token) once authenticated. We can pass the callback URL to this window, and we know it will call it with either the user token or error.
The second is the window opened by MusicKitJS that hosts the auth URL and that is out of our reach.
Passing options to auth flow
So at this point we've established how to trigger the Apple Music login page and get the user token back. Now, to make this into a useful packaged passportJS strategy, we still need to enable to pass options through.
As seen earlier, we know that you can pass app name and icon to the auth window. For these, the process was fairly simple, since these can be passed to MusicKitJS during initialization.
However, the question remained how to pass the parameters to the URL that we control.
One option was to require the user to add a server router endpoint. Upside of this is that we could pass all options as query params. BUT, then it would be user's responsibility to ensure that the right route is triggered. This is not the standard for passportJS strategies, where this should be abstracted away.
Other option was to respond directly with the HTML that will contain our logic to initialize MusicKitJS, call authorize
, capture user token, and then redirect to the callback URL with the user token.
Since passportJS can be used with multiple frameworks, I didn't want to bake any templating engine into the package. Instead, the HTML string is constructed on the fly with vanilla string.replace
to inject the user's options into the HTML. Not the cleanest solution, but hey, it works! π€·
...But as always, there's a catch π€
Spotify strategy has a showDialog option that enable to force to show the login dialog. In my app, I was using that option show the dialog when users want to change the associated account.
But once I was logged in into Apple Music, I was never prompted for login again.
Crawling through the MusicKitJS code again, I saw that authorize
checks if user token already exists, so it has to be stored somewhere.
And indeed, when I looked through the localStorage, there was an entry that started with music.
and ended with .u
. When the entry was there, the authorization was not trigger. when it was removed, I got asked to re-authorize on calling authorize
.
Since we decided not to mess with/replace MusicKitJS, if we want to force the auth window to open, we need to make sure localStorage is free of Apple Music user tokens before we call authorize
.
So in the HTML that controls MusicKitJS, we also clean the relevant localStorage keys in prompted to:
Conclusion
Not gonna lie, this was quite a journey. And not gonna lie, the Apple Music auth flow could've been design better, without the tight coupling to a parent window, and without the tight coupling to MusicKitJS.
Better design would lower the barrier for developers, and hence increase uptake - the number of developers trying out and authenticating with Apple Music. That would increase the number of apps or services that use the Apple Music API, which would improve Apple's positioning.
But luckily, now, the barrier has been lowered, and you can simply authenticate users against Apple Music. All you have to do is use passport-apple-music! π
Top comments (2)
I feel your pain, man. This is the only resource I can find online that mentions this. While "Sign in with Apple" gives you the ability to pass in
redirect_url
and all that stuff to an actual auth URL, they decided Apple Music would not? Sigh. It's a bad design indeed. For me it's mostly because if a user comes from a Facebook post to our site, it's within a WebView component, which doesn't pop up a new window to the Music URL but instead navigates to it within the same browser instance. So there's essentially no way for me to see what was returned, not to mention it can'twindow.close()
anyway, so you're just stuck on the auth page.this article is PURE GOLD, thank you man...