🎯 Learning Path:
Welcome to the REL-ID Forgot Password codelab! This tutorial builds upon your existing MFA implementation to add secure password recovery capabilities using REL-ID SDK's verification challenge.
In this codelab, you'll enhance your existing MFA application with:
By completing this codelab, you'll master:
forgotPassword() API with proper sync response handlingchallengeMode and ENABLE_FORGOT_PASSWORD configurationgetActivationCode events for OTP/email verificationBefore starting this codelab, ensure you have:
ENABLE_FORGOT_PASSWORD capabilityThe 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-MFA-forgot-password folder in the repository you cloned earlier
This codelab extends your MFA application with three core forgot password components:
Before implementing forgot password functionality, let's understand the key SDK events and APIs that power the password recovery workflow.
The password recovery process follows this event-driven pattern:
VerifyPasswordScreen (challengeMode=0 and ENABLE_FORGOT_PASSWORD=true) → forgotPassword() API → getActivationCode Event →
User Enters OTP → setActivationCode() API → getUserConsentForLDA/getPassword Event →
Password Reset Complete → onUserLoggedIn Event → Dashboard
The REL-ID SDK triggers these main events during forgot password flow:
Event Type | Description | User Action Required |
Verification challenge triggered after forgotPassword() | User enters OTP/verification code | |
LDA setup required after verification (Path A) | User approves biometric authentication setup | |
Direct password reset required (Path B) | User creates new password with policy validation | |
Automatic login after successful password reset | System navigates to dashboard automatically |
Forgot password functionality requires specific conditions:
// Forgot password display conditions
challengeMode == 0 AND ENABLE_FORGOT_PASSWORD == "true"
Condition | Description | Display Forgot Password |
| Manual password entry mode | ✅ Required condition |
| Password creation mode | ❌ Not applicable |
| Server feature enabled | ✅ Required configuration |
| Server feature disabled | ❌ Hide forgot password link |
Add these Dart definitions to understand the forgot password API structure:
// lib/uniken/services/rdna_service.dart (forgot password addition)
/// Initiates forgot password flow for password reset
///
/// ## Parameters
/// - [userId]: Optional user ID for the forgot password flow
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
Future<RDNASyncResponse> forgotPassword([String? userId]) async {
print('RdnaService - Initiating forgot password flow for userId: ${userId ?? "current user"}');
final response = await _rdnaClient.forgotPassword(userId);
if (response.error?.longErrorCode == 0) {
print('RdnaService - ForgotPassword sync success, starting verification challenge');
return response;
} else {
print('RdnaService - ForgotPassword sync error: ${response.error?.errorString}');
throw response;
}
}
Let's implement the forgot password API in your service layer following established REL-ID SDK patterns.
Add the forgot password method to your existing service implementation:
// lib/uniken/services/rdna_service.dart (addition to existing class)
/// Initiates forgot password flow for password reset
///
/// This method initiates the forgot password flow when challengeMode == 0 and ENABLE_FORGOT_PASSWORD is true.
/// It triggers a verification challenge followed by password reset process.
/// Can only be used on an active device and requires user verification.
///
/// ## See Also
/// https://developer.uniken.com/docs/forgot-password
///
/// ## Workflow
/// 1. User initiates forgot password
/// 2. SDK triggers verification challenge (e.g., activation code, email OTP)
/// 3. User completes challenge
/// 4. SDK validates challenge
/// 5. User sets new password
/// 6. SDK logs user in automatically
///
/// ## Response Validation Logic
/// 1. Check error.longErrorCode: 0 = success, > 0 = error
/// 2. Success typically starts verification challenge flow
/// 3. Error Code 170 = Feature not supported
/// 4. Async events will be handled by event listeners
///
/// ## Parameters
/// - [userId]: Optional user ID for the forgot password flow
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
Future<RDNASyncResponse> forgotPassword([String? userId]) async {
print('RdnaService - Initiating forgot password flow for userId: ${userId ?? "current user"}');
final response = await _rdnaClient.forgotPassword(userId);
print('RdnaService - ForgotPassword sync response received');
print('RdnaService - Sync response:');
print(' Long Error Code: ${response.error?.longErrorCode}');
print(' Short Error Code: ${response.error?.shortErrorCode}');
return response;
}
Notice how this implementation follows the exact pattern established by other service methods:
Pattern Element | Implementation Detail |
Async/Await | Uses Flutter's async/await for asynchronous operations |
Error Checking | Validates |
Logging Strategy | Comprehensive print statements for debugging |
Direct Return | Returns response directly - caller handles success/error |
Now let's enhance your VerifyPasswordScreen to display forgot password functionality conditionally based on challenge mode and server configuration.
Implement the logic to determine when forgot password should be available:
// lib/tutorial/screens/mfa/verify_password_screen.dart (additions)
/// Check if forgot password is enabled from challenge info
/// Show "Forgot Password" only when:
/// - challengeMode is 0 (manual password entry)
/// - ENABLE_FORGOT_PASSWORD is true
bool _isForgotPasswordEnabled() {
if (widget.eventData?.challengeMode != 0) return false;
// Check for ENABLE_FORGOT_PASSWORD in challenge info
final challengeInfo = widget.eventData?.challengeResponse?.challengeInfo;
if (challengeInfo != null) {
final enableForgotPassword = challengeInfo.firstWhere(
(info) => info.key == 'ENABLE_FORGOT_PASSWORD',
orElse: () => RDNAChallengeInfo(key: '', value: 'false'),
).value;
return enableForgotPassword?.toLowerCase() == 'true';
}
// Default to true for challengeMode 0 if configuration is not available
// This maintains backward compatibility
return true;
}
Enhance your screen's state management to handle forgot password loading:
// lib/tutorial/screens/mfa/verify_password_screen.dart (state additions)
class _VerifyPasswordScreenState extends ConsumerState<VerifyPasswordScreen> {
final _passwordController = TextEditingController();
String? _error;
bool _isSubmitting = false;
bool _isForgotPasswordLoading = false;
String? _userId;
int? _attemptsLeft;
/// Check if any operation is in progress
bool _isAnyOperationInProgress() {
return _isSubmitting || _isForgotPasswordLoading;
}
}
Add the forgot password handling logic with proper error management:
// lib/tutorial/screens/mfa/verify_password_screen.dart (handler implementation)
/// Handle forgot password flow
Future<void> _handleForgotPassword() async {
if (_isForgotPasswordLoading || _isSubmitting) return;
setState(() {
_isForgotPasswordLoading = true;
_error = null;
_validationMessage = null;
});
print('VerifyPasswordScreen - Initiating forgot password flow for userID: $_userId');
final rdnaService = RdnaService.getInstance();
final response = await rdnaService.forgotPassword(_userId);
print('VerifyPasswordScreen - ForgotPassword sync response received');
print(' Long Error Code: ${response.error?.longErrorCode}');
print(' Short Error Code: ${response.error?.shortErrorCode}');
if (response.error?.longErrorCode == 0) {
// Success - SDK will handle async events (getActivationCode, getPassword, etc.)
print('VerifyPasswordScreen - Forgot password initiated, waiting for async events');
setState(() {
_validationSuccess = true;
_validationMessage = 'Password reset initiated successfully!';
_isForgotPasswordLoading = false;
});
} else {
setState(() {
_error = response.error?.errorString ?? 'Failed to initiate forgot password';
_isForgotPasswordLoading = false;
});
}
}
Implement the forgot password link with proper loading states:
// Forgot Password Link - Only show when challengeMode == 0 and ENABLE_FORGOT_PASSWORD is true
if (_isForgotPasswordEnabled()) ...[
const SizedBox(height: 16),
if (_isForgotPasswordLoading)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3498db)),
),
),
SizedBox(width: 12),
Text(
'Initiating password reset...',
style: TextStyle(
fontSize: 16,
color: Color(0xFF3498db),
fontWeight: FontWeight.w500,
),
),
],
)
else
TextButton(
onPressed: _isAnyOperationInProgress() ? null : _handleForgotPassword,
child: Text(
'Forgot Password?',
style: TextStyle(
fontSize: 16,
color: _isAnyOperationInProgress()
? const Color(0xFFbdc3c7)
: const Color(0xFF3498db),
decoration: TextDecoration.underline,
fontWeight: FontWeight.w500,
),
),
),
],
The forgot password flow involves a sequence of events that your event manager must handle properly. Let's ensure your event handling supports the complete flow.
After calling forgotPassword(), the SDK triggers this event sequence:
// Complete forgot password event flow
forgotPassword() → getActivationCode → getUserConsentForLDA/getPassword → onUserLoggedIn
Ensure your rdnaEventManager.dart has proper handlers for all forgot password events:
// lib/uniken/services/rdna_event_manager.dart (verify these handlers exist)
/// Handle activation code request (triggered after forgotPassword)
void setGetActivationCodeHandler(RDNAGetActivationCodeCallback? callback) {
_getActivationCodeHandler = callback;
}
void _onGetActivationCode(dynamic activationCodeData) {
final data = activationCodeData as RDNAGetActivationCode;
print('EventManager - onGetActivationCode triggered for forgot password flow');
if (_getActivationCodeHandler != null) {
_getActivationCodeHandler!(data);
}
}
/// Handle LDA consent request (one possible path after verification)
void setGetUserConsentForLDAHandler(RDNAGetUserConsentForLDACallback? callback) {
_getUserConsentForLDAHandler = callback;
}
void _onGetUserConsentForLDA(dynamic ldaConsentData) {
final data = ldaConsentData as RDNAGetUserConsentForLDA;
print('EventManager - onGetUserConsentForLDA triggered in forgot password flow');
if (_getUserConsentForLDAHandler != null) {
_getUserConsentForLDAHandler!(data);
}
}
/// Handle password reset request (alternative path after verification)
void setGetPasswordHandler(RDNAGetPasswordCallback? callback) {
_getPasswordHandler = callback;
}
void _onGetPassword(dynamic passwordData) {
final data = passwordData as RDNAGetPassword;
print('EventManager - onGetPassword triggered in forgot password flow');
print(' Challenge Mode: ${data.challengeMode}');
if (_getPasswordHandler != null) {
_getPasswordHandler!(data);
}
}
/// Handle successful login (final step of forgot password flow)
void setUserLoggedInHandler(RDNAUserLoggedInCallback? callback) {
_userLoggedInHandler = callback;
}
void _onUserLoggedIn(dynamic loggedInData) {
final data = loggedInData as RDNAUserLoggedIn;
print('EventManager - onUserLoggedIn triggered - forgot password flow complete');
if (_userLoggedInHandler != null) {
_userLoggedInHandler!(data);
}
}
Ensure your navigation system properly handles the forgot password event chain with type-safe route management.
Update your navigation configuration to support forgot password parameters:
// lib/tutorial/navigation/app_router.dart (route enhancements)
final appRouter = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/verify-password',
name: 'verifyPasswordScreen',
builder: (context, state) {
final data = state.extra as RDNAGetPassword?;
return VerifyPasswordScreen(eventData: data);
},
),
GoRoute(
path: '/activation-code',
name: 'activationCodeScreen',
builder: (context, state) {
final data = state.extra as RDNAGetActivationCode?;
return ActivationCodeScreen(eventData: data);
},
),
GoRoute(
path: '/set-password',
name: 'setPasswordScreen',
builder: (context, state) {
final data = state.extra as RDNAGetPassword?;
return SetPasswordScreen(eventData: data);
},
),
GoRoute(
path: '/lda-consent',
name: 'ldaConsentScreen',
builder: (context, state) {
final data = state.extra as RDNAGetUserConsentForLDA?;
return UserLDAConsentScreen(eventData: data);
},
),
GoRoute(
path: '/dashboard',
name: 'dashboardScreen',
builder: (context, state) {
final data = state.extra as RDNAUserLoggedIn?;
return DashboardScreen(eventData: data);
},
),
],
);
Ensure proper event routing in your global SDK event provider:
// lib/uniken/providers/sdk_event_provider.dart
class _SDKEventProviderWidgetState extends ConsumerState<SDKEventProviderWidget> {
@override
void initState() {
super.initState();
_setupSDKEventHandlers();
}
void _setupSDKEventHandlers() {
final rdnaService = RdnaService.getInstance();
final eventManager = rdnaService.getEventManager();
// Navigation handlers for forgot password event chain
eventManager.setGetActivationCodeHandler(_handleGetActivationCode);
eventManager.setGetPasswordHandler(_handleGetPassword);
eventManager.setGetUserConsentForLDAHandler(_handleGetUserConsentForLDA);
eventManager.setUserLoggedInHandler(_handleUserLoggedIn);
}
void _handleGetActivationCode(RDNAGetActivationCode data) {
appRouter.goNamed('activationCodeScreen', extra: data);
}
void _handleGetPassword(RDNAGetPassword data) {
// Route based on challengeMode
if (data.challengeMode == 0) {
appRouter.goNamed('verifyPasswordScreen', extra: data);
} else {
appRouter.goNamed('setPasswordScreen', extra: data);
}
}
void _handleGetUserConsentForLDA(RDNAGetUserConsentForLDA data) {
appRouter.goNamed('ldaConsentScreen', extra: data);
}
void _handleUserLoggedIn(RDNAUserLoggedIn data) {
appRouter.goNamed('dashboardScreen', extra: data);
}
}
Let's test your forgot password implementation with comprehensive scenarios to ensure proper functionality.
Setup Requirements:
ENABLE_FORGOT_PASSWORD = "true"challengeMode = 0Test Steps:
Expected Results:
Setup Requirements:
ENABLE_FORGOT_PASSWORD = "false" or missingchallengeMode = 0Test Steps:
Expected Results:
Setup Requirements:
challengeMode = 1 (password creation mode)Test Steps:
Expected Results:
# Run on connected device
flutter run
# Run with verbose logging
flutter run -v
# Run on specific device
flutter run -d <device-id>
# Hot reload during testing (press 'r' in terminal)
# Hot restart during testing (press 'R' in terminal)
Prepare your forgot password implementation for production deployment with these essential considerations.
Here's your complete reference implementation combining all the patterns and best practices covered in this codelab.
// lib/tutorial/screens/mfa/verify_password_screen.dart (complete implementation)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rdna_client/rdna_struct.dart';
import '../../../uniken/services/rdna_service.dart';
import '../../../uniken/utils/rdna_event_utils.dart';
import '../components/custom_button.dart';
import '../components/custom_input.dart';
import '../components/status_banner.dart';
import '../components/close_button.dart';
class VerifyPasswordScreen extends ConsumerStatefulWidget {
final RDNAGetPassword? eventData;
const VerifyPasswordScreen({
super.key,
this.eventData,
});
@override
ConsumerState<VerifyPasswordScreen> createState() =>
_VerifyPasswordScreenState();
}
class _VerifyPasswordScreenState extends ConsumerState<VerifyPasswordScreen> {
final _passwordController = TextEditingController();
String? _error;
bool _isSubmitting = false;
bool _isForgotPasswordLoading = false;
String? _validationMessage;
bool _validationSuccess = false;
bool _obscurePassword = true;
String? _userId;
int? _attemptsLeft;
@override
void initState() {
super.initState();
_processEventData();
}
@override
void didUpdateWidget(VerifyPasswordScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-process when eventData changes (SDK re-triggers getPassword with error)
if (widget.eventData != oldWidget.eventData) {
print('VerifyPasswordScreen - Event data updated, re-processing');
_processEventData();
}
}
void _processEventData() {
if (widget.eventData == null) return;
final data = widget.eventData!;
setState(() {
_userId = data.userId;
_attemptsLeft = data.attemptsLeft;
});
// Check for API errors first
if (RDNAEventUtils.hasApiError(data.error)) {
final errorMessage = RDNAEventUtils.getErrorMessage(data.error, null);
setState(() {
_error = errorMessage;
_validationSuccess = false;
});
return;
}
// Check for status errors
if (RDNAEventUtils.hasStatusError(data.challengeResponse?.status)) {
final errorMessage = RDNAEventUtils.getErrorMessage(
null, data.challengeResponse?.status);
setState(() {
_error = errorMessage;
_validationSuccess = false;
});
return;
}
// Success - ready for password input
setState(() {
_validationSuccess = true;
_validationMessage = 'Ready to verify password';
_error = null;
});
}
/// Check if forgot password is enabled from challenge info
bool _isForgotPasswordEnabled() {
if (widget.eventData?.challengeMode != 0) return false;
final challengeInfo = widget.eventData?.challengeResponse?.challengeInfo;
if (challengeInfo != null) {
final enableForgotPassword = challengeInfo.firstWhere(
(info) => info.key == 'ENABLE_FORGOT_PASSWORD',
orElse: () => RDNAChallengeInfo(key: '', value: 'false'),
).value;
return enableForgotPassword?.toLowerCase() == 'true';
}
return true;
}
/// Handle forgot password flow
Future<void> _handleForgotPassword() async {
if (_isForgotPasswordLoading || _isSubmitting) return;
setState(() {
_isForgotPasswordLoading = true;
_error = null;
_validationMessage = null;
});
final rdnaService = RdnaService.getInstance();
final response = await rdnaService.forgotPassword(_userId);
if (response.error?.longErrorCode == 0) {
setState(() {
_validationSuccess = true;
_validationMessage = 'Password reset initiated successfully!';
_isForgotPasswordLoading = false;
});
} else {
setState(() {
_error = response.error?.errorString ?? 'Failed to initiate forgot password';
_isForgotPasswordLoading = false;
});
}
}
Future<void> _handleVerifyPassword() async {
final password = _passwordController.text;
setState(() {
_isSubmitting = true;
_error = null;
});
final rdnaService = RdnaService.getInstance();
final response = await rdnaService.setPassword(
password,
RDNAChallengeOpMode.RDNA_CHALLENGE_OP_VERIFY,
);
if (response.error?.longErrorCode == 0) {
setState(() {
_validationSuccess = true;
_isSubmitting = false;
});
} else {
setState(() {
_error = response.error?.errorString ?? 'Unknown error';
_isSubmitting = false;
});
}
}
Future<void> _handleClose() async {
final rdnaService = RdnaService.getInstance();
await rdnaService.resetAuthState();
}
bool _isFormValid() {
return _passwordController.text.isNotEmpty && _error == null;
}
bool _isAnyOperationInProgress() {
return _isSubmitting || _isForgotPasswordLoading;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: SafeArea(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Verify Password',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF2c3e50),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Enter your password to continue',
style: TextStyle(
fontSize: 16,
color: Color(0xFF7f8c8d),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
if (_userId != null) ...[
const Text(
'Welcome back',
style: TextStyle(fontSize: 18, color: Color(0xFF2c3e50)),
),
const SizedBox(height: 4),
Text(
_userId!,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF3498db),
),
),
const SizedBox(height: 20),
],
if (_attemptsLeft != null)
StatusBanner(
type: StatusBannerType.warning,
message: '$_attemptsLeft attempt${_attemptsLeft != 1 ? 's' : ''} remaining',
),
if (_error != null)
StatusBanner(
type: StatusBannerType.error,
message: _error!,
),
CustomInput(
label: 'Password',
value: _passwordController.text,
onChanged: (value) {
setState(() {
_passwordController.text = value;
if (_error != null) _error = null;
});
},
placeholder: 'Enter your password',
obscureText: _obscurePassword,
enabled: !_isAnyOperationInProgress(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
color: const Color(0xFF7f8c8d),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
CustomButton(
title: _isSubmitting ? 'Verifying...' : 'Verify Password',
onPress: _handleVerifyPassword,
loading: _isSubmitting,
disabled: !_isFormValid() || _isAnyOperationInProgress(),
),
// Forgot Password Link
if (_isForgotPasswordEnabled()) ...[
const SizedBox(height: 16),
if (_isForgotPasswordLoading)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3498db)),
),
),
SizedBox(width: 12),
Text(
'Initiating password reset...',
style: TextStyle(
fontSize: 16,
color: Color(0xFF3498db),
fontWeight: FontWeight.w500,
),
),
],
)
else
TextButton(
onPressed: _isAnyOperationInProgress() ? null : _handleForgotPassword,
child: Text(
'Forgot Password?',
style: TextStyle(
fontSize: 16,
color: _isAnyOperationInProgress()
? const Color(0xFFbdc3c7)
: const Color(0xFF3498db),
decoration: TextDecoration.underline,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFe8f4f8),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Enter your password to verify your identity and continue.',
style: TextStyle(
fontSize: 14,
color: Color(0xFF2c3e50),
),
textAlign: TextAlign.center,
),
),
],
),
),
CustomCloseButton(
onPressed: _handleClose,
disabled: _isAnyOperationInProgress(),
),
],
),
),
),
);
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
}
The following image showcases the screen from the sample application:

Congratulations! You've successfully implemented secure forgot password functionality with the REL-ID SDK.
✅ Conditional Forgot Password UI - Smart display logic based on challenge mode and server configuration ✅ Secure API Integration - Proper forgotPassword() implementation with error handling ✅ Event Chain Management - Complete flow from verification to password reset to login ✅ Production-Ready Code - Comprehensive error handling, loading states, and security practices ✅ User Experience Excellence - Clear feedback, intuitive flow, and accessibility support
🔐 You've mastered secure password recovery with REL-ID SDK!
Your implementation provides users with a seamless, secure way to recover their accounts while maintaining the highest security standards. Use this foundation to build robust authentication experiences that users can trust.