π― Learning Path:
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.
In this codelab, you'll enhance your existing notification application with:
challengeMode = 3 (RDNA_OP_AUTHORIZE_NOTIFICATION)getPassword callback preservation for challengeMode 3By completing this codelab, you'll master:
Before starting this codelab, ensure you have:
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
This codelab extends your notification application with three core step-up authentication components:
getPassword callback preservation for challengeMode 3 in GetNotificationsViewControlleronUpdateNotification 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.
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
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 is specifically for notification action authorization:
Challenge Mode | Purpose | User Action Required | Screen | Trigger |
| Verify existing password | Enter password to login | VerifyPasswordViewController | User login attempt |
| Set new password | Create password during activation | SetPasswordViewController | First-time activation |
| Update password (user-initiated) | Provide current + new password | UpdatePasswordViewController | User taps "Update Password" |
| Authorize notification action | Re-enter password for verification | StepUpPasswordViewController (Modal) | updateNotification() requires auth |
| Update expired password | Provide current + new password | UpdateExpiryPasswordViewController | Server detects expired password |
Important: The SDK automatically determines which authentication method to use based on:
Login Method | Enrolled Methods | Step-Up Authentication Method | SDK Behavior |
Password | Password only | Password | SDK triggers |
LDA | LDA only | LDA | SDK prompts biometric internally, no |
Password | Both Password & LDA | Password | SDK triggers |
LDA | Both Password & LDA | LDA (with Password fallback) | SDK attempts LDA first. If user cancels, SDK directly triggers |
The REL-ID SDK triggers these main events during step-up authentication:
Event Type | Description | User Action Required |
Password required for notification action authorization | User re-enters password for verification | |
Notification action result (success/failure/auth errors) | System handles response and displays result |
Step-up authentication can fail with these critical errors:
Error/Status Code | Type | Meaning | SDK Behavior | Action Required |
| Status | Success - action completed | Continue normal flow | Display success message |
| Status | Password expired during action | SDK triggers logout | Show alert BEFORE logout |
| Status | Attempts exhausted | SDK triggers logout | Show alert BEFORE logout |
| Error | LDA cancelled and Password NOT enrolled | No fallback available | Show alert, allow retry |
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.
The StepUpPasswordViewController needs to:
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:

Now let's implement the screen-level event handler that will intercept getPassword events with challengeMode = 3 and display our step-up password dialog.
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
}
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 ...
}
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()
}
}
}
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.
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()
}
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.
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()
}
}
}
}
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 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 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.
Before testing, ensure your REL-ID server is configured for step-up authentication:
Test the basic password step-up flow:
getNotifications() succeededgetPassword event triggered again with erroronUpdateNotification event with statusCode 100Test biometric authentication step-up:
getPassword event triggeredonUpdateNotification event with statusCode 100Test automatic fallback when user cancels biometric (requires both Password & LDA enrolled):
getPassword with challengeMode 3onUpdateNotification event with statusCode 100Test error handling when password expires during action:
onUpdateNotification receives statusCode 110onUserLoggedOff eventTest error handling when authentication attempts are exhausted:
onUpdateNotification receives statusCode 153onUserLoggedOff eventTest that keyboard doesn't hide action buttons:
Use this checklist to verify your implementation:
Let's understand why we chose screen-level handling for challengeMode = 3 instead of global handling.
The implementation handles getPassword with challengeMode = 3 at the screen level (GetNotificationsViewController) rather than globally. This is a deliberate architectural choice with significant benefits.
Advantages:
// 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
}
Disadvantages if we used global approach:
// 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(...)
}
}
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 |
Screen-level handlers are recommended when:
Global handlers are appropriate when:
Let's address common issues you might encounter when implementing step-up authentication.
Symptoms:
getPassword event logged but modal doesn't displayPossible Causes & Solutions:
// β 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(...)
}
// β
Correct - Register in viewDidLoad
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupTableView()
setupEventHandlers() // Don't forget this!
loadNotifications()
}
// β
Correct - Find top-most view controller
var topVC: UIViewController = self
while let presented = topVC.presentedViewController {
topVC = presented
}
topVC.present(modal, animated: false)
Symptoms:
getPassword triggers againSolution: 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
}
// ...
}
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)
Symptoms:
getPassword events for other modes not handledSolution: 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
}
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
}
}
}
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
}
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?()
}
}
}
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.
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 ...
}
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
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.")
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
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
}
}
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."
)
}
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
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"
])
Always test these security scenarios:
Let's optimize the step-up authentication implementation for better performance.
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()
}
Batch related state updates:
// β
Better - Update related state together
stepUpAttemptsLeft = attemptsLeft
stepUpSubmitting = false
stepUpErrorMessage = nil
updateStepUpModalState() // Single UI update
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 ...
}
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()
}
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
}
Clean up timers and observers:
// β
Correct - Cleanup pattern
deinit {
validationTimer?.invalidate()
print("StepUpPasswordViewController - deallocated")
}
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.
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
Authentication Method Selection:
Error Handling:
Architecture Pattern:
Security Best Practices:
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!