π― 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-flutter.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 GetNotificationsScreenonUpdateNotification 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) β
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 is specifically for notification action authorization:
Challenge Mode | Purpose | User Action Required | Screen | Trigger |
| Verify existing password | Enter password to login | VerifyPasswordScreen | User login attempt |
| Set new password | Create password during activation | SetPasswordScreen | First-time activation |
| Update password (user-initiated) | Provide current + new password | UpdatePasswordScreen | User taps "Update Password" |
| Authorize notification action | Re-enter password for verification | StepUpPasswordDialog (Modal) | updateNotification() requires auth |
| Update expired password | Provide current + new password | UpdateExpiryPasswordScreen | 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 |
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.
The StepUpPasswordDialog needs to:
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,
),
),
),
),
],
),
),
],
),
),
),
);
}
}
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:

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
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();
}
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 ...
}
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;
});
}
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.
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;
});
}
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.
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'),
),
],
),
);
}
}
}
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 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,
),
],
);
}
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.
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 (GetNotificationsScreen) rather than globally. This is a deliberate architectural choice with significant benefits.
Advantages:
// 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
}
Disadvantages if we used global approach:
// 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: /* ??? */);
}
}
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) |
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 dialog doesn't displayPossible Causes & Solutions:
// β Wrong - No setState
_showStepUpAuth = true;
// β
Correct - With setState
setState(() {
_showStepUpAuth = true;
});
// β Wrong - Missing handler setup
@override
void initState() {
super.initState();
_loadNotifications();
}
// β
Correct - Setup handlers
@override
void initState() {
super.initState();
_setupEventHandlers();
_loadNotifications();
}
// β Wrong - Action modal still visible
setState(() {
_showStepUpAuth = true;
});
// β
Correct - Close action modal first
setState(() {
_showActionModal = false;
_showStepUpAuth = true;
});
Symptoms:
getPassword triggers againSolution: 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();
}
}
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 */),
],
)
Symptoms:
getPassword events for other modes not handledSolution: 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();
}
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'),
),
],
),
);
}
}
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
}
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 */),
);
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.
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();
}
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
}
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. */);
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
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'),
),
],
),
);
}
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.'),
),
);
}
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'),
)
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'}');
Always test these security scenarios:
Let's optimize the step-up authentication implementation for better performance.
Minimize unnecessary rebuilds:
// β
Correct - Batch state updates
setState(() {
_showActionModal = false;
_showStepUpAuth = true;
_stepUpErrorMessage = '';
_stepUpAttemptsLeft = 3;
});
// Note: Flutter automatically batches setState calls
Conditional rendering with SizedBox.shrink():
// β
Correct - Efficient hidden widget
@override
Widget build(BuildContext context) {
if (!widget.visible) {
return const SizedBox.shrink();
}
return Dialog(/* content */);
}
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()
Clean up controllers and focus nodes:
// β
Correct - Cleanup pattern
@override
void dispose() {
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
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');
}
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.
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
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 Flutter applications.
Happy Coding! π