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