🎯 Learning Path:

  1. Complete REL-ID Complete Activation & Login Flow Codelab
  2. Complete REL-ID Additional Device Activation Flow With Notifications Codelab
  3. You are here β†’ Step-Up Authentication for Notification Actions

Welcome to the REL-ID Step-Up Authentication with Notifications codelab! This tutorial builds upon your existing MFA implementation to add secure re-authentication for sensitive notification actions using REL-ID SDK's step-up authentication capabilities.

What You'll Build

In this codelab, you'll enhance your existing notification application with:

What You'll Learn

By completing this codelab, you'll master:

  1. Step-Up Authentication Concept: Understanding when and why re-authentication is required for notification actions
  2. Authentication Method Selection: How SDK determines password vs LDA based on user's login method
  3. Screen-Level Event Handlers: Implementing callback preservation pattern for challengeMode 3
  4. StepUpPasswordViewController: Building modal password dialog with attempts counter
  5. LDA Fallback Handling: Managing biometric cancellation with automatic password fallback
  6. Keyboard Optimization: Implementing ScrollView to prevent keyboard from hiding buttons
  7. Error State Management: Auto-clearing password fields on authentication failure
  8. Critical Status Code Handling: Displaying alerts before SDK triggers logout for status codes 110 and 153
  9. Error Code Management: Managing LDA cancellation (error code 131) with retry

Prerequisites

Before starting this codelab, ensure you have:

Get the Code from GitHub

The code to get started can be found in a GitHub repository.

You can clone the repository using the following command:

git clone https://github.com/uniken-public/codelab-ios.git

Navigate to the relid-step-up-auth-notification folder in the repository you cloned earlier

Codelab Architecture Overview

This codelab extends your notification application with three core step-up authentication components:

  1. StepUpPasswordViewController: Modal password dialog with attempts counter, error display, and keyboard management
  2. Screen-Level Event Handler: getPassword callback preservation for challengeMode 3 in GetNotificationsViewController
  3. Enhanced Error Handling: onUpdateNotification event handler for critical errors (110, 153, 131)

Before implementing step-up authentication, let's understand the key SDK events and APIs that power the notification action re-authentication workflow.

What is Step-Up Authentication?

Step-up authentication is a security mechanism that requires users to re-authenticate when performing sensitive operations, even if they're already logged in. For notification actions, this adds an extra layer of security.

User Logged In β†’ Acts on Notification β†’ updateNotification() API β†’
SDK Checks if Action Requires Auth β†’ Step-Up Authentication Required β†’
Password or LDA Verification β†’ onUpdateNotification Event β†’ Success/Failure

Step-Up Authentication Event Flow

The step-up authentication process follows this event-driven pattern:

User Taps Notification Action β†’ updateNotification(uuid, action) API Called β†’
SDK Determines Auth Method (Based on Login Method + Enrolled Credentials) β†’

IF Password Required:
  SDK Triggers getPassword Event (challengeMode=3) β†’
  StepUpPasswordViewController Displays β†’ User Enters Password β†’
  setPassword(password, 3) API β†’ onUpdateNotification Event

IF LDA Required:
  SDK Prompts Biometric Internally β†’ User Authenticates β†’
  onUpdateNotification Event (No getPassword event)

IF LDA Cancelled AND Password Enrolled:
  SDK Directly Triggers getPassword Event (challengeMode=3) β†’ No Error, Seamless Fallback β†’
  StepUpPasswordViewController Displays β†’ User Enters Password β†’
  setPassword(password, 3) API β†’ onUpdateNotification Event

IF LDA Cancelled AND Password NOT Enrolled:
  onUpdateNotification Event with error code 131 β†’
  Show Alert "Authentication Cancelled" β†’ User Can Retry LDA

Challenge Mode 3 - RDNA_OP_AUTHORIZE_NOTIFICATION

Challenge Mode 3 is specifically for notification action authorization:

Challenge Mode

Purpose

User Action Required

Screen

Trigger

challengeMode = 0

Verify existing password

Enter password to login

VerifyPasswordViewController

User login attempt

challengeMode = 1

Set new password

Create password during activation

SetPasswordViewController

First-time activation

challengeMode = 2

Update password (user-initiated)

Provide current + new password

UpdatePasswordViewController

User taps "Update Password"

challengeMode = 3

Authorize notification action

Re-enter password for verification

StepUpPasswordViewController (Modal)

updateNotification() requires auth

challengeMode = 4

Update expired password

Provide current + new password

UpdateExpiryPasswordViewController

Server detects expired password

Authentication Method Selection Logic

Important: The SDK automatically determines which authentication method to use based on:

  1. How the user logged in (Password or LDA)
  2. What authentication methods are enrolled for the app

Login Method

Enrolled Methods

Step-Up Authentication Method

SDK Behavior

Password

Password only

Password

SDK triggers getPassword with challengeMode 3

LDA

LDA only

LDA

SDK prompts biometric internally, no getPassword event

Password

Both Password & LDA

Password

SDK triggers getPassword with challengeMode 3

LDA

Both Password & LDA

LDA (with Password fallback)

SDK attempts LDA first. If user cancels, SDK directly triggers getPassword (no error)

Core Step-Up Authentication Events

The REL-ID SDK triggers these main events during step-up authentication:

Event Type

Description

User Action Required

getPassword (challengeMode=3)

Password required for notification action authorization

User re-enters password for verification

onUpdateNotification

Notification action result (success/failure/auth errors)

System handles response and displays result

Error Codes and Status Handling

Step-up authentication can fail with these critical errors:

Error/Status Code

Type

Meaning

SDK Behavior

Action Required

statusCode = 100

Status

Success - action completed

Continue normal flow

Display success message

statusCode = 110

Status

Password expired during action

SDK triggers logout

Show alert BEFORE logout

statusCode = 153

Status

Attempts exhausted

SDK triggers logout

Show alert BEFORE logout

error code = 131

Error

LDA cancelled and Password NOT enrolled

No fallback available

Show alert, allow retry

UpdateNotification API Pattern

The updateNotification SDK method is used to submit user actions on notifications:

// RDNAService.swift - Notification action submission

/// Update Notification - Submit user action response for notification
/// After successful call, SDK triggers onUpdateNotification callback with update status.
/// If action requires authentication, SDK will trigger:
/// - getPassword event with challengeMode 3 (if password required)
/// - Biometric prompt internally (if LDA required)
/// - Parameters:
///   - notificationID: The notification UUID to update
///   - response: The action response value selected by user
/// - Returns: RDNAError indicating success or failure
func updateNotification(_ notificationID: String, response: String) -> RDNAError {
    print("RDNAService - Updating notification: \(notificationID) with response: \(response)")
    let error = rdna.updateNotification(notificationID, withResponse: response)

    if error.longErrorCode == 0 {
        print("RDNAService - UpdateNotification request successful, awaiting onUpdateNotification callback")
    } else {
        print("RDNAService - UpdateNotification failed: \(error.errorString)")
    }

    return error
}

Let's create the modal password view controller that will be displayed when step-up authentication is required.

Understanding the View Controller Requirements

The StepUpPasswordViewController needs to:

Create the StepUpPasswordViewController

Create a new Swift file for the password modal view controller:

// StepUpPasswordViewController.swift

/**
 * Step-Up Password View Controller
 *
 * Modal dialog for step-up authentication during notification actions.
 * Handles challengeMode = 3 (RDNA_OP_AUTHORIZE_NOTIFICATION) when the SDK
 * requires password verification before allowing a notification action.
 *
 * Features:
 * - Password input with visibility toggle
 * - Attempts left counter with color-coding
 * - Error message display
 * - Loading state during authentication
 * - Notification context display (title)
 * - Auto-focus on password field
 * - Auto-clear password on error
 * - Keyboard management with ScrollView
 *
 * Usage:
 * Present modally over current view controller when SDK triggers
 * getPassword with challengeMode = 3
 */

import UIKit
import RELID

/// Step-Up Password View Controller
/// Modal dialog for password re-authentication when SDK requires step-up auth (challengeMode = 3)
class StepUpPasswordViewController: UIViewController {

    // MARK: - IBOutlets

    @IBOutlet weak var overlayView: UIView!
    @IBOutlet weak var modalContainer: UIView!
    @IBOutlet weak var headerView: UIView!
    @IBOutlet weak var headerTitle: UILabel!
    @IBOutlet weak var headerSubtitle: UILabel!
    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var contentView: UIView!
    @IBOutlet weak var notificationContainer: UIView!
    @IBOutlet weak var notificationTitleLabel: UILabel!
    @IBOutlet weak var attemptsContainer: UIView!
    @IBOutlet weak var attemptsLabel: UILabel!
    @IBOutlet weak var errorContainer: UIView!
    @IBOutlet weak var errorContainerHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var errorLabel: UILabel!
    @IBOutlet weak var inputLabel: UILabel!
    @IBOutlet weak var passwordInputWrapper: UIView!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var visibilityButton: UIButton!
    @IBOutlet weak var submitButton: UIButton!
    @IBOutlet weak var submitButtonLabel: UILabel!
    @IBOutlet weak var submitLoadingIndicator: UIActivityIndicatorView!
    @IBOutlet weak var cancelButton: UIButton!

    // MARK: - Properties

    /// Notification title to display in dialog
    var notificationTitle: String = ""

    /// Number of attempts left for password entry
    var attemptsLeft: Int = 3

    /// Error message to display (if any)
    var errorMessage: String? = nil

    /// Whether password submission is in progress
    var isSubmitting: Bool = false {
        didSet {
            if isViewLoaded {
                updateSubmittingState()
            }
        }
    }

    /// Closure called when user submits password
    var onSubmitPassword: ((String) -> Void)?

    /// Closure called when user cancels dialog
    var onCancel: (() -> Void)?

    private var isPasswordVisible: Bool = false
    private var isDismissing: Bool = false

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        print("StepUpPasswordViewController - viewDidLoad called")
        print("  notificationTitle: \(notificationTitle)")
        print("  attemptsLeft: \(attemptsLeft)")
        print("  isSubmitting: \(isSubmitting)")

        setupUI()
        updateUIState()

        print("StepUpPasswordViewController - UI setup complete")

        // Auto-focus password field after a short delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            self.passwordTextField.becomeFirstResponder()
        }
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        print("StepUpPasswordViewController - viewDidAppear called")

        // Animate modal appearance
        modalContainer.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
        modalContainer.alpha = 0
        overlayView.alpha = 0

        print("StepUpPasswordViewController - Starting modal animation")
        UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseOut) {
            self.modalContainer.transform = .identity
            self.modalContainer.alpha = 1
            self.overlayView.alpha = 1
        }
    }

    // MARK: - Setup

    private func setupUI() {
        // Overlay (semi-transparent background)
        overlayView.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.75)

        // Modal container
        modalContainer.backgroundColor = .white
        modalContainer.layer.cornerRadius = 16
        modalContainer.layer.shadowColor = UIColor.black.cgColor
        modalContainer.layer.shadowOffset = CGSize(width: 0, height: 4)
        modalContainer.layer.shadowOpacity = 0.3
        modalContainer.layer.shadowRadius = 8

        // Header
        headerView.backgroundColor = UIColor(hex: "#3b82f6")
        headerView.layer.cornerRadius = 16
        headerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]

        headerTitle.text = "πŸ” Authentication Required"
        headerTitle.font = UIFont.boldSystemFont(ofSize: 20)
        headerTitle.textColor = .white
        headerTitle.textAlignment = .center

        headerSubtitle.text = "Please verify your password to authorize this action"
        headerSubtitle.font = UIFont.systemFont(ofSize: 14)
        headerSubtitle.textColor = UIColor(hex: "#dbeafe")
        headerSubtitle.textAlignment = .center
        headerSubtitle.numberOfLines = 0

        // Scroll view
        scrollView.showsVerticalScrollIndicator = false
        scrollView.keyboardDismissMode = .interactive

        // Notification container
        notificationContainer.backgroundColor = UIColor(hex: "#f0f9ff")
        notificationContainer.layer.cornerRadius = 8
        notificationContainer.layer.borderWidth = 0
        notificationContainer.layer.borderColor = UIColor(hex: "#3b82f6")?.cgColor
        // Add left border using layer
        let leftBorder = CALayer()
        leftBorder.backgroundColor = UIColor(hex: "#3b82f6")?.cgColor
        leftBorder.frame = CGRect(x: 0, y: 0, width: 4, height: notificationContainer.bounds.height)
        leftBorder.name = "leftBorder"
        notificationContainer.layer.addSublayer(leftBorder)

        notificationTitleLabel.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
        notificationTitleLabel.textColor = UIColor(hex: "#1e40af")
        notificationTitleLabel.textAlignment = .center
        notificationTitleLabel.numberOfLines = 0

        // Attempts container
        attemptsContainer.layer.cornerRadius = 8
        attemptsLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
        attemptsLabel.textAlignment = .center

        // Error container
        errorContainer.backgroundColor = UIColor(hex: "#fef2f2")
        errorContainer.layer.cornerRadius = 8
        errorContainer.layer.borderWidth = 0
        errorContainer.layer.borderColor = UIColor(hex: "#dc2626")?.cgColor
        // Add left border
        let errorLeftBorder = CALayer()
        errorLeftBorder.backgroundColor = UIColor(hex: "#dc2626")?.cgColor
        errorLeftBorder.frame = CGRect(x: 0, y: 0, width: 4, height: errorContainer.bounds.height)
        errorLeftBorder.name = "leftBorder"
        errorContainer.layer.addSublayer(errorLeftBorder)

        errorLabel.font = UIFont.systemFont(ofSize: 14)
        errorLabel.textColor = UIColor(hex: "#7f1d1d")
        errorLabel.textAlignment = .center
        errorLabel.numberOfLines = 0

        // Input label
        inputLabel.text = "Password"
        inputLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
        inputLabel.textColor = UIColor(hex: "#374151")

        // Password input wrapper
        passwordInputWrapper.layer.borderWidth = 1
        passwordInputWrapper.layer.borderColor = UIColor(hex: "#d1d5db")?.cgColor
        passwordInputWrapper.layer.cornerRadius = 8
        passwordInputWrapper.backgroundColor = .white

        // Password text field
        passwordTextField.isSecureTextEntry = true
        passwordTextField.placeholder = "Enter your password"
        passwordTextField.font = UIFont.systemFont(ofSize: 16)
        passwordTextField.textColor = UIColor(hex: "#1f2937")
        passwordTextField.autocapitalizationType = .none
        passwordTextField.autocorrectionType = .no
        passwordTextField.returnKeyType = .done
        passwordTextField.delegate = self

        // Visibility button
        visibilityButton.setTitle("πŸ™ˆ", for: .normal)
        visibilityButton.titleLabel?.font = UIFont.systemFont(ofSize: 20)

        // Submit button
        submitButton.backgroundColor = UIColor(hex: "#3b82f6")
        submitButton.layer.cornerRadius = 8
        submitButton.layer.shadowColor = UIColor.black.cgColor
        submitButton.layer.shadowOffset = CGSize(width: 0, height: 2)
        submitButton.layer.shadowOpacity = 0.1
        submitButton.layer.shadowRadius = 2

        submitButtonLabel.text = "Verify & Continue"
        submitButtonLabel.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
        submitButtonLabel.textColor = .white

        submitLoadingIndicator.hidesWhenStopped = true

        // Cancel button
        cancelButton.backgroundColor = UIColor(hex: "#f3f4f6")
        cancelButton.layer.cornerRadius = 8
        cancelButton.setTitle("Cancel", for: .normal)
        cancelButton.setTitleColor(UIColor(hex: "#6b7280"), for: .normal)
        cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // Update left border heights
        if let leftBorder = notificationContainer.layer.sublayers?.first(where: { $0.name == "leftBorder" }) {
            leftBorder.frame = CGRect(x: 0, y: 0, width: 4, height: notificationContainer.bounds.height)
        }

        if let errorLeftBorder = errorContainer.layer.sublayers?.first(where: { $0.name == "leftBorder" }) {
            errorLeftBorder.frame = CGRect(x: 0, y: 0, width: 4, height: errorContainer.bounds.height)
        }
    }

    private func updateUIState() {
        // Notification title
        notificationTitleLabel.text = notificationTitle

        // Attempts counter
        let shouldShowAttempts = attemptsLeft <= 3
        attemptsContainer.isHidden = !shouldShowAttempts

        if shouldShowAttempts {
            let color = getAttemptsColor()
            attemptsContainer.backgroundColor = color.withAlphaComponent(0.2)
            attemptsLabel.textColor = color

            let attemptWord = attemptsLeft == 1 ? "attempt" : "attempts"
            attemptsLabel.text = "\(attemptsLeft) \(attemptWord) remaining"
        }

        // Error message
        if let error = errorMessage, !error.isEmpty {
            errorContainer.isHidden = false
            errorContainerHeightConstraint.constant = 60
            errorLabel.text = error

            // Clear password field when error is shown
            passwordTextField.text = ""
        } else {
            errorContainer.isHidden = true
            errorContainerHeightConstraint.constant = 0
        }

        // Button states
        updateSubmittingState()
    }

    private func updateSubmittingState() {
        let password = passwordTextField.text ?? ""
        let canSubmit = !password.trimmingCharacters(in: .whitespaces).isEmpty && !isSubmitting

        submitButton.isEnabled = canSubmit
        submitButton.alpha = canSubmit ? 1.0 : 0.6

        cancelButton.isEnabled = !isSubmitting
        cancelButton.alpha = isSubmitting ? 0.6 : 1.0

        passwordTextField.isEnabled = !isSubmitting
        visibilityButton.isEnabled = !isSubmitting

        if isSubmitting {
            submitButtonLabel.text = "Verifying..."
            submitLoadingIndicator.startAnimating()
        } else {
            submitButtonLabel.text = "Verify & Continue"
            submitLoadingIndicator.stopAnimating()
        }
    }

    private func getAttemptsColor() -> UIColor {
        if attemptsLeft == 1 {
            return UIColor(hex: "#dc2626") ?? .red // Red
        } else if attemptsLeft == 2 {
            return UIColor(hex: "#f59e0b") ?? .orange // Orange
        } else {
            return UIColor(hex: "#10b981") ?? .green // Green
        }
    }

    // MARK: - Actions

    @IBAction func visibilityButtonTapped(_ sender: UIButton) {
        isPasswordVisible.toggle()
        passwordTextField.isSecureTextEntry = !isPasswordVisible
        visibilityButton.setTitle(isPasswordVisible ? "πŸ‘οΈ" : "πŸ™ˆ", for: .normal)
    }

    @IBAction func submitButtonTapped(_ sender: UIButton) {
        handleSubmit()
    }

    @IBAction func cancelButtonTapped(_ sender: UIButton) {
        dismiss()
    }

    @IBAction func overlayTapped(_ sender: UITapGestureRecognizer) {
        if !isSubmitting {
            dismiss()
        }
    }

    private func handleSubmit() {
        guard let password = passwordTextField.text?.trimmingCharacters(in: .whitespaces),
              !password.isEmpty,
              !isSubmitting else {
            return
        }

        onSubmitPassword?(password)
    }

    // MARK: - Public Methods

    /// Update dialog state from parent view controller
    func updateState(attemptsLeft: Int, errorMessage: String?, isSubmitting: Bool) {
        self.attemptsLeft = attemptsLeft
        self.errorMessage = errorMessage
        self.isSubmitting = isSubmitting
        updateUIState()
    }

    /// Dismiss modal with animation
    func dismiss() {
        guard !isDismissing else {
            print("StepUpPasswordViewController - Already dismissing, ignoring")
            return
        }

        isDismissing = true
        passwordTextField.resignFirstResponder()

        UIView.animate(withDuration: 0.2, animations: {
            self.modalContainer.alpha = 0
            self.overlayView.alpha = 0
        }) { _ in
            self.dismiss(animated: false) {
                self.onCancel?()
            }
        }
    }
}

// MARK: - UITextFieldDelegate

extension StepUpPasswordViewController: UITextFieldDelegate {
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        handleSubmit()
        return true
    }

    func textFieldDidChangeSelection(_ textField: UITextField) {
        // Update button state as user types
        updateSubmittingState()
    }
}

The following image showcases the screen from the sample application:

Step-Up Authentication Screen

Now let's implement the screen-level event handler that will intercept getPassword events with challengeMode = 3 and display our step-up password dialog.

Understanding Callback Preservation Pattern

The callback preservation pattern ensures our screen-level handler doesn't break existing global handlers:

// Preserve original handler
originalGetPasswordHandler = RDNADelegateManager.shared.onGetPassword

// Set new handler that chains with original
RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
    if challengeMode.rawValue == 3 {
        // Handle challengeMode 3 in screen
        self?.handleScreenSpecificLogic(...)
    } else {
        // Pass other modes to original handler
        self?.originalGetPasswordHandler?(userID, challengeMode, attemptsLeft, response, error)
    }
}

// Cleanup: restore original handler when screen unmounts
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    RDNADelegateManager.shared.onGetPassword = originalGetPasswordHandler
}

Enhance GetNotificationsViewController with Step-Up Auth State

Add step-up authentication state properties to your GetNotificationsViewController:

// GetNotificationsViewController.swift (additions)

class GetNotificationsViewController: UIViewController {

    // ... existing properties ...

    // Step-Up Authentication State (NEW - for challengeMode 3)
    private var showStepUpAuth = false
    private var stepUpAttemptsLeft: Int = 3
    private var stepUpErrorMessage: String? = nil
    private var stepUpSubmitting = false
    private var stepUpModal: StepUpPasswordViewController?
    private var originalGetPasswordHandler: ((_ userID: String, _ challengeMode: RDNAChallengeOpMode, _ attemptsLeft: Int, _ response: RDNAChallengeResponse, _ error: RDNAError) -> Void)?

    // ... existing code ...
}

Implement getPassword Handler for ChallengeMode 3

Add the handler that intercepts getPassword events with challengeMode = 3:

// GetNotificationsViewController.swift (addition)

/// Handle getPassword event with callback preservation for challengeMode 3
/// Implements the screen-level handler pattern
private func handleGetPasswordStepUp(
    userID: String,
    challengeMode: RDNAChallengeOpMode,
    attemptsLeft: Int,
    response: RDNAChallengeResponse,
    error: RDNAError
) {
    print("GetNotificationsViewController - getPassword called, mode: \(challengeMode.rawValue)")

    // Only handle challengeMode 3 (RDNA_OP_AUTHORIZE_NOTIFICATION) - step-up auth
    if challengeMode.rawValue != 3 {
        // For other challenge modes, call the original handler
        originalGetPasswordHandler?(userID, challengeMode, attemptsLeft, response, error)
        return
    }

    // STEP-UP AUTH: challengeMode = 3
    print("GetNotificationsViewController - Step-up auth required, attempts left: \(attemptsLeft)")

    // Update step-up state
    stepUpAttemptsLeft = attemptsLeft
    stepUpSubmitting = false

    // Check for errors
    let statusCode = response.status.statusCode
    let statusMessage = response.status.statusMessage

    if statusCode != 100 {  // RESP_STATUS_SUCCESS
        stepUpErrorMessage = !statusMessage.isEmpty ? statusMessage : "Authentication failed. Please try again."
        print("GetNotificationsViewController - Step-up auth error: \(stepUpErrorMessage ?? "")")
    } else {
        stepUpErrorMessage = nil
    }

    // Show step-up password modal on top
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
        guard let self = self else { return }

        if !self.showStepUpAuth {
            self.showStepUpAuth = true
            self.showStepUpModal()
        } else {
            // Modal already showing, just update its state
            self.updateStepUpModalState()
        }
    }
}

Set Up Event Handler with Cleanup

Wire up the event handler with proper lifecycle management:

// GetNotificationsViewController.swift (additions)

private func setupEventHandlers() {
    // Setup SDK event handlers
    RDNADelegateManager.shared.onGetNotifications = { [weak self] status in
        self?.handleNotificationsReceived(status: status)
    }

    RDNADelegateManager.shared.onUpdateNotification = { [weak self] status in
        self?.handleUpdateNotificationReceived(status: status)
    }

    // STEP-UP AUTH: Preserve original getPassword handler and chain for challengeMode 3
    originalGetPasswordHandler = RDNADelegateManager.shared.onGetPassword

    RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
        self?.handleGetPasswordStepUp(userID: userID, challengeMode: challengeMode, attemptsLeft: attemptsLeft, response: response, error: error)
    }
}

private func cleanupEventHandlers() {
    // Cleanup SDK event handlers
    RDNADelegateManager.shared.onGetNotifications = nil
    RDNADelegateManager.shared.onUpdateNotification = nil

    // STEP-UP AUTH: Restore original getPassword handler
    RDNADelegateManager.shared.onGetPassword = originalGetPasswordHandler
    originalGetPasswordHandler = nil
}

override func viewDidLoad() {
    super.viewDidLoad()
    setupUI()
    setupTableView()
    MenuViewController.setupSideMenu(for: self)
    setupEventHandlers()

    // Auto-load notifications on screen mount
    loadNotifications()
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    // Cleanup event handlers when screen unmounts
    cleanupEventHandlers()
}

Now let's implement the password submission handler that will be called when the user submits their password from the StepUpPasswordViewController.

Handle Password Submission

Add the handler that submits the password to the SDK:

// GetNotificationsViewController.swift (addition)

/// Handle password submission from step-up modal
private func handleStepUpPasswordSubmit(password: String) {
    print("GetNotificationsViewController - Submitting step-up password")

    stepUpSubmitting = true
    updateStepUpModalState()

    // Call setPassword with challengeMode 3 (RDNA_OP_AUTHORIZE_NOTIFICATION)
    let error = RDNAService.shared.setPassword(password, challengeMode: RDNAChallengeOpMode(rawValue: 3)!)

    if error.longErrorCode != 0 {
        print("GetNotificationsViewController - setPassword error: \(error.errorString)")
        stepUpSubmitting = false
        stepUpErrorMessage = error.errorString
        updateStepUpModalState()
    }
    // Success will trigger getPassword again (if wrong) or onUpdateNotification (if correct)
}

/// Handle step-up modal cancellation
private func handleStepUpCancel() {
    print("GetNotificationsViewController - Step-up auth cancelled")

    // Reset action modal loading state so user can try again
    actionLoading = false
    actionModal?.isActionLoading = false

    dismissStepUpModal()
}

Store Notification Context on Action Selection

When user selects an action, store the notification context for the step-up dialog:

// GetNotificationsViewController.swift (modification)

private func handleActionPress(action: NotificationItem.NotificationAction, notification: NotificationItem) {
    if actionLoading { return }

    actionLoading = true
    actionModal?.isActionLoading = true
    print("GetNotificationsViewController - Action pressed: \(action.action) for notification: \(notification.notificationUUID)")

    // Store notification context for potential step-up auth (already stored in selectedNotification)
    // The selectedNotification property is set when showActionModal is called

    let error = RDNAService.shared.updateNotification(notification.notificationUUID, response: action.action)

    if error.longErrorCode != 0 {
        actionLoading = false
        actionModal?.isActionLoading = false
        showAlert(title: "Update Failed", message: error.errorString)
    }
    // Success will be handled by handleUpdateNotificationReceived
}

Now let's implement comprehensive error handling for the onUpdateNotification event, including critical errors that require alerts before logout.

Implement Enhanced UpdateNotification Handler

Add the handler that processes onUpdateNotification events with proper error handling:

// GetNotificationsViewController.swift (addition)

/// Handle update notification response from onUpdateNotification event
/// ENHANCED: Handles step-up auth critical errors (110, 153, 131)
private func handleUpdateNotificationReceived(status: RDNAStatusUpdateNotification) {
    print("GetNotificationsViewController - Received update notification event")
    actionLoading = false
    actionModal?.isActionLoading = false
    stepUpSubmitting = false

    // STEP-UP AUTH: Check for LDA cancelled (error code 131)
    // This only occurs when LDA is cancelled AND Password is NOT enrolled
    // If Password IS enrolled, SDK directly triggers getPassword (no error)
    let error = status.error
    if error.longErrorCode == 131 {
        print("GetNotificationsViewController - LDA cancelled, Password not enrolled")
        dismissStepUpModal()

        showAlert(
            title: "Authentication Cancelled",
            message: "Local device authentication was cancelled. Please try again."
        ) {
            // Keep action modal open to allow user to retry
        }
        return
    }

    // Check for other SDK errors
    if error.longErrorCode != 0 {
        let errorMessage = error.errorString
        print("GetNotificationsViewController - Update error: \(errorMessage)")

        // Dismiss modals FIRST, then show alert
        dismissStepUpModal()
        dismissActionModal()

        // Show alert after a short delay (let modals dismiss first)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
            self.showAlert(title: "Update Failed", message: errorMessage) {
                // Refresh notifications after error
                self.loadNotifications()
            }
        }
        return
    }

    // Check response status
    let responseStatus = status.status
    let statusCode = responseStatus.statusCode.rawValue

    if statusCode == 0 || statusCode == 100 {  // RESP_STATUS_SUCCESS
        print("GetNotificationsViewController - Update successful")
        // Success - dismiss modals and show success alert
        dismissStepUpModal()
        dismissActionModal()

        // Refresh notifications
        loadNotifications()

        // Show success alert after a short delay (let modals dismiss first)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
            self.showAlert(
                title: "Success",
                message: responseStatus.message ?? "Action completed successfully"
            )
        }

    } else if statusCode == 110 || statusCode == 153 {
        // STEP-UP AUTH: Critical errors (110=password expired, 153=attempts exhausted) - show alert BEFORE SDK logout
        print("GetNotificationsViewController - Critical error: \(statusCode)")
        let statusMessage = responseStatus.message

        // Dismiss modals FIRST
        dismissStepUpModal()
        dismissActionModal()

        // Show alert BEFORE SDK triggers logout
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
            self.showAlert(
                title: "Authentication Failed",
                message: statusMessage ?? "Authentication failed"
            ) {
                print("GetNotificationsViewController - Waiting for SDK to trigger logout flow")
                // SDK will automatically trigger onUserLoggedOff event
                // AppCoordinator will handle navigation to home
            }
        }
    } else {
        let statusMessage = responseStatus.message ?? "Update failed"
        print("GetNotificationsViewController - Update status error: \(statusMessage)")

        // Dismiss modals FIRST, then show alert
        dismissStepUpModal()
        dismissActionModal()

        // Show alert after a short delay (let modals dismiss first)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
            self.showAlert(title: "Update Failed", message: statusMessage) {
                // Refresh notifications after alert
                self.loadNotifications()
            }
        }
    }
}

Understanding Error Code Flow

The error handling flow for different scenarios:

LDA Cancelled (Password IS enrolled):
  User cancels biometric β†’ SDK directly triggers getPassword (challengeMode 3) β†’
  No error, seamless fallback β†’ StepUpPasswordViewController shows

LDA Cancelled (Password NOT enrolled):
  User cancels biometric β†’ onUpdateNotification (error code 131) β†’
  Show alert "Authentication Cancelled" β†’ User can retry LDA

Password Expired (statusCode 110):
  Password authentication fails β†’ onUpdateNotification (statusCode 110) β†’
  Show alert "Authentication Failed - Password Expired" β†’
  SDK triggers onUserLoggedOff β†’ AppCoordinator navigates to home

Attempts Exhausted (statusCode 153):
  Too many failed attempts β†’ onUpdateNotification (statusCode 153) β†’
  Show alert "Authentication Failed - Attempts Exhausted" β†’
  SDK triggers onUserLoggedOff β†’ AppCoordinator navigates to home

Success (statusCode 100):
  Authentication successful β†’ onUpdateNotification (statusCode 100) β†’
  Show alert "Success" β†’ Navigate to dashboard

Now let's add the methods to show and dismiss the step-up password modal.

Add Modal Management Methods

Add methods to display and manage the step-up password modal:

// GetNotificationsViewController.swift (additions)

/// Show step-up password modal
private func showStepUpModal() {
    print("GetNotificationsViewController - showStepUpModal called")

    guard let notification = selectedNotification else {
        print("GetNotificationsViewController - No selected notification for step-up auth")
        return
    }

    print("GetNotificationsViewController - Selected notification: \(notification.subject)")

    // Load StepUpPasswordViewController from Storyboard
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    print("GetNotificationsViewController - Instantiating StepUpPasswordViewController from Storyboard")

    guard let modal = storyboard.instantiateViewController(withIdentifier: "StepUpPasswordViewController") as? StepUpPasswordViewController else {
        print("GetNotificationsViewController - ❌ Failed to load StepUpPasswordViewController from Storyboard")
        return
    }

    print("GetNotificationsViewController - βœ… StepUpPasswordViewController instantiated successfully")

    modal.notificationTitle = notification.subject
    modal.attemptsLeft = stepUpAttemptsLeft
    modal.errorMessage = stepUpErrorMessage

    print("GetNotificationsViewController - Setting isSubmitting to: \(stepUpSubmitting)")
    modal.isSubmitting = stepUpSubmitting

    // Handle password submission
    modal.onSubmitPassword = { [weak self] password in
        self?.handleStepUpPasswordSubmit(password: password)
    }

    // Handle cancellation
    modal.onCancel = { [weak self] in
        self?.handleStepUpCancel()
    }

    // Present modal
    modal.modalPresentationStyle = .overFullScreen
    modal.modalTransitionStyle = .crossDissolve

    stepUpModal = modal

    // Present from the top-most view controller in the hierarchy
    var topVC: UIViewController = self
    while let presented = topVC.presentedViewController {
        topVC = presented
    }

    print("GetNotificationsViewController - Presenting step-up modal from: \(type(of: topVC))")
    topVC.present(modal, animated: false) // Animation handled by modal's viewDidAppear
    print("GetNotificationsViewController - Step-up modal presented")
}

/// Update step-up modal state
private func updateStepUpModalState() {
    stepUpModal?.updateState(
        attemptsLeft: stepUpAttemptsLeft,
        errorMessage: stepUpErrorMessage,
        isSubmitting: stepUpSubmitting
    )
}

/// Dismiss step-up modal
private func dismissStepUpModal() {
    guard showStepUpAuth, let modal = stepUpModal else { return }

    showStepUpAuth = false
    stepUpErrorMessage = nil

    modal.dismiss()
    stepUpModal = nil
}

Add Helper Method for Alerts

Add a utility method for displaying alerts:

// GetNotificationsViewController.swift (addition)

private func showAlert(title: String, message: String, completion: (() -> Void)? = nil) {
    let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
        completion?()
    })
    present(alert, animated: true)
}

Now let's test the complete step-up authentication implementation with various scenarios.

Server Configuration

Before testing, ensure your REL-ID server is configured for step-up authentication:

Test Scenario 1: Password Step-Up (User Logged in with Password)

Test the basic password step-up flow:

  1. Complete MFA flow and log in to dashboard using password
  2. Navigate to Notifications screen from drawer menu
  3. Verify notifications loaded - Check that getNotifications() succeeded
  4. Tap notification action button (e.g., "Approve", "Reject")
  5. Verify updateNotification API called - Check console logs
  6. Verify step-up dialog appears:
    • Modal should display with notification title
    • "Authentication Required" header visible
    • Password input field should be focused
    • Attempts counter shows "3 attempts remaining" in green
    • Action modal should be closed
  7. Enter incorrect password and submit
  8. Verify error handling:
    • getPassword event triggered again with error
    • Error message displayed in red box
    • Password field automatically cleared
    • Attempts counter decremented to "2 attempts remaining" (orange)
  9. Enter correct password and submit
  10. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Dialog closes
    • Notification list refreshed

Test Scenario 2: LDA Step-Up (User Logged in with LDA)

Test biometric authentication step-up:

  1. Complete MFA flow and log in to dashboard using LDA (biometric)
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Verify LDA prompt appears:
    • System biometric prompt (Face ID or Touch ID)
    • No getPassword event triggered
    • No password dialog displayed
  5. Complete biometric authentication
  6. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Notification list refreshed

Test Scenario 3: LDA Cancellation with Password Fallback

Test automatic fallback when user cancels biometric (requires both Password & LDA enrolled):

  1. Enroll both Password and LDA during activation
  2. Log in using LDA (biometric)
  3. Navigate to Notifications screen
  4. Tap notification action button
  5. LDA prompt appears - System biometric prompt
  6. Cancel the biometric prompt (tap "Cancel" button)
  7. Verify fallback behavior:
    • SDK automatically triggers getPassword with challengeMode 3
    • StepUpPasswordViewController appears as fallback
    • No error alert displayed (seamless fallback)
  8. Enter password and submit
  9. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Notification list refreshed

Test Scenario 4: Critical Error - Password Expired (statusCode 110)

Test error handling when password expires during action:

  1. Log in with password that will expire during the action
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Enter password in step-up dialog
  5. Verify critical error handling:
    • onUpdateNotification receives statusCode 110
    • Alert displays BEFORE logout: "Authentication Failed - Password Expired"
    • Step-up dialog closes
    • Action modal closes
  6. Tap "OK" on alert
  7. Verify logout flow:
    • SDK triggers onUserLoggedOff event
    • AppCoordinator handles logout
    • User navigated to home screen

Test Scenario 5: Critical Error - Attempts Exhausted (statusCode 153)

Test error handling when authentication attempts are exhausted:

  1. Log in with password
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Enter wrong password 3 times:
    • First attempt: "3 attempts remaining" (green)
    • Second attempt: "2 attempts remaining" (orange)
    • Third attempt: "1 attempt remaining" (red)
  5. Verify attempts exhausted:
    • onUpdateNotification receives statusCode 153
    • Alert displays BEFORE logout: "Authentication Failed - Attempts Exhausted"
    • Step-up dialog closes
    • Action modal closes
  6. Tap "OK" on alert
  7. Verify logout flow:
    • SDK triggers onUserLoggedOff event
    • AppCoordinator handles logout
    • User navigated to home screen

Test Scenario 6: Keyboard Management

Test that keyboard doesn't hide action buttons:

  1. Log in and navigate to Notifications screen
  2. Tap notification action button
  3. Step-up dialog appears
  4. Tap password input field
  5. Verify keyboard behavior:
    • Keyboard appears
    • Dialog buttons ("Verify & Continue", "Cancel") remain visible
    • ScrollView allows scrolling if needed
    • Buttons are not hidden behind keyboard
  6. Test on both simulator and device

Verification Checklist

Use this checklist to verify your implementation:

Let's understand why we chose screen-level handling for challengeMode = 3 instead of global handling.

Design Decision Rationale

The implementation handles getPassword with challengeMode = 3 at the screen level (GetNotificationsViewController) rather than globally. This is a deliberate architectural choice with significant benefits.

Screen-Level Handler Approach (Current Implementation)

Advantages:

  1. Context Access: Direct access to notification data (title, message, action) already loaded in screen
  2. Modal Management: Easy to manage modal stack (close action modal β†’ open password dialog)
  3. State Locality: All step-up auth state lives where it's used, no property passing
  4. UI Flow: Modal overlay maintains screen context, better UX
  5. Lifecycle Management: Handler active only when screen is mounted, automatic cleanup
  6. Callback Preservation: Chains with global handler, doesn't break other challenge modes
// GetNotificationsViewController.swift - Screen-level approach
private func handleGetPasswordStepUp(...) {
    // Only handle challengeMode 3
    if challengeMode.rawValue != 3 {
        originalGetPasswordHandler?(...)
        return
    }

    // Screen has direct access to notification context
    showStepUpModal()
    // Notification title, message, action already in selectedNotification property
}

Global Handler Approach (Alternative - Not Used)

Disadvantages if we used global approach:

  1. No Context Access: Notification data not available in global coordinator
  2. Complex State Management: Need to pass notification data through coordinator
  3. Navigation Overhead: Navigate to new screen instead of modal overlay
  4. Poor UX: User loses context of which notification they're acting on
  5. Tight Coupling: Hard to reuse pattern for other step-up auth scenarios
  6. Maintenance Burden: Flow scattered across multiple files
// AppCoordinator.swift - Global approach (NOT USED)
RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
    if challengeMode.rawValue == 3 {
        // Problems:
        // - Notification context not available here
        // - Need complex state management to pass data
        // - Navigation to new screen breaks UX
        self?.showStepUpAuth(...)
    }
}

Architecture Comparison

Aspect

Screen-Level Handler (βœ… Current)

Global Handler (❌ Alternative)

Context Access

Direct access to notification data

Need state management layer

UI Pattern

Modal overlay on same screen

Navigate to new screen

Modal Management

Simple (close one, open another)

Complex (cross-screen modals)

Code Locality

All related code in one place

Scattered across multiple files

Maintenance

Easy to understand and modify

Hard to trace flow

Cleanup

Automatic on unmount

Manual cleanup needed

Reusability

Pattern reusable for other screens

Tightly coupled to specific flow

State Management

Local properties, no passing

Need to pass through coordinator

When to Use Each Pattern

Screen-level handlers are recommended when:

Global handlers are appropriate when:

Let's address common issues you might encounter when implementing step-up authentication.

Issue 1: Step-Up Dialog Not Appearing

Symptoms:

Possible Causes & Solutions:

  1. Missing weak self in closures: Closure capturing issues causing memory problems
// ❌ Wrong - No weak self
RDNADelegateManager.shared.onGetPassword = { userID, challengeMode, ... in
    self.handleGetPasswordStepUp(...)
}

// βœ… Correct - With weak self
RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, ... in
    self?.handleGetPasswordStepUp(...)
}
  1. Handler not registered: Forgot to call setupEventHandlers()
// βœ… Correct - Register in viewDidLoad
override func viewDidLoad() {
    super.viewDidLoad()
    setupUI()
    setupTableView()
    setupEventHandlers() // Don't forget this!
    loadNotifications()
}
  1. Modal already presented: Check presentedViewController
// βœ… Correct - Find top-most view controller
var topVC: UIViewController = self
while let presented = topVC.presentedViewController {
    topVC = presented
}
topVC.present(modal, animated: false)

Issue 2: Password Field Not Clearing on Retry

Symptoms:

Solution: Add error message observer to auto-clear

// βœ… Correct - Auto-clear password on error
private func updateUIState() {
    // ...
    if let error = errorMessage, !error.isEmpty {
        errorContainer.isHidden = false
        errorContainerHeightConstraint.constant = 60
        errorLabel.text = error

        // Clear password field when error is shown
        passwordTextField.text = ""
    } else {
        errorContainer.isHidden = true
        errorContainerHeightConstraint.constant = 0
    }
    // ...
}

Issue 3: Keyboard Hiding Action Buttons

Symptoms:

Solution: Ensure proper ScrollView and maxHeight configuration

// βœ… Correct - ScrollView with proper config
// In Storyboard or programmatic layout:
// - modalContainer maxHeight = 80% of superview
// - scrollView inside modalContainer
// - contentView inside scrollView
// - buttons OUTSIDE scrollView (in fixed footer)

Issue 4: Global Handler Broken After Screen Unmounts

Symptoms:

Solution: Ensure proper cleanup in viewWillDisappear

// βœ… Correct - Restore original handler on cleanup
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    cleanupEventHandlers()
}

private func cleanupEventHandlers() {
    RDNADelegateManager.shared.onGetNotifications = nil
    RDNADelegateManager.shared.onUpdateNotification = nil

    // Critical: restore original handler
    RDNADelegateManager.shared.onGetPassword = originalGetPasswordHandler
    originalGetPasswordHandler = nil
}

Issue 5: Alert Not Showing Before Logout

Symptoms:

Solution: Ensure alert is shown with delay for modal dismissal

// βœ… Correct - Show alert BEFORE SDK logout with delay
if statusCode == 110 || statusCode == 153 {
    // Dismiss modals FIRST
    dismissStepUpModal()
    dismissActionModal()

    // Show alert after delay (let modals dismiss first)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
        self.showAlert(
            title: "Authentication Failed",
            message: statusMessage ?? "Authentication failed"
        ) {
            print("Waiting for SDK to trigger logout")
            // SDK will trigger logout after this
        }
    }
}

Issue 6: LDA Fallback Not Working

Symptoms:

Solution: Verify both Password and LDA are enrolled

// This fallback only works when BOTH Password and LDA are enrolled
// If only LDA is enrolled, cancellation triggers error code 131

// In handleUpdateNotificationReceived:
if error.longErrorCode == 131 {
    // LDA cancelled, Password not enrolled
    showAlert(
        title: "Authentication Cancelled",
        message: "Local device authentication was cancelled. Please try again."
    )
    // SDK will automatically trigger getPassword if Password is enrolled
    // Otherwise, user can retry LDA by tapping action again
}

Issue 7: Modal Not Dismissible on Cancel

Symptoms:

Solution: Implement proper dismiss() method

// βœ… Correct - Proper dismissal with animation
func dismiss() {
    guard !isDismissing else {
        print("Already dismissing, ignoring")
        return
    }

    isDismissing = true
    passwordTextField.resignFirstResponder()

    UIView.animate(withDuration: 0.2, animations: {
        self.modalContainer.alpha = 0
        self.overlayView.alpha = 0
    }) { _ in
        self.dismiss(animated: false) {
            self.onCancel?()
        }
    }
}

Debugging Tips

Enable detailed logging to troubleshoot issues:

// Add detailed console logs at each step
print("GetNotificationsViewController - getPassword event:", [
    "challengeMode": challengeMode.rawValue,
    "attemptsLeft": attemptsLeft,
    "statusCode": response.status.statusCode.rawValue,
    "statusMessage": response.status.statusMessage
])

print("GetNotificationsViewController - State before showing modal:", [
    "showStepUpAuth": showStepUpAuth,
    "stepUpAttemptsLeft": stepUpAttemptsLeft,
    "stepUpErrorMessage": stepUpErrorMessage ?? "nil",
    "stepUpNotificationTitle": selectedNotification?.subject ?? "nil"
])

print("GetNotificationsViewController - onUpdateNotification:", [
    "errorCode": error.longErrorCode,
    "statusCode": responseStatus.statusCode.rawValue,
    "statusMessage": responseStatus.message ?? "nil"
])

Let's review important security considerations for step-up authentication implementation.

Password Handling

Never log or expose passwords:

// ❌ Wrong - Logging password
print("Password submitted:", password)

// βœ… Correct - Only log that password was submitted
print("Password submitted for step-up auth")

Clear sensitive data on unmount:

// βœ… Correct - Clear password in dismiss()
func dismiss() {
    guard !isDismissing else { return }
    isDismissing = true

    passwordTextField.text = "" // Clear password
    passwordTextField.resignFirstResponder()

    // ... dismissal animation ...
}

Authentication Method Respect

Never bypass step-up authentication:

// ❌ Wrong - Allowing action without auth
if requiresAuth {
    // Don't try to bypass by calling action again
}

// βœ… Correct - Always respect SDK's auth requirement
let error = RDNAService.shared.updateNotification(uuid, response: action)
// Let SDK handle auth requirement via events

Error Message Sanitization

Don't expose sensitive information in error messages:

// ❌ Wrong - Exposing system details
showAlert(title: "Error", message: "Database connection failed: \(sqlError.details)")

// βœ… Correct - User-friendly generic message
showAlert(title: "Error", message: "Unable to process action. Please try again.")

Attempt Limiting

Respect server-configured attempt limits:

// βœ… Correct - Use SDK-provided attempts
stepUpAttemptsLeft = attemptsLeft

// ❌ Wrong - Ignoring SDK attempts and implementing custom limit
let maxAttempts = 5 // Don't do this

Session Security

Handle critical errors properly:

// βœ… Correct - Show alert BEFORE logout
if statusCode == 110 || statusCode == 153 {
    showAlert(
        title: "Authentication Failed",
        message: statusMessage ?? "Authentication failed"
    ) {
        // SDK will trigger logout automatically
    }
}

Biometric Fallback Security

Implement proper LDA cancellation handling:

// βœ… Correct - Allow retry or fallback based on enrollment
if error.longErrorCode == 131 {
    // If both Password & LDA enrolled: SDK falls back to password
    // If only LDA enrolled: Allow user to retry LDA
    showAlert(
        title: "Authentication Cancelled",
        message: "Local device authentication was cancelled. Please try again."
    )
}

Modal Security

Prevent dismissal during sensitive operations:

// βœ… Correct - Disable dismissal during submission
@IBAction func overlayTapped(_ sender: UITapGestureRecognizer) {
    if !isSubmitting {
        dismiss()
    }
}

// Disable cancel button during submission
cancelButton.isEnabled = !isSubmitting
cancelButton.alpha = isSubmitting ? 0.6 : 1.0

Audit and Monitoring

Log security-relevant events:

// βœ… Correct - Log auth attempts and results
print("Step-up authentication initiated for notification:", notificationUUID)
print("Step-up authentication result:", [
    "success": statusCode == 100,
    "attemptsRemaining": attemptsLeft,
    "authMethod": challengeMode.rawValue == 3 ? "Password" : "LDA"
])

Testing Security Scenarios

Always test these security scenarios:

  1. Attempt exhaustion: Verify logout after max attempts
  2. Password expiry: Verify proper error handling for expired passwords
  3. Concurrent sessions: Test behavior with multiple devices
  4. Network failures: Ensure graceful handling of connection issues
  5. Biometric spoofing: Verify SDK handles biometric security
  6. Replay attacks: SDK prevents replay of authentication tokens

Let's optimize the step-up authentication implementation for better performance.

Weak Self in Closures

Always use weak self to prevent retain cycles:

// βœ… Correct - Minimal retain cycles
RDNADelegateManager.shared.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
    self?.handleGetPasswordStepUp(...)
}

modal.onSubmitPassword = { [weak self] password in
    self?.handleStepUpPasswordSubmit(password: password)
}

modal.onCancel = { [weak self] in
    self?.handleStepUpCancel()
}

Avoid Unnecessary State Updates

Batch related state updates:

// βœ… Better - Update related state together
stepUpAttemptsLeft = attemptsLeft
stepUpSubmitting = false
stepUpErrorMessage = nil
updateStepUpModalState() // Single UI update

Optimize Modal Rendering

Only instantiate modal when needed:

// βœ… Correct - Lazy instantiation
private func showStepUpModal() {
    guard let notification = selectedNotification else {
        return
    }

    // Only instantiate when actually showing
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    guard let modal = storyboard.instantiateViewController(withIdentifier: "StepUpPasswordViewController") as? StepUpPasswordViewController else {
        return
    }

    // ... configure and present ...
}

Debounce Password Input

Optional: Debounce validation if implementing complex password rules:

// Optional optimization for complex validation
private var validationTimer: Timer?

func textFieldDidChangeSelection(_ textField: UITextField) {
    // Debounce validation
    validationTimer?.invalidate()
    validationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in
        self?.validatePassword()
    }

    // Update button state immediately
    updateSubmittingState()
}

Memoize Complex Calculations

Cache attempts color calculation:

// βœ… Correct - Cache color value
private var cachedAttemptsColor: UIColor?
private var cachedAttemptsLeft: Int?

private func getAttemptsColor() -> UIColor {
    if let cached = cachedAttemptsColor, cachedAttemptsLeft == attemptsLeft {
        return cached
    }

    let color: UIColor
    if attemptsLeft == 1 {
        color = UIColor(hex: "#dc2626") ?? .red
    } else if attemptsLeft == 2 {
        color = UIColor(hex: "#f59e0b") ?? .orange
    } else {
        color = UIColor(hex: "#10b981") ?? .green
    }

    cachedAttemptsColor = color
    cachedAttemptsLeft = attemptsLeft
    return color
}

Memory Management

Clean up timers and observers:

// βœ… Correct - Cleanup pattern
deinit {
    validationTimer?.invalidate()
    print("StepUpPasswordViewController - deallocated")
}

Performance Monitoring

Monitor step-up auth performance:

// Optional: Add performance monitoring
let startTime = CFAbsoluteTimeGetCurrent()

let error = RDNAService.shared.setPassword(password, challengeMode: RDNAChallengeOpMode(rawValue: 3)!)

let duration = CFAbsoluteTimeGetCurrent() - startTime
print("Step-up auth setPassword completed in \(String(format: "%.2f", duration * 1000))ms")

Congratulations! You've successfully implemented step-up authentication for notification actions with REL-ID SDK.

What You've Accomplished

In this codelab, you've learned how to:

βœ… Understand Step-Up Authentication: Learned when and why re-authentication is required for sensitive operations

βœ… Create StepUpPasswordViewController: Built a modal password view controller with attempts counter, error handling, and keyboard management

βœ… Implement Screen-Level Event Handler: Used callback preservation pattern to handle challengeMode = 3 at screen level

βœ… Handle LDA and Password Flows: Supported both biometric authentication and password-based step-up with automatic fallback

βœ… Manage Critical Errors: Properly handled status codes 110, 153 with alerts before logout and error code 131 with alert

βœ… Optimize Keyboard Behavior: Implemented ScrollView to prevent buttons from being hidden

βœ… Auto-Clear Password Fields: Automatically cleared password when authentication failed and SDK triggered retry

βœ… Understand Architecture Decisions: Learned why screen-level handlers are better than global handlers for step-up auth

Key Takeaways

Authentication Method Selection:

Error Handling:

Architecture Pattern:

Security Best Practices:

Additional Resources

Thank you for completing this codelab! You now have the knowledge to implement secure, production-ready step-up authentication for notification actions in your iOS applications.

Happy Coding!