PassKeys Logo

Login PassKey Code Walkthrough

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

Logging in is simpler, but we still have the challenge/response process, as we did with creation.

With logging in, the client doesn’t need the server to prepare to create a new account, so it just needs a challenge.

What to Look At

The login process calls a number of methods within the client and server.

Client

  1. PKD_Handler.login()
    This is the initial call from the GUI. It will call the next method.
  2. PKD_Handler._getLoginChallenge()
    This sends a challenge request to the server.
  3. PKD_Handler.authorizationController(controller inAuthController:, didCompleteWithAuthorization:)
    This is the Authorization Controller delegate callback. It is called, if the user approves the authorization. It calls the next method.
  4. PKD_Handler._postLoginResponse(to:, payload:)
    This sends the signed data package to the server.

Server

  1. PKDServer._loginChallenge()
    This sets up the challenge. It generates the challenge.
    NOTE: In our implementation, this also queries the database for allowed credential IDs.
  2. PKDServer._loginCompletion()
    This completes the login, and generates (and returns) the bearer token.

Steps

Step One: The Client Requests A Challenge From the Server

Figure 1: The Client Requests A Challenge From the Server

When the user selects “Login,” from the initial (logged-out) screen, the UI calls PKD_Handler.login(). This, in turn, calls PKD_Handler._getLoginChallenge():

    func login(completion inCompletion: @escaping ServerResponseCallback) {
        DispatchQueue.main.async {
            self.lastOperation = .login
            guard self._isBiometricAuthAvailable else {
                self.lastError = PKD_Errors.biometricsNotAvailable
                return
            }
            self.lastError = .none
            if !self.isLoggedIn {
                self._getLoginChallenge { inResponse in

PKD_Handler._getLoginChallenge() simply sends a GET call to the server, asking for a login challenge:

    private func _getLoginChallenge(completion inCompletion: @escaping (Result<(challenge: String, allowedIDs: [String]), Error>) -> Void) {
        let urlString = "\(self._baseURIString)/index.php?operation=\(UserOperation.login.rawValue)"
        
        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,
                      !data.isEmpty,
                      let simpleJSON = try? JSONSerialization.jsonObject(with: data, options: []),
                      let mainDict = simpleJSON as? [String: Any] {
                guard let allowedIDs = mainDict["allowedIDs"] as? [String]
                else {
                    inCompletion(.failure(PKD_Errors.noAvailablePassKeys))
                    return
                }
                if let challenge = mainDict["challenge"] as? String {
                    inCompletion(.success((challenge, allowedIDs)))
                }
            } else {
                inCompletion(.failure(PKD_Errors.communicationError(nil)))
            }
        }.resume()
    }

Step Two: The Server Creates A Challenge

On the server end, we call PKDServer._loginChallenge():

    private function _loginChallenge() {
        $challenge = random_bytes(32);
        // Pass on to the next step.
        $_SESSION['loginChallenge'] = $challenge;
        $stmt = $this->_pdoInstance->prepare('SELECT credentialId FROM webauthn_credentials');
        $stmt->execute();
        $allowedIDs = [];
        
        foreach($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            if (!empty($row['credentialId'])) {
                $allowedIDs[] = $row['credentialId'];
            }
        }
        
        header('Content-Type: application/json');
        echo json_encode(['challenge' => base64url_encode($challenge), 'allowedIDs' => $allowedIDs]);
    }

It’s very simple. It just creates a challenge, by randomizing 32 bytes. In our case, we also use this call, to fetch a list of allowed stored credentials.

NOTE: In our implementation, the server also sends the client a list of all its stored Credential IDs. This allows the client to avoid showing the user PassKeys that are not actually available on the server (for example, if the user deleted a server account, but did not delete their local copy of the PassKey). This may be something that a “paranoid” implementation might want to avoid, as it may give bad actors some information about the server. However, it’s not actually very much information, as the Credential IDs won’t do much good, without the associated Private Keys. They are basically untyped hashes, so they don’t provide any information about the accounts.

    private function _loginChallenge() {
        $challenge = random_bytes(32);
        // Pass on to the next step.
        $_SESSION['loginChallenge'] = $challenge;
        $stmt = $this->_pdoInstance->prepare('SELECT credentialId FROM webauthn_credentials');
        $stmt->execute();
        $allowedIDs = [];
        
        foreach($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            if (!empty($row['credentialId'])) {
                $allowedIDs[] = $row['credentialId'];
            }
        }
        
        header('Content-Type: application/json');
        echo json_encode(['challenge' => base64url_encode($challenge), 'allowedIDs' => $allowedIDs]);
    }

Step Three: The Server Sends the Challenge Back to the Client

Figure 2: The Server Returns the Challenge to the Client

Step Four: The Client Receives the Challenge From the Server

The server sends back a simple JSON array, with the challenge string, and an array of “allowed IDs.” These are the Credential IDs of the accounts that already exist on the server. It will not be provided, if there are no IDs yet.

Back in PKD_Handler._getLoginChallenge(), we get the response from the server, and parse it:

    private func _getLoginChallenge(completion inCompletion: @escaping (Result<(challenge: String, allowedIDs: [String]), Error>) -> Void) {
        let urlString = "\(self._baseURIString)/index.php?operation=\(UserOperation.login.rawValue)"
        
        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,
                      !data.isEmpty,
                      let simpleJSON = try? JSONSerialization.jsonObject(with: data, options: []),
                      let mainDict = simpleJSON as? [String: Any] {
                guard let allowedIDs = mainDict["allowedIDs"] as? [String]
                else {
                    inCompletion(.failure(PKD_Errors.noAvailablePassKeys))
                    return
                }
                if let challenge = mainDict["challenge"] as? String {
                    inCompletion(.success((challenge, allowedIDs)))
                }
            } else {
                inCompletion(.failure(PKD_Errors.communicationError(nil)))
            }
        }.resume()
    }

The client receives the response to the challenge request, and parses it into its components, and returns these as a tuple, in the passed-in completion handler:

inCompletion(.success((challenge, allowedIDs)))

Step Five: The Client Creates the Login Data Package

Which takes us back to the original PKD_Handler.login() method:

                self._getLoginChallenge { inResponse in
                    DispatchQueue.main.async {
                        switch inResponse {
                        case .success(let inResponse):
                            // inResponse.challenge is the challenge string (Base64URL-encoded).
                            // inResponse.allowedIDs is an Array of String, with each string being an allowed credential ID (Base64-encoded).
                            if let challengeData = inResponse.challenge._base64urlDecodedData {
                                let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: self._relyingParty)
                                let request = provider.createCredentialAssertionRequest(challenge: challengeData)
                                let controller = ASAuthorizationController(authorizationRequests: [request])
                                controller.delegate = self
                                controller.presentationContextProvider = self
                                
                                // This part will filter out IDs that we have deleted on the server, but not on the device. It makes sure that we only present PassKeys that exist on the server.
                                let allowedCredentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor] = inResponse.allowedIDs.compactMap { Data(base64Encoded: $0) }.filter { !$0.isEmpty }.map {
                                    ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
                                }
                                
                                guard !allowedCredentials.isEmpty else {
                                    self.lastError = PKD_Errors.noAvailablePassKeys
                                    inCompletion(.failure(PKD_Errors.noAvailablePassKeys))
                                    break
                                }
                                
                                request.allowedCredentials = allowedCredentials
                                
                                controller.performRequests()
                            } else {
                                self.lastError = PKD_Errors.communicationError(nil)
                                inCompletion(.failure(PKD_Errors.communicationError(nil)))
                            }
                            
                        case .failure(let inError):
                            if let error = inError as? PKD_Errors {
                                self.lastError = error
                                inCompletion(.failure(error))
                            } else {
                                self.lastError = PKD_Errors.communicationError(inError)
                                inCompletion(.failure(PKD_Errors.communicationError(inError)))
                            }
                        }
                    }
                }

These lines are where the challenge is added into a data package that will be digitally signed, and returned to the server:

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: self._relyingParty)
let request = provider.createCredentialAssertionRequest(challenge: challengeData)

These lines are where we use the returned Credential IDs to build an “allow” list of credentials:

let allowedCredentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor] = inResponse.allowedIDs.compactMap { Data(base64Encoded: $0) }.filter { !$0.isEmpty }.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0) }
                                
guard !allowedCredentials.isEmpty else {
    self.lastError = PKD_Errors.noAvailablePassKeys
    inCompletion(.failure(PKD_Errors.noAvailablePassKeys))
    break
}
                                
request.allowedCredentials = allowedCredentials

When controller.performRequests() is called, the UI is invoked, and, if the user authorizes, the package is signed by the private key, and the PKD_Handler.authorizationController(controller inAuthController:, didCompleteWithAuthorization:) delegate call is invoked.

This time, the second path is chosen:

    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)
            }
        }
    }

Step Six: The Client Sends the Login Data Package to the Server

Figure 3: The Client Sends the Signed Login Data to the Server

This time, PKD_Handler._postLoginResponse(to:, payload:) is called:

    private func _postLoginResponse(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 token = String(data: data, encoding: .utf8),
                           !token.isEmpty {
                            self._bearerToken = token
                            DispatchQueue.main.async { self.isLoggedIn = true }
                        } else if 404 == response.statusCode {
                            self.lastError = PKD_Errors.noUserID
                            self.isLoggedIn = false
                        } else {
                            self.lastError = PKD_Errors.communicationError(nil)
                            self.isLoggedIn = false
                        }
                    } else {
                        self.lastError = PKD_Errors.communicationError(nil)
                        self.isLoggedIn = false
                    }
                }
            }.resume()
        }
    }

Step Seven: The Server Validates the Data Package, and Logs in the User

On the server end, PKDServer._loginCompletion() is called, and it uses lbuchs/WebAuthn to validate the data package:

    private function _loginCompletion() {
        $userId = "";
        $credentialId = $this->_getArgs->credentialId;
        if (empty($credentialId)) {
            http_response_code(400);
            echo '💩';   // Oh, poo.
            exit;
        }
        
        $row = [];
        $stmt = $this->_pdoInstance->prepare('SELECT userId, displayName, signCount, publicKey FROM webauthn_credentials WHERE credentialId = ?');
        $stmt->execute([$credentialId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        $userId = $row['userId'];
            
        if (empty($userId)) {
            http_response_code(404);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'User not found']);
            exit;
        }
        
        // This is a signed record, with various user data.
        $clientDataJSON = base64_decode($this->_postArgs['clientDataJSON']);
        // A record, with any authenticator (YubiKey, etc. Not used for our demo). 
        $authenticatorData = base64_decode($this->_postArgs['authenticatorData']);
        // The signature for the record, against the public key.
        $signature = base64_decode($this->_postArgs['signature']); 
        // The public key for the passkey.
        $publicKey = $row['publicKey'];
        // The challenge that was returned to the client, for signing.
        $challenge = $_SESSION['loginChallenge'];
        // The number of times it has been validated.
        $signCount = intval($row['signCount']);
        
        // If there was no signed client data record, with a matching challenge, then game over, man.
        if (!empty($publicKey) && !empty($credentialId) && !empty($userId)) {
            try {
                $success = $this->_webAuthnInstance->processGet(
                    $clientDataJSON,
                    $authenticatorData,
                    $signature,
                    $publicKey,
                    $challenge,
                    $signCount
                );
                
                // 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));  
                
                // Increment the sign count and store the new bearer token.
                $newSignCount = intval($this->_webAuthnInstance->getSignatureCounter());
                $stmt = $this->_pdoInstance->prepare('UPDATE webauthn_credentials SET signCount = ?, bearerToken = ? WHERE credentialId = ?');
                $stmt->execute([$newSignCount, $bearerToken, $credentialId]);
                
                $_SESSION['bearerToken'] = $bearerToken;    // Pass it on, in the session.
                echo($bearerToken);
            } catch (Exception $e) {
                // Try to clear the token, if we end up here.
                $stmt = $this->_pdoInstance->prepare('UPDATE webauthn_credentials SET bearerToken = NULL WHERE credentialId = ?');
                $stmt->execute([$credentialId]);
                
                http_response_code(401);
                header('Content-Type: application/json');
                echo json_encode(['error' => $e->getMessage()]);
            }
        } else {
            http_response_code(404);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'User Not Found']);
        }
    }

Step Eight: The Server Responds With the Bearer Token (Login)

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

Step Nine: The Client Sets the Published Login Property

Once the client receives the bearer token, the app is logged in. Setting the PKD_Handler.isLoggedIn property will broadcast to the UI that it needs to be rebuilt.

    private func _postLoginResponse(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 token = String(data: data, encoding: .utf8),
                           !token.isEmpty {
                            self._bearerToken = token
                            DispatchQueue.main.async { self.isLoggedIn = true }
                        } else if 404 == response.statusCode {
                            self.lastError = PKD_Errors.noUserID
                            self.isLoggedIn = false
                        } else {
                            self.lastError = PKD_Errors.communicationError(nil)
                            self.isLoggedIn = false
                        }
                    } else {
                        self.lastError = PKD_Errors.communicationError(nil)
                        self.isLoggedIn = false
                    }
                }
            }.resume()
        }
    }


ERRATA: That second “DispatchQueue” is redundant. It’s a leftover, from earlier work.

At this point, the client is logged into the server. We’re done with the PassKey.