🎯 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. StepUpPasswordDialog Widget: Building modal password dialog with attempts counter
  5. LDA Fallback Handling: Managing biometric cancellation with automatic password fallback
  6. Keyboard Optimization: Implementing SingleChildScrollView 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-flutter.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. StepUpPasswordDialog: Modal password dialog widget with attempts counter, error display, and keyboard management
  2. Screen-Level Event Handler: getPassword callback preservation for challengeMode 3 in GetNotificationsScreen
  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) β†’
  StepUpPasswordDialog 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 β†’
  StepUpPasswordDialog 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

VerifyPasswordScreen

User login attempt

challengeMode = 1

Set new password

Create password during activation

SetPasswordScreen

First-time activation

challengeMode = 2

Update password (user-initiated)

Provide current + new password

UpdatePasswordScreen

User taps "Update Password"

challengeMode = 3

Authorize notification action

Re-enter password for verification

StepUpPasswordDialog (Modal)

updateNotification() requires auth

challengeMode = 4

Update expired password

Provide current + new password

UpdateExpiryPasswordScreen

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

Add these Dart definitions to understand the updateNotification API structure:

// lib/uniken/services/rdna_service.dart (notification APIs)

/// Updates notification with user's action selection
///
/// @param notificationUUID The unique identifier of the notification
/// @param action The action selected by the user
/// @returns Future<RDNASyncResponse> that resolves with sync response structure
///
/// Note: If action requires authentication, SDK will trigger:
/// - getPassword event with challengeMode 3 (if password required)
/// - Biometric prompt internally (if LDA required)
Future<RDNASyncResponse> updateNotification(
  String notificationUUID,
  String action,
) async {
  print('RdnaService - Updating notification: $notificationUUID with action: $action');
  final syncResponse = await _rdnaClient.updateNotification(notificationUUID, action);
  print('  Long Error Code: ${syncResponse.error?.longErrorCode}');
  return syncResponse;
}

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

Understanding the Dialog Requirements

The StepUpPasswordDialog needs to:

Create the StepUpPasswordDialog Widget

Create a new file for the password dialog modal:

// lib/uniken/components/modals/step_up_password_dialog.dart

/// Step-Up Password Dialog Component
///
/// 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 SingleChildScrollView
///
/// Usage:
/// ```dart
/// StepUpPasswordDialog(
///   visible: showStepUpAuth,
///   notificationTitle: 'Payment Approval',
///   notificationMessage: 'Approve payment of \$500',
///   userID: 'john.doe',
///   attemptsLeft: 3,
///   errorMessage: errorMsg,
///   isSubmitting: false,
///   onSubmitPassword: (password) => handlePasswordSubmit(password),
///   onCancel: () => setState(() => showStepUpAuth = false),
/// )
/// ```

import 'package:flutter/material.dart';

class StepUpPasswordDialog extends StatefulWidget {
  final bool visible;
  final String notificationTitle;
  final String notificationMessage;
  final String userID;
  final int attemptsLeft;
  final String? errorMessage;
  final bool isSubmitting;
  final Function(String) onSubmitPassword;
  final VoidCallback onCancel;

  const StepUpPasswordDialog({
    super.key,
    required this.visible,
    required this.notificationTitle,
    required this.notificationMessage,
    required this.userID,
    required this.attemptsLeft,
    this.errorMessage,
    required this.isSubmitting,
    required this.onSubmitPassword,
    required this.onCancel,
  });

  @override
  State<StepUpPasswordDialog> createState() => _StepUpPasswordDialogState();
}

class _StepUpPasswordDialogState extends State<StepUpPasswordDialog> {
  final TextEditingController _passwordController = TextEditingController();
  final FocusNode _passwordFocusNode = FocusNode();
  bool _showPassword = false;

  @override
  void initState() {
    super.initState();

    // Add listener to rebuild when text changes (to enable/disable button)
    _passwordController.addListener(() {
      setState(() {
        // Rebuild to update button state
      });
    });

    // Auto-focus password input when dialog opens
    if (widget.visible) {
      Future.delayed(const Duration(milliseconds: 300), () {
        if (mounted) {
          _passwordFocusNode.requestFocus();
        }
      });
    }
  }

  @override
  void didUpdateWidget(StepUpPasswordDialog oldWidget) {
    super.didUpdateWidget(oldWidget);

    // Clear password when modal becomes visible
    if (widget.visible && !oldWidget.visible) {
      _passwordController.clear();
      _showPassword = false;
      Future.delayed(const Duration(milliseconds: 300), () {
        if (mounted) {
          _passwordFocusNode.requestFocus();
        }
      });
    }

    // Clear password field when error message changes (wrong password)
    if (widget.errorMessage != null && widget.errorMessage != oldWidget.errorMessage) {
      _passwordController.clear();
    }
  }

  @override
  void dispose() {
    _passwordController.dispose();
    _passwordFocusNode.dispose();
    super.dispose();
  }

  void _handleSubmit() {
    if (_passwordController.text.trim().isEmpty || widget.isSubmitting) {
      return;
    }
    widget.onSubmitPassword(_passwordController.text.trim());
  }

  /// Get color for attempts counter based on remaining attempts
  Color _getAttemptsColor() {
    if (widget.attemptsLeft == 1) return const Color(0xFFDC2626); // Red
    if (widget.attemptsLeft == 2) return const Color(0xFFF59E0B); // Orange
    return const Color(0xFF10B981); // Green
  }

  @override
  Widget build(BuildContext context) {
    if (!widget.visible) {
      return const SizedBox.shrink();
    }

    return PopScope(
      canPop: !widget.isSubmitting,
      onPopInvokedWithResult: (didPop, result) {
        if (!didPop && !widget.isSubmitting) {
          widget.onCancel();
        }
      },
      child: Dialog(
        backgroundColor: Colors.transparent,
        insetPadding: const EdgeInsets.all(20),
        child: Container(
          constraints: const BoxConstraints(maxWidth: 480),
          decoration: BoxDecoration(
            color: const Color(0xFFFFFFFF),
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withValues(alpha: 0.3),
                offset: const Offset(0, 4),
                blurRadius: 8,
              ),
            ],
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // Header
              Container(
                padding: const EdgeInsets.all(20),
                decoration: const BoxDecoration(
                  color: Color(0xFF3B82F6),
                  borderRadius: BorderRadius.only(
                    topLeft: Radius.circular(16),
                    topRight: Radius.circular(16),
                  ),
                ),
                child: const Column(
                  children: [
                    Text(
                      'πŸ” Authentication Required',
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Color(0xFFFFFFFF),
                      ),
                      textAlign: TextAlign.center,
                    ),
                    SizedBox(height: 8),
                    Text(
                      'Please verify your password to authorize this action',
                      style: TextStyle(
                        fontSize: 14,
                        color: Color(0xFFDBEAFE),
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ],
                ),
              ),

              // Content with Scroll
              Flexible(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.all(20),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      // Notification Title
                      Container(
                        padding: const EdgeInsets.all(12),
                        decoration: BoxDecoration(
                          color: const Color(0xFFF0F9FF),
                          borderRadius: BorderRadius.circular(8),
                          border: const Border(
                            left: BorderSide(
                              color: Color(0xFF3B82F6),
                              width: 4,
                            ),
                          ),
                        ),
                        child: Text(
                          widget.notificationTitle,
                          style: const TextStyle(
                            fontSize: 15,
                            fontWeight: FontWeight.w600,
                            color: Color(0xFF1E40AF),
                          ),
                          textAlign: TextAlign.center,
                        ),
                      ),

                      const SizedBox(height: 16),

                      // Attempts Left Counter
                      if (widget.attemptsLeft <= 3)
                        Container(
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            color: _getAttemptsColor().withValues(alpha: 0.2),
                            borderRadius: BorderRadius.circular(8),
                          ),
                          child: Text(
                            '${widget.attemptsLeft} attempt${widget.attemptsLeft != 1 ? 's' : ''} remaining',
                            style: TextStyle(
                              fontSize: 14,
                              fontWeight: FontWeight.w600,
                              color: _getAttemptsColor(),
                            ),
                            textAlign: TextAlign.center,
                          ),
                        ),

                      if (widget.attemptsLeft <= 3) const SizedBox(height: 16),

                      // Error Display
                      if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty)
                        Container(
                          padding: const EdgeInsets.all(12),
                          margin: const EdgeInsets.only(bottom: 16),
                          decoration: BoxDecoration(
                            color: const Color(0xFFFEF2F2),
                            borderRadius: BorderRadius.circular(8),
                            border: const Border(
                              left: BorderSide(
                                color: Color(0xFFDC2626),
                                width: 4,
                              ),
                            ),
                          ),
                          child: Text(
                            widget.errorMessage!,
                            style: const TextStyle(
                              fontSize: 14,
                              color: Color(0xFF7F1D1D),
                            ),
                            textAlign: TextAlign.center,
                          ),
                        ),

                      // Password Input
                      Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          const Text(
                            'Password',
                            style: TextStyle(
                              fontSize: 14,
                              fontWeight: FontWeight.w600,
                              color: Color(0xFF374151),
                            ),
                          ),
                          const SizedBox(height: 8),
                          Container(
                            decoration: BoxDecoration(
                              border: Border.all(
                                color: const Color(0xFFD1D5DB),
                              ),
                              borderRadius: BorderRadius.circular(8),
                              color: const Color(0xFFFFFFFF),
                            ),
                            child: Row(
                              children: [
                                Expanded(
                                  child: TextField(
                                    controller: _passwordController,
                                    focusNode: _passwordFocusNode,
                                    obscureText: !_showPassword,
                                    enabled: !widget.isSubmitting,
                                    decoration: const InputDecoration(
                                      hintText: 'Enter your password',
                                      hintStyle: TextStyle(
                                        color: Color(0xFF9CA3AF),
                                      ),
                                      border: InputBorder.none,
                                      contentPadding: EdgeInsets.all(12),
                                    ),
                                    style: const TextStyle(
                                      fontSize: 16,
                                      color: Color(0xFF1F2937),
                                    ),
                                    textInputAction: TextInputAction.done,
                                    onSubmitted: (_) => _handleSubmit(),
                                  ),
                                ),
                                IconButton(
                                  onPressed: widget.isSubmitting
                                      ? null
                                      : () {
                                          setState(() {
                                            _showPassword = !_showPassword;
                                          });
                                        },
                                  icon: Text(
                                    _showPassword ? 'πŸ‘οΈ' : 'πŸ™ˆ',
                                    style: const TextStyle(fontSize: 20),
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ),

              // Action Buttons
              Container(
                padding: const EdgeInsets.all(20),
                decoration: const BoxDecoration(
                  border: Border(
                    top: BorderSide(
                      color: Color(0xFFF3F4F6),
                    ),
                  ),
                ),
                child: Column(
                  children: [
                    // Submit Button
                    SizedBox(
                      width: double.infinity,
                      child: ElevatedButton(
                        onPressed: (_passwordController.text.trim().isEmpty ||
                                widget.isSubmitting)
                            ? null
                            : _handleSubmit,
                        style: ElevatedButton.styleFrom(
                          backgroundColor: const Color(0xFF3B82F6),
                          disabledBackgroundColor:
                              const Color(0xFF3B82F6).withValues(alpha: 0.6),
                          padding: const EdgeInsets.all(16),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(8),
                          ),
                          elevation: 2,
                        ),
                        child: widget.isSubmitting
                            ? const Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  SizedBox(
                                    width: 20,
                                    height: 20,
                                    child: CircularProgressIndicator(
                                      strokeWidth: 2,
                                      valueColor: AlwaysStoppedAnimation<Color>(
                                        Color(0xFFFFFFFF),
                                      ),
                                    ),
                                  ),
                                  SizedBox(width: 8),
                                  Text(
                                    'Verifying...',
                                    style: TextStyle(
                                      color: Color(0xFFFFFFFF),
                                      fontSize: 16,
                                      fontWeight: FontWeight.w600,
                                    ),
                                  ),
                                ],
                              )
                            : const Text(
                                'Verify & Continue',
                                style: TextStyle(
                                  color: Color(0xFFFFFFFF),
                                  fontSize: 16,
                                  fontWeight: FontWeight.w600,
                                ),
                              ),
                      ),
                    ),

                    const SizedBox(height: 12),

                    // Cancel Button
                    SizedBox(
                      width: double.infinity,
                      child: ElevatedButton(
                        onPressed: widget.isSubmitting ? null : widget.onCancel,
                        style: ElevatedButton.styleFrom(
                          backgroundColor: const Color(0xFFF3F4F6),
                          disabledBackgroundColor:
                              const Color(0xFFF3F4F6).withValues(alpha: 0.6),
                          padding: const EdgeInsets.all(16),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(8),
                          ),
                          elevation: 0,
                        ),
                        child: const Text(
                          'Cancel',
                          style: TextStyle(
                            color: Color(0xFF6B7280),
                            fontSize: 16,
                            fontWeight: FontWeight.w600,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Export the Dialog Widget

Update the modals index file to export the new widget:

// lib/uniken/components/modals/index.dart

export 'step_up_password_dialog.dart';

The following image showcases the screen from the sample application:

Step-Up Authentication Dialog

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
final originalHandler = eventManager.getPasswordHandler;

// Set new handler that chains with original
eventManager.setGetPasswordHandler((RDNAGetPassword data) {
  if (data.challengeMode == 3) {
    // Handle challengeMode 3 in screen
    handleScreenSpecificLogic(data);
  } else {
    // Pass other modes to original handler
    if (originalHandler != null) {
      originalHandler(data);
    }
  }
});

// Cleanup: restore original handler when screen unmounts
@override
void dispose() {
  eventManager.setGetPasswordHandler(originalHandler);
  super.dispose();
}

Enhance GetNotificationsScreen with Step-Up Auth State

Add step-up authentication state management to your GetNotificationsScreen:

// lib/tutorial/screens/notification/get_notifications_screen.dart (additions)

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rdna_client/rdna_struct.dart';
import '../../../uniken/providers/sdk_event_provider.dart';
import '../../../uniken/components/modals/step_up_password_dialog.dart';
import '../../../uniken/services/rdna_event_manager.dart';

class GetNotificationsScreen extends ConsumerStatefulWidget {
  final RDNAUserLoggedIn? sessionData;

  const GetNotificationsScreen({
    super.key,
    this.sessionData,
  });

  @override
  ConsumerState<GetNotificationsScreen> createState() =>
      _GetNotificationsScreenState();
}

class _GetNotificationsScreenState
    extends ConsumerState<GetNotificationsScreen> {
  // Existing notification state
  bool _isLoading = true;
  List<RDNANotification> _notifications = [];
  RDNANotification? _selectedNotification;
  bool _showActionModal = false;
  String? _error;
  bool _actionLoading = false;

  // Step-up authentication state
  bool _showStepUpAuth = false;
  String? _stepUpNotificationUUID;
  String _stepUpNotificationTitle = '';
  String _stepUpNotificationMessage = '';
  String? _stepUpAction;
  int _stepUpAttemptsLeft = 3;
  String _stepUpErrorMessage = '';
  bool _stepUpSubmitting = false;

  // Store original getPassword handler for callback preservation
  RDNAGetPasswordCallback? _originalGetPasswordHandler;

  // ... existing code ...
}

Implement getPassword Handler for ChallengeMode 3

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

// lib/tutorial/screens/notification/get_notifications_screen.dart (addition)

/// Handle getPassword event for step-up authentication (challengeMode = 3)
/// This handler intercepts only challengeMode 3 and passes other modes to the global handler
void _handleGetPasswordStepUp(RDNAGetPassword data) {
  print('GetNotificationsScreen - getPassword event:');
  print('  Challenge Mode: ${data.challengeMode}');
  print('  Attempts Left: ${data.attemptsLeft}');
  print('  Status Code: ${data.challengeResponse?.status?.statusCode}');

  // Only handle challengeMode 3 (RDNA_OP_AUTHORIZE_NOTIFICATION)
  if (data.challengeMode != 3) {
    print('GetNotificationsScreen - Not challengeMode 3, passing to original handler');
    // Pass to original handler for other challenge modes (0, 1, 2, 4)
    if (_originalGetPasswordHandler != null) {
      _originalGetPasswordHandler!(data);
    }
    return;
  }

  // Hide action modal to show step-up modal on top
  setState(() {
    _showActionModal = false;
    _stepUpAttemptsLeft = data.attemptsLeft ?? 3;
    _stepUpSubmitting = false;
  });

  // Check for error status codes
  final statusCode = data.challengeResponse?.status?.statusCode;
  final statusMessage = data.challengeResponse?.status?.statusMessage;

  if (statusCode != null && statusCode != 100) {
    // Failed authentication - show error
    setState(() {
      _stepUpErrorMessage = statusMessage ?? 'Authentication failed. Please try again.';
    });
  } else {
    // Clear any previous errors
    setState(() {
      _stepUpErrorMessage = '';
    });
  }

  // Show step-up modal
  setState(() {
    _showStepUpAuth = true;
  });
}

Set Up Event Handler with Cleanup

Wire up the event handler with proper lifecycle management:

// lib/tutorial/screens/notification/get_notifications_screen.dart (addition)

@override
void initState() {
  super.initState();
  _setupEventHandlers();
  _loadNotifications();
}

@override
void dispose() {
  _cleanupEventHandlers();
  super.dispose();
}

/// Set up event handlers for notification events
void _setupEventHandlers() {
  final rdnaService = ref.read(rdnaServiceProvider);
  final eventManager = rdnaService.getEventManager();

  // Preserve original getPassword handler
  _originalGetPasswordHandler = eventManager.getPasswordHandler;

  // Set new handler that chains with original
  eventManager.setGetPasswordHandler((RDNAGetPassword data) {
    _handleGetPasswordStepUp(data);
  });

  // Set notification event handlers
  eventManager.setGetNotificationsHandler(_handleNotificationsReceived);
  eventManager.setUpdateNotificationHandler(_handleUpdateNotificationReceived);
}

/// Cleanup event handlers
void _cleanupEventHandlers() {
  final rdnaService = ref.read(rdnaServiceProvider);
  final eventManager = rdnaService.getEventManager();

  // Restore original getPassword handler
  eventManager.setGetPasswordHandler(_originalGetPasswordHandler);
  eventManager.setGetNotificationsHandler(null);
  eventManager.setUpdateNotificationHandler(null);
}

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

Handle Password Submission

Add the handler that submits the password to the SDK:

// lib/tutorial/screens/notification/get_notifications_screen.dart (addition)

/// Handle password submission from StepUpPasswordDialog
/// Calls setPassword API with challengeMode 3
Future<void> _handleStepUpPasswordSubmit(String password) async {
  print('GetNotificationsScreen - Submitting step-up password (challengeMode 3)');

  setState(() {
    _stepUpSubmitting = true;
    _stepUpErrorMessage = '';
  });

  try {
    final rdnaService = ref.read(rdnaServiceProvider);

    // Call setPassword with challengeMode 3 for step-up auth
    final syncResponse = await rdnaService.setPassword(
      password,
      RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION,
    );

    print('GetNotificationsScreen - setPassword sync response received');
    print('  Long Error Code: ${syncResponse.error?.longErrorCode}');

    // Check for immediate API errors
    if (syncResponse.error?.longErrorCode != 0) {
      final errorMessage = syncResponse.error?.errorString ?? 'Authentication failed';
      print('GetNotificationsScreen - setPassword API error: $errorMessage');

      setState(() {
        _stepUpSubmitting = false;
        _stepUpErrorMessage = errorMessage;
      });
      return;
    }

    // If successful, SDK will trigger onUpdateNotification event
    // Keep modal open until we receive the response
    print('GetNotificationsScreen - setPassword API call successful, waiting for event');
  } catch (error) {
    print('GetNotificationsScreen - setPassword exception: $error');
    setState(() {
      _stepUpSubmitting = false;
      _stepUpErrorMessage = 'An unexpected error occurred';
    });
  }
}

/// Handle step-up authentication cancellation
/// Closes the dialog and resets state
void _handleStepUpCancel() {
  print('GetNotificationsScreen - Step-up authentication cancelled');

  setState(() {
    _showStepUpAuth = false;
    _stepUpNotificationUUID = null;
    _stepUpAction = null;
    _stepUpErrorMessage = '';
    _stepUpSubmitting = false;
  });
}

Store Notification Context on Action Selection

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

// lib/tutorial/screens/notification/get_notifications_screen.dart (modification)

/// Handle notification action selection
/// Calls updateNotification API, which may trigger step-up auth
Future<void> _handleActionPress(RDNAAction action) async {
  if (_selectedNotification == null || _actionLoading) {
    return;
  }

  final notification = _selectedNotification!;

  print('GetNotificationsScreen - Action selected: ${action.action}');

  setState(() {
    _actionLoading = true;
  });

  // Store notification context for potential step-up auth
  setState(() {
    _stepUpNotificationUUID = notification.notificationUuid;
    _stepUpNotificationTitle = notification.body.isNotEmpty
        ? notification.body[0].subject ?? 'Notification Action'
        : 'Notification Action';
    _stepUpNotificationMessage = notification.body.isNotEmpty
        ? notification.body[0].message ?? ''
        : '';
    _stepUpAction = action.action;
    _stepUpAttemptsLeft = 3; // Reset attempts
    _stepUpErrorMessage = ''; // Clear errors
  });

  try {
    print('GetNotificationsScreen - Calling updateNotification API');
    final rdnaService = ref.read(rdnaServiceProvider);

    await rdnaService.updateNotification(
      notification.notificationUuid!,
      action.action!,
    );

    print('GetNotificationsScreen - UpdateNotification API call successful');
    // Response will be handled by _handleUpdateNotificationReceived
    // If step-up auth is required, SDK will trigger getPassword with challengeMode 3
  } catch (error) {
    print('GetNotificationsScreen - UpdateNotification API error: $error');

    setState(() {
      _actionLoading = false;
      _stepUpNotificationUUID = null;
      _stepUpAction = null;
    });

    // Extract error message
    String errorMessage = 'Failed to update notification';
    if (error is RDNASyncResponse) {
      errorMessage = error.error?.errorString ?? errorMessage;
    }

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Update Failed'),
          content: Text(errorMessage),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
  }
}

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:

// lib/tutorial/screens/notification/get_notifications_screen.dart (addition)

/// Handle onUpdateNotification event
/// Processes success, critical errors, and LDA cancellation
void _handleUpdateNotificationReceived(RDNAStatusUpdateNotification data) {
  print('GetNotificationsScreen - onUpdateNotification event:');
  print('  Error Code: ${data.error?.longErrorCode}');
  print('  Status Code: ${data.pArgs?.response?.statusCode}');

  setState(() {
    _actionLoading = false;
    _stepUpSubmitting = false;
  });

  // 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)
  if (data.error != null && data.error?.longErrorCode == 131) {
    print('GetNotificationsScreen - LDA cancelled, Password not enrolled');

    setState(() {
      _showStepUpAuth = false;
    });

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Authentication Cancelled'),
          content: const Text(
            'Local device authentication was cancelled. Please try again.',
          ),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                // Keep action modal open to allow user to retry LDA
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
    return;
  }

  // Check for other API errors
  if (data.error != null && data.error?.longErrorCode != 0) {
    final errorMessage = data.error?.errorString ?? 'Failed to update notification';
    print('GetNotificationsScreen - Update notification error: $errorMessage');

    setState(() {
      _showStepUpAuth = false;
      _showActionModal = false;
    });

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Update Failed'),
          content: Text(errorMessage),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                _loadNotifications();
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
    return;
  }

  // Extract response data
  final responseData = data.pArgs?.response;
  final statusCode = responseData?.statusCode;
  final statusMessage = responseData?.statusMsg ?? 'Action completed successfully';

  print('GetNotificationsScreen - Response status:');
  print('  Status Code: $statusCode');
  print('  Status Message: $statusMessage');

  if (statusCode == 100) {
    // Success - action completed
    print('GetNotificationsScreen - Notification action successful');

    setState(() {
      _showStepUpAuth = false;
      _showActionModal = false;
    });

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Success'),
          content: Text(statusMessage),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                // Navigate to dashboard (using go_router)
                // You can adjust this based on your routing setup
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }

    // Reload notifications to reflect the change
    _loadNotifications();
  } else if (statusCode == 110 || statusCode == 153) {
    // Critical errors - show alert BEFORE SDK logout
    // statusCode 110 = Password expired during action
    // statusCode 153 = Attempts exhausted
    print('GetNotificationsScreen - Critical error, SDK will trigger logout');

    setState(() {
      _showStepUpAuth = false;
      _showActionModal = false;
    });

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Authentication Failed'),
          content: Text(statusMessage),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                print('GetNotificationsScreen - Waiting for SDK to trigger logout flow');
                // SDK will automatically trigger onUserLoggedOff event
                // SDKEventProvider will handle navigation to login
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
  } else {
    // Other errors
    print('GetNotificationsScreen - Update notification failed with status: $statusCode');

    setState(() {
      _showStepUpAuth = false;
      _showActionModal = false;
    });

    if (mounted) {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Update Failed'),
          content: Text(statusMessage),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
  }
}

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 β†’ StepUpPasswordDialog 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 β†’ SDKEventProvider navigates to login

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

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

Now let's add the dialog to the screen's build method so it displays when step-up authentication is required.

Add Dialog to Build Method

Add the StepUpPasswordDialog widget to your GetNotificationsScreen build:

// lib/tutorial/screens/notification/get_notifications_screen.dart (addition to build method)

@override
Widget build(BuildContext context) {
  return Stack(
    children: [
      Scaffold(
        appBar: AppBar(
          title: const Text('Notifications'),
        ),
        drawer: const Drawer(
          child: DrawerContent(),
        ),
        body: _isLoading
            ? const Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    CircularProgressIndicator(),
                    SizedBox(height: 16),
                    Text('Loading notifications...'),
                  ],
                ),
              )
            : _error != null
                ? Center(
                    child: Padding(
                      padding: const EdgeInsets.all(20),
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          const Icon(
                            Icons.error_outline,
                            size: 48,
                            color: Colors.red,
                          ),
                          const SizedBox(height: 16),
                          Text(
                            _error!,
                            textAlign: TextAlign.center,
                          ),
                          const SizedBox(height: 16),
                          ElevatedButton(
                            onPressed: _loadNotifications,
                            child: const Text('Retry'),
                          ),
                        ],
                      ),
                    ),
                  )
                : _notifications.isEmpty
                    ? const Center(
                        child: Text('No notifications available'),
                      )
                    : ListView.builder(
                        itemCount: _notifications.length,
                        itemBuilder: (context, index) {
                          final notification = _notifications[index];
                          return _buildNotificationItem(notification);
                        },
                      ),
      ),

      // Step-Up Password Dialog
      StepUpPasswordDialog(
        visible: _showStepUpAuth,
        notificationTitle: _stepUpNotificationTitle,
        notificationMessage: _stepUpNotificationMessage,
        userID: widget.sessionData?.userId ?? '',
        attemptsLeft: _stepUpAttemptsLeft,
        errorMessage: _stepUpErrorMessage.isNotEmpty ? _stepUpErrorMessage : null,
        isSubmitting: _stepUpSubmitting,
        onSubmitPassword: _handleStepUpPasswordSubmit,
        onCancel: _handleStepUpCancel,
      ),
    ],
  );
}

Verify State Management

Ensure all state updates are properly managed with setState:

// lib/tutorial/screens/notification/get_notifications_screen.dart (verification)

// All state variables should be instance variables
bool _showStepUpAuth = false;
String _stepUpNotificationTitle = '';
int _stepUpAttemptsLeft = 3;
String _stepUpErrorMessage = '';
bool _stepUpSubmitting = false;

// All updates should be wrapped in setState
setState(() {
  _showStepUpAuth = true;
  _stepUpAttemptsLeft = data.attemptsLeft ?? 3;
  _stepUpErrorMessage = errorMessage;
});

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:
    • Dialog 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
    • Navigates to dashboard

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, Touch ID, Fingerprint)
    • No getPassword event triggered
    • No password dialog displayed
  5. Complete biometric authentication
  6. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Navigates to dashboard

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" or use back button)
  7. Verify fallback behavior:
    • SDK automatically triggers getPassword with challengeMode 3
    • StepUpPasswordDialog appears as fallback
    • No error alert shown (seamless transition)
  8. Enter password and submit
  9. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Navigates to dashboard

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
    • SDKEventProvider handles logout
    • User navigated to login 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
    • SDKEventProvider handles logout
    • User navigated to login 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
    • SingleChildScrollView allows scrolling if needed
    • Buttons are not hidden behind keyboard
  6. Test on both iOS and Android

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 (GetNotificationsScreen) 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 prop drilling
  4. UI Flow: Modal overlay maintains screen context, better UX
  5. Lifecycle Management: Handler active only when screen mounted, automatic cleanup
  6. Callback Preservation: Chains with global handler, doesn't break other challenge modes
// GetNotificationsScreen.dart - Screen-level approach
void _handleGetPasswordStepUp(RDNAGetPassword data) {
  // Only handle challengeMode 3
  if (data.challengeMode != 3) {
    if (_originalGetPasswordHandler != null) {
      _originalGetPasswordHandler!(data);
    }
    return;
  }

  // Screen has direct access to notification context
  setState(() {
    _showStepUpAuth = true;
  });
  // Notification title, message, action already in state
}

Global Handler Approach (Alternative - Not Used)

Disadvantages if we used global approach:

  1. No Context Access: Notification data not available in global provider
  2. Complex State Management: Need global state to pass notification data
  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
// SDKEventProvider.dart - Global approach (NOT USED)
void _handleGetPassword(RDNAGetPassword data) {
  if (data.challengeMode == 3) {
    // Problems:
    // - Notification context not available here
    // - Need complex state management to pass data
    // - Navigation to new screen breaks UX
    appRouter.goNamed('stepUpAuthScreen', extra: /* ??? */);
  }
}

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 setState, no providers

Need global state (Riverpod)

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 setState: State updates not wrapped in setState
// ❌ Wrong - No setState
_showStepUpAuth = true;

// βœ… Correct - With setState
setState(() {
  _showStepUpAuth = true;
});
  1. Incorrect Handler Setup: Missing handler in event manager setup
// ❌ Wrong - Missing handler setup
@override
void initState() {
  super.initState();
  _loadNotifications();
}

// βœ… Correct - Setup handlers
@override
void initState() {
  super.initState();
  _setupEventHandlers();
  _loadNotifications();
}
  1. Modal Hidden Behind Action Modal: Action modal not closed before showing password dialog
// ❌ Wrong - Action modal still visible
setState(() {
  _showStepUpAuth = true;
});

// βœ… Correct - Close action modal first
setState(() {
  _showActionModal = false;
  _showStepUpAuth = true;
});

Issue 2: Password Field Not Clearing on Retry

Symptoms:

Solution: Password clearing is handled in didUpdateWidget

// βœ… Correct - Auto-clear password on error
@override
void didUpdateWidget(StepUpPasswordDialog oldWidget) {
  super.didUpdateWidget(oldWidget);

  // Clear password field when error message changes
  if (widget.errorMessage != null &&
      widget.errorMessage != oldWidget.errorMessage) {
    _passwordController.clear();
  }
}

Issue 3: Keyboard Hiding Action Buttons

Symptoms:

Solution: Ensure proper SingleChildScrollView configuration

// βœ… Correct - SingleChildScrollView with proper config
Column(
  mainAxisSize: MainAxisSize.min,
  children: [
    // Header (fixed)
    Container(/* header */),

    // Content (scrollable)
    Flexible(
      child: SingleChildScrollView(
        padding: const EdgeInsets.all(20),
        child: Column(/* content */),
      ),
    ),

    // Buttons (fixed)
    Container(/* buttons */),
  ],
)

Issue 4: Global Handler Broken After Screen Unmounts

Symptoms:

Solution: Ensure proper cleanup in dispose

// βœ… Correct - Restore original handler on cleanup
@override
void dispose() {
  final rdnaService = ref.read(rdnaServiceProvider);
  final eventManager = rdnaService.getEventManager();

  // Critical: restore original handler
  eventManager.setGetPasswordHandler(_originalGetPasswordHandler);

  super.dispose();
}

Issue 5: Alert Not Showing Before Logout

Symptoms:

Solution: Ensure alert is shown in onUpdateNotification handler

// βœ… Correct - Show alert BEFORE SDK logout
if (statusCode == 110 || statusCode == 153) {
  setState(() {
    _showStepUpAuth = false;
    _showActionModal = false;
  });

  // Show alert first
  if (mounted) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Authentication Failed'),
        content: Text(statusMessage),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              // SDK will trigger logout after this
            },
            child: const Text('OK'),
          ),
        ],
      ),
    );
  }
}

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 should allow retry, not fallback

// In onUpdateNotification handler:
if (data.error?.longErrorCode == 131) {
  // LDA cancelled
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Authentication Cancelled'),
      content: const Text('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 Android

Symptoms:

Solution: PopScope is implemented in StepUpPasswordDialog

// βœ… Already implemented in StepUpPasswordDialog
return PopScope(
  canPop: !widget.isSubmitting,
  onPopInvokedWithResult: (didPop, result) {
    if (!didPop && !widget.isSubmitting) {
      widget.onCancel();
    }
  },
  child: Dialog(/* content */),
);

Debugging Tips

Enable detailed logging to troubleshoot issues:

// Add detailed console logs at each step
print('GetNotificationsScreen - getPassword event:');
print('  Challenge Mode: ${data.challengeMode}');
print('  Attempts Left: ${data.attemptsLeft}');
print('  Status Code: ${data.challengeResponse?.status?.statusCode}');

print('GetNotificationsScreen - State before showing modal:');
print('  _showStepUpAuth: $_showStepUpAuth');
print('  _stepUpAttemptsLeft: $_stepUpAttemptsLeft');
print('  _stepUpNotificationTitle: $_stepUpNotificationTitle');

print('GetNotificationsScreen - onUpdateNotification:');
print('  Error Code: ${data.error?.longErrorCode}');
print('  Status Code: ${data.pArgs?.response?.statusCode}');

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 on dispose
@override
void dispose() {
  _passwordController.clear();
  _passwordController.dispose();
  super.dispose();
}

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
try {
  await rdnaService.updateNotification(uuid, action);
  // Let SDK handle auth requirement via events
} catch (error) {
  // Handle API errors only
}

Error Message Sanitization

Don't expose sensitive information in error messages:

// ❌ Wrong - Exposing system details
showDialog(/* Error: Database connection failed: ${sqlError.details} */);

// βœ… Correct - User-friendly generic message
showDialog(/* Error: Unable to process action. Please try again. */);

Attempt Limiting

Respect server-configured attempt limits:

// βœ… Correct - Use SDK-provided attempts
setState(() {
  _stepUpAttemptsLeft = data.attemptsLeft ?? 3;
});

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

Session Security

Handle critical errors properly:

// βœ… Correct - Show alert BEFORE logout
if (statusCode == 110 || statusCode == 153) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Authentication Failed'),
      content: Text(statusMessage),
      actions: [
        TextButton(
          onPressed: () {
            Navigator.of(context).pop();
            // SDK will trigger logout automatically
          },
          child: const Text('OK'),
        ),
      ],
    ),
  );
}

Biometric Fallback Security

Implement proper LDA cancellation handling:

// βœ… Correct - Allow retry or fallback based on enrollment
if (data.error?.longErrorCode == 131) {
  // If both Password & LDA enrolled: SDK falls back to password
  // If only LDA enrolled: Allow user to retry LDA
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Authentication Cancelled'),
      content: const Text('Local device authentication was cancelled. Please try again.'),
    ),
  );
}

Modal Security

Prevent dismissal during sensitive operations:

// βœ… Correct - Disable dismissal during submission
PopScope(
  canPop: !widget.isSubmitting,
  onPopInvokedWithResult: (didPop, result) {
    if (!didPop && !widget.isSubmitting) {
      widget.onCancel();
    }
  },
  child: Dialog(/* content */),
)

// Disable cancel button during submission
ElevatedButton(
  onPressed: widget.isSubmitting ? null : widget.onCancel,
  child: const Text('Cancel'),
)

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 == 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.

State Management Optimization

Minimize unnecessary rebuilds:

// βœ… Correct - Batch state updates
setState(() {
  _showActionModal = false;
  _showStepUpAuth = true;
  _stepUpErrorMessage = '';
  _stepUpAttemptsLeft = 3;
});

// Note: Flutter automatically batches setState calls

Optimize Modal Rendering

Conditional rendering with SizedBox.shrink():

// βœ… Correct - Efficient hidden widget
@override
Widget build(BuildContext context) {
  if (!widget.visible) {
    return const SizedBox.shrink();
  }

  return Dialog(/* content */);
}

Memoize Complex Calculations

Cache attempts color calculation:

// βœ… Correct - Method call (Flutter rebuilds efficiently)
Color _getAttemptsColor() {
  if (widget.attemptsLeft == 1) return const Color(0xFFDC2626);
  if (widget.attemptsLeft == 2) return const Color(0xFFF59E0B);
  return const Color(0xFF10B981);
}

// Used in build:
color: _getAttemptsColor()

Memory Management

Clean up controllers and focus nodes:

// βœ… Correct - Cleanup pattern
@override
void dispose() {
  _passwordController.dispose();
  _passwordFocusNode.dispose();
  super.dispose();
}

Performance Monitoring

Monitor step-up auth performance:

// Optional: Add performance monitoring
final startTime = DateTime.now();

try {
  await rdnaService.setPassword(password, RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION);
  final duration = DateTime.now().difference(startTime);
  print('Step-up auth completed in ${duration.inMilliseconds}ms');
} catch (error) {
  final duration = DateTime.now().difference(startTime);
  print('Step-up auth failed after ${duration.inMilliseconds}ms');
}

Widget Const Optimization

Use const constructors where possible:

// βœ… Correct - Const widgets for better performance
const Text(
  'πŸ” Authentication Required',
  style: TextStyle(
    fontSize: 20,
    fontWeight: FontWeight.bold,
    color: Color(0xFFFFFFFF),
  ),
)

const SizedBox(height: 16)

const Color(0xFF3B82F6)

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 StepUpPasswordDialog: Built a modal password dialog widget 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 SingleChildScrollView and proper focus handling 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 Flutter applications.

Happy Coding! πŸš€