Passkeys for native iOS app authentication
Jan Gerle
Posted on July 19, 2021
In this second part of our passkeys series, we will be modifying Apple's Shiny iOS app to make use of the same passkeys (a.k.a. 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 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 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. So when we started to work on the app we quickly found out that Apple's APIs are still a bit... beta. We ran into some 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 these errors as well, so we assume that Apple will fix them sooner or later.
Prerequisites
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 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...
Reminder: 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.
Getting the iOS app to work
After having completed all of the above prerequisites we can proceed to configure the Shiny mobile app.
- Clone the repo of our WebAuthn-enabled Shiny app from Github
- Open it up in Xcode, select the Shiny Project and:
- In the Signing & Capabilities pane choose your team from the Team drop-down menu to let Xcode automatically manage your provisioning profile.
- Add the Associated Domains capability, and specify your domain with the webcredentials service (e.g.
webcredentials:yourdomain.com
).
- Get your Team ID (e.g.
1ABC23DEF4
) and the generated Bundle Identifier (e.g.com.example.apple-samplecode.Shiny1ABC23DEF4
) - In your web app you need to setup the Apple Site Association by adding the following line to your
config/config.yaml
file:iosAppId: "<Team ID>.<Bundle Identifier>"
, e.g.iosAppId: "1ABC23DEF4.com.example.apple-samplecode.Shiny1ABC23DEF4"
- Save the file and restart the webapp
- In the mobile app in the Accountmanager file change the domain variable to your web app's domain:
let domain = "yourdomain.com"
- Attach your iPhone or iPad to your Mac and select it as execution environment in Xcode
- In Xcode, clean the build folder and start the 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.
The source code
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.
Signing in
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:
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
This creates a provider to work with a passkey stored in the iCloud Keychain. It takes our domain as the only option for now.
Getting the challenge
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:
getAuthenticationOptions() { assertionRequestOptions in ... }
The getAuthenticationOptions()
function itself basically calls our web app's /authentication_initialize
endpoint, we have used that endpoint in the browser flow as well:
func getAuthenticationOptions(completionHandler: @escaping (CredentialAssertion) -> Void) {
AF.request("https://\(domain)/authentication_initialize", method: .get).responseDecodable(of: CredentialAssertion.self) { ... }
}
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:
let challenge = assertionRequestOptions.publicKey.challenge.decodeBase64Url()!
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:
let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
Next we check if we require user verification and enable it:
if let userVerification = assertionRequestOptions.publicKey.userVerification {
assertionRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference.init(rawValue: userVerification)
}
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, a.k.a. 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.
Please keep in mind that your biometrics, your PIN, or your password WILL NEVER LEAVE YOUR DEVICE!
Signing the challenge with your passkey
To finally sign our login challenge (a.k.a. assertion request) we create an ASAuthorizationController
and hand it over our assertionRequest
.
// Pass in any mix of supported sign in request types.
let authController = ASAuthorizationController(authorizationRequests: [ assertionRequest ] )
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
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.
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {...}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {...}
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 :D These asserted credentials are being sent to your web app's backend using the sendAuthenticationResponse()
function to verify them with the Hanko API.
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
...
switch authorization.credential {
...
case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
logger.log("A credential was used to authenticate: \(credentialAssertion)")
// After the server has verified the assertion, sign the user in.
sendAuthenticationResponse(params: credentialAssertion) {
self.didFinishSignIn()
}
...
}
On success, the didFinishSignIn()
function is being 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.
Registering a new account
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 like the signInWith()
function by creating a publicKeyCredentialProvider
:
func signUpWith(userName: String, anchor: ASPresentationAnchor) {
self.authenticationAnchor = anchor
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
getRegistrationOptions(username: userName) /* ... */
}
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:
func getRegistrationOptions(username: String, ...) {
AF.request("https://\(domain)/registration_initialize?user_name=\(username)", method: .get). /* ... */
}
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:
func signUpWith(userName: String, anchor: ASPresentationAnchor) {
/* ... */
getRegistrationOptions(username: userName) { creationRequest in
let challenge = creationRequest.publicKey.challenge.decodeBase64Url()!
let userID = creationRequest.publicKey.user.id.decodeBase64Url()!
let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(challenge: challenge,name: userName, userID: userID)
/* ... */
}
}
User verification and device trust model
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
:
getRegistrationOptions(username: userName) { creationRequest in
/* ... */
if let attestation = creationRequest.publicKey.attestation {
registrationRequest.attestationPreference = ASAuthorizationPublicKeyCredentialAttestationKind.init(rawValue: attestation)
}
if let userVerification = creationRequest.publicKey.authenticatorSelection?.userVerification {
registrationRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference.init(rawValue: userVerification)
}
/* ... */
}
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.
Having assembled the registrationRequest
we now proceed with the authController
like before during sign in.
getRegistrationOptions(username: userName) { creationRequest in
/* ... */
let authController = ASAuthorizationController(authorizationRequests: [ registrationRequest ] )
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
}
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.
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
let logger = Logger()
switch authorization.credential {
/* ... */
case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:
logger.log("A new credential was registered: \(credentialRegistration)")
sendRegistrationResponse(params: credentialRegistration) {
self.didFinishSignIn()
}
/* ... */
}
}
This leads us to our Shiny screen again. Voilá, that's it!
I hope you have enjoyed this article. If you have any questions: just post a comment below!
Posted on July 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.