Section 16/171 menit

16. Real Use Cases

16. Real Use Cases

Use Case 1: Banking App dengan Full Passkey Flow

Skenario: Aplikasi mobile banking yang mengganti 2FA SMS dengan passkeys — lebih aman, tidak ada SIM-swap risk, dan UX jauh lebih baik.

swift
// MARK: - Banking Auth Service

enum BankingAuthError: LocalizedError {
    case accountLocked
    case deviceNotTrusted
    case sessionExpired
    case passkeysNotSupported

    var errorDescription: String? {
        switch self {
        case .accountLocked: return "Akun dikunci. Hubungi customer service."
        case .deviceNotTrusted: return "Perangkat ini belum terdaftar."
        case .sessionExpired: return "Sesi habis. Silakan login ulang."
        case .passkeysNotSupported: return "Perangkat tidak mendukung Passkeys."
        }
    }
}

@MainActor
@Observable
final class BankingAuthService {
    var isAuthenticated = false
    var currentUser: BankUser?
    var authStep: AuthStep = .checking

    enum AuthStep {
        case checking           // cek apakah sudah ada session
        case needsLogin         // belum login
        case biometricPrompt    // menunggu Face ID
        case sessionActive      // berhasil
        case error(BankingAuthError)
    }

    private let registrationManager = PasskeyRegistrationManager()
    private let authManager = PasskeyAuthenticationManager()

    // Entry point: cek session lama atau mulai auth baru
    func initializeSession() async {
        authStep = .checking

        // Cek apakah ada token valid di Keychain
        if let savedToken = KeychainHelper.loadToken(), !savedToken.isExpired {
            // Tetap perlu verifikasi biometrik untuk transaksi sensitif
            currentUser = try? await BankingAPI.shared.validateToken(savedToken.value)
            authStep = currentUser != nil ? .sessionActive : .needsLogin
            isAuthenticated = currentUser != nil
        } else {
            authStep = .needsLogin
        }
    }

    func loginWithPasskey() async {
        authStep = .biometricPrompt
        do {
            // 1. Begin challenge
            let beginResponse = try await BankingAPI.shared.beginAuth()

            let challenge = PasskeyAssertionChallenge(
                challenge: Data(base64URLEncoded: beginResponse.challenge)!,
                relyingPartyID: "yourbank.com",
                allowedCredentialIDs: beginResponse.allowedCredentials.compactMap {
                    Data(base64URLEncoded: $0)
                }
            )

            // 2. Face ID / Touch ID
            let assertion = try await authManager.authenticate(with: challenge)

            // 3. Verifikasi server + issue token
            let authResult = try await BankingAPI.shared.finishAuth(
                credentialID: assertion.credentialID.base64URLEncodedString(),
                clientDataJSON: assertion.rawClientDataJSON.base64URLEncodedString(),
                authenticatorData: assertion.rawAuthenticatorData.base64URLEncodedString(),
                signature: assertion.signature.base64URLEncodedString()
            )

            // 4. Simpan token di Keychain
            KeychainHelper.saveToken(AuthToken(
                value: authResult.token,
                expiresAt: authResult.expiresAt
            ))

            currentUser = authResult.user
            isAuthenticated = true
            authStep = .sessionActive

        } catch BankingAuthError.accountLocked {
            authStep = .error(.accountLocked)
        } catch PasskeyError.userCancelled {
            authStep = .needsLogin
        } catch {
            authStep = .error(.sessionExpired)
        }
    }

    // Re-verify untuk transaksi sensitif (transfer, ubah PIN, dll)
    func reVerifyForSensitiveOperation() async throws {
        let beginResponse = try await BankingAPI.shared.beginAuth()
        let challenge = PasskeyAssertionChallenge(
            challenge: Data(base64URLEncoded: beginResponse.challenge)!,
            relyingPartyID: "yourbank.com",
            allowedCredentialIDs: []
        )
        let assertion = try await authManager.authenticate(with: challenge)
        _ = try await BankingAPI.shared.verifyForTransaction(
            credentialID: assertion.credentialID.base64URLEncodedString(),
            signature: assertion.signature.base64URLEncodedString(),
            authenticatorData: assertion.rawAuthenticatorData.base64URLEncodedString()
        )
    }
}

// MARK: - Placeholder Types
struct BankUser { let id: String; let name: String; let maskedAccount: String }
struct AuthToken { let value: String; let expiresAt: Date; var isExpired: Bool { expiresAt < Date() } }

class BankingAPI {
    static let shared = BankingAPI()
    func beginAuth() async throws -> (challenge: String, allowedCredentials: [String]) { ("", []) }
    func finishAuth(credentialID: String, clientDataJSON: String, authenticatorData: String, signature: String) async throws -> (token: String, expiresAt: Date, user: BankUser) {
        ("", Date(), BankUser(id: "", name: "", maskedAccount: ""))
    }
    func validateToken(_ token: String) async throws -> BankUser? { nil }
    func verifyForTransaction(credentialID: String, signature: String, authenticatorData: String) async throws {}
}

class KeychainHelper {
    static func saveToken(_ token: AuthToken) {}
    static func loadToken() -> AuthToken? { nil }
}

// MARK: - Transfer View dengan Re-Verification

struct TransferView: View {
    let authService: BankingAuthService
    @State private var amount = ""
    @State private var recipient = ""
    @State private var isVerifying = false
    @State private var transferError: String?
    @State private var showSuccess = false

    var body: some View {
        Form {
            Section("Detail Transfer") {
                TextField("Nomor Rekening Tujuan", text: $recipient)
                    .keyboardType(.numberPad)
                TextField("Jumlah (Rp)", text: $amount)
                    .keyboardType(.decimalPad)
            }

            Section {
                Button {
                    Task { await initiateTransfer() }
                } label: {
                    if isVerifying {
                        HStack {
                            ProgressView()
                            Text("Memverifikasi...")
                        }
                    } else {
                        Label("Transfer dengan Face ID", systemImage: "faceid")
                    }
                }
                .disabled(isVerifying || amount.isEmpty || recipient.isEmpty)
            }

            if let error = transferError {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.caption)
            }
        }
        .navigationTitle("Transfer Dana")
        .alert("Transfer Berhasil", isPresented: $showSuccess) {
            Button("OK") {}
        }
    }

    @MainActor
    private func initiateTransfer() async {
        isVerifying = true
        transferError = nil
        do {
            // Step 1: Re-verify dengan passkey sebelum transfer
            try await authService.reVerifyForSensitiveOperation()

            // Step 2: Execute transfer
            try await BankingAPI.shared.verifyForTransaction(
                credentialID: "", signature: "", authenticatorData: ""
            )
            showSuccess = true
        } catch PasskeyError.userCancelled {
            // user batal — tidak tampilkan error
        } catch {
            transferError = error.localizedDescription
        }
        isVerifying = false
    }
}

Use Case 2: Upgrade Password Lama ke Passkey

Skenario: App lama dengan jutaan user yang punya password — tambahkan passkeys secara bertahap tanpa memaksa user.

swift
// MARK: - Progressive Passkey Adoption

@MainActor
@Observable
final class PasskeyUpgradeManager {
    var showUpgradePrompt = false
    var upgradeState: UpgradeState = .idle

    enum UpgradeState {
        case idle, registering, success, error(String)
    }

    private let registrationManager = PasskeyRegistrationManager()

    // Dipanggil setelah login password berhasil
    func checkAndOfferUpgrade(for user: User) async {
        // Jangan tawari berulang kali — cek user preference
        let hasDeclinedRecently = UserDefaults.standard.bool(
            forKey: "passkey_upgrade_declined_\(user.id)"
        )
        guard !hasDeclinedRecently else { return }

        // Cek apakah platform mendukung passkeys (iOS 16+)
        guard #available(iOS 16.0, *) else { return }

        // Cek apakah user sudah punya passkey
        let hasPasskey = try? await AuthAPIClient.shared.userHasPasskey(userID: user.id)
        guard hasPasskey == false else { return }

        // Tawari upgrade
        showUpgradePrompt = true
    }

    func registerPasskeyForCurrentUser(_ user: User) async {
        upgradeState = .registering
        do {
            let serverChallenge = try await AuthAPIClient.shared.beginPasskeyRegistration(
                username: user.name
            )
            let challenge = PasskeyRegistrationChallenge(
                challenge: Data(base64URLEncoded: serverChallenge.challenge)!,
                userID: Data(user.id.utf8),
                userName: user.name,
                userDisplayName: user.name,
                relyingPartyID: "yourapp.com",
                relyingPartyName: "Your App"
            )
            let response = try await registrationManager.registerPasskey(with: challenge)
            try await AuthAPIClient.shared.finishPasskeyRegistration(
                credentialID: response.credentialID.base64URLEncodedString(),
                clientDataJSON: response.rawClientDataJSON.base64URLEncodedString(),
                attestationObject: response.rawAttestationObject.base64URLEncodedString()
            )
            upgradeState = .success
            showUpgradePrompt = false
        } catch PasskeyError.userCancelled {
            // User skip — simpan preference agar tidak ditanya terlalu sering
            UserDefaults.standard.set(true, forKey: "passkey_upgrade_declined_\(user.id)")
            upgradeState = .idle
            showUpgradePrompt = false
        } catch {
            upgradeState = .error(error.localizedDescription)
        }
    }
}

extension AuthAPIClient {
    func userHasPasskey(userID: String) async throws -> Bool { false }
}

// MARK: - Upgrade Prompt Sheet

struct PasskeyUpgradeSheet: View {
    let user: User
    @Bindable var manager: PasskeyUpgradeManager

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "key.fill")
                .font(.system(size: 56))
                .foregroundStyle(.blue)

            Text("Login Lebih Cepat dengan Passkey")
                .font(.title2.bold())
                .multilineTextAlignment(.center)

            VStack(alignment: .leading, spacing: 12) {
                FeatureRow(icon: "faceid", text: "Login seketika dengan Face ID")
                FeatureRow(icon: "lock.shield", text: "Tidak bisa di-phishing")
                FeatureRow(icon: "icloud", text: "Otomatis tersedia di semua perangkat Apple")
            }

            switch manager.upgradeState {
            case .idle:
                Button("Buat Passkey Sekarang") {
                    Task { await manager.registerPasskeyForCurrentUser(user) }
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)

                Button("Lewati untuk Sekarang") {
                    manager.showUpgradePrompt = false
                }
                .foregroundStyle(.secondary)

            case .registering:
                ProgressView("Membuat passkey...")

            case .success:
                Label("Passkey berhasil dibuat!", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
                    .font(.headline)

            case .error(let message):
                Text(message)
                    .foregroundStyle(.red)
                    .font(.caption)
                Button("Coba Lagi") {
                    Task { await manager.registerPasskeyForCurrentUser(user) }
                }
            }
        }
        .padding()
        .presentationDetents([.medium])
    }
}

struct FeatureRow: View {
    let icon: String
    let text: String

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: icon)
                .foregroundStyle(.blue)
                .frame(width: 24)
            Text(text)
        }
    }
}