Section 5/171 menit

5. Registrasi Passkey

5. Registrasi Passkey

Registrasi passkey dilakukan satu kali per user per device/account. Setelah terdaftar, passkey tersedia di semua perangkat Apple user via iCloud Keychain.

Model dan Helper

swift
// MARK: - Models

struct PasskeyRegistrationChallenge {
    let challenge: Data          // random bytes dari server
    let userID: Data             // user handle (opaque, bukan username)
    let userName: String         // display name untuk UI picker
    let userDisplayName: String  // nama lengkap untuk UI
    let relyingPartyID: String   // "yourdomain.com"
    let relyingPartyName: String // "Your App Name"
}

struct PasskeyRegistrationResponse {
    let credentialID: Data
    let rawClientDataJSON: Data
    let rawAttestationObject: Data
}

// MARK: - Registration Manager

import AuthenticationServices
import os

@MainActor
final class PasskeyRegistrationManager: NSObject {
    private var authController: ASAuthorizationController?
    private var registrationContinuation: CheckedContinuation<PasskeyRegistrationResponse, Error>?

    private let logger = Logger(subsystem: "com.yourapp", category: "Passkey")

    func registerPasskey(with challenge: PasskeyRegistrationChallenge) async throws -> PasskeyRegistrationResponse {
        return try await withCheckedThrowingContinuation { continuation in
            self.registrationContinuation = continuation

            let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
                relyingPartyIdentifier: challenge.relyingPartyID
            )

            let request = provider.createCredentialRegistrationRequest(
                challenge: challenge.challenge,
                name: challenge.userName,
                userID: challenge.userID
            )

            // Konfigurasi attestation preference
            request.attestationPreference = .none  // atau .direct untuk enterprise

            // User verification: required = wajib biometrik/PIN
            request.userVerificationPreference = .required

            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()

            self.authController = controller
        }
    }
}

// MARK: - ASAuthorizationControllerDelegate

extension PasskeyRegistrationManager: ASAuthorizationControllerDelegate {
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        guard let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
            registrationContinuation?.resume(throwing: PasskeyError.unexpectedCredentialType)
            return
        }

        logger.info("Passkey registered: credentialID=\(credential.credentialID.base64URLEncodedString())")

        let response = PasskeyRegistrationResponse(
            credentialID: credential.credentialID,
            rawClientDataJSON: credential.rawClientDataJSON,
            rawAttestationObject: credential.rawAttestationObject ?? Data()
        )

        registrationContinuation?.resume(returning: response)
        registrationContinuation = nil
    }

    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        // User cancel adalah kasus normal, bukan error fatal
        if let authError = error as? ASAuthorizationError,
           authError.code == .canceled {
            logger.debug("Passkey registration cancelled by user")
            registrationContinuation?.resume(throwing: PasskeyError.userCancelled)
        } else {
            logger.error("Passkey registration failed: \(error.localizedDescription)")
            registrationContinuation?.resume(throwing: error)
        }
        registrationContinuation = nil
    }
}

// MARK: - ASAuthorizationControllerPresentationContextProviding

extension PasskeyRegistrationManager: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        // Di SwiftUI, ambil window dari current scene
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let window = windowScene.windows.first else {
            return UIWindow()
        }
        return window
    }
}

// MARK: - Errors

enum PasskeyError: LocalizedError {
    case unexpectedCredentialType
    case userCancelled
    case invalidChallenge
    case serverVerificationFailed

    var errorDescription: String? {
        switch self {
        case .unexpectedCredentialType: return "Tipe credential tidak dikenali"
        case .userCancelled: return "Registrasi dibatalkan"
        case .invalidChallenge: return "Challenge dari server tidak valid"
        case .serverVerificationFailed: return "Verifikasi server gagal"
        }
    }
}

// MARK: - Data Extension untuk Base64URL

extension Data {
    func base64URLEncodedString() -> String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }

    init?(base64URLEncoded string: String) {
        var base64 = string
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        let remainder = base64.count % 4
        if remainder > 0 {
            base64 += String(repeating: "=", count: 4 - remainder)
        }
        self.init(base64Encoded: base64)
    }
}

Penggunaan Registrasi

swift
// MARK: - Penggunaan di ViewModel

@MainActor
@Observable
final class RegistrationViewModel {
    var state: RegistrationState = .idle
    private let registrationManager = PasskeyRegistrationManager()
    private let authAPI: AuthAPIClient

    enum RegistrationState {
        case idle, loading, success, error(String)
    }

    init(authAPI: AuthAPIClient) {
        self.authAPI = authAPI
    }

    func registerPasskey(for username: String) async {
        state = .loading
        do {
            // 1. Minta challenge dari server
            let serverChallenge = try await authAPI.beginPasskeyRegistration(username: username)

            let challenge = PasskeyRegistrationChallenge(
                challenge: Data(base64URLEncoded: serverChallenge.challenge)!,
                userID: Data(serverChallenge.userID.utf8),
                userName: username,
                userDisplayName: serverChallenge.displayName,
                relyingPartyID: "yourdomain.com",
                relyingPartyName: "Your App"
            )

            // 2. Tampilkan UI passkey ke user
            let response = try await registrationManager.registerPasskey(with: challenge)

            // 3. Kirim bukti ke server untuk verifikasi
            try await authAPI.finishPasskeyRegistration(
                credentialID: response.credentialID.base64URLEncodedString(),
                clientDataJSON: response.rawClientDataJSON.base64URLEncodedString(),
                attestationObject: response.rawAttestationObject.base64URLEncodedString()
            )

            state = .success
        } catch PasskeyError.userCancelled {
            state = .idle  // user cancel — tidak perlu tampilkan error
        } catch {
            state = .error(error.localizedDescription)
        }
    }
}