Technology
9 Min.
Read

Passkey authentication for native iOS apps

IMPORTANT NOTE: This post was released in summer 2021 when Apple gave us a first preview on passkeys. Some of the content below is still relevant, but our product has evolved since and the code examples won't work anymore. We suggest you take a look at the new Hanko Passkey API. You can also check out our passkey demo at passkeys.io.

---

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.

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 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.

Getting the iOS app to work

After having completed all of the above prerequisites we can proceed to configure the Shiny mobile app.

  1. Clone the repo of our WebAuthn-enabled Shiny app from Github
  2. Open it up in Xcode, select the Shiny Project
  3. In the Signing & Capabilities pane choose your team from the Team drop-down menu to let Xcode automatically manage your provisioning profile
  4. Add the Associated Domains capability and specify your domain with the webcredentials service (e.g. webcredentials:yourdomain.com).
  5. Get your Team ID (e.g. 1ABC23DEF4) and the generated Bundle Identifier (e.g. com.example.apple-samplecode.Shiny1ABC23DEF4)
  6. 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"
  7. Save the file and restart the web app
  8. In the mobile app in the Accountmanager file change the domain variable to your web app's domain: let domain = "yourdomain.com"
  9. Attach your iPhone or iPad to your Mac and chose it as execution environment 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, 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!

Signing the challenge with your passkey

To finally sign our login challenge (aka 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. 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 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 again like in 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." - Hanko documentation: Authenticator trust model

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 enjoyed this article. If you have any questions join our Slack community and discuss with us and other developers.

arrow
Back to overview

More blog posts

Don't miss out on latest blog posts, new releases and features of Hanko's products, and more.

Your submission has been received!
Something went wrong.