Passkeys for native iOS app authentication
Tags: ios, swift, webauthn, passwordless
Published: 2021-07-19 21:09:55

*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](https://dev.to/hanko/how-to-support-apple-icloud-passkeys-with-webauthn-on-ios-15-and-macos-monterey-37h3).*

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](https://developer.apple.com/videos/play/wwdc2021/10106/). 

**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](https://console.hanko.io/), 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](https://dev.to/hanko/how-to-support-apple-icloud-passkeys-with-webauthn-on-ios-15-and-macos-monterey-37h3) up and running on your own HTTPS-capable host with a valid certificate. Use [Let's Encrypt](https://letsencrypt.org/) 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](https://developer.apple.com/documentation/xcode/supporting-associated-domains)

### 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](https://developer.apple.com/download/) 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](https://developer.apple.com/de/support/beta-software/).

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.

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

```swift
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:

```swift
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:

```swift
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:

```swift
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:

```swift
let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
```

Next we check if we require user verification and enable it:

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

```swift
// 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.

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

```swift
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`:

```swift
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:

```swift
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:

```swift
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`:

```swift
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](https://docs.hanko.io/guides/attestation)

Having assembled the `registrationRequest` we now proceed with the `authController` like before during sign in.

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

```swift
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!