iosswiftuiwebauthnpasskey

How to intgrate Passkey in iOS App with custom Python backend using WebAuthn


Intro
I've my own backend serving API's to Android and iOS Apps written Python using the Flask framework and the py_webauthn library (https://github.com/duo-labs/py_webauthn).

I've succesfully managed to build an Android App and API's that communicate with eachother that allows users to register new passkeys and validate passkeys when signing in.

Now I'm repeating this process for iOS using SwiftUI in Xcode 15.4 targeting iOS 16.4 or higher. For this I've had a look at the Apple Food Truck App example (see: https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app).

Passkey registration process for Android
Following steps work fine on Android:

  1. User wants to register a new passkey
  2. Device requests a challenge from the server
  3. Server receives request and uses the received users email as name and fullname as displayname and generates a unique id as user id to generate the challenge using WebAuthn generate_registration_options
  4. The options generated by WebAuth are send back to the device as json
  5. The device creates a Public Key Credential Request, based on the received options containing the challenge
  6. The user sees the device asking for confirmation to generate a passkey based on biometrics (or whatever) and completes the passkey creation process on the device
  7. The device sends the registration response json to the server
  8. The server verifies the received registration response using WebAuth verify_registration_response function. It uses the received registration response as input even as the generated challenge from step 3 as expected_challenge and the expected_origin formatted as "android:apk-key-hash:8363ghjjhg_blablabla_87234765^^%"
  9. The server replies back to the device if verification was a succes or not.

Issue
When I try to implement the same steps in the iOS App I get stuck on step 7 and 8. The WebAuthn validate_registration_response always comes back with "Client data challenge was not expected challenge".

In steps 7 and 8 I see differences into how Google and Apple implemented these steps. It starts with that Android accepts the complete options json that the generate_registration_options function of WebAuthn produces. For iOS it seems that I have to strip the challenge out of this response and pass it to:

    @Environment(\.authorizationController) private var authorizationController

let authorizationResult = try await authorizationController.performRequests(
                [passkeyRegistrationRequest(passkeyChallenge: passkeyChallenge, username: username, name: name)],
                    options: options)

private func passkeyRegistrationRequest(passkeyChallenge: Data, username: String, name: String) async -> ASAuthorizationRequest {
        ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingPartyIdentifier)
           .createCredentialRegistrationRequest(challenge: passkeyChallenge, name: name, userID: Data(username.utf8))
    }

Described steps 5 & 6 are handled in this code, but when I end up at step 7 in code:

private func handleAuthorizationResult(_ authorizationResult: ASAuthorizationResult, username: String? = nil) async throws -> String {
        switch authorizationResult {
        case let .password(passwordCredential):
            // Unused
            Logger.authorization.log("Password authorization succeeded: \(passwordCredential)")
        case let .passkeyAssertion(passkeyAssertion):
            // Unused
            // The login was successful.
            Logger.authorization.log("Passkey authorization succeeded: \(passkeyAssertion)")
        case let .passkeyRegistration(passkeyRegistration):
            // The registration was successful.
            Logger.authorization.log("Passkey registration succeeded: \(passkeyRegistration")
                        
            guard let attestationObject = passkeyRegistration.rawAttestationObject else { return "" }
            let clientDataJSON = passkeyRegistration.rawClientDataJSON
            let credentialID = passkeyRegistration.credentialID
            
            // Build the attestaion object
            let payload = ["rawId": credentialID.base64URLEncode(), // .base64EncodedString(), // Base64
                           "id": passkeyRegistration.credentialID.base64URLEncode(),
                           "authenticatorAttachment": "platform", // Optional parameter
                           "clientExtensionResults": [String: Any](), // Optional parameter
                           "type": "public-key",
                           "response": [
                            "attestationObject": attestationObject.base64EncodedString(),
                            "clientDataJSON": clientDataJSON.base64EncodedString()
                           ]
            ] as [String: Any]
            
            var payloadJSONText = ""
            if let payloadJSONData = try? JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed) {
                payloadJSONText = String(data: payloadJSONData, encoding: .utf8) ?? ""
            }

            Logger.authorization.log("Passkey registration succeeded 2: \(payloadJSONText)")
            return payloadJSONText
        default:
            Logger.authorization.error("Received an unknown authorization result.")
            // Throw an error and return to the caller.
            throw AuthorizationHandlingError.unknownAuthorizationResult(authorizationResult)
        }
        return ""
    }

In case let .passkeyRegistration(passkeyRegistration):, I send the returned payload back to the server where it should be validated using WebAuthn verify_registration_response function. But I've been able to get this working.

The difference here is that I can see is that Android has an expected_origin of "android:apk-key-hash:8363ghjjhg_blablabla_87234765^^%" and for iOS it seems that the origin is something like a URL format of my server that I used to create the challenge with, but I'm not sure here.

What I tried so far
I also thought there could be issues where WebAuthn should expect something in base64url format instead of base64, tried several combinations of formats for the field, but no luck.

I can't find anything so far on the internet that could help me forward with this. Everything I can find is using either iOS libraries or WebAuthn internet services and that it not what I want.

It seems that the validation on the server is not something that others do during the registration of a new passkey. Am I trying to do the impossible here?

Due to this I haven't even started with signing a user in, so I can't say at this moment if there is any issue there.

Who can help me in the correct direction?


Solution

  • Ok, I figured it out and its working now for iOS as well. I'm not 100% confident that my solution is as it should be, but it seems to work.

    I have to make notice here that I seem to be spoiled by how Google implemented it. On iOS, the passkey implementation seems less automated and you have to do more yourself.

    So my first mistake I made was that I didn't Base64URL decode the challenge I received from the server. The payload I was creating was in the right direction and I ended up with:

    let payload = ["rawId": passkeyRegistration.credentialID.base64URLEncode(),
                   "id": passkeyRegistration.credentialID.base64URLEncode(),
                   "authenticatorAttachment": "platform",
                   "clientExtensionResults": [String: Any](),
                   "type": "public-key",
                   "response": [
                       "transports": ["internal"],
                       "attestationObject": passkeyRegistration.rawAttestationObject!.base64URLEncode(),
                       "clientDataJSON": passkeyRegistration.rawClientDataJSON.base64URLEncode()
                       ]
                   ] as [String: Any]
    

    On the server side I the expected_origin should be set to https://www.example.com (or whatever your site address is).

    Using this, the WebAuth verify_registration_response function can now verify the response properly and all works well.

    I also implemented the steps to perform a login, with the lessons learned from the steps above I was able to make that work as well. Below is the required payload for signing in using the WebAuth verify_authentication_response function.

    let payload = ["rawId": passkeyAssertion.credentialID.base64URLEncode(),
                   "id": passkeyAssertion.credentialID.base64URLEncode(),
                   "authenticatorAttachment": "platform",
                   "clientExtensionResults": [String: Any](),
                   "type": "public-key",
                   "response": [
                       "clientDataJSON": passkeyAssertion.rawClientDataJSON.base64URLEncode()
                       "authenticatorData": passkeyAssertion.rawAuthenticatorData.base64URLEncode(),
                       "signature": passkeyAssertion.signature.base64URLEncode(),
                       "userHandle": passkeyAssertion.userID.base64URLEncode()
                       ]
                   ] as [String: Any]
    

    Hopefully this will help other as well. Just let me know if I wasn't clear enough.