webauthn_secure_storage 0.2.1 copy "webauthn_secure_storage: ^0.2.1" to clipboard
webauthn_secure_storage: ^0.2.1 copied to clipboard

App-facing package for the webauthn_secure_storage federated plugin.

example/lib/main.dart

import 'dart:io';

import 'package:webauthn_secure_storage/webauthn_secure_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:logging_appenders/logging_appenders.dart';

final MemoryAppender logMessages = MemoryAppender();

final _logger = Logger('main');

void main() {
  Logger.root.level = Level.ALL;
  PrintAppender().attachToLogger(Logger.root);
  logMessages.attachToLogger(Logger.root);
  _logger.fine('Application launched. (v2)');
  runApp(const MyApp());
}

class StringBufferWrapper with ChangeNotifier {
  final StringBuffer _buffer = StringBuffer();

  void writeln(String line) {
    _buffer.writeln(line);
    notifyListeners();
  }

  @override
  String toString() => _buffer.toString();
}

class ShortFormatter extends LogRecordFormatter {
  @override
  StringBuffer formatToStringBuffer(LogRecord rec, StringBuffer sb) {
    sb.write(
      '${rec.time.hour}:${rec.time.minute}:${rec.time.second} ${rec.level.name} '
      '${rec.message}',
    );

    if (rec.error != null) {
      sb.write(rec.error);
    }
    // ignore: avoid_as
    final stackTrace =
        rec.stackTrace ??
        (rec.error is Error ? (rec.error as Error).stackTrace : null);
    if (stackTrace != null) {
      sb.write(stackTrace);
    }
    return sb;
  }
}

class MemoryAppender extends BaseLogAppender {
  MemoryAppender() : super(ShortFormatter());

  final StringBufferWrapper log = StringBufferWrapper();

  @override
  void handle(LogRecord record) {
    log.writeln(formatter.format(record));
  }
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> {
  final String baseName = 'default';

  static final _authStorageInitOptions = StorageFileInitOptions();
  static final _customPromptInitOptions = StorageFileInitOptions(
    androidBiometricOnly: false,
    androidAuthenticationValidityDuration: const Duration(seconds: 5),
    darwinBiometricOnly: false,
    darwinTouchIDAuthenticationForceReuseContextDuration: const Duration(
      seconds: 5,
    ),
    androidUseStrongBox: false,
  );

  BiometricStorageFile? _authStorage;
  BiometricStorageFile? _storage;
  BiometricStorageFile? _customPrompt;

  final TextEditingController _writeController = TextEditingController(
    text: 'Lorem Ipsum',
  );

  @override
  void initState() {
    super.initState();
    logMessages.log.addListener(_logChanged);
    // _checkAuthenticate();
  }

  @override
  void dispose() {
    logMessages.log.removeListener(_logChanged);
    super.dispose();
  }

  Future<CanAuthenticateResponse> _checkAuthenticate(
    StorageFileInitOptions? options,
  ) async {
    final response = await BiometricStorage().canAuthenticate(options: options);
    _logger.info('checked if authentication was possible: $response');
    return response;
  }

  Future<void> _logLoginFlowGuidance() async {
    final storage = BiometricStorage();
    final isSupported = await storage.isSupported(
      options: _authStorageInitOptions,
    );
    if (!isSupported) {
      _logger.info(
        'Biometric login is not supported on this platform. '
        'Skip directly to regular login without error.',
      );
      return;
    }

    final response = await storage.canAuthenticate(
      options: _authStorageInitOptions,
    );
    if (response.canAuthenticateWithBiometrics) {
      _logger.info(
        'Biometric login is available. On app start, attempt to read the '
        'stored credential and let the platform prompt automatically.',
      );
      return;
    }

    _logger.info(
      'Biometric-backed storage is supported, but biometrics are not currently '
      'available ($response). Fall back to regular login and optionally offer '
      'an opt-in toggle after password success.',
    );
  }

  void _logChanged() => setState(() {});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Plugin example app')),
        body: Column(
          children: [
            const Text('Methods:'),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                ElevatedButton(
                  child: const Text('init'),
                  onPressed: () async {
                    _logger.finer('Initializing $baseName');
                    await _logLoginFlowGuidance();
                    final storageApi = BiometricStorage();
                    final authStorageSupported = await storageApi.isSupported(
                      options: _authStorageInitOptions,
                    );
                    final authStorageSupport = await _checkAuthenticate(
                      _authStorageInitOptions,
                    );
                    if (!authStorageSupported) {
                      _logger.severe(
                        'Unable to use authenticate. Unable to get storage.',
                      );
                      return;
                    }
                    final supportsAuthenticated =
                        authStorageSupport.canAuthenticateWithBiometrics;
                    if (supportsAuthenticated) {
                      _authStorage = await storageApi.getStorageIfSupported(
                        '${baseName}_authenticated',
                        options: _authStorageInitOptions,
                      );
                    }
                    _storage = await storageApi.getStorageIfSupported(
                      '${baseName}_unauthenticated',
                      options: StorageFileInitOptions(
                        authenticationRequired: false,
                      ),
                    );
                    final supportsCustomPrompt = await _checkAuthenticate(
                      _customPromptInitOptions,
                    );
                    if (supportsCustomPrompt.canAuthenticateWithBiometrics) {
                      _customPrompt = await storageApi.getStorageIfSupported(
                        '${baseName}_customPrompt',
                        options: _customPromptInitOptions,
                        promptInfo: const PromptInfo(
                          iosPromptInfo: IosPromptInfo(
                            saveTitle: 'Custom save title',
                            accessTitle: 'Custom access title.',
                          ),
                          androidPromptInfo: AndroidPromptInfo(
                            title: 'Custom title',
                            subtitle: 'Custom subtitle',
                            description: 'Custom description',
                            negativeButton: 'Nope!',
                          ),
                        ),
                      );
                    }
                    setState(() {});
                    _logger.info('initialized $baseName');
                  },
                ),
                ElevatedButton(
                  child: const Text('Delete and dispose all'),
                  onPressed: () async {
                    _logger.info('Deleting and disposing all storages');
                    try {
                      if (_authStorage != null) {
                        await _authStorage!.deleteAndDispose();
                        _authStorage = null;
                        _logger.finer(
                          'Deleted and disposed authenticated storage',
                        );
                      }
                      if (_storage != null) {
                        await _storage!.deleteAndDispose();
                        _storage = null;
                        _logger.finer(
                          'Deleted and disposed unauthenticated storage',
                        );
                      }
                      if (_customPrompt != null) {
                        await _customPrompt!.deleteAndDispose();
                        _customPrompt = null;
                        _logger.finer(
                          'Deleted and disposed custom prompt storage',
                        );
                      }
                      setState(() {});
                      _logger.info('All storages deleted and disposed');
                    } catch (e) {
                      _logger.severe(
                        'Error deleting and disposing storages: $e',
                      );
                    }
                  },
                ),
              ],
            ),
            ...?_appArmorButton(),
            ...(_authStorage == null
                ? []
                : [
                    const Text(
                      'Biometric Authentication',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    StorageActions(
                      storageFile: _authStorage!,
                      writeController: _writeController,
                    ),
                    const Divider(),
                  ]),
            ...?(_storage == null
                ? null
                : [
                    const Text(
                      'Unauthenticated',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    StorageActions(
                      storageFile: _storage!,
                      writeController: _writeController,
                    ),
                    const Divider(),
                  ]),
            ...?(_customPrompt == null
                ? null
                : [
                    const Text(
                      'Custom Prompts w/ 5s auth validity',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    StorageActions(
                      storageFile: _customPrompt!,
                      writeController: _writeController,
                    ),
                    const Divider(),
                  ]),
            const Divider(),
            TextField(
              decoration: const InputDecoration(
                labelText: 'Example text to write',
              ),
              controller: _writeController,
            ),
            Expanded(
              child: Container(
                color: Colors.white,
                constraints: const BoxConstraints.expand(),
                child: SingleChildScrollView(
                  reverse: true,
                  child: Container(
                    padding: const EdgeInsets.all(16),
                    child: Text(logMessages.log.toString()),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  List<Widget>? _appArmorButton() => kIsWeb || !Platform.isLinux
      ? null
      : [
          ElevatedButton(
            child: const Text('Check App Armor'),
            onPressed: () async {
              if (await BiometricStorage().linuxCheckAppArmorError()) {
                _logger.info(
                  'Got an error! User has to authorize us to '
                  'use secret service.',
                );
                _logger.info(
                  'Run: `snap connect webauthn-secure-storage-example:password-manager-service`',
                );
              } else {
                _logger.info('all good.');
              }
            },
          ),
        ];
}

class StorageActions extends StatelessWidget {
  const StorageActions({
    super.key,
    required this.storageFile,
    required this.writeController,
  });

  final BiometricStorageFile storageFile;
  final TextEditingController writeController;

  @override
  Widget build(BuildContext context) {
    return Wrap(
      children: <Widget>[
        ElevatedButton(
          child: const Text('read'),
          onPressed: () async {
            _logger.fine('reading from ${storageFile.name}');
            try {
              final result = await storageFile.read();
              _logger.fine('read: {$result}');
            } on AuthException catch (e) {
              if (e.code == AuthExceptionCode.userCanceled) {
                _logger.info('User canceled.');
                return;
              }
              rethrow;
            }
          },
        ),
        ElevatedButton(
          child: const Text('read with force'),
          onPressed: () async {
            _logger.fine(
              'reading with forceBiometricAuthentication from ${storageFile.name}',
            );
            try {
              final result = await storageFile.read(
                forceBiometricAuthentication: true,
              );
              _logger.fine('read: {$result}');
            } on AuthException catch (e) {
              if (e.code == AuthExceptionCode.userCanceled) {
                _logger.info('User canceled.');
                return;
              }
              rethrow;
            }
          },
        ),
        ElevatedButton(
          child: const Text('write'),
          onPressed: () async {
            _logger.fine('Going to write...');
            try {
              await storageFile.write(
                ' [${DateTime.now()}] ${writeController.text}',
              );
              _logger.info('Written content.');
            } on AuthException catch (e) {
              if (e.code == AuthExceptionCode.userCanceled) {
                _logger.info('User canceled.');
                return;
              }
              rethrow;
            }
          },
        ),
        ElevatedButton(
          child: const Text('write with forceBiometricAuthentication'),
          onPressed: () async {
            _logger.fine('Going to write with force...');
            try {
              await storageFile.write(
                ' [${DateTime.now()}] ${writeController.text}',
                forceBiometricAuthentication: true,
              );
              _logger.info('Written content.');
            } on AuthException catch (e) {
              if (e.code == AuthExceptionCode.userCanceled) {
                _logger.info('User canceled.');
                return;
              }
              rethrow;
            }
          },
        ),
        ElevatedButton(
          child: const Text('delete'),
          onPressed: () async {
            _logger.fine('deleting...');
            await storageFile.delete();
            _logger.info('Deleted.');
          },
        ),
      ],
    );
  }
}