From 5d400a4595db8f2c54ee27f72aae944c3249e8a2 Mon Sep 17 00:00:00 2001 From: Dillon Nys Date: Sat, 2 Mar 2024 16:46:34 -0800 Subject: [PATCH] Create Auth protocol --- packages/celest_auth/dart_test.yaml | 5 + .../lib/src/client/auth_client.android.dart | 4 +- .../lib/src/client/auth_client.dart | 12 +- .../lib/src/client/auth_client.darwin.dart | 4 +- .../lib/src/client/auth_client.native.dart | 4 +- .../lib/src/client/auth_client.web.dart | 4 +- .../src/client/auth_client_platform.vm.dart | 15 +- .../src/client/auth_client_platform.web.dart | 11 +- .../passkeys/passkey_client.android.dart | 16 +- .../src/client/passkeys/passkey_client.dart | 15 +- .../passkeys/passkey_client.darwin.dart | 10 +- .../client/passkeys/passkey_client.web.dart | 93 ++--- .../passkeys/passkey_client_platform.vm.dart | 13 +- .../passkeys/passkey_client_platform.web.dart | 11 +- .../passkeys/passkey_client_web_test.dart | 32 -- packages/celest_core/lib/celest_core.dart | 4 + .../lib/src/auth/auth_protocol.dart | 23 ++ .../lib/src/auth/passkey_types.dart} | 320 +++++++++++++++--- .../lib/src/util}/base64_raw_url.dart | 0 19 files changed, 418 insertions(+), 178 deletions(-) create mode 100644 packages/celest_auth/dart_test.yaml delete mode 100644 packages/celest_auth/test/client/passkeys/passkey_client_web_test.dart create mode 100644 packages/celest_core/lib/src/auth/auth_protocol.dart rename packages/{celest_auth/lib/src/client/passkeys/passkey_models.dart => celest_core/lib/src/auth/passkey_types.dart} (64%) rename packages/{celest_auth/lib/src/client => celest_core/lib/src/util}/base64_raw_url.dart (100%) diff --git a/packages/celest_auth/dart_test.yaml b/packages/celest_auth/dart_test.yaml new file mode 100644 index 00000000..82ba5990 --- /dev/null +++ b/packages/celest_auth/dart_test.yaml @@ -0,0 +1,5 @@ +override_platforms: + firefox: + settings: + # Workaround for: https://github.com/dart-lang/test/issues/2194 + executable: /Applications/Firefox.app/Contents/MacOS/firefox diff --git a/packages/celest_auth/lib/src/client/auth_client.android.dart b/packages/celest_auth/lib/src/client/auth_client.android.dart index 28aa725b..db07e57f 100644 --- a/packages/celest_auth/lib/src/client/auth_client.android.dart +++ b/packages/celest_auth/lib/src/client/auth_client.android.dart @@ -9,7 +9,9 @@ import 'package:jni/jni.dart'; import 'package:logging/logging.dart'; final class AuthClientAndroid extends AuthClientPlatform { - AuthClientAndroid() : super.base() { + AuthClientAndroid({ + required super.protocol, + }) : super.base() { Jni.initDLApi(); _celestAuth.init(_applicationContext); } diff --git a/packages/celest_auth/lib/src/client/auth_client.dart b/packages/celest_auth/lib/src/client/auth_client.dart index 2753a290..c82a39d8 100644 --- a/packages/celest_auth/lib/src/client/auth_client.dart +++ b/packages/celest_auth/lib/src/client/auth_client.dart @@ -1,10 +1,18 @@ import 'package:celest_auth/src/client/auth_client_platform.vm.dart' if (dart.library.js_interop) 'package:celest_auth/src/client/auth_client_platform.web.dart'; +import 'package:celest_core/celest_core.dart'; import 'package:meta/meta.dart'; abstract base class AuthClient { - factory AuthClient() = AuthClientPlatform; + factory AuthClient({ + required AuthProtocol protocol, + }) = AuthClientPlatform; @protected - AuthClient.base(); + AuthClient.base({ + required this.protocol, + }); + + @protected + final AuthProtocol protocol; } diff --git a/packages/celest_auth/lib/src/client/auth_client.darwin.dart b/packages/celest_auth/lib/src/client/auth_client.darwin.dart index 7db56298..b106b544 100644 --- a/packages/celest_auth/lib/src/client/auth_client.darwin.dart +++ b/packages/celest_auth/lib/src/client/auth_client.darwin.dart @@ -6,7 +6,9 @@ import 'package:celest_auth/src/client/auth_client_platform.vm.dart'; import 'package:celest_auth/src/platform/darwin/authentication_services.ffi.dart'; final class AuthClientDarwin extends AuthClientPlatform { - AuthClientDarwin() : super.base(); + AuthClientDarwin({ + required super.protocol, + }) : super.base(); final _authenticationServices = AuthenticationServices(DynamicLibrary.process()); diff --git a/packages/celest_auth/lib/src/client/auth_client.native.dart b/packages/celest_auth/lib/src/client/auth_client.native.dart index 8d5aaad6..dc67327d 100644 --- a/packages/celest_auth/lib/src/client/auth_client.native.dart +++ b/packages/celest_auth/lib/src/client/auth_client.native.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'package:celest_auth/src/client/auth_client_platform.vm.dart'; final class AuthClientNative extends AuthClientPlatform { - AuthClientNative() : super.base(); + AuthClientNative({ + required super.protocol, + }) : super.base(); // /// Launches the given URL. // Future _launchUrl(String url) async { diff --git a/packages/celest_auth/lib/src/client/auth_client.web.dart b/packages/celest_auth/lib/src/client/auth_client.web.dart index 7c57d58f..f20c559f 100644 --- a/packages/celest_auth/lib/src/client/auth_client.web.dart +++ b/packages/celest_auth/lib/src/client/auth_client.web.dart @@ -3,7 +3,9 @@ import 'package:path/path.dart'; import 'package:web/web.dart'; final class AuthClientWeb extends AuthClientPlatform { - AuthClientWeb() : super.base(); + AuthClientWeb({ + required super.protocol, + }) : super.base(); String get _baseUrl { final baseElement = document.querySelector('base') as HTMLBaseElement?; diff --git a/packages/celest_auth/lib/src/client/auth_client_platform.vm.dart b/packages/celest_auth/lib/src/client/auth_client_platform.vm.dart index b80fca1f..621a61fa 100644 --- a/packages/celest_auth/lib/src/client/auth_client_platform.vm.dart +++ b/packages/celest_auth/lib/src/client/auth_client_platform.vm.dart @@ -2,21 +2,24 @@ import 'package:celest_auth/src/client/auth_client.android.dart'; import 'package:celest_auth/src/client/auth_client.dart'; import 'package:celest_auth/src/client/auth_client.darwin.dart'; import 'package:celest_auth/src/client/auth_client.native.dart'; +import 'package:celest_core/celest_core.dart'; // ignore: implementation_imports import 'package:celest_core/src/util/globals.dart'; import 'package:meta/meta.dart'; import 'package:os_detect/os_detect.dart' as os; abstract base class AuthClientPlatform extends AuthClient { - factory AuthClientPlatform() { + factory AuthClientPlatform({ + required AuthProtocol protocol, + }) { if (kIsDartNative) { - return AuthClientNative(); + return AuthClientNative(protocol: protocol); } if (os.isIOS || os.isMacOS) { - return AuthClientDarwin(); + return AuthClientDarwin(protocol: protocol); } if (os.isAndroid) { - return AuthClientAndroid(); + return AuthClientAndroid(protocol: protocol); } throw UnsupportedError( 'The current platform is not supported: ${os.operatingSystem}', @@ -24,5 +27,7 @@ abstract base class AuthClientPlatform extends AuthClient { } @protected - AuthClientPlatform.base() : super.base(); + AuthClientPlatform.base({ + required super.protocol, + }) : super.base(); } diff --git a/packages/celest_auth/lib/src/client/auth_client_platform.web.dart b/packages/celest_auth/lib/src/client/auth_client_platform.web.dart index 1901343a..2a14abd2 100644 --- a/packages/celest_auth/lib/src/client/auth_client_platform.web.dart +++ b/packages/celest_auth/lib/src/client/auth_client_platform.web.dart @@ -1,14 +1,17 @@ import 'package:celest_auth/src/client/auth_client.dart'; import 'package:celest_auth/src/client/auth_client.web.dart'; +import 'package:celest_core/celest_core.dart'; // ignore: implementation_imports import 'package:celest_core/src/util/globals.dart'; import 'package:meta/meta.dart'; import 'package:os_detect/os_detect.dart' as os; abstract base class AuthClientPlatform extends AuthClient { - factory AuthClientPlatform() { + factory AuthClientPlatform({ + required AuthProtocol protocol, + }) { if (kIsWeb) { - return AuthClientWeb(); + return AuthClientWeb(protocol: protocol); } throw UnsupportedError( 'The current platform is not supported: ${os.operatingSystem}', @@ -16,5 +19,7 @@ abstract base class AuthClientPlatform extends AuthClient { } @protected - AuthClientPlatform.base() : super.base(); + AuthClientPlatform.base({ + required super.protocol, + }) : super.base(); } diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client.android.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client.android.dart index 2e1d6a4f..c13a168c 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client.android.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client.android.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'package:celest_auth/src/client/passkeys/passkey_client_platform.vm.dart'; -import 'package:celest_auth/src/client/passkeys/passkey_models.dart'; import 'package:celest_auth/src/platform/android/jni_bindings.ffi.dart' hide Exception, Uri; import 'package:celest_auth/src/platform/android/jni_helpers.dart'; +import 'package:celest_core/celest_core.dart'; import 'package:jni/jni.dart'; final class PasskeyClientAndroid extends PasskeyClientPlatform { - PasskeyClientAndroid() : super.base(); + PasskeyClientAndroid({ + required super.protocol, + }) : super.base(); late final Activity _mainActivity = Activity.fromRef(Jni.getCurrentActivity()); @@ -50,15 +52,16 @@ final class PasskeyClientAndroid extends PasskeyClientPlatform { @override Future register( - PasskeyRegistrationOptions options, + PasskeyRegistrationRequest request, ) async { - final request = CreatePublicKeyCredentialRequest.new7( + final options = await protocol.requestRegistration(request: request); + final jRequest = CreatePublicKeyCredentialRequest.new7( jsonEncode(options.toJson()).toJString(), ); final responseCallback = Completer(); _credentialManager.createCredentialAsync( _mainActivityContext, - request, + jRequest, CancellationSignal(), _threadPool, CredentialManagerCallback authenticate( PasskeyAuthenticationRequest request, ) async { + final options = await protocol.requestAuthentication(request: request); final jRequest = GetCredentialRequest_Builder() .addCredentialOption( GetPublicKeyCredentialOption.new3( - jsonEncode(request.toJson()).toJString(), + jsonEncode(options.toJson()).toJString(), ), ) .build(); diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client.dart index e25cb476..a22081c2 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client.dart @@ -1,17 +1,24 @@ import 'package:celest_auth/src/client/passkeys/passkey_client_platform.vm.dart' if (dart.library.js_interop) 'package:celest_auth/src/client/passkeys/passkey_client_platform.web.dart'; -import 'package:celest_auth/src/client/passkeys/passkey_models.dart'; +import 'package:celest_core/celest_core.dart'; import 'package:meta/meta.dart'; abstract base class PasskeyClient { - factory PasskeyClient() = PasskeyClientPlatform; + factory PasskeyClient({ + required PasskeyProtocol protocol, + }) = PasskeyClientPlatform; @protected - PasskeyClient.base(); + PasskeyClient.base({ + required this.protocol, + }); + + @protected + final PasskeyProtocol protocol; Future get isSupported; Future register( - PasskeyRegistrationOptions options, + PasskeyRegistrationRequest request, ); Future authenticate( PasskeyAuthenticationRequest request, diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client.darwin.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client.darwin.dart index 70f05efc..1232a64b 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client.darwin.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client.darwin.dart @@ -1,16 +1,18 @@ import 'package:celest_auth/src/client/passkeys/passkey_client_platform.vm.dart'; -import 'package:celest_auth/src/client/passkeys/passkey_models.dart'; +import 'package:celest_core/celest_core.dart'; final class PasskeyClientDarwin extends PasskeyClientPlatform { - PasskeyClientDarwin() : super.base(); + PasskeyClientDarwin({ + required super.protocol, + }) : super.base(); @override Future get isSupported => throw UnimplementedError(); @override Future register( - PasskeyRegistrationOptions options, - ) { + PasskeyRegistrationRequest request, + ) async { throw UnimplementedError(); } diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client.web.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client.web.dart index eb01216c..49a7972e 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client.web.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client.web.dart @@ -5,15 +5,19 @@ import 'dart:typed_data'; import 'package:celest_auth/src/client/passkeys/passkey_client_platform.web.dart'; import 'package:celest_auth/src/client/passkeys/passkey_exception.dart'; -import 'package:celest_auth/src/client/passkeys/passkey_models.dart'; +import 'package:celest_core/celest_core.dart'; import 'package:web/web.dart' hide COSEAlgorithmIdentifier, AuthenticatorTransport, - AuthenticatorAttachment; + AuthenticatorAttachment, + UserVerificationRequirement, + ResidentKeyRequirement; final class PasskeyClientWeb extends PasskeyClientPlatform { - PasskeyClientWeb() : super.base(); + PasskeyClientWeb({ + required super.protocol, + }) : super.base(); @override Future get isSupported async { @@ -21,53 +25,29 @@ final class PasskeyClientWeb extends PasskeyClientPlatform { if (!publicKeyCredential.typeofEquals('function')) { return false; } + // TODO(dnys1): Check conditional mediation for autofill support. + // https://web.dev/articles/passkey-registration#feature_detection return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() .toDart .then((value) => value.toDart); } - static Uint8List _getRandomValues(int size) { - final values = Uint8List(size); - window.crypto.getRandomValues(values.toJS); - return values; - } - @override Future register( - PasskeyRegistrationOptions options, + PasskeyRegistrationRequest request, ) async { if (!await isSupported) { throw const PasskeyException( message: 'Passkeys are not supported in this environment', ); } + final options = await protocol.requestRegistration(request: request); final credential = await window.navigator.credentials .create( CredentialCreationOptions( - publicKey: PublicKeyCredentialCreationOptions( - challenge: - (options.challenge ?? _getRandomValues(32)).buffer.toJS, - rp: PublicKeyCredentialRpEntity( - id: options.rpId, - )..name = options.rpName, - user: PublicKeyCredentialUserEntity( - id: utf8.encode(options.userId).buffer.toJS, - displayName: options.userDisplayName ?? '', - )..name = options.userName, - // This Relying Party will accept either an ES256 or RS256 - // credential, but prefers an ES256 credential. - pubKeyCredParams: [ - for (final algId in COSEAlgorithmIdentifier.defaultSupported) - PublicKeyCredentialParameters( - type: 'public-key', - alg: algId, - ), - ].toJS, - authenticatorSelection: AuthenticatorSelectionCriteria( - // Try to use UV if possible. This is also the default. - userVerification: 'preferred', - ), - timeout: const Duration(minutes: 5).inMilliseconds, + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON( + options.toJson().jsify() + as PublicKeyCredentialCreationOptionsJSON, ), ), ) @@ -110,29 +90,12 @@ final class PasskeyClientWeb extends PasskeyClientPlatform { message: 'Passkeys are not supported in this environment', ); } + final options = await protocol.requestAuthentication(request: request); final credential = await window.navigator.credentials .get( CredentialRequestOptions( - publicKey: PublicKeyCredentialRequestOptions( - challenge: request.challenge.buffer.toJS, - rpId: request.rpId, - allowCredentials: - (request.allowCredentials ?? const []) - .map( - (credential) => PublicKeyCredentialDescriptor( - id: credential.id.buffer.toJS, - type: 'public-key', - transports: (credential.transports ?? - const []) - .map((transport) => transport.toJS) - .toList() - .toJS, - ), - ) - .toList() - .toJS, - userVerification: request.userVerification, - timeout: const Duration(minutes: 5).inMilliseconds, + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON( + options.toJson().jsify() as PublicKeyCredentialRequestOptionsJSON, ), ), ) @@ -168,54 +131,42 @@ final class PasskeyClientWeb extends PasskeyClientPlatform { } extension on AuthenticatorAttestationResponse { - @JS('getTransports') - external JSArray _getTransports(); - List? get transports { // Continue to play it safe with `getTransports()` for now, even when L3 // types say it's required if (!getProperty('getTransports'.toJS).typeofEquals('function')) { return null; } - final transports = _getTransports(); + final transports = getTransports(); return transports.toDart.cast(); } - @JS('getPublicKeyAlgorithm') - external COSEAlgorithmIdentifier _getPublicKeyAlgorithm(); - COSEAlgorithmIdentifier? get publicKeyAlgorithm { // L3 says this is required, but browser and webview support are still // not guaranteed. if (!getProperty('getPublicKeyAlgorithm'.toJS).typeofEquals('function')) { return null; } - return _getPublicKeyAlgorithm(); + return getPublicKeyAlgorithm() as COSEAlgorithmIdentifier; } - @JS('getPublicKey') - external JSArrayBuffer _getPublicKey(); - Uint8List? get publicKey { // L3 says this is required, but browser and webview support are still // not guaranteed. if (!getProperty('getPublicKey'.toJS).typeofEquals('function')) { return null; } - final publicKey = _getPublicKey(); - return publicKey.toDart.asUint8List(); + final publicKey = getPublicKey(); + return publicKey?.toDart.asUint8List(); } - @JS('getAuthenticatorData') - external JSArrayBuffer _getAuthenticatorData(); - Uint8List? get authenticatorData { // L3 says this is required, but browser and webview support are still // not guaranteed. if (!getProperty('getAuthenticatorData'.toJS).typeofEquals('function')) { return null; } - final authenticatorData = _getAuthenticatorData(); + final authenticatorData = getAuthenticatorData(); return authenticatorData.toDart.asUint8List(); } } diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.vm.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.vm.dart index 54fd6835..15697ab5 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.vm.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.vm.dart @@ -1,19 +1,22 @@ import 'package:celest_auth/src/client/passkeys/passkey_client.android.dart'; import 'package:celest_auth/src/client/passkeys/passkey_client.dart'; import 'package:celest_auth/src/client/passkeys/passkey_client.darwin.dart'; +import 'package:celest_core/celest_core.dart'; // ignore: implementation_imports import 'package:celest_core/src/util/globals.dart'; import 'package:meta/meta.dart'; import 'package:os_detect/os_detect.dart' as os; abstract base class PasskeyClientPlatform extends PasskeyClient { - factory PasskeyClientPlatform() { + factory PasskeyClientPlatform({ + required PasskeyProtocol protocol, + }) { if (kIsFlutter) { if (os.isIOS || os.isMacOS) { - return PasskeyClientDarwin(); + return PasskeyClientDarwin(protocol: protocol); } if (os.isAndroid) { - return PasskeyClientAndroid(); + return PasskeyClientAndroid(protocol: protocol); } } throw UnsupportedError( @@ -22,5 +25,7 @@ abstract base class PasskeyClientPlatform extends PasskeyClient { } @protected - PasskeyClientPlatform.base() : super.base(); + PasskeyClientPlatform.base({ + required super.protocol, + }) : super.base(); } diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.web.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.web.dart index 67da2c28..c585e71a 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.web.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_client_platform.web.dart @@ -1,14 +1,17 @@ import 'package:celest_auth/src/client/passkeys/passkey_client.dart'; import 'package:celest_auth/src/client/passkeys/passkey_client.web.dart'; +import 'package:celest_core/celest_core.dart'; // ignore: implementation_imports import 'package:celest_core/src/util/globals.dart'; import 'package:meta/meta.dart'; import 'package:os_detect/os_detect.dart' as os; abstract base class PasskeyClientPlatform extends PasskeyClient { - factory PasskeyClientPlatform() { + factory PasskeyClientPlatform({ + required PasskeyProtocol protocol, + }) { if (kIsWeb) { - return PasskeyClientWeb(); + return PasskeyClientWeb(protocol: protocol); } throw UnsupportedError( 'The current platform is not supported: ${os.operatingSystem}', @@ -16,5 +19,7 @@ abstract base class PasskeyClientPlatform extends PasskeyClient { } @protected - PasskeyClientPlatform.base() : super.base(); + PasskeyClientPlatform.base({ + required super.protocol, + }) : super.base(); } diff --git a/packages/celest_auth/test/client/passkeys/passkey_client_web_test.dart b/packages/celest_auth/test/client/passkeys/passkey_client_web_test.dart deleted file mode 100644 index 8ff27146..00000000 --- a/packages/celest_auth/test/client/passkeys/passkey_client_web_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -@TestOn('browser') -library; - -import 'dart:convert'; - -import 'package:celest_auth/src/client/passkeys/passkey_client.web.dart'; -import 'package:celest_auth/src/client/passkeys/passkey_models.dart'; -import 'package:test/test.dart'; - -void main() { - group('PasskeyClient', () { - group('register', () { - test('can register a new passkey', () async { - final client = PasskeyClientWeb(); - final registrationResponse = await client.register( - PasskeyRegistrationOptions( - rpName: 'Celest', - rpId: 'localhost', - userId: 'test', - userName: 'alice', - ), - ); - print('Got response: $registrationResponse'); - print( - 'Got response JSON: ${_prettyJson(registrationResponse.toJson())}', - ); - }); - }); - }); -} - -String _prettyJson(Object? o) => const JsonEncoder.withIndent(' ').convert(o); diff --git a/packages/celest_core/lib/celest_core.dart b/packages/celest_core/lib/celest_core.dart index cb69a98f..f8f4d967 100644 --- a/packages/celest_core/lib/celest_core.dart +++ b/packages/celest_core/lib/celest_core.dart @@ -1,6 +1,10 @@ /// Celest code shared between the client and the cloud. library; +/// Auth +export 'src/auth/auth_protocol.dart'; +export 'src/auth/passkey_types.dart'; + /// Exceptions export 'src/exception/celest_exception.dart'; export 'src/exception/cloud_exception.dart'; diff --git a/packages/celest_core/lib/src/auth/auth_protocol.dart b/packages/celest_core/lib/src/auth/auth_protocol.dart new file mode 100644 index 00000000..72a2ad9d --- /dev/null +++ b/packages/celest_core/lib/src/auth/auth_protocol.dart @@ -0,0 +1,23 @@ +import 'package:celest_core/celest_core.dart'; + +abstract interface class AuthProtocol { + PasskeyProtocol get passkeys; +} + +abstract interface class PasskeyProtocol { + Future requestRegistration({ + required PasskeyRegistrationRequest request, + }); + + Future verifyRegistration({ + required PasskeyRegistrationResponse registration, + }); + + Future requestAuthentication({ + required PasskeyAuthenticationRequest request, + }); + + Future verifyAuthentication({ + required PasskeyAuthenticationResponse authentication, + }); +} diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_models.dart b/packages/celest_core/lib/src/auth/passkey_types.dart similarity index 64% rename from packages/celest_auth/lib/src/client/passkeys/passkey_models.dart rename to packages/celest_core/lib/src/auth/passkey_types.dart index 12118529..3b1ecfe2 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_models.dart +++ b/packages/celest_core/lib/src/auth/passkey_types.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:celest_auth/src/client/base64_raw_url.dart'; +import 'package:celest_core/src/util/base64_raw_url.dart'; /// A simple test to determine if a hostname is a properly-formatted domain name /// @@ -13,19 +13,73 @@ bool _isValidDomain(String hostname) { /// From: https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html final _validDomain = RegExp(r'^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$'); +final class PasskeyRegistrationRequest { + const PasskeyRegistrationRequest({ + required this.username, + String? displayName, + this.authenticatorSelection, + }) : displayName = displayName ?? ''; + + factory PasskeyRegistrationRequest.fromJson(Map json) { + if (json + case { + 'username': final String username, + 'displayName': final String displayName, + }) { + return PasskeyRegistrationRequest( + username: username, + displayName: displayName, + authenticatorSelection: json['authenticatorSelection'] != null + ? AuthenticatorSelectionCriteria.fromJson( + json['authenticatorSelection'] as Map, + ) + : null, + ); + } + throw FormatException('Invalid registration request: $json'); + } + + final String username; + final String displayName; + final AuthenticatorSelectionCriteria? authenticatorSelection; + + Map toJson() => { + 'username': username, + 'displayName': displayName, + if (authenticatorSelection case final authenticatorSelection?) + 'authenticatorSelection': authenticatorSelection.toJson(), + }; + + @override + String toString() { + final buffer = StringBuffer() + ..writeln('PasskeyRegistrationRequest(') + ..writeln(' username: $username,') + ..writeln(' displayName: $displayName,'); + if (authenticatorSelection case final authenticatorSelection?) { + buffer.writeln(' authenticatorSelection: $authenticatorSelection,'); + } + buffer.write(')'); + return buffer.toString(); + } +} + final class PasskeyRegistrationOptions { PasskeyRegistrationOptions({ + required this.challenge, required this.rpName, required this.rpId, required this.userId, required this.userName, - this.userDisplayName, - this.challenge, + String? userDisplayName, this.timeout = const Duration(minutes: 5), - }) : assert( + this.authenticatorSelection, + this.publicKeyCredentialParameters, + }) : assert( _isValidDomain(rpId), 'Invalid rpId (must be a valid domain): $rpId', - ); + ), + userDisplayName = userDisplayName ?? ''; factory PasskeyRegistrationOptions.fromJson(Map json) { if (json @@ -34,22 +88,30 @@ final class PasskeyRegistrationOptions { 'name': final String rpName, 'id': final String rpId, }, + 'challenge': final String challenge, 'user': { - 'id': final String userId, - 'name': final String userName, - } && - final user, + 'id': final String userId, + 'name': final String userName, + 'displayName': final String userDisplayName, + }, }) { return PasskeyRegistrationOptions( rpName: rpName, rpId: rpId, userId: userId, userName: userName, - userDisplayName: user['displayName'] as String?, - challenge: json['challenge'] != null - ? base64RawUrl.decode(json['challenge'] as String) - : null, + userDisplayName: userDisplayName, + challenge: base64RawUrl.decode(challenge), timeout: Duration(milliseconds: (json['timeout'] as num).toInt()), + authenticatorSelection: json['authenticatorSelection'] != null + ? AuthenticatorSelectionCriteria.fromJson( + json['authenticatorSelection'] as Map, + ) + : null, + publicKeyCredentialParameters: (json['pubKeyCredParams'] as List?) + ?.cast>() + .map(PublicKeyCredentialParameter.fromJson) + .toList(), ); } throw FormatException('Invalid registration options: $json'); @@ -59,9 +121,11 @@ final class PasskeyRegistrationOptions { final String rpId; final String userId; final String userName; - final String? userDisplayName; - final Uint8List? challenge; + final String userDisplayName; + final Uint8List challenge; final Duration timeout; + final AuthenticatorSelectionCriteria? authenticatorSelection; + final List? publicKeyCredentialParameters; Map toJson() => { 'rp': { @@ -71,11 +135,16 @@ final class PasskeyRegistrationOptions { 'user': { 'id': userId, 'name': userName, - if (userDisplayName != null) 'displayName': userDisplayName, + 'displayName': userDisplayName, }, - if (challenge case final challenge?) - 'challenge': base64RawUrl.encode(challenge), + 'challenge': base64RawUrl.encode(challenge), 'timeout': timeout.inMilliseconds, + if (authenticatorSelection case final authenticatorSelection?) + 'authenticatorSelection': authenticatorSelection.toJson(), + if (publicKeyCredentialParameters + case final publicKeyCredentialParameters?) + 'pubKeyCredParams': + publicKeyCredentialParameters.map((el) => el.toJson()).toList(), }; @override @@ -85,16 +154,22 @@ final class PasskeyRegistrationOptions { ..writeln(' rpName: $rpName,') ..writeln(' rpId: $rpId,') ..writeln(' userId: $userId,') - ..writeln(' userName: $userName,'); - if (userDisplayName != null) { - buffer.writeln(' userDisplayName: $userDisplayName,'); + ..writeln(' userName: $userName,') + ..writeln(' userDisplayName: $userDisplayName,') + ..writeln(' challenge: ${base64RawUrl.encode(challenge)},') + ..writeln(' timeout: $timeout,'); + if (authenticatorSelection case final authenticatorSelection?) { + buffer.writeln(' authenticatorSelection: $authenticatorSelection,'); } - if (challenge != null) { - buffer.writeln(' challenge: ${base64RawUrl.encode(challenge!)},'); + if (publicKeyCredentialParameters + case final publicKeyCredentialParameters?) { + buffer.writeln(' publicKeyCredentialParameters: ['); + for (final el in publicKeyCredentialParameters) { + buffer.writeln(' $el,'); + } + buffer.writeln(' ],'); } - buffer - ..writeln(' timeout: $timeout,') - ..write(')'); + buffer.write(')'); return buffer.toString(); } } @@ -152,7 +227,10 @@ final class PasskeyRegistrationResponse { throw FormatException('Invalid registration response: $json'); } + /// A base64url-encoded string representing the credential ID. final String id; + + /// The credential ID in its raw binary form. final Uint8List rawId; String get type => 'public-key'; final PasskeyClientData clientData; @@ -161,6 +239,9 @@ final class PasskeyRegistrationResponse { final COSEAlgorithmIdentifier? publicKeyAlgorithm; final Uint8List? publicKey; final Uint8List? authenticatorData; + + /// This will be set to `platform` when the credential is created on a + /// passkey-capable device. final AuthenticatorAttachment? authenticatorAttachment; Map toJson() => { @@ -219,6 +300,118 @@ final class PasskeyRegistrationResponse { } } +final class PublicKeyCredentialParameter { + const PublicKeyCredentialParameter({ + required this.algorithm, + }); + + factory PublicKeyCredentialParameter.fromJson(Map json) { + if (json + case { + 'type': 'public-key', + 'alg': final int algorithm, + }) { + return PublicKeyCredentialParameter( + algorithm: COSEAlgorithmIdentifier._(algorithm), + ); + } + throw FormatException('Invalid credential parameter: $json'); + } + + String get type => 'public-key'; + final COSEAlgorithmIdentifier algorithm; + + Map toJson() => { + 'type': type, + 'alg': algorithm, + }; + + @override + String toString() { + final buffer = StringBuffer() + ..write('PasskeyCredentialParameter(') + ..write('type: $type, ') + ..write('algorithm: $algorithm') + ..write(')'); + return buffer.toString(); + } +} + +final class AuthenticatorSelectionCriteria { + const AuthenticatorSelectionCriteria({ + this.authenticatorAttachment, + this.residentKey, + bool? requireResidentKey, + UserVerificationRequirement? userVerification, + }) : requireResidentKey = requireResidentKey ?? false, + userVerification = + userVerification ?? UserVerificationRequirement.preferred; + + const AuthenticatorSelectionCriteria.passkey() + : + // Try to use UV if possible. + // + // Why not required: https://passkeys.dev/docs/use-cases/bootstrapping/#a-note-about-user-verification + userVerification = UserVerificationRequirement.required, + + // "platform" indicates that the RP wants a platform authenticator + // (an authenticator embedded to the platform device) which will + // not prompt to insert e.g. a USB security key. The user has a + // simpler option to create a passkey. + authenticatorAttachment = AuthenticatorAttachment.platform, + + // A discoverable credential (resident key) stores user + // information to the passkey and lets users select the account + // upon authentication. + residentKey = ResidentKeyRequirement.required, + + // This property is retained for backward compatibility from + // WebAuthn Level 1, an older version of the specification. Set + // this to true if residentKey is 'required', otherwise set it + // to false. + requireResidentKey = true; + + factory AuthenticatorSelectionCriteria.fromJson(Map json) { + return AuthenticatorSelectionCriteria( + authenticatorAttachment: + json['authenticatorAttachment'] as AuthenticatorAttachment?, + requireResidentKey: json['requireResidentKey'] as bool?, + residentKey: json['residentKey'] as ResidentKeyRequirement?, + userVerification: + json['userVerification'] as UserVerificationRequirement?, + ); + } + + final AuthenticatorAttachment? authenticatorAttachment; + final ResidentKeyRequirement? residentKey; + final bool requireResidentKey; + final UserVerificationRequirement userVerification; + + Map toJson() => { + if (authenticatorAttachment != null) + 'authenticatorAttachment': authenticatorAttachment, + if (residentKey != null) 'residentKey': residentKey, + 'requireResidentKey': requireResidentKey, + 'userVerification': userVerification, + }; + + @override + String toString() { + final buffer = StringBuffer()..write('AuthenticatorSelectionCriteria('); + if (authenticatorAttachment != null) { + buffer.write('authenticatorAttachment: $authenticatorAttachment, '); + } + if (residentKey != null) { + buffer.write('residentKey: $residentKey, '); + } + buffer + ..write('requireResidentKey: $requireResidentKey, ') + ..write('userVerification: $userVerification') + ..write(')'); + return buffer.toString(); + } +} + /// The client data returned by the authenticator during registration and /// authentication. /// @@ -333,7 +526,7 @@ final class PasskeyClientDataTokenBinding { final class PasskeyDescriptor { const PasskeyDescriptor({ required this.id, - this.transports, + this.transports = const [], }); factory PasskeyDescriptor.fromJson(Map json) { @@ -343,51 +536,93 @@ final class PasskeyDescriptor { }) { return PasskeyDescriptor( id: base64RawUrl.decode(id), - transports: (json['transports'] as List?)?.cast(), + transports: (json['transports'] as List?)?.cast() ?? const [], ); } throw FormatException('Invalid passkey descriptor: $json'); } final Uint8List id; - final List? transports; + final List transports; Map toJson() => { 'id': base64RawUrl.encode(id), - if (transports != null) 'transports': transports, + 'transports': transports, }; @override String toString() => 'PasskeyDescriptor(' 'id: ${base64RawUrl.encode(id)}, ' - 'transports: ${transports?.join(', ')})'; + 'transports: ${transports.join(', ')})'; } final class PasskeyAuthenticationRequest { - PasskeyAuthenticationRequest({ + const PasskeyAuthenticationRequest({ + required this.username, + UserVerificationRequirement? userVerification, + }) : userVerification = + userVerification ?? UserVerificationRequirement.preferred; + + factory PasskeyAuthenticationRequest.fromJson(Map json) { + return PasskeyAuthenticationRequest( + username: json['username'] as String, + userVerification: + json['userVerification'] as UserVerificationRequirement?, + ); + } + + final String username; + final UserVerificationRequirement userVerification; + + Map toJson() => { + 'username': username, + 'userVerification': userVerification, + }; + + @override + String toString() { + final buffer = StringBuffer() + ..writeln('PasskeyAuthenticationRequest(') + ..writeln(' username: $username,') + ..writeln(' userVerification: $userVerification,') + ..write(')'); + return buffer.toString(); + } +} + +final class PasskeyAuthenticationOptions { + PasskeyAuthenticationOptions({ required this.rpId, required this.challenge, this.timeout = const Duration(minutes: 5), - this.allowCredentials, + List? allowCredentials, UserVerificationRequirement? userVerification, }) : assert( _isValidDomain(rpId), 'Invalid rpId (must be a valid domain): $rpId', ), + allowCredentials = allowCredentials ?? const [], userVerification = userVerification ?? UserVerificationRequirement.preferred; - factory PasskeyAuthenticationRequest.fromJson(Map json) { + factory PasskeyAuthenticationOptions.fromJson(Map json) { if (json case { 'rpId': final String rpId, 'challenge': final String challenge, 'timeout': final int timeout, + 'allowCredentials': final List allowCredentials, }) { - return PasskeyAuthenticationRequest( + return PasskeyAuthenticationOptions( rpId: rpId, challenge: base64RawUrl.decode(challenge), timeout: Duration(milliseconds: timeout), + allowCredentials: allowCredentials + .cast>() + .map(PasskeyDescriptor.fromJson) + .toList(), + userVerification: + json['userVerification'] as UserVerificationRequirement?, ); } throw FormatException('Invalid authentication request: $json'); @@ -396,16 +631,14 @@ final class PasskeyAuthenticationRequest { final String rpId; final Uint8List challenge; final Duration timeout; - final List? allowCredentials; + final List allowCredentials; final UserVerificationRequirement userVerification; Map toJson() => { 'rpId': rpId, 'challenge': base64RawUrl.encode(challenge), 'timeout': timeout.inMilliseconds, - if (allowCredentials case final allowCredentials?) - 'allowCredentials': - allowCredentials.map((el) => el.toJson()).toList(), + 'allowCredentials': allowCredentials.map((el) => el.toJson()).toList(), 'userVerification': userVerification, }; @@ -416,7 +649,7 @@ final class PasskeyAuthenticationRequest { ..writeln(' rpId: $rpId,') ..writeln(' challenge: ${base64RawUrl.encode(challenge)},') ..writeln(' timeout: $timeout,') - ..writeln(' allowCredentials: ${allowCredentials?.join(', ')},') + ..writeln(' allowCredentials: ${allowCredentials.join(', ')},') ..writeln(' userVerification: $userVerification,') ..write(')'); return buffer.toString(); @@ -608,3 +841,10 @@ extension type const UserVerificationRequirement._(String requirement) static const preferred = UserVerificationRequirement._('preferred'); static const required = UserVerificationRequirement._('required'); } + +extension type const ResidentKeyRequirement._(String requirement) + implements String { + static const discouraged = ResidentKeyRequirement._('discouraged'); + static const preferred = ResidentKeyRequirement._('preferred'); + static const required = ResidentKeyRequirement._('required'); +} diff --git a/packages/celest_auth/lib/src/client/base64_raw_url.dart b/packages/celest_core/lib/src/util/base64_raw_url.dart similarity index 100% rename from packages/celest_auth/lib/src/client/base64_raw_url.dart rename to packages/celest_core/lib/src/util/base64_raw_url.dart