<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M74D8PB" height="0" width="0" style="display:none;visibility:hidden">
Loading
Skip to NavigationSkip to Main Content
Register Passkeys in iOS with the MyAccounts API
Okta Identity Engine
API Access Management
Overview

This article details how to use the Okta MyAccounts API to register Passkeys with iOS Applications.

Applies To
Cause

The Okta IDX pipeline supports registering Passkey Credentials during Authenticator Enrollment.
At times, it may be desirable to allow Passkey registration outside of an authentication flow.

Solution

The example below builds on the Okta-Mobile-Swift IDX Sample Application v2.1.2. Functionality is added to enable Passkey Enrollment after logging into the sample app.

 

The following Sample App files will be modified:

  • ProfileTableViewController.swift
  • IDXStartViewController.swift

A prerequisite is to have the sample application configured for Apple Associated domains. For more information, please check the Configure Passkeys for Native iOS Applications article.

 

Steps

  1. Edit IDXStartViewController to specify the required acr_value.
...
var context = InteractionCodeFlow.Context()
// add below
context.acrValues = ["urn:okta:loa:2fa:any:ifpossible"]
// end
if let recoveryToken = ClientConfiguration.active?.recoveryToken {
...

 

  1. Edit Okta.plist and update the scope property to include scope okta.myAccount.webauthn.manage.
...
<key>scope</key>
<string>openid profile okta.myAccount.webauthn.manage offline_access</string>
...

 

  1. Edit ProfileTableViewController with below additions.
...
// add import
import AuthenticationServices
// end
...
...
.actions: [
    // add action for button
    .init(kind: .action, id: "registerNewPasskey", title: "Register A New Passkey"),
    // end
    .init(kind: .destructive, id: "signout", title: "Sign Out")
]
...
...
case "refresh":
            refresh()
// add method to invoke
case "registerNewPasskey":
            registerNewPasskey()
// end
case "details":
...
...
}
...
// add the below at the bottom of file
extension ProfileTableViewController {
    struct StartRegistrationResponse: Codable {
        let options: Options
    }
    struct Options: Codable {
        let challenge: String
        let rp: Rp
        let user: User
        let authenticatorSelection: AuthenticatorSelection
    }
    struct Rp: Codable {
        let id: String?
        let id2: String?
    }
    struct User: Codable {
        let id: String
        let displayName: String
        let name: String
    }
    struct AuthenticatorSelection: Codable {
        let userVerification: String?
    }
    struct FinishRegistrationRequest: Codable {
        let clientData: String
        let attestation: String
    }
    struct FinishRegistrationResponse: Codable {
        let id: String
        let status: String
    }
    
    
    // Start the Passkey Registration Process
    func registerNewPasskey() {
        guard let request = createMyAccountRequest(requestPath: "/idp/myaccount/webauthn/registration") else { return }

        Task { @MainActor in
            do {
                let (data, response) = try await URLSession.shared.data(for: request)
                let decodedResponse = try JSONDecoder().decode(StartRegistrationResponse.self, from: data)
                
                guard let challengeData = decodedResponse.options.challenge.decodeBase64Url() else {
                    showAlert(title: "Error", message: "Failed to decode challenge string to Data.")
                    return
                }
                guard let userIDData = decodedResponse.options.user.id.data(using: .utf8) else {
                    showAlert(title: "Error", message: "Failed to convert userID string to Data.")
                    return
                }
                
                // rp.id will be populated if Org has configured default Relying Party
                let rp = decodedResponse.options.rp.id ?? (Credential.default!.oauth2.openIdConfiguration?.issuer.host())!
                
                let credentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rp)
                let credentialRegistrationRequest = credentialProvider.createCredentialRegistrationRequest(
                    challenge: challengeData, // The challenge from your server
                    name: decodedResponse.options.user.name,
                    userID: userIDData
                )
                
                credentialRegistrationRequest.displayName = decodedResponse.options.user.displayName
                
                if let userVerification = decodedResponse.options.authenticatorSelection.userVerification {
                    credentialRegistrationRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference(rawValue: userVerification)
                }

                let authorizationController = ASAuthorizationController(authorizationRequests: [credentialRegistrationRequest])
                authorizationController.delegate = self
                authorizationController.presentationContextProvider = self // Delegate to provide a window
                authorizationController.performRequests()
            } catch {
                print(error)
                showAlert(title: "Error", message: error.localizedDescription)
            }
        }
    }
    
    func createMyAccountRequest(requestPath: String) -> URLRequest? {
        guard let credential = Credential.default else {
            showAlert(title: "Passkey Error", message: "No Stored Credential")
            return nil
        }
        guard let host = credential.oauth2.openIdConfiguration?.issuer.host() else {
            showAlert(title: "Passkey Error", message: "No Issuer Configured")
            return nil
        }
        
        let url = URL(string: "https://\(host)\(requestPath)")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json; okta-version=1.0.0", forHTTPHeaderField: "Accept")
        request.setValue("Bearer " + credential.token.accessToken, forHTTPHeaderField: "Authorization")
        
        return request
    }
    
    func showAlert(title: String, message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alert.addAction(.init(title: "Ok", style: .cancel))
        alert.popoverPresentationController?.sourceView = self.view
        self.present(alert, animated: true, completion: nil)
    }
}

extension ProfileTableViewController: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return self.view.window!
    }
    
    // Success handler.
    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        if let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
            // Get the registration data.
            let rawAttestationObject = registration.rawAttestationObject!.base64EncodedString()
            let rawClientDataJSON = registration.rawClientDataJSON.base64EncodedString()
            let body = FinishRegistrationRequest(clientData: rawClientDataJSON, attestation: rawAttestationObject)
            guard var request = createMyAccountRequest(requestPath: "/idp/myaccount/webauthn") else { return }
            
            Task { @MainActor in
                do {
                    request.httpBody = try JSONEncoder().encode(body)
                    let (data, response) = try await URLSession.shared.data(for: request)
                    
                    guard let httpResponse = response as? HTTPURLResponse else {
                        showAlert(title: "Passkey Error", message: "Invalid response type.")
                        return
                    }
                    if httpResponse.statusCode == 201 {
                        let decodedResponse = try JSONDecoder().decode(FinishRegistrationResponse.self, from: data)
                        showAlert(title: "Passkey Registered", message: "Registered Credential ID: \(decodedResponse.id), Status: \(decodedResponse.status)")
                    } else {
                        showAlert(title: "Passkey Error", message: "Unexpected HTTP Response: \(httpResponse.statusCode)")
                    }
                } catch {
                    print(error)
                    showAlert(title: "Error", message: error.localizedDescription)
                }
            }
        }
    }

    // Error handler.
    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        showAlert(title: "Registration failed", message: error.localizedDescription)
    }
}

extension String {
    func decodeBase64Url() -> Data? {
        var base64 = self
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        
        while base64.count % 4 != 0 {
            base64 += "="
        }
        
        return Data(base64Encoded: base64)
    }
}

 

Summary of Changes

Scope okta.myAccount.webauthn.manage and ACR value urn:okta:loa:2fa:any:ifpossible were added.

  • okta.myAccount.webauthn.manage is needed for the MyAccount WebAuthn API calls.
  • urn:okta:loa:2fa:any:ifpossible is used to step up the session, so Okta will grant okta.myAccount.webauthn.manage.

NOTE: *All Okta MyAccount API must include an http 'Accept' header with the value application/json; okta-version=1.0.0 or Okta will reject the request.


ProfileTableViewController Modifications:

  • The first couple of additions add a Passkey enrollment button.
  • The final addition adds structs, utility functions, and the below key function/handler which handles Credential Registration

registerNewPasskey():

    • Starts the registration process by calling the MyAccount API startWebAuthnEnrollment.
    • Uses the response to build an appropriate credentialRegistrationRequest.
    • Uses the credentialRegistrationRequest to create ASAuthorizationController and sets ProfileTableViewController as the delegate.  
      • Once ASAuthorizationController performs the request, iOS will handle the screens that prompt the user during registration.

authorizationController():

    • Will handle a successful/failed credential registration callback with the device.
    • Builds the appropriate response message from the returned credential.
    • Finishes the registration process by calling the MyAccount API createWebAuthnEnrollment.

 

Required Okta Org Changes

In addition to the code changes, a couple of changes will need to be made to the OIDC Application and Authorization Server in Okta.

Authorization Server

  1. Navigate to Security > API > Authorization Servers > ${Auth_Server} > Scopes.
  2. Click Add Scope.
  3. For "name", enter okta.myAccount.webauthn.manage.
  4. Optionally add a "Display name" / "Description" and keep the rest of the defaults.
  5. Click Save.

OIDC Application

  • Navigate to Applications > Applications > ${App} > Okta API Scopes.
  • Search for okta.myAccount.webauthn.manage.
  • click "Grant" if not already granted.

 

References

 

Loading
Register Passkeys in iOS with the MyAccounts API