🎯 Learning Path:

  1. Complete REL-ID Complete Activation & Login Flow Codelab
  2. Complete REL-ID Additional Device Activation Flow Codelab
  3. Complete REL-ID Forgot Password Flow Codelab
  4. You are here → Password Expiry Flow Implementation

Welcome to the REL-ID Password Expiry codelab! This tutorial builds upon your existing MFA implementation to add secure expired password update capabilities using REL-ID SDK's updatePassword API.

What You'll Build

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

What You'll Learn

By completing this codelab, you'll master:

  1. Password Expiry Detection: Identifying when password has expired and routing to update flow
  2. UpdatePassword API Integration: Implementing updatePassword(current, new, challengeMode) with proper handling
  3. Password Policy Extraction: Parsing RELID_PASSWORD_POLICY from challenge data
  4. Password Reuse Handling: Detecting and recovering from password reuse errors (statusCode 164)
  5. Three-Field Validation: Validating current, new, and confirm passwords with proper error messages
  6. Production Security Patterns: Implement secure password expiry with comprehensive error handling

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-MFA-password-expiry folder in the repository you cloned earlier

Codelab Architecture Overview

This codelab extends your MFA application with three core password expiry components:

  1. UpdateExpiryPasswordScreen: Three-field password form with policy display and validation
  2. UpdatePassword API Integration: Service layer implementation following established SDK patterns
  3. getPassword Event Routing Enhancement: SDKEventProvider routing for challengeMode 4 detection

Before implementing password expiry functionality, let's understand the key SDK events and APIs that power the expired password update workflow.

Password Expiry Event Flow

The password expiry process follows this event-driven pattern:

Login with Expired Password with challengeMode=0 → Server Detects Expiry (statusCode 118) →
SDK Triggers getPassword Event with challengeMode=4 → UpdateExpiryPasswordScreen Displays →
User Updates Password → updatePassword(current, new, challengeMode) API → onUserLoggedIn Event → Dashboard

Password Expiry Trigger Mechanism

When a user's password expires, the login flow changes:

Step

Event

Description

1. User Login

VerifyPasswordScreen with challengeMode = 0

User enters credentials for standard login

2. Password Expired

Server returns statusCode = 118

Server detects password has expired

3. SDK Re-triggers

getPassword event with challengeMode = 4

SDK automatically requests password update

4. User Shows Screen

UpdateExpiryPasswordScreen displays

Show UpdateExpiryPasswordScreen with current, new, and confirm password fields

5. User Update Password

updatePassword API

User must provide current and new password

Challenge Mode 4 - RDNA_OP_UPDATE_ON_EXPIRY

Challenge Mode 4 is specifically for expired password updates:

Challenge Mode

Purpose

User Action Required

Screen

challengeMode = 0

Verify existing password

Enter password to login

VerifyPasswordScreen

challengeMode = 1

Set new password

Create password during activation

SetPasswordScreen

challengeMode = 4

Update expired password

Provide current + new password

UpdateExpiryPasswordScreen

Core Password Expiry Event Types

The REL-ID SDK triggers these main events during password expiry flow:

Event Type

Description

User Action Required

getPassword (challengeMode=4)

Password expiry detected, update required

User provides current and new passwords

onUserLoggedIn

Automatic login after successful password update

System navigates to dashboard automatically

Password Policy Extraction

Password expiry flow uses the same default policy key as password creation:

Flow

Policy Key

Description

Password Creation (challengeMode=1)

RELID_PASSWORD_POLICY

Policy for new password creation

Password Expiry (challengeMode=4)

RELID_PASSWORD_POLICY

Policy for expired password update

Password Reuse Detection

The server maintains password history and detects reuse:

Status Code

Meaning

Action

statusCode = 118

Password has expired

Initial trigger for password update

statusCode = 164

Password reuse detected

Clear fields and prompt for different password

UpdatePassword API Pattern

Add these Dart definitions to understand the updatePassword API structure:

// lib/uniken/services/rdna_service.dart (password expiry addition)

/// Updates password when expired (Password Expiry Flow)
///
/// This method is specifically used for updating expired passwords during the MFA flow.
/// When a password is expired during login (challengeMode=0), the SDK automatically
/// re-triggers getPassword() with challengeMode=4 (RDNA_OP_UPDATE_ON_EXPIRY).
/// The app should then call this method with both current and new passwords.
///
/// ## Parameters
/// - [currentPassword]: The user's current password
/// - [newPassword]: The new password to set
/// - [challengeMode]: Challenge mode (should be RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY for password expiry)
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
///
/// ## Events Triggered
/// - onUserLoggedIn: Successful password update triggers login event
/// - getPassword: May be re-triggered if validation fails
Future<RDNASyncResponse> updatePassword(
  String currentPassword,
  String newPassword,
  RDNAChallengeOpMode challengeMode
) async {
  print('RdnaService - Updating expired password (challengeMode: $challengeMode)');

  final response = await _rdnaClient.updatePassword(
    currentPassword,
    newPassword,
    challengeMode
  );

  print('RdnaService - UpdatePassword sync response received');
  print('  Long Error Code: ${response.error?.longErrorCode}');
  print('  Short Error Code: ${response.error?.shortErrorCode}');

  return response;
}

Let's implement the updatePassword API in your service layer following established REL-ID SDK patterns.

Enhance rdna_service.dart with UpdatePassword

Add the updatePassword method to your existing service implementation:

// lib/uniken/services/rdna_service.dart (addition to existing class)

/// Updates password when expired (Password Expiry Flow)
///
/// This method is specifically used for updating expired passwords during the MFA flow.
/// When a password is expired during login (challengeMode=0), the SDK automatically
/// re-triggers getPassword() with challengeMode=4 (RDNA_OP_UPDATE_ON_EXPIRY).
/// The app should then call this method with both current and new passwords.
///
/// ## Workflow
/// 1. User logs in with expired password (challengeMode=0)
/// 2. Server detects expiry (statusCode=118)
/// 3. SDK triggers getPassword with challengeMode=4
/// 4. App displays UpdateExpiryPasswordScreen
/// 5. User provides current and new passwords
/// 6. App calls updatePassword(current, new, challengeMode)
/// 7. SDK validates and updates password
/// 8. SDK logs user in automatically (onUserLoggedIn event)
///
/// ## Response Validation Logic
/// 1. Check error.longErrorCode: 0 = success, > 0 = error
/// 2. On success, triggers onUserLoggedIn event immediately
/// 3. On failure, may trigger getPassword again with error status
/// 4. StatusCode 164 = Password reuse error (used in last N passwords)
/// 5. Async events will be handled by event listeners
///
/// ## Parameters
/// - [currentPassword]: The user's current password
/// - [newPassword]: The new password to set
/// - [challengeMode]: Challenge mode (should be RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY for password expiry)
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
///
/// ## See Also
/// - [Password Expiry Documentation](https://developer.uniken.com/docs/password-expiry)
Future<RDNASyncResponse> updatePassword(
  String currentPassword,
  String newPassword,
  RDNAChallengeOpMode challengeMode
) async {
  print('RdnaService - Updating expired password (challengeMode: $challengeMode)');

  final response = await _rdnaClient.updatePassword(
    currentPassword,
    newPassword,
    challengeMode
  );

  print('RdnaService - UpdatePassword sync response received');
  print('  Long Error Code: ${response.error?.longErrorCode}');
  print('  Short Error Code: ${response.error?.shortErrorCode}');

  return response;
}

Service Pattern Consistency

Notice how this implementation follows the exact pattern established by other service methods:

Pattern Element

Implementation Detail

Future Wrapper

Returns Future for async/await usage

Error Checking

SDK response includes error?.longErrorCode for validation

Logging Strategy

Comprehensive logging for debugging (without exposing passwords)

Error Handling

Caller checks response.error?.longErrorCode for success

Challenge Mode

Uses RDNAChallengeOpMode enum (RDNA_OP_UPDATE_ON_EXPIRY)

Now let's enhance your SDKEventProvider to detect and route challengeMode 4 to the UpdateExpiryPasswordScreen.

Add Challenge Mode 4 Detection

Update your existing _handleGetPassword callback in SDKEventProvider:

// lib/uniken/providers/sdk_event_provider.dart (enhancement to existing handler)

/// Event handler for get password requests
///
/// Navigates to appropriate password screen based on challengeMode:
/// - challengeMode 0: VerifyPasswordScreen (login/verify)
/// - challengeMode 1: SetPasswordScreen (create new password)
/// - challengeMode 4: UpdateExpiryPasswordScreen (update expired password)
void _handleGetPassword(RDNAGetPassword data) {
  print('SDKEventProvider - Get password event received');
  print('  Status Code: ${data.challengeResponse?.status?.statusCode}');
  print('  UserID: ${data.userId}, ChallengeMode: ${data.challengeMode}, AttemptsLeft: ${data.attemptsLeft}');

  // Navigate based on challenge mode
  if (data.challengeMode == 0) {
    // Mode 0: Verify existing password (login)
    print('SDKEventProvider - Routing to VerifyPasswordScreen (challengeMode 0)');
    appRouter.goNamed('verifyPasswordScreen', extra: data);
  } else if (data.challengeMode == 4) {
    // Mode 4: Update expired password (RDNA_OP_UPDATE_ON_EXPIRY)
    print('SDKEventProvider - Routing to UpdateExpiryPasswordScreen (challengeMode 4)');
    appRouter.goNamed('updateExpiryPasswordScreen', extra: data);
  } else {
    // Mode 1 or other: Set new password
    print('SDKEventProvider - Routing to SetPasswordScreen (challengeMode ${data.challengeMode})');
    appRouter.goNamed('setPasswordScreen', extra: data);
  }
}

Challenge Mode Routing Logic

The enhanced routing logic handles three password scenarios:

Challenge Mode

Screen

Purpose

challengeMode = 0

VerifyPasswordScreen

Verify existing password for login

challengeMode = 1

SetPasswordScreen

Set new password during activation

challengeMode = 4

UpdateExpiryPasswordScreen

Update expired password

Status Message Extraction

The server provides status messages that explain why password update is required:

Status Code

Typical Status Message

118

"Password has expired. Please contact the admin."

164

"Please enter a new password as your entered password has been used by you previously. You are not allowed to use last N passwords."

Now let's create the UpdateExpiryPasswordScreen component with three password fields, comprehensive validation, and keyboard management.

Create the Screen Component

Create a new file for the password expiry screen:

// lib/tutorial/screens/mfa/update_expiry_password_screen.dart

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/password_policy_utils.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';

/// Update Expiry Password Screen Component
///
/// Handles the password expiry flow where users must update their expired
/// password by providing current password, new password, and confirmation.
class UpdateExpiryPasswordScreen extends ConsumerStatefulWidget {
  final RDNAGetPassword? eventData;

  const UpdateExpiryPasswordScreen({
    super.key,
    this.eventData,
  });

  @override
  ConsumerState<UpdateExpiryPasswordScreen> createState() =>
      _UpdateExpiryPasswordScreenState();
}

class _UpdateExpiryPasswordScreenState
    extends ConsumerState<UpdateExpiryPasswordScreen> {
  final _currentPasswordController = TextEditingController();
  final _newPasswordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  final _currentPasswordFocusNode = FocusNode();
  final _newPasswordFocusNode = FocusNode();
  final _confirmPasswordFocusNode = FocusNode();

  String? _error;
  bool _isSubmitting = false;
  bool _obscureCurrentPassword = true;
  bool _obscureNewPassword = true;
  bool _obscureConfirmPassword = true;
  String? _passwordPolicyMessage;
  String? _userName;
  int _challengeMode = 4; // RDNA_OP_UPDATE_ON_EXPIRY

  @override
  void initState() {
    super.initState();
    _processEventData();
    // Auto-focus on current password field
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _currentPasswordFocusNode.requestFocus();
    });
  }

  @override
  void didUpdateWidget(UpdateExpiryPasswordScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Re-process when eventData changes
    if (widget.eventData != oldWidget.eventData) {
      print('UpdateExpiryPasswordScreen - Event data updated, re-processing');
      _processEventData();
    }
  }

  @override
  void dispose() {
    _currentPasswordController.dispose();
    _newPasswordController.dispose();
    _confirmPasswordController.dispose();
    _currentPasswordFocusNode.dispose();
    _newPasswordFocusNode.dispose();
    _confirmPasswordFocusNode.dispose();
    super.dispose();
  }

  // ... (Continue with remaining handler functions in next section)
}

Key Implementation Features

Feature

Implementation Detail

Three Password Fields

Current, new, and confirm password with TextEditingController

Focus Management

FocusNode for each field to control keyboard focus

ConsumerStatefulWidget

Riverpod integration for state management

Error Handling

Automatic field clearing on API and status errors

Loading States

Proper _isSubmitting state management

Let's implement comprehensive password validation for the three-field form.

Add Event Processing and Field Clearing

Implement handlers to process event data and clear fields on errors:

// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (additions)

/// Handle response data from event
void _processEventData() {
  if (widget.eventData == null) return;

  final responseData = widget.eventData!;
  print('UpdateExpiryPasswordScreen - Processing response data from event');

  setState(() {
    _userName = responseData.userId ?? '';
    _challengeMode = responseData.challengeMode ?? 4;
  });

  // Extract and process password policy
  final policyJsonString = RDNAEventUtils.getChallengeValue(
    responseData.challengeResponse?.challengeInfo,
    'RELID_PASSWORD_POLICY',
  );
  if (policyJsonString != null) {
    final policyMessage = parseAndGeneratePolicyMessage(policyJsonString);
    setState(() {
      _passwordPolicyMessage = policyMessage;
    });
    print('UpdateExpiryPasswordScreen - Password policy extracted: $policyMessage');
  }

  // Check for API errors first
  if (RDNAEventUtils.hasApiError(responseData.error)) {
    final errorMessage = RDNAEventUtils.getErrorMessage(responseData.error, null);
    print('UpdateExpiryPasswordScreen - API error: $errorMessage');
    setState(() {
      _error = errorMessage;
      _isSubmitting = false;
    });
    _clearPasswordFields();
    return;
  }

  // Check for status errors (including password reuse errors like statusCode 164)
  if (RDNAEventUtils.hasStatusError(responseData.challengeResponse?.status)) {
    final errorMessage = RDNAEventUtils.getErrorMessage(
        responseData.error, responseData.challengeResponse?.status);
    print('UpdateExpiryPasswordScreen - Status error: $errorMessage');
    setState(() {
      _error = errorMessage;
      _isSubmitting = false;
    });
    _clearPasswordFields();
    return;
  }
}

/// Clear all password fields
void _clearPasswordFields() {
  _currentPasswordController.clear();
  _newPasswordController.clear();
  _confirmPasswordController.clear();
  _currentPasswordFocusNode.requestFocus();
}

/// Handle input changes - update and clear error when user types
void _onCurrentPasswordChanged(String value) {
  setState(() {
    _currentPasswordController.text = value;
    if (_error != null) _error = null;
  });
}

void _onNewPasswordChanged(String value) {
  setState(() {
    _newPasswordController.text = value;
    if (_error != null) _error = null;
  });
}

void _onConfirmPasswordChanged(String value) {
  setState(() {
    _confirmPasswordController.text = value;
    if (_error != null) _error = null;
  });
}

Implement Update Password Logic

Add the main validation and update logic:

// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (additions)

/// Handle password update submission
Future<void> _handleUpdatePassword() async {
  if (_isSubmitting) return;

  final trimmedCurrentPassword = _currentPasswordController.text.trim();
  final trimmedNewPassword = _newPasswordController.text.trim();
  final trimmedConfirmPassword = _confirmPasswordController.text.trim();

  // Basic validation
  if (trimmedCurrentPassword.isEmpty) {
    setState(() {
      _error = 'Please enter your current password';
    });
    _currentPasswordFocusNode.requestFocus();
    return;
  }

  if (trimmedNewPassword.isEmpty) {
    setState(() {
      _error = 'Please enter a new password';
    });
    _newPasswordFocusNode.requestFocus();
    return;
  }

  if (trimmedConfirmPassword.isEmpty) {
    setState(() {
      _error = 'Please confirm your new password';
    });
    _confirmPasswordFocusNode.requestFocus();
    return;
  }

  // Check password match
  if (trimmedNewPassword != trimmedConfirmPassword) {
    setState(() {
      _error = 'New password and confirm password do not match';
    });
    // Show alert dialog
    if (mounted) {
      await showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Password Mismatch'),
          content: const Text('New password and confirm password do not match'),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                _newPasswordController.clear();
                _confirmPasswordController.clear();
                _newPasswordFocusNode.requestFocus();
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
    return;
  }

  // Check if new password is same as current password
  if (trimmedCurrentPassword == trimmedNewPassword) {
    setState(() {
      _error = 'New password must be different from current password';
    });
    // Show alert dialog
    if (mounted) {
      await showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Invalid New Password'),
          content: const Text('Your new password must be different from your current password'),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                _newPasswordController.clear();
                _confirmPasswordController.clear();
                _newPasswordFocusNode.requestFocus();
              },
              child: const Text('OK'),
            ),
          ],
        ),
      );
    }
    return;
  }

  setState(() {
    _isSubmitting = true;
    _error = null;
  });

  try {
    print('UpdateExpiryPasswordScreen - Updating password with challengeMode: $_challengeMode');

    final rdnaService = RdnaService.getInstance();
    final response = await rdnaService.updatePassword(
      trimmedCurrentPassword,
      trimmedNewPassword,
      RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY, // challengeMode 4
    );

    print('UpdateExpiryPasswordScreen - UpdatePassword sync response successful');
    print('  longErrorCode: ${response.error?.longErrorCode}');
    print('  shortErrorCode: ${response.error?.shortErrorCode}');

    // Check sync response for errors
    if (response.error?.longErrorCode != 0) {
      final errorMessage = response.error?.errorString ?? 'Unknown error';
      print('UpdateExpiryPasswordScreen - Sync error: $errorMessage');
      setState(() {
        _error = errorMessage;
        _isSubmitting = false;
      });
      _clearPasswordFields();
      return;
    }

    // Success - wait for onUserLoggedIn event
    // Event handlers in SDKEventProvider will handle the navigation
    print('UpdateExpiryPasswordScreen - Sync success, waiting for onUserLoggedIn event');
  } catch (error) {
    print('UpdateExpiryPasswordScreen - Runtime error: $error');
    setState(() {
      _error = 'An unexpected error occurred. Please try again.';
      _isSubmitting = false;
    });
    _clearPasswordFields();
  }
}

/// Handle close button - reset authentication state
void _handleClose() {
  print('UpdateExpiryPasswordScreen - Calling resetAuthState');
  final rdnaService = RdnaService.getInstance();
  rdnaService.resetAuthState().then((_) {
    print('UpdateExpiryPasswordScreen - ResetAuthState successful');
  }).catchError((error) {
    print('UpdateExpiryPasswordScreen - ResetAuthState error: $error');
  });
}

/// Check if form is valid
bool get _isFormValid {
  return _currentPasswordController.text.trim().isNotEmpty &&
      _newPasswordController.text.trim().isNotEmpty &&
      _confirmPasswordController.text.trim().isNotEmpty &&
      _error == null;
}

Validation Rules Summary

Validation Rule

Error Message

Action

Current password empty

"Please enter your current password"

Focus current password field

New password empty

"Please enter a new password"

Focus new password field

Confirm password empty

"Please confirm your new password"

Focus confirm password field

Passwords don't match

"New password and confirm password do not match"

Clear new and confirm fields

New = Current password

"New password must be different from current password"

Clear new and confirm fields

Now let's build the complete UI with password policy requirements display and responsive layout.

Add the Build Method with ScrollView

Complete the component with the full UI:

// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (UI rendering)

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: const Color(0xFFf8f9fa),
    body: SafeArea(
      child: Stack(
        children: [
          SingleChildScrollView(
            padding: const EdgeInsets.all(20),
            keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                const SizedBox(height: 60), // Space for close button

                const Text(
                  'Update Expired Password',
                  style: TextStyle(
                    fontSize: 28,
                    fontWeight: FontWeight.bold,
                    color: Color(0xFF2c3e50),
                  ),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 8),

                const Text(
                  'Your password has expired. Please update it to continue.',
                  style: TextStyle(
                    fontSize: 16,
                    color: Color(0xFF7f8c8d),
                  ),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 30),

                // User Information
                if (_userName != null && _userName!.isNotEmpty)
                  Column(
                    children: [
                      const Text(
                        'Welcome',
                        style: TextStyle(
                          fontSize: 18,
                          color: Color(0xFF2c3e50),
                        ),
                        textAlign: TextAlign.center,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        _userName!,
                        style: const TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                          color: Color(0xFF3498db),
                        ),
                        textAlign: TextAlign.center,
                      ),
                      const SizedBox(height: 20),
                    ],
                  ),

                // Password Policy Display
                if (_passwordPolicyMessage != null)
                  Container(
                    padding: const EdgeInsets.all(16),
                    margin: const EdgeInsets.only(bottom: 20),
                    decoration: BoxDecoration(
                      color: const Color(0xFFf0f8ff),
                      border: const Border(
                        left: BorderSide(
                          color: Color(0xFF3498db),
                          width: 4,
                        ),
                      ),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        const Text(
                          'Password Requirements',
                          style: TextStyle(
                            fontSize: 16,
                            fontWeight: FontWeight.bold,
                            color: Color(0xFF2c3e50),
                          ),
                        ),
                        const SizedBox(height: 8),
                        Text(
                          _passwordPolicyMessage!,
                          style: const TextStyle(
                            fontSize: 14,
                            color: Color(0xFF2c3e50),
                            height: 1.43,
                          ),
                        ),
                      ],
                    ),
                  ),

                // Error Display
                if (_error != null)
                  StatusBanner(
                    type: StatusBannerType.error,
                    message: _error!,
                  ),

                // Current Password Input
                CustomInput(
                  label: 'Current Password',
                  value: _currentPasswordController.text,
                  onChanged: _onCurrentPasswordChanged,
                  placeholder: 'Enter current password',
                  obscureText: _obscureCurrentPassword,
                  enabled: !_isSubmitting,
                  focusNode: _currentPasswordFocusNode,
                  textInputAction: TextInputAction.next,
                  onSubmitted: () => _newPasswordFocusNode.requestFocus(),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscureCurrentPassword
                          ? Icons.visibility
                          : Icons.visibility_off,
                      color: const Color(0xFF7f8c8d),
                    ),
                    onPressed: () {
                      setState(() {
                        _obscureCurrentPassword = !_obscureCurrentPassword;
                      });
                    },
                  ),
                ),
                const SizedBox(height: 20),

                // New Password Input
                CustomInput(
                  label: 'New Password',
                  value: _newPasswordController.text,
                  onChanged: _onNewPasswordChanged,
                  placeholder: 'Enter new password',
                  obscureText: _obscureNewPassword,
                  enabled: !_isSubmitting,
                  focusNode: _newPasswordFocusNode,
                  textInputAction: TextInputAction.next,
                  onSubmitted: () => _confirmPasswordFocusNode.requestFocus(),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscureNewPassword
                          ? Icons.visibility
                          : Icons.visibility_off,
                      color: const Color(0xFF7f8c8d),
                    ),
                    onPressed: () {
                      setState(() {
                        _obscureNewPassword = !_obscureNewPassword;
                      });
                    },
                  ),
                ),
                const SizedBox(height: 20),

                // Confirm New Password Input
                CustomInput(
                  label: 'Confirm New Password',
                  value: _confirmPasswordController.text,
                  onChanged: _onConfirmPasswordChanged,
                  placeholder: 'Confirm new password',
                  obscureText: _obscureConfirmPassword,
                  enabled: !_isSubmitting,
                  focusNode: _confirmPasswordFocusNode,
                  textInputAction: TextInputAction.done,
                  onSubmitted: _handleUpdatePassword,
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscureConfirmPassword
                          ? Icons.visibility
                          : Icons.visibility_off,
                      color: const Color(0xFF7f8c8d),
                    ),
                    onPressed: () {
                      setState(() {
                        _obscureConfirmPassword = !_obscureConfirmPassword;
                      });
                    },
                  ),
                ),
                const SizedBox(height: 20),

                // Submit Button
                CustomButton(
                  title: _isSubmitting
                      ? 'Updating Password...'
                      : 'Update Password',
                  onPress: _handleUpdatePassword,
                  loading: _isSubmitting,
                  disabled: !_isFormValid,
                ),

                // Help Text
                Container(
                  margin: const EdgeInsets.only(top: 20),
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: const Color(0xFFe8f4f8),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: const Text(
                    'Update your password. Your new password must be different from your current password.',
                    style: TextStyle(
                      fontSize: 14,
                      color: Color(0xFF2c3e50),
                      height: 1.43,
                    ),
                    textAlign: TextAlign.center,
                  ),
                ),
              ],
            ),
          ),

          // Close Button
          CustomCloseButton(
            onPressed: _handleClose,
            disabled: _isSubmitting,
          ),
        ],
      ),
    ),
  );
}

UI Component Breakdown

Component

Purpose

Key Props

Scaffold

Root widget with background color

backgroundColor: Color(0xFFf8f9fa)

SafeArea

Ensures content within safe display area

Handles notches and system UI

Stack

Layered layout for overlay elements

Close button over scrollable content

SingleChildScrollView

Scrollable container with keyboard handling

keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag

Policy Container

Display password requirements

Shows parsed RELID_PASSWORD_POLICY

Three CustomInput Fields

Current, new, confirm passwords

Each with FocusNode for keyboard navigation

StatusBanner

Display errors (including statusCode 164)

Conditional rendering

CustomButton

Trigger password update

Disabled until form valid

Focus Navigation Pattern

Each CustomInput component has keyboard navigation configured:

Input Field

Focus Node

Next Field Focus

Current Password

_currentPasswordFocusNode

Focuses _newPasswordFocusNode when "Next" pressed

New Password

_newPasswordFocusNode

Focuses _confirmPasswordFocusNode when "Next" pressed

Confirm Password

_confirmPasswordFocusNode

Calls _handleUpdatePassword when "Done" pressed

This creates a seamless user experience where pressing the keyboard's "Next" button automatically advances to the next field, and "Done" on the final field submits the form.

The following images showcase screens from the sample application:

Expiry Update Password Screen

Expiry Update Password Screen

Let's register the UpdateExpiryPasswordScreen in your navigation configuration.

Update AppRouter with New Route

Add the screen route to your GoRouter configuration:

// lib/tutorial/navigation/app_router.dart (route additions)

import 'package:go_router/go_router.dart';
import 'package:rdna_client/rdna_struct.dart';
import '../screens/mfa/update_expiry_password_screen.dart';

final appRouter = GoRouter(
  initialLocation: '/',
  routes: [
    // ... other routes

    GoRoute(
      path: '/update-expiry-password',
      name: 'updateExpiryPasswordScreen',
      builder: (context, state) {
        final data = state.extra as RDNAGetPassword?;
        return UpdateExpiryPasswordScreen(eventData: data);
      },
    ),
  ],
);

Export from MFA Index

Add the screen to your MFA screens export:

// lib/tutorial/screens/mfa/index.dart

export 'check_user_screen.dart';
export 'activation_code_screen.dart';
export 'user_lda_consent_screen.dart';
export 'set_password_screen.dart';
export 'verify_password_screen.dart';
export 'update_expiry_password_screen.dart';
export 'verify_auth_screen.dart';
export 'dashboard_screen.dart';

Navigation Flow Verification

Verify your navigation flow is complete:

Step

Navigation Event

Screen

1. User Login

getPassword (challengeMode=0)

VerifyPasswordScreen

2. Password Expired

getPassword (challengeMode=4)

UpdateExpiryPasswordScreen

3. Password Updated

onUserLoggedIn

DashboardScreen

Now let's test the complete password expiry implementation with various scenarios.

Test Scenario 1: Standard Password Expiry Flow

Follow these steps to test standard password expiry:

  1. Login with expired password
    • Use VerifyPasswordScreen (challengeMode = 0)
    • Enter credentials for user with expired password
  2. Verify automatic navigation
    • SDK should detect expiry (statusCode 118)
    • SDK triggers getPassword with challengeMode = 4
    • App navigates to UpdateExpiryPasswordScreen
  3. Check password policy display
    • Verify "Password Requirements" section appears
    • Confirm policy is extracted from RELID_PASSWORD_POLICY
    • Check policy message is user-friendly
  4. Update password
    • Enter current password
    • Enter new password (meeting policy requirements)
    • Enter confirm password (matching new password)
    • Tap "Update Password"
  5. Verify automatic login
    • SDK should trigger onUserLoggedIn event
    • App should navigate to Dashboard automatically

Test Scenario 2: Password Reuse Detection

Test password reuse error handling:

  1. Navigate to UpdateExpiryPasswordScreen (following Scenario 1 steps 1-3)
  2. Enter recently used password
    • Current password: [user's current password]
    • New password: [password used in last N passwords]
    • Confirm password: [same as new password]
    • Tap "Update Password"
  3. Verify reuse detection
    • SDK returns statusCode 164
    • SDK re-triggers getPassword with challengeMode = 4
    • Error message displayed: "Please enter a new password as your entered password has been used by you previously..."
  4. Verify automatic field clearing
    • All three password fields should clear automatically
    • User can retry with different password
    • Error message remains visible
  5. Retry with valid password
    • Enter current password again
    • Enter new password (not in history)
    • Enter confirm password
    • Verify successful update and login

Test Scenario 3: Validation Errors

Test all validation rules:

Test Case

Expected Error

Expected Behavior

Empty current password

"Please enter your current password"

Focus current password field

Empty new password

"Please enter a new password"

Focus new password field

Empty confirm password

"Please confirm your new password"

Focus confirm password field

Passwords don't match

"New password and confirm password do not match"

Alert + clear new/confirm fields

New = Current password

"New password must be different from current password"

Alert + clear new/confirm fields

Test Scenario 4: Password Policy Violations

Test password policy enforcement:

  1. Navigate to UpdateExpiryPasswordScreen
  2. Check displayed policy requirements
    • Note minimum length, character requirements
    • Note any special restrictions
  3. Enter policy-violating password
    • Example: Too short, missing uppercase, etc.
    • Tap "Update Password"
  4. Verify server-side validation
    • Server should return policy violation error
    • SDK re-triggers getPassword with error
    • Fields should clear automatically
    • Error message should display policy violation

Debugging Tips

If you encounter issues, check these areas:

Issue

Possible Cause

Solution

Policy not displaying

Using wrong policy key

Update extraction key to RELID_PASSWORD_POLICY

Fields not clearing

Missing field clear logic in error handling

Add clear() calls for all controllers

Navigation not working

challengeMode 4 not routed in SDKEventProvider

Add if (data.challengeMode == 4) routing

API not called

Form validation failing

Check _isFormValid getter logic

Focus not working

FocusNode not properly assigned

Verify each CustomInput has correct focusNode

Before deploying password expiry functionality to production, review these important considerations.

Security Best Practices

Practice

Implementation

Importance

Never log passwords

Remove all print statements that might expose passwords

Critical

Password history

Respect server-configured history limits

High

Policy enforcement

Always display and enforce RELID_PASSWORD_POLICY

High

Error handling

Clear fields on all errors to prevent data exposure

High

User Experience Optimization

Enhance user experience with these patterns:

// 1. Clear, specific error messages
if (trimmedNewPassword == trimmedCurrentPassword) {
  setState(() {
    _error = 'New password must be different from current password';
  });
  // Show alert dialog with actionable guidance
  await showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Invalid New Password'),
      content: const Text('Your new password must be different from your current password'),
    ),
  );
}

// 2. Automatic field clearing on errors
if (RDNAEventUtils.hasStatusError(responseData.challengeResponse?.status)) {
  setState(() {
    _error = errorMessage;
    _isSubmitting = false;
  });
  _clearPasswordFields();
}

// 3. Keyboard navigation with FocusNode
CustomInput(
  textInputAction: TextInputAction.next,
  onSubmitted: () => _newPasswordFocusNode.requestFocus(),
)

// 4. Scroll behavior for better input visibility
SingleScrollView(
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  child: Column(...),
)

Performance Considerations

Consideration

Implementation

Controllers

Use TextEditingController for efficient text input management

Focus Nodes

Use FocusNode for efficient focus management

Immutability

Use const constructors where possible for performance

Error recovery

Implement retry logic with proper state cleanup

Testing Checklist

Before production deployment, verify:

What You've Accomplished

Congratulations! You've successfully implemented REL-ID Password Expiry functionality in your Flutter application.

You now have:

Password Expiry Detection: Automatic detection and routing of challengeMode 4

UpdatePassword API: Full integration with proper error handling

Three-Field Validation: Current, new, and confirm password validation

Password Policy Display: Extraction and display of RELID_PASSWORD_POLICY

Password Reuse Handling: StatusCode 164 detection with automatic field clearing

Production-Ready: Secure, user-friendly password expiry flow

Additional Resources

Thank you for completing the REL-ID Password Expiry Flow Codelab!

You're now equipped to build secure, production-ready password expiry workflows that provide excellent user experience while maintaining strong security standards.