PassKeys Logo

Create PassKey Code Walkthrough

This entry is part 4 of 8 in the series Implementing PassKeys in iOS

In our code walkthrough, we’ll look at both the client code (Swift), and the server code (PHP).

The creation process is the most code-intensive part of using PassKeys.

What to Look At

In this post, we’ll be looking at some particular methods, within each of the implementation classes. These are:

The Client

  1. PKD_Handler.create()
    This is the “overall call” of the PKD_Handler public API. It calls the next two methods.
  2. PKD_Handler._getCreateChallenge()
    This asks the server to generate a creation challenge. It sends the PassKey name that was chosen by the user, in the app GUI.
  3. PKD_Handler._nextStepInCreate()
    This starts the internal ASAuthorizationPlatformPublicKeyCredentialProvider process of generating a PassKey.
  4. PKD_Handler.authorizationController(_, didCompleteWithAuthorization)
    This is a delegate callback for the ASAuthorizationPlatformPublicKeyCredentialProvider. It will call the next method.
  5. PKD_Handler._postCreateResponse()
    This will create a final PassKey registration instance to be sent to the server, and will send it for server completion.

The Server

  1. PKDServer._createChallenge()
    This generates the challenge string, as well as the initial PassKey registration record.
  2. PKDServer._createCompletion()
    This stores the public key and credential record for the newly-generated PassKey, and also logs the user in.

Steps

We’ll walk through each step in the creation process.

Step One: The Client Asks the Server to Create A PassKey Request (WebAuthn Record) and Challenge

Figure 1: The Client Request A Create Challenge From the Server

The first thing that happens, is that the client gets its public API PKD_Handler.create() method called from the GUI. The method accepts a string, naming the PassKey. That string comes from the PassKey Name Text Field, in the GUI, and the method is called, when the user selects the “Register” button.

The PKD_Handler.create() function, in turn, calls the internal PKD_Handler._getCreateChallenge() method:

It uses an inline closure to capture the completion of the method (NOTE: That closure can be called from any thread).

if !self.isLoggedIn {
    self._getCreateChallenge(passKeyName: inPassKeyName) { inCreateChallengeResponse in

This is the PKD_Handler._getCreateChallenge() method, which sends a GET call to the server, requesting the challenge.

private func _getCreateChallenge(passKeyName inPassKeyName: String, completion inCompletion: @escaping (Result<_PublicKeyCredentialCreationOptionsStruct, Error>) -> Void) {
// We need to create a unique user ID. After this, we're done with it. We do it here, because PHP doesn't actually have a true built-in UUID generator, and we do.
if let urlIDString = UUID().uuidString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
var urlString = "\(self._baseURIString)/index.php?operation=\(UserOperation.createUser.rawValue)&userId=\(urlIDString)"
if let passKeyName = inPassKeyName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
!passKeyName.isEmpty {
urlString += "&displayName=\(passKeyName)"
}
guard let url = URL(string: urlString) else { return }
self._session.dataTask(with: url) { inData, inResponse, inError in
if let error = inError {
inCompletion(.failure(error))
} else if let data = inData,
let response = inResponse as? HTTPURLResponse {
if 200 == response.statusCode {
do {
let decoder = JSONDecoder()
let options = try decoder.decode(_PublicKeyCredentialCreationOptionsStruct.self, from: data)
inCompletion(.success(options))
} catch {
self._clearUserInfo()
inCompletion(.failure(PKD_Errors.communicationError(nil)))
}
} else if 409 == response.statusCode {
self._clearUserInfo()
inCompletion(.failure(PKD_Errors.alreadyRegistered))
} else {
self._clearUserInfo()
inCompletion(.failure(PKD_Errors.communicationError(nil)))
}
} else {
self._clearUserInfo()
if let error = inError {
inCompletion(.failure(error))
} else {
inCompletion(.failure(PKD_Errors.communicationError(nil)))
}
return
}
}.resume()
} else {
self._clearUserInfo()
inCompletion(.failure(PKD_Errors.noUserID))
}
}

Note that we use a URLSession, managed by the class instance (PKD_Handler._cachedSession). The reason for this, is that we need to maintain a session, throughout the login. The server uses this session to temporarily store data, and to validate the bearer token.

Step Two: The Server Creates an Initial WebAuthn Structure

The server receives the request from the client, and uses the lbuchs/WebAuthn library, to generate a create package to be returned to the client, in the PKDServer._createChallenge() method:

    private function _createChallenge() {
        $userId = $this->_getArgs->userId;   // The user ID needs to be unique in this server.
        // After this, the client can forget the user ID. It will no longer be used in exchanges.
        $displayName = $this->_getArgs->displayName;
        if (empty($displayName)) {
            $displayName = "New User";
        }
        
        $stmt = $this->_pdoInstance->prepare('SELECT credentialId FROM webauthn_credentials WHERE userId = ?');
        $stmt->execute([$userId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        
        if (!empty($userId) && empty($row)) {
            // We will use the function to create a registration object,
            // which will need to be presented in a subsequent call.
            $args = $this->_webAuthnInstance->getCreateArgs($userId, $userId, $displayName);
            
            // We encode the challenge data as a Base64 URL-encoded string.
            $binaryString = $this->_webAuthnInstance->getChallenge()->getBinaryString();
            $base64urlChallenge = base64url_encode($binaryString);
            // We do the same for the binary unique user ID.
            // NOTE: This needs to be Base64URL encoded, not just Base64 encoded.
            $userIdEncoded = base64url_encode($args->publicKey->user->id->getBinaryString());
              
            // We replace the ones given by the function (basic Base64), with the Base64 URL-encoded strings.
            $args->publicKey->challenge = $base64urlChallenge;
            $args->publicKey->user->id = $userIdEncoded;
            
            // We will save these in the session, which must be preserved for the next step.
            $_SESSION['createUserID'] = $userId;
            $_SESSION['createDisplayName'] = $displayName;
            $_SESSION['createChallenge'] = $base64urlChallenge;
        
            header('Content-Type: application/json');
            echo json_encode(['publicKey' => $args->publicKey]);
        } elseif (!empty($row)) {
            http_response_code(409);
            echo json_encode(['error' => 'User Already Registered']);
        } else {
            http_response_code(400);
            echo '💩';   // Oh, poo.
        }
    }

Step Three: The Server Responds With A WebAuthn Structure

Figure 2: The Server Sends Back the Creation WebAuthn Credentials

The value of the inCreateChallengeResponse argument is a typical Result<success, failure>, with .success having an associated value that consists of a _PublicKeyCredentialCreationOptionsStruct. This is a structure that “cherry picks” pertinent data items from the server response, and is used by the client to build the create record, in the next step.

if !self.isLoggedIn {
    self._getCreateChallenge(passKeyName: inPassKeyName) { inCreateChallengeResponse in

Step Four: The Client Creates A New PassKey

At this point, we bring in the Authentication Services SDK. This is what generates and stores the PassKey, on the client.

Once the client has the server challenge and creation data, it uses that, to have an instance of ASAuthorizationController ask the user to authorize the PassKey creation. If the authorization is successful, then a new PassKey is created.

This is done in the PKD_Handler._nextStepInCreate() method.

First, we use an instance of ASAuthorizationPlatformPublicKeyCredentialProvider to generate a credential request. Note that we pass in our local understanding of the Relying Party. The server will use this as a “first-line sanity check.” We also decode and prepare the challenge from the server, into an opaque Data instance. We do the same for the user ID and PassKey name (called “Display Name,” here). Once these are all in the request, they can’t be changed, because the Auth Services will sign the data package.

    private func _nextStepInCreate(with inOptions: _PublicKeyCredentialCreationOptionsStruct, completion inCompletion: @escaping (Result) -> Void) {
        if let challengeData = inOptions.publicKey.challenge._base64urlDecodedData,
           let userIDData = inOptions.publicKey.user.id._base64urlDecodedData {
            let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: inOptions.publicKey.rp.id)

            let request = provider.createCredentialRegistrationRequest(
                challenge: challengeData,
                name: inOptions.publicKey.user.displayName,
                userID: userIDData
            )
            
            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()
        } else {
            self._clearUserInfo()
            inCompletion(.failure(PKD_Errors.noUserID))
        }
    }

Then, we supply this request to an instance of ASAuthorizationController:

    private func _nextStepInCreate(with inOptions: _PublicKeyCredentialCreationOptionsStruct, completion inCompletion: @escaping (Result) -> Void) {
        if let challengeData = inOptions.publicKey.challenge._base64urlDecodedData,
           let userIDData = inOptions.publicKey.user.id._base64urlDecodedData {
            let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: inOptions.publicKey.rp.id)

            let request = provider.createCredentialRegistrationRequest(
                challenge: challengeData,
                name: inOptions.publicKey.user.displayName,
                userID: userIDData
            )
            
            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()
        } else {
            self._clearUserInfo()
            inCompletion(.failure(PKD_Errors.noUserID))
        }
    }

After that, when controller.performRequests() is called, the process is handed off to the authorization services. The PassKey creation and biometric authentication screens are shown to the user, and, if successful, the authorizationController(controller:didCompleteWithAuthorization:) delegate callback is made (PKD_Handler.authorizationController(controller:didCompleteWithAuthorization:)), and we check the credential type, to see if this is a create or login step:

    public func authorizationController(controller inAuthController: ASAuthorizationController, didCompleteWithAuthorization inAuthorization: ASAuthorization) {
        if let credential = inAuthorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
            let payload: [String: String] = [
                "clientDataJSON": credential.rawClientDataJSON.base64EncodedString(),
                "attestationObject": credential.rawAttestationObject?.base64EncodedString() ?? ""
            ]
            
            self._postCreateResponse(to: "\(self._baseURIString)/index.php?operation=\(UserOperation.createUser.rawValue)", payload: payload)
        } else if let assertion = inAuthorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
            let idString = assertion.credentialID.base64EncodedString() // We encode the credential ID as regular Base64
            if !idString.isEmpty,
               let urlIDString = idString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) {    // Need to use alphanumerics, because the Base64 encoding can have "+".
                // This contains the data that we'll need to pass to WebAuthn, on the server. It will be sent via POST.
                let payload: [String: String] = [
                    "clientDataJSON": assertion.rawClientDataJSON.base64EncodedString(),        // The client data JSON. This is the basic information about the client.
                    "authenticatorData": assertion.rawAuthenticatorData.base64EncodedString(),  // This is authentication/credential information (also JSON)
                    "signature": assertion.signature.base64EncodedString(),                     // This is the signature hash for the above data. It uses the internal private key that corresponds with the public one on the server.
                    "credentialId": idString                                                    // This is how we find which credential to match, on the server.
                ]
                
                self._postLoginResponse(to: "\(self._baseURIString)/index.php?operation=\(UserOperation.login.rawValue)&credentialId=\(urlIDString)", payload: payload)
            }
        }
    }

NOTE: Even though we are passing a default attestation object, our example doesn’t actually take advantage of it. This is a way to increase the security of a PassKey.

Step Five: The Client Returns a Signed Data Package to the Server, Along With a Public Key

Figure 3: The Client Sends The Public Key to the Server

The delegate callback will invoke the PKD_Handler._postCreateResponse(to:payload:) method, with the signed authentication data structures, as JSON objects. These will be sent to the server in a POST operation:

    private func _postCreateResponse(to inURLString: String, payload inPayload: [String: Any]) {
        guard let url = URL(string: inURLString),
              let responseData = try? JSONSerialization.data(withJSONObject: inPayload),
              !responseData.isEmpty
        else { return }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.httpBody = responseData
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("\(responseData.count)", forHTTPHeaderField: "Content-Length")
        DispatchQueue.main.async {
            self._bearerToken = nil
            self.lastError = .none
            self._session.dataTask(with: request) { inData, inResponse, inError in
                guard let response = inResponse as? HTTPURLResponse else { return }
                DispatchQueue.main.async {
                    if let data = inData {
                        if 200 == response.statusCode,
                           let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: String],
                           let token = dict["bearerToken"],
                           !token.isEmpty {
                            let decoder = JSONDecoder()
                            if let userData = try? decoder.decode(_UserDataStruct.self, from: data) {
                                self._bearerToken = token   // We log in, after creating.
                                self.originalDisplayName = userData.displayName
                                self.originalCredo = userData.credo
                                self.isLoggedIn = true
                            } else {
                                self.lastError = PKD_Errors.communicationError(nil)
                                self.isLoggedIn = false
                            }
                        } else {
                            self.lastError = PKD_Errors.communicationError(nil)
                            self.isLoggedIn = false
                        }
                    }
                }
            }.resume()
        }
    }

Step Six: The Server Stores the Public Key, and Logs the User In

Figure 4: The Server Responds With A Success Indication (the Bearer Token)

This is handled on the server end, via the PKDServer._createCompletion() method. We again use the lbuchs/WebAuthn library to validate the challenge, and the data returned by the client. If it all checks out, the server then creates the database entries for the new user, and logs the user in, by generating a new bearer token:

    private function _createCompletion() {
        // Create a new token, as this is a new login.
        // NOTE: This needs to be Base64URL encoded, not just Base64 encoded.
        $bearerToken = base64url_encode(random_bytes(32));
        $userId = $_SESSION['createUserID'];
        $displayName = $_SESSION['createDisplayName'];
        $clientDataJSON = base64_decode($this->_postArgs['clientDataJSON']);
        $attestationObject = base64_decode($this->_postArgs['attestationObject']);
        $challenge = base64url_decode($_SESSION['createChallenge']);  // NOTE: Base64URL encoded.
        
        if (!empty($clientDataJSON) && !empty($attestationObject)) {
            try {
                // This is where the data to be stored for the subsequent logins is generated.
                $data = $this->_webAuthnInstance->processCreate(    $clientDataJSON,
                                                                    $attestationObject,
                                                                    $challenge);
                
                // We will be storing all this into the database.
                $params = [
                    $userId,
                    base64_encode($data->credentialId),
                    $displayName,
                    intval($data->signCount),
                    $bearerToken,
                    $data->credentialPublicKey
                ];
                
                // Create a new credential record.
                $stmt = $this->_pdoInstance->prepare('INSERT INTO webauthn_credentials (userId, credentialId, displayName, signCount, bearerToken, publicKey) VALUES (?, ?, ?, ?, ?, ?)');
                $stmt->execute($params);
                // Create a new user data record.
                $stmt = $this->_pdoInstance->prepare('INSERT INTO passkeys_demo_users (userId, displayName, credo) VALUES (?, ?, ?)');
                $stmt->execute([$userId, $displayName, ""]);
                // Send these on to the next step.
                $_SESSION['bearerToken'] = $bearerToken;
        
                header('Content-Type: application/json');
                echo json_encode(['displayName' => $displayName, 'credo' => '', 'bearerToken' => $bearerToken]);
            } catch (Exception $e) {
                http_response_code(400);
                header('Content-Type: application/json');
                echo json_encode(['error' => $e->getMessage()]);
            }
        } else {
            http_response_code(400);
            echo '💩';   // Oh, poo.
        }
    }

Note the bearer token being saved in the $_SESSION global variable, after we have created everything. This is the login process. We use the session to track the login. Simple, but fairly secure. That’s why we need to maintain the session on the client.

Step Seven: The Client Responds to the Creation Result

There are no callbacks, here. The PKD_Handler class is set up as a Combine ObservableObject, and we manipulate published properties to indicate success or failure:

if let userData = try? decoder.decode(_UserDataStruct.self, from: data) {
    self._bearerToken = token   // We log in, after creating.
    self.originalDisplayName = userData.displayName
    self.originalCredo = userData.credo
    self.isLoggedIn = true
} else {
    self.lastError = PKD_Errors.communicationError(nil)
    self.isLoggedIn = false
}

The client UI frameworks are set up to observe the PKD_Handler.isLoggedIn and the PKD_Handler.lastError properties, so changing these, causes the UI to refresh.

We’re done with the PassKey and authentication services, here. The next time that we’ll need them, is when we want to log in again.