In this second part of our passkeys series, we will be modifying Apple's Shiny iOS app to make use of the same passkeys (aka WebAuthn credentials) that we created with our web app from part one.
So far we saw that the passkeys are automatically synced between your Apple devices and you could use them in Safari with the web app. Now it is time to make them accessible in native iOS apps as well.
Remember: This will allow you to register to a website with Touch ID in Safari, then download the app on your iPhone, and sign in to that app again with Touch ID or Face ID without creating and using a password at all. It also works the other way around, of course.
What are passkeys again, you ask? It's a brand new technology for secure passwordless authentication based on the WebAuthn protocol introduced by Apple at WWDC21.
A word of warning: We were pretty excited when we heard that Apple finally supports platform-wide WebAuthn on iOS. Since Apple's iOS WebAuthn APIs are still in beta, we ran into WebAuthn-related bugs and had to create an experimental version of our Authentication API to work around them. Please activate the experimental features in the Hanko Authentication API. To accomplish this, log into your Hanko Console, select the Relying Party, and navigate to the "General Settings". On the right side you will find the button to activate the experimental features. We have created bug reports at Apple and provided them with the details. It seems like other developers have encountered the errors as well, so we assume that Apple will fix them sooner or later.
Running the web app on HTTPS: We assume that you have the web app from part one up and running on your own HTTPS-capable host with a valid certificate. Use Let's Encrypt for a start. Otherwise the mobile app integration will fail due to the required Associated Domains capability.
"Associated domains establish a secure association between domains and your app so you can share credentials or provide features in your app from your website." - Apple Developer documentation
Apple Developer account: The Associated Domains capability is only available to you if you are on a paid Apple Developer plan. As a team member you need access to the Developer Resources.
Xcode 13 (Beta): You need Xcode 13 to work on this project and enjoy beautiful APIs like "ASAuthorizationPlatformPublicKeyCredentialRegistration" 👀.
iOS or iPadOS 15 (Dev Beta): To make use of the new Keychain-sync feature you need a device with iOS or iPadOS 15 which is currently available at Apple's Developer Beta program. This device needs to be configured in Xcode, obviously. iCloud needs to be set up with the same Apple ID as on your Mac! Otherwise there will be no syncing.
Enable Platform Authenticator Syncing on your Apple devices: In iOS 15, turn on the Syncing Platform Authenticator switch under Settings > Developer. The Developer menu is available on your device when you set it up as a development device in Xcode. In macOS Monterey, go to Safari > Preferences, click the Advanced tab, and select the “Show Develop menu in menu bar” option. Then enable the Develop > Enable Syncing Platform Authenticator menu item in Safari.
After having completed all of the above prerequisites we can proceed to configure the Shiny mobile app.
The first start might take a while, but after a few seconds you should have the Shiny app running on your device. If you have created an account in the web app using your Mac, you should be presented with a sign-in dialogue, asking for Touch ID.
Basically we have taken Apple's Shiny app and taught it to sign in users with passkeys. We have removed most of the superfluous password parts, but other than that tried not to modify it too much so you can clearly see the steps needed.
The magic happens in the Shared folder, especially in the AccountManager.swift file - so let's start there. The original Shiny gives us some hints on where it needs to be amended.
The only external library we have added to the code is Alamofire to comfortably make HTTP requests.
In the steps above you have already modified the first line of code in the AccountManager: you have set the domain of your web app. You have set up the Apple site association prior as well. These steps are crucial, as they are the foundation to share credentials between your web app and the native app we are looking at right now. The domain name in this case will be used to create login challenges and validate them via http calls to your web app.
The first function we need to amend is the signInWith() function. To sign a user in, we first need to fetch a unique challenge from the Hanko Authentication API via our web app. This challenge will be signed with the passkey and returned to the web app for validation, again utilizing the Hanko Authentication API.
We have our first encounter with Apple's new API right at the beginning of the signInWith() function:
This creates a provider to work with a passkey stored in the iCloud Keychain. It takes our domain as the only option for now.
Next, we are fetching the challenge from the server with the getAuthenticationOptions() function and have the result available in the assertionRequestOptions variable in the following function block:
The getAuthenticationOptions() function itself basically calls our web app's /authentication_initialize endpoint, we have used that endpoint in the browser flow as well:
The actual challenge is delivered to us Base64URL encoded. The object used to store the response is the CredentialAssertion object defined in the Models.swift file. Go check it out – it is pretty straight forward. As Swift does only deal with plain Base64 we are using a helper function to convert the challenge:
You can find the decodeBase64Url() function at the end of the AccountManager.swift file. It takes an Base64URL string as an input. Basically it replaces a few characters and sorts out the padding. As a result it gives us plain Base64.
Once we have our challenge we can proceed to create the assertion request. This request will be signed with the passkey. We are using the publicKeyCredentialProvider which we have created earlier for that:
Next we check if we require user verification and enable it:
Please admire Apple's new beautiful API at this point for a second: ASAuthorizationPublicKeyCredentialUserVerificationPreference. Feels a bit like the German Donaudampfschiffahrtskapitänsmütze...
User Verification is...
The technical process by which an authenticator locally authorizes the invocation of the authenticatorMakeCredential and authenticatorGetAssertion operations.
So, if a relying party, aka your web app, requires User Verification, it actually triggers the local authentication in the means of Touch ID, Face ID, or PIN/password to unlock your Keychain and access the passkey to generate a signature.
Please keep in mind that your biometrics, your PIN, or your password WILL NEVER LEAVE YOUR DEVICE!
To finally sign our login challenge (aka assertion request) we create an ASAuthorizationController and hand it over our assertionRequest.
If you compare this to the original Shiny app you can see that you could also send password credentials at this point. We have removed this part for the sake of focus, clarity and because our web app purposefully does not do passwords.
It is important to understand that our AccountManager class implements the ASAuthorizationControllerDelegate interface. The official Apple documentation defines delegation as:
Delegation is a design pattern that enables a class to hand off (or delegate) some of its responsibilities to an instance of another class.
So when the performRequests() method is called at the end, our own authorizationController() function is being called, because we have delegated it to ourselves with the authController.delegate = self two lines earlier. We have two versions of the authorizationController(), one for the success case and one for the failure.
Let's have a look at the happy-path: During the authController.performRequests() execution, right before our authorizationController() is being called, the user has been presented with the prompt to unlock the Keychain, asking for Touch ID, Face ID, or the local device's PIN. In our case the passkey connected with the domain has been granted access to and was used to sign the challenge.
We have an ASAuthorization object now and it contains the asserted credentials, stored in the credential property – it's an ASAuthorizationPlatformPublicKeyCredentialAssertion in Apple API speak. These asserted credentials are being sent to your web app's backend using the sendAuthenticationResponse() function to verify them with the Hanko API.
On success, the didFinishSignIn() function is called, which in turn triggers the presentation of the screen for a signed-in user – today's Shiny!
And while we are at it: the first occasion that triggers the signInWith() function is the viewDidAppear() method of the SignInViewController. So right at the start of our app it tries to sign in the user, using a passkey that is registered for your domain. If there is no matching passkey on your iPhone or iPad, nothing visible happens. In our debug environment we can see an error message in the console in that case, courtesy to our logger.log() call.
To be able to register a new passkey-protected account, we create the function signUpWith() in the AccountManager class. This function essentially takes a username, tries to register an account with our web app and subsequently creates a new passkey for it.
We start off again like in the signInWith() function by creating a publicKeyCredentialProvider:
To register our desired username with the web app, we are using the getRegistrationOptions() method defined in our AccountManager class. It makes use of our web app's /registration_initialize endpoint with the username as GET parameter:
Looking once again at the happy path, in case of success we receive a creationRequest object. We are picking the challenge and user ID from it, converting them from Base64URL to Base64 in the process. We use both to create our actual credentials creation request, based on the publicKeyCredentialProvider object:
In case our web app would require some sort of attestation (device or authenticator trust model, see below) or user verification we extract them from the creationRequest just like the challenge and user ID before and apply them to the registrationRequest:
"Attestation serves the purpose of providing a cryptographic proof of the authenticator attributes to the relying party in order to ensure that credentials originate from a trusted device with verifiable characteristics." - Hanko documentation: Authenticator trust model
Having assembled the registrationRequest we now proceed with the authController like before during sign in.
Having delegated the authorizationController to ourselves (see above), this time the switch catches on the ASAuthorizationPlatformPublicKeyCredentialRegistration once the user has granted permission to create the new passkey. sendRegistrationResponse has the web app verify and finalize the registration on the /authentication_finalize endpoint and calls the app's didFinishSignIn() on success.
This leads us to our Shiny screen again. Voilá, that's it!
I hope you enjoyed this article. If you have any questions join our slack community and discuss with us and other developers.