webauthn 0.0.2 copy "webauthn: ^0.0.2" to clipboard
webauthn: ^0.0.2 copied to clipboard

A plugin implementing the WebAuthn authenticator model for generating Public Key Credentials

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webauthn/webauthn.dart';

void main() {
  runApp(const MyApp());
}

const makeCredentialJson = '''{
    "authenticatorExtensions": "",
    "clientDataHash": "LTCT/hWLtJenIgi0oUhkJz7dE8ng+pej+i6YI1QQu60=",
    "credTypesAndPubKeyAlgs": [
        ["public-key", -7]
    ],
    "excludeCredentials": [{
        "type": "public-key",
        "id": "lVGyXHwz6vdYignKyctbkIkJto/ADbYbHhE7+ss/87o="
    }],
    "requireResidentKey": true,
    "requireUserPresence": true,
    "requireUserVerification": false,
    "rp": {
        "name": "webauthn.io",
        "id": "webauthn.io"
    },
    "user": {
        "name": "testuser",
        "displayName": "Test User",
        "id": "/QIAAAAAAAAAAA=="
    }
}''';

const getAssertionJson = '''{
    "allowCredentialDescriptorList": [{
        "id": "jVtTOKLHRMN17I66w48XWuJadCitXg0xZKaZvHdtW6RDCJhxO6Cfff9qbYnZiMQ1pl8CzPkXcXEHwpQYFknN2w==",
        "type": "public-key"
    }],
    "authenticatorExtensions": "",
    "clientDataHash": "LTCT/hWLtJenIgi0oUhkJz7dE8ng+pej+i6YI1QQu60=",
    "requireUserPresence": true,
    "requireUserVerification": false,
    "rpId": "webauthn.io"
}''';

class CredentialData {
  final String username;
  final Attestation attestation;

  CredentialData(this.username, this.attestation);
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'WebAuthn Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'WebAuthn Flutter Plugin Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _auth = Authenticator(true, false);
  final _formKey = GlobalKey<FormState>();
  final _usernameController = TextEditingController();

  final _credentials = <CredentialData>[];
  int? _highlightCredentialIdx;

  bool _processing = false;

  Future<T?> _lockAndExecute<T>(Future<T> Function() callback) async {
    T? result;
    if (!_processing) {
      setState(() {
        _processing = true;
      });

      final data = _formKey.currentState;
      if (data != null && data.validate()) {
        result = await callback();
      }

      setState(() {
        _processing = false;
      });
    }
    return result;
  }

  void _startResetSelection() {
    Future.delayed(const Duration(seconds: 2)).then((_) => {
          setState(() {
            _highlightCredentialIdx = null;
          })
        });
  }

  void _createCredential() async {
    final credentialId = await _lockAndExecute(() async {
      try {
        final username = _usernameController.text.trim();
        final options =
            MakeCredentialOptions.fromJson(jsonDecode(makeCredentialJson));
        options.userEntity = UserEntity(
          id: Uint8List.fromList(username.codeUnits),
          displayName: username,
          name: username,
        );

        final attestation = await _auth.makeCredential(options);

        setState(() {
          _usernameController.text = '';
          _highlightCredentialIdx = _credentials.length;
          _credentials.add(CredentialData(username, attestation));
        });
        _startResetSelection();

        return attestation.getCredentialIdBase64();
      } on AuthenticatorException catch (e) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Attestation error: ${e.message}'),
          ),
        );
        return null;
      }
    });

    if (credentialId != null && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Attestation created: $credentialId'),
        ),
      );
    }
  }

  void _createAttestation() async {
    final credentialId = await _lockAndExecute(() async {
      try {
        final username = _usernameController.text.trim();
        final options =
            GetAssertionOptions.fromJson(jsonDecode(getAssertionJson));
        // Only allow credentials currently in the state with a matching username
        // The requesting server should be doing this and sending which credentials
        // they are expecting you to try to verify.
        options.allowCredentialDescriptorList = _credentials
            .where((e) => e.username == username)
            .map((e) => PublicKeyCredentialDescriptor(
                type: PublicKeyCredentialType.publicKey,
                id: e.attestation.getCredentialId()))
            .toList();

        // User not found
        if (options.allowCredentialDescriptorList!.isEmpty) {
          throw AuthenticatorException('Username not found');
        }

        final assertion = await _auth.getAssertion(options);
        final credentialId = base64.encode(assertion.selectedCredentialId);
        final credentialIdx = _credentials.indexWhere(
            (e) => e.attestation.getCredentialIdBase64() == credentialId);

        setState(() {
          _usernameController.text = '';
          _highlightCredentialIdx = credentialIdx >= 0 ? credentialIdx : null;
        });
        _startResetSelection();

        return credentialId;
      } on AuthenticatorException catch (e) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('Assertion error: ${e.message}'),
            ),
          );
        }
        return null;
      }
    });

    if (credentialId != null && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Assertion succeeded for: $credentialId'),
        ),
      );
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Form(
          key: _formKey,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Flexible(
                child: ListView.separated(
                  reverse: true,
                  shrinkWrap: true,
                  itemBuilder: (context, index) {
                    final creds = _credentials[index];

                    return Container(
                      color: (_highlightCredentialIdx != null &&
                              _highlightCredentialIdx == index)
                          ? theme.colorScheme.secondary
                          : theme.scaffoldBackgroundColor,
                      height: 50,
                      child: Row(
                        children: [
                          Expanded(
                            flex: 1,
                            child: Padding(
                              padding: const EdgeInsets.only(left: 8.0),
                              child: SelectableText(creds.username),
                            ),
                          ),
                          Expanded(
                            flex: 2,
                            child: Padding(
                              padding: const EdgeInsets.only(right: 8.0),
                              child: SelectableText(
                                  creds.attestation.getCredentialIdBase64()),
                            ),
                          ),
                        ],
                      ),
                    );
                  },
                  separatorBuilder: (context, index) => const Divider(),
                  itemCount: _credentials.length,
                ),
              ),
              TextFormField(
                controller: _usernameController,
                decoration: const InputDecoration(hintText: 'Username'),
                maxLength: 15,
                maxLengthEnforcement: MaxLengthEnforcement.enforced,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return 'Please enter a username';
                  }
                  if (value.length > 15) {
                    return 'Username is too long';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 30),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton(
                    onPressed: _processing ? null : _createCredential,
                    child: const Text('Register'),
                  ),
                  const SizedBox(width: 30),
                  ElevatedButton(
                    onPressed: _processing ? null : _createAttestation,
                    child: const Text('Login'),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}