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