So They've Signed in with Apple, Now What?
As a consumer, a hater of passwords, and a privacy-focused developer, I'm very happy that Apple is launching Sign in with Apple this fall. It is one of my must-dos for the Slopes iOS 13 update launching in September, and I'm even going so far as to offer existing users the ability to add Sign in with Apple to their account when they install my iOS 13 update.
As such I've been digging into implementing Sign in with Apple for Slopes over the last week, and I'm about to wrap up the project. As I worked, though, one thing I kept hearing from anyone doing the same is "oh, boy, did you manage to find any documentation??" The WWDC videos are great primers on how to get the iOS UI to do its thing, but there's a lot of details about what you as a developer should do after the sign in that'll inform how you integrate this new API.
So I want to talk about how I'm going to use the APIs in Slopes, the tradeoffs I considered, and what I learned along the way. I'm no security expert, so grain of salt etc etc, but I think I've got a good handle on all this now.
My Starting Point
When I started this endeavor Slopes mirrored what I assume many apps do: I save the username and password in the keychain and use that when Slopes needs to auth with my server.
On the server side (self-hosted PHP, specifically Laravel) my auth endpoint vends JWTs (JSON Web Token) for the client to use, which the app then sends as part of future requests instead of sending the username and password every time. JWTs are strings that are signed by a trusted source (my server in this case) allowing those who receive the token to verify the data in it wasn't tampered with. When you decode a JWT it contains a bunch of information for how to verify this signature, and its payload usually contains additional information like the user ID and also things like expiration dates for the token itself. In my case, Slopes tokens are somewhat short-lived, generally lasting for the length of a user's session. After a token expires my app needs to re-auth to get a new token from my server.
Unfortunately Sign in with Apple is not compatable with this flow and I had to rework my auth flow to use Refresh Tokens (a thing in JWT, but not a required part of the spec). More on this later.
Nice to Meet You
Go watch the WWDC Session 706 for a great 101 of the iOS/watchOS/macOS Sign in with Apple API (and then follow that up with the What's New in Authentication video), but to summarize:
ASAuthorizationControllerduring registration and you get a
userstring (which looks like
001135.4ae9301edbe64c94adb03e9c9bda155b.1433and is supposed to be "stable", meaning the same, across devices for that user and your app). Additionally you can request an email and full name. Lastly, you get some other fields that might not look important at first, but they are. More on one of those fields later.
- That same controller is used to trigger future sign ins, too, on the now-existing account.
- An important note: this controller always triggers a dialog asking users to actually Sign in with Apple and which details (like real email vs fake email) to share. You can't use it in the background to grab the user ID again or anything.
ASAuthorizationAppleIDProvider.getCredentialState:forUserIDanytime after they've signed in in to verify that they are still signed in into their Apple ID. You can do this in the background, and are encouraged to do it every time your app launches. The completion for the method provides no information about the user itself, it just confirms if the user ID you were given is still signed in.
- There's a notification on NotificationCenter for this that you should subscribe to, too, so you can know if they signed out via iOS while your app happened to still be running.
The videos make it clear how to deal with authentication in-app using all that. Auth and then store the
user locally so we can verify the user in the future to sign them out if needed.
Unfortunately Apple stops short of how to use everything they give us above to verify sign ins on your server. After all, while operating on-device we can trust Apple's APIs not to lie to us, but as soon as we send this data to the server we need a way for said server to verify that someone isn't spoofing a
user string. Normally you use the users' password for verification that they can access a given account, but we don't have that anymore.
Turns out data to verify is actually all there, but they don't get into it.
So I will.
Taking Users Online
One of the most important fields Apple gives us at registration / sign in is what they call the
identityToken, which happens to be a JWT (I'm going to refer to their token as "identity token", since Slopes uses JWTs also, to avoid confusion). The identity token lets us verify that that
user string came from Apple, not someone trying to spoof an account or give fake non-Apple-generated IDs. We do this by decoding and looking at the identity token's
sub field (the "subject", or user) and comparing that against the
user string and making sure they match. We can trust the identity token from Apple for this match requirement because like I said earlier JWTs are signed, meaning we can verify it is authentic.
So that whole identity token string is our password, our "secret." If I pass both the identity token and the user string to my server, the server can verify that the credentials are in fact valid and from Apple.
Decoding the identity token is easy, many APIs use JWTs already. https://jwt.io is great resource for them (used in the screenshot above), and even lets you paste in tokens to inspect what's in them while debugging. I'm sure whatever backend framework you're using has some kind of library to support decoding / encoding JWTs.
Validating the identity token on my server was a bit of trial and error, though. Apple's documentation on this part is very minimal (read: basically none), assuming you already know how the process they've adopted should work. Their identity token uses RS256 to generate the signature, which means we need Apple's public key to verify the signature. I knew that much from using JWTs myself before, but I didn't know how to get that public key. Their docs reference a URL to access the public key, https://appleid.apple.com/auth/keys, but don't tell you how to use the data there in this effort. It certainly doesn't look like any public key I've seen before.
Lots of Googling / learning later, I figured how it all works.
Apple hosts the data needed to generate their public key. They are sending back what is known as a JWK (JSON Web Key) (TIL that's a thing. There's some documentation out there if you Google that, but not a ton, but at least they are using something that has a spec). You can use the
n (modulus) and
e (exponent) fields to create a full public key using ... some cryptographic magic via OpenSSL. But fun times ahead: if you get something wrong as you're coding this you still generate a key, it'll just be wrong. The verification of the identity token fails and you don't know what exactly you messed up. The
n field gave me trouble; it's base64 but the urlsafe variant (swap
/, add back the needed
= padding. I forgot those two parts). Then you've gotta convert the data from those two fields into big integers, then feed that into OpenSSL the right way.
TL;DR: find a library to do this for you, or to read some existing confirmed-to-be-working code so you don't spin your wheels. I found a good PHP reference that works here.
But once you get that key generation working, you're green! You've got Apple's public key and you can use that to validate that the identity token you've been handed is authentic, and use that to confirm that the user id provided is also authentic.
You'll need to store off the user field both locally, and in your server's database associated with your user during registration. Both during registration and sign in you validate the
user field against the user (
sub) listed in the identity token on registration and log in.
I Thought I Was Done 🥺
Remember back to when I said I store my user's email and their password in keychain, and send that up every time I need to re-auth to get my own JWT (not to be confused with Apple's)? How do I do this for Sign in with Apple? Well, the
user field is our "username" and our
identityToken is our password.
Not so fast.
Remember, there is no way to get that identity token again without prompting the user to log in via that controller? While some third party auth SDKs let you ask for the currently logged in user without triggering some UI, Apple only gives this to you at reg / sign in. We have to maintain that
user ourself, remember?
OK, so just store both the
user and the
identity token in keychain, and send them both up anytime I need to re-auth, right? Same thing as I did with users and passwords.
Nope. If you check out the identity token Apple gives you you'll notice that the expiration field (
exp) is just 10 minutes after its issue-date (
iat). That means Apple is telling you not to consider the identity token valid for more than 10 minutes. You could trust the token past that expiration but you really really shouldn't; if you use a JWT library, it'll likely throw an exception if you try to decode an expired JWT, that's how much of a bad idea this is. The idea here is that if someone man-in-the-middle's your app talking to your server at the perfect time and grabs Apple's user ID and JWT (allowing them everything they need to spoof that user down the road), 10 minutes later you'll stop trusting that identity token from Apple so the attacker can't spoof anymore. No massive password breaches here (and why I'm willing to screenshot an identity token above 🙃).
I was rabbit holing on how to work around this on Twitter and @ChrisAljoudi (thanks!) made a great observation that forced me to re-evaluate my entire local auth storage system: Apple is forcing us to divorce our ongoing authentication from their authentication. AKA Apple gives us everything we need to verify users at the time of log in and registration, and it is up to our apps to worry about how we continue to trust them after that, without Apple's credentials.
For Slopes, that meant a bit of a restructure in my auth logic. I don't want to get too far into the technical bits, but basically now when users registers or signs in to Slopes (regardless of using Sign in with Apple or a user / password) the app gets a "refresh token" from my server that gets saved to the keychain. I won't ever save the user / password locally anymore, and instead pass that refresh token up in their place to get access tokens to my API, and those access tokens work as they did before.
Basically instead of storing user-entered data for auth, I'm storing something my server gives my app saying "give me this again in the future, and I can verify it is you."
(Aside: the concept of storing a refresh token instead of the raw credentials is how web apps are able to allow users to remove specific devices from being allowed to log in to an account, without needing access to the device. They just invalidate the individual refresh token by blacklisting it.)
Some assorted musings, observations, and gotchas with Sign in with Apple:
- On your log in screen make sure you provide both the Apple ID login type (
ASAuthorizationAppleIDProvider) and the associated domain / plain-old keychain login type (
ASAuthorizationPasswordProvider). If you do the dialog that comes up is smart enough to only options during log in that already exist for the user and your app (or no dialog at all if neither exist). If, however, you only pass
ASAuthorizationAppleIDProvideron your log in screen, you can trigger the registration flow on your log in screen. So make sure you have your associated domains set up and you support both sign in methods to avoid this. 🙃
- When you validate the identity token make sure the key ID (
kid) field matches what is at Apple's public key URL ("AIDOPK1" at the moment). That is telling you which of their public keys to use. While there is only one right now, that could change. Apple could also easily revoke a public key for security reasons. If you're going to cache the computed public key once you generate it, only access the cached version if Apple's public key URL still has the key ID listed.
- Also validate that the
aud(audience) field in Apple's identity token matches what you expect. For most apps this will be the bundle ID, but you can allow one Sign in with Apple to work for multiple apps you control (say, a Catalyst mac app and its iOS counterpart, both with their own bundle ID).
- To test the revocation of a Sign in with Apple user ID, go to Settings.app ➡️ your Apple ID at the top ➡️ Password & Security ➡️ Apps Using your Apple ID. You can remove any app's access to leverage Sign in with Apple there.
- If a user revokes Sign in with Apple, but then goes back to register with it in your app, so far in my testing the
uservalue stays the same. This is great news for account recovery / lockout / customer support. Consider this revoke action more of a "log out" than a deletion or something.
- For this reason when a user revokes Sign in with Apple in Slopes I don't null the
usercolumn from their database record. Instead I'm debating an in-app option to remove Sign in with Apple from a user's account. I have, already, added support for adding Sign in with Apple to an existing account and also adding a password to a Sign in with Apple based account.