diff --git a/packages/celest_auth/android/src/main/kotlin/dev/celest/celest_auth/CelestAuth.kt b/packages/celest_auth/android/src/main/kotlin/dev/celest/celest_auth/CelestAuth.kt index ecee1ae9..defd47b9 100644 --- a/packages/celest_auth/android/src/main/kotlin/dev/celest/celest_auth/CelestAuth.kt +++ b/packages/celest_auth/android/src/main/kotlin/dev/celest/celest_auth/CelestAuth.kt @@ -1,79 +1,64 @@ package dev.celest.celest_auth -import android.content.BroadcastReceiver +import android.app.Activity import android.content.Context -import android.content.Intent -import android.net.Uri +import android.os.CancellationSignal +import android.service.voice.VoiceInteractionSession.ActivityId import androidx.annotation.Keep +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential +import androidx.credentials.CredentialManagerCallback import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.GetCredentialException -import com.google.android.gms.tasks.OnSuccessListener -import com.google.android.libraries.identity.googleid.GetGoogleIdOption -import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.gms.fido.Fido import kotlinx.coroutines.coroutineScope +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine @Keep -class CelestAuthListener: BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent?) { - TODO("Not yet implemented") - } -} - -@Keep -class CelestAuth { - private lateinit var context: Context - private lateinit var credentialManager: CredentialManager - - fun init(context: Context) { - this.context = context - credentialManager = CredentialManager.create(context) - } - - fun signInWithCustomTabs( - uri: Uri, - onSuccess: () -> Unit, - onError: (Exception) -> Unit, - ) { - - } +class CelestAuth(private val mainActivity: Activity) { + private val credentialManager: CredentialManager = CredentialManager.create(mainActivity) + private val executor = Executors.newCachedThreadPool() - // Adapted from: https://developer.android.com/training/sign-in/credential-manager - suspend fun signInWithGoogle( - clientId: String, - nonce: String - ) { - // TODO: GetSignInWithGoogleOption? - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(true) - .setAutoSelectEnabled(true) - .setServerClientId(clientId) - .setNonce(nonce) - .build() - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - coroutineScope { - try { - val result = credentialManager.getCredential(context, request) - } catch (e: GetCredentialException) { - TODO() - } - } + fun register( + requestJson: String, + callback: CredentialManagerCallback, + ): CancellationSignal { + val request = CreatePublicKeyCredentialRequest( + requestJson = requestJson, + preferImmediatelyAvailableCredentials = true + ) + val cancellationSignal = CancellationSignal() + credentialManager.createCredentialAsync( + mainActivity, + request, + cancellationSignal, + executor, + callback, + ) + return cancellationSignal } - fun handleSignIn(result: GetCredentialResponse) { - when (val credential = result.credential) { - is CustomCredential -> { - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - // Use googleIdTokenCredential and extract id to validate and - // authenticate on your server. - val googleIdTokenCredential = GoogleIdTokenCredential - .createFrom(credential.data) - } - } - } + fun authenticate( + requestJson: String, + callback: CredentialManagerCallback, + ): CancellationSignal { + val request = GetCredentialRequest.Builder().addCredentialOption( + GetPublicKeyCredentialOption(requestJson = requestJson) + ).build() + val cancellationSignal = CancellationSignal() + credentialManager.getCredentialAsync( + mainActivity, + request, + cancellationSignal, + executor, + callback, + ) + return cancellationSignal } } \ No newline at end of file diff --git a/packages/celest_auth/darwin/Classes/CelestAuth.swift b/packages/celest_auth/darwin/Classes/CelestAuth.swift index 18b2554e..051778b0 100644 --- a/packages/celest_auth/darwin/Classes/CelestAuth.swift +++ b/packages/celest_auth/darwin/Classes/CelestAuth.swift @@ -7,6 +7,7 @@ import AppKit #endif import AuthenticationServices +import OSLog public typealias OnSuccess = (UnsafePointer) -> Void public typealias OnError = (CelestAuthErrorCode, UnsafePointer) -> Void @@ -15,6 +16,13 @@ public typealias OnError = (CelestAuthErrorCode, UnsafePointer) -> Void case unknown = 0 case unsupported = 1 case serde = 2 + + // From ASAuthorizationError.Code + case canceled = 1001 + case invalidResponse = 1002 + case notHandled = 1003 + case failed = 1004 + case notInteractive = 1005 } @objc protocol CelestAuthProtocol: NSObjectProtocol { @@ -36,6 +44,8 @@ public typealias OnError = (CelestAuthErrorCode, UnsafePointer) -> Void onSuccess: @escaping OnSuccess, onError: @escaping OnError ) + + @objc func cancel() } @objc public class CelestAuth: NSObject, CelestAuthProtocol { @@ -53,6 +63,10 @@ public typealias OnError = (CelestAuthErrorCode, UnsafePointer) -> Void impl.isPasskeysSupported } + @objc public func cancel() { + impl.cancel() + } + @objc public func register(request: String, onSuccess: @escaping OnSuccess, onError: @escaping OnError) { impl.register(request: request, onSuccess: onSuccess, onError: onError) } @@ -68,12 +82,21 @@ public typealias OnError = (CelestAuthErrorCode, UnsafePointer) -> Void @available(iOS 15.0, macOS 12.0, *) class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + + private let logger = Logger(subsystem: "dev.celest.celest_auth", category: "debug") + private weak var controller: ASAuthorizationController? private var onSuccess: OnSuccess? private var onError: OnError? var isPasskeysSupported: Bool { true } + func cancel() { + if #available(iOS 16.0, macOS 13.0, *) { + controller?.cancel() + } + } + func register( request: String, onSuccess: @escaping OnSuccess, @@ -84,8 +107,7 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll let challenge = Data(base64URLEncoded: options.challenge), let userID = options.user.id.data(using: .utf8) else { - onError(.serde, "Failed to deserialize registration request") - return + return onError(.serde, "Failed to deserialize registration request".unsafePointer) } self.onSuccess = onSuccess self.onError = onError @@ -98,7 +120,12 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest]) authController.delegate = self authController.presentationContextProvider = self - authController.performRequests() + if #available(iOS 16.0, macOS 13.0, *) { + authController.performRequests(options: .preferImmediatelyAvailableCredentials) + } else { + authController.performRequests() + } + self.controller = authController } func authenticate( @@ -110,8 +137,7 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll let options = try? JSONDecoder().decode(PasskeyAuthenticationOptions.self, from: data), let challenge = Data(base64URLEncoded: options.challenge) else { - onError(.serde, "Failed to deserialize authentication request") - return + return onError(.serde, "Failed to deserialize authentication request".unsafePointer) } self.onSuccess = onSuccess self.onError = onError @@ -120,7 +146,12 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest]) authController.delegate = self authController.presentationContextProvider = self - authController.performRequests() + if #available(iOS 16.0, macOS 13.0, *) { + authController.performRequests(options: .preferImmediatelyAvailableCredentials) + } else { + authController.performRequests() + } + self.controller = authController } private func reset() { @@ -133,12 +164,14 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll reset() } - private func complete(error: CelestAuthErrorCode, _ message: String) { + private func complete(error: CelestAuthErrorCode, message: String) { + logger.error("Authorization completed with error: \(message)") onError?(error, message.unsafePointer) reset() } private func complete(error: Error) { + logger.error("Authorization completed with error: \(error)") onError?(.unknown, error.localizedDescription.unsafePointer) reset() } @@ -159,26 +192,30 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll } public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + logger.debug("Authorization completed successfully with result: \(authorization)") + switch authorization.credential { + case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration: let response = PasskeyRegistration(credential: credential) guard let responseJson = try? JSONEncoder().encode(response) else { - complete(error: .serde, "Failed to serialize registration response") - return + return complete(error: .serde, message: "Failed to serialize registration response") } - return complete(value: responseJson) - } else if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + complete(value: responseJson) + case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion: let response = PasskeyAuthentication(credential: credential) guard let responseJson = try? JSONEncoder().encode(response) else { - complete(error: .serde, "Failed to serialize authentication response") - return + return complete(error: .serde, message: "Failed to serialize authentication response") } - return complete(value: responseJson) - } else { - complete(error: .unknown, "Unknown credential type: \(authorization.self)") + complete(value: responseJson) + default: + complete(error: .unknown, message: "Unknown credential type: \(authorization.self)") } } public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + logger.error("Authorization completed with error: \(error)") + if let error = error as? ASAuthorizationError { + return complete(error: .init(rawValue: error.errorCode) ?? .unknown, message: error.localizedDescription) + } complete(error: error) } } @@ -186,6 +223,8 @@ class CelestAuthSupported: NSObject, CelestAuthProtocol, ASAuthorizationControll class CelestAuthUnsupported: NSObject, CelestAuthProtocol { var isPasskeysSupported: Bool { false } + func cancel() {} + func register(request: String, onSuccess: @escaping OnSuccess, onError: @escaping OnError) { onError(.unsupported, "Unsupported platform".unsafePointer) } diff --git a/packages/celest_auth/example/ios/Runner/Runner.entitlements b/packages/celest_auth/example/ios/Runner/Runner.entitlements index 328f4467..c9c0c10e 100644 --- a/packages/celest_auth/example/ios/Runner/Runner.entitlements +++ b/packages/celest_auth/example/ios/Runner/Runner.entitlements @@ -4,7 +4,7 @@ com.apple.developer.associated-domains - webcredentials:65b7-136-24-157-119.ngrok-free.app?mode=developer + webcredentials:a102-136-24-157-119.ngrok-free.app?developer=true diff --git a/packages/celest_auth/example/lib/main.dart b/packages/celest_auth/example/lib/main.dart index e6345805..06199ddf 100644 --- a/packages/celest_auth/example/lib/main.dart +++ b/packages/celest_auth/example/lib/main.dart @@ -2,9 +2,10 @@ import 'package:celest_auth/celest_auth.dart'; import 'package:celest_core/celest_core.dart'; import 'package:corks/corks.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; final authClient = AuthClient( - baseUri: Uri.http('localhost:8080'), + baseUri: Uri.https('a102-136-24-157-119.ngrok-free.app'), ); final passkeys = PasskeyPlatform(protocol: authClient.passkeys); @@ -36,13 +37,15 @@ final class _State { class _MainAppState extends State { final _state = ValueNotifier(_State(false)); + final _controller = TextEditingController(); + Future? _request; Future signInWithPasskey() async { try { _state.value = _State(true); final response = await passkeys.register( - const PasskeyRegistrationRequest( - username: 'dillon@celest.dev', + PasskeyRegistrationRequest( + username: _controller.text, ), ); await authClient.passkeys.verifyRegistration( @@ -72,23 +75,86 @@ class _MainAppState extends State { return MaterialApp( home: Scaffold( body: Center( - child: ValueListenableBuilder( - valueListenable: _state, - builder: (context, state, child) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Currently signed in: ${state.user?.email}'), - const SizedBox(height: 16), - TextButton( - onPressed: !state.signingIn && state.user == null - ? signInWithPasskey - : null, - child: const Text('Sign in with Passkey'), - ), - ], - ); - }, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: ValueListenableBuilder( + valueListenable: _state, + builder: (context, state, child) { + final isSignedIn = state.user != null; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (state.user case final user?) + Text('Currently signed in: ${user.email}') + else + const Text('Currently signed out'), + if (_request case final request?) ...[ + const SizedBox(height: 16), + SizedBox( + height: 50, + child: Center( + child: FutureBuilder( + future: request, + builder: (context, snapshot) => switch (snapshot) { + AsyncSnapshot(:final Object error) || + AsyncSnapshot( + data: http.Response( + statusCode: != 200, + body: final Object error + ) + ) => + Text('Error: $error'), + AsyncSnapshot(data: final response?) => + Text('Response: ${response.body}'), + _ => const CircularProgressIndicator(), + }, + ), + ), + ), + ], + const SizedBox(height: 16), + if (!isSignedIn) ...[ + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Email', + ), + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + TextButton( + onPressed: !state.signingIn ? signInWithPasskey : null, + child: const Text('Sign in with Passkey'), + ), + ] else ...[ + TextButton( + onPressed: () { + _state.value = _State(false); + }, + child: const Text('Sign out'), + ), + ], + const SizedBox(height: 16), + TextButton( + onPressed: () { + setState(() { + _request = http.get( + Uri.parse( + 'https://a102-136-24-157-119.ngrok-free.app/authenticated', + ), + ); + }); + }, + child: const Text('Make HTTP request'), + ), + ], + ); + }, + ), ), ), ), diff --git a/packages/celest_auth/example/macos/Runner/DebugProfile.entitlements b/packages/celest_auth/example/macos/Runner/DebugProfile.entitlements index 4fe45f71..8682becb 100644 --- a/packages/celest_auth/example/macos/Runner/DebugProfile.entitlements +++ b/packages/celest_auth/example/macos/Runner/DebugProfile.entitlements @@ -4,7 +4,7 @@ com.apple.developer.associated-domains - webcredentials:65b7-136-24-157-119.ngrok-free.app?mode=developer + webcredentials:a102-136-24-157-119.ngrok-free.app?mode=developer com.apple.security.app-sandbox diff --git a/packages/celest_auth/example/macos/Runner/Release.entitlements b/packages/celest_auth/example/macos/Runner/Release.entitlements index 6e10687a..188738b3 100644 --- a/packages/celest_auth/example/macos/Runner/Release.entitlements +++ b/packages/celest_auth/example/macos/Runner/Release.entitlements @@ -4,7 +4,7 @@ com.apple.developer.associated-domains - webcredentials:65b7-136-24-157-119.ngrok-free.app?mode=developer + webcredentials:a102-136-24-157-119.ngrok-free.app?mode=developer com.apple.security.app-sandbox diff --git a/packages/celest_auth/lib/src/client/auth_platform.android.dart b/packages/celest_auth/lib/src/client/auth_platform.android.dart index ba593514..d6febbd8 100644 --- a/packages/celest_auth/lib/src/client/auth_platform.android.dart +++ b/packages/celest_auth/lib/src/client/auth_platform.android.dart @@ -13,7 +13,6 @@ final class AuthPlatformAndroid extends AuthPlatformImpl { required super.protocol, }) : super.base() { Jni.initDLApi(); - _celestAuth.init(_applicationContext); } static final Logger _logger = Logger('Celest.AuthClientAndroid'); @@ -21,7 +20,7 @@ final class AuthPlatformAndroid extends AuthPlatformImpl { /// A code to identify the result of the custom tabs request. static const int _customTabsRequestCode = 7777; - late final CelestAuth _celestAuth = CelestAuth(); + late final CelestAuth _celestAuth = CelestAuth(_mainActivity); late final Activity _mainActivity = Activity.fromRef(Jni.getCurrentActivity()); diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.android.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.android.dart index 4a8da748..19139d4c 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.android.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.android.dart @@ -11,66 +11,73 @@ import 'package:jni/jni.dart'; final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { PasskeyPlatformAndroid({ required super.protocol, - }) : super.base(); + }) : super.base() { + Jni.initDLApi(); + } late final Activity _mainActivity = Activity.fromRef(Jni.getCurrentActivity()); - late final Context _mainActivityContext = - Context.fromRef(_mainActivity.reference); - late final Context _applicationContext = - Context.fromRef(Jni.getCachedApplicationContext()); - late final Executor _threadPool = Executor.fromRef( - Executors.newCachedThreadPool().reference, - ); - late final CredentialManager _credentialManager = - CredentialManager.create(_applicationContext); + late final CelestAuth _celestAuth = CelestAuth(_mainActivity); + CancellationSignal? _cancellationSignal; + + // Throws with java.lang.NoSuchMethodError: no static method + // "Landroidx/credentials/CredentialManager;.create(Landroid/content/Context;)Landroidx/credentials/CredentialManager;" + // late final CredentialManager _credentialManager = + // CredentialManager.create(_mainActivityContext); @override Future get isSupported async { - final callback = Completer(); + final callback = Completer(); final client = Fido.getFido2ApiClient(_mainActivity); final isAvailable = client.isUserVerifyingPlatformAuthenticatorAvailable(); + final onSuccess = OnSuccessListener.implement( + $OnSuccessListenerImpl( + TResult: isAvailable.TResult, + onSuccess: callback.complete, + ), + ); + final onError = OnFailureListener.implement( + // TODO(dnys1): Convert to PasskeyException + $OnFailureListenerImpl(onFailure: (error) { + callback.completeError(error); + }), + ); isAvailable - ..addOnSuccessListener( - OnSuccessListener.implement( - $OnSuccessListenerImpl( - TResult: isAvailable.TResult, - onSuccess: (boolean) => callback.complete( - boolean.booleanValue(releaseOriginal: true), - ), - ), - ), - ) - ..addOnFailureListener( - OnFailureListener.implement( - // TODO(dnys1): Convert to PasskeyException - $OnFailureListenerImpl(onFailure: callback.completeError), - ), - ); - return callback.future; + ..addOnSuccessListener(onSuccess) + ..addOnFailureListener(onError); + final result = await callback.future; + return result.booleanValue(); + } + + @override + void cancel() { + if (_cancellationSignal case final cancellationSignal?) { + cancellationSignal.cancel(); + if (!cancellationSignal.isReleased) { + cancellationSignal.release(); + } + } + _cancellationSignal = null; } @override Future register( PasskeyRegistrationRequest request, ) async { + if (!await isSupported) { + throw const PasskeyUnsupportedException(); + } final options = await protocol.requestRegistration(request: request); - final jRequest = CreatePublicKeyCredentialRequest.new7( - jsonEncode(options.toJson()).toJString(), - ); - final responseCallback = Completer(); - _credentialManager.createCredentialAsync( - _mainActivityContext, - jRequest, - CancellationSignal(), - _threadPool, + final requestJson = jsonEncode(options.toJson()).toJString(); + final responseCallback = Completer(); + _cancellationSignal = _celestAuth.register( + requestJson, CredentialManagerCallback.implement( $CredentialManagerCallbackImpl( R: CreateCredentialResponse.type, E: CreateCredentialException.type, - onResult: (resp) => responseCallback - .complete(resp.as(CreatePublicKeyCredentialResponse.type)), + onResult: responseCallback.complete, onError: responseCallback.completeError, ), ), @@ -78,8 +85,9 @@ final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { try { final response = await responseCallback.future; final passkeyJson = response + .as(CreatePublicKeyCredentialResponse.type) .getRegistrationResponseJson() - .toDartString(releaseOriginal: true); + .toDartString(); return PasskeyRegistrationResponse.fromJson( jsonDecode(passkeyJson) as Map, ); @@ -123,8 +131,7 @@ final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { "An unknown error occurred from a 3rd party SDK. Check logs for additional details."); } throw Exception( - "Unexpected exception type: " - "${e.getType().toDartString(releaseOriginal: true)}", + "Unexpected exception type: ${e.getType().toDartString()}", ); } } @@ -133,26 +140,25 @@ final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { Future authenticate( PasskeyAuthenticationRequest request, ) async { + if (!await isSupported) { + throw const PasskeyUnsupportedException(); + } final options = await protocol.requestAuthentication(request: request); - final jRequest = GetCredentialRequest_Builder() - .addCredentialOption( - GetPublicKeyCredentialOption.new3( - jsonEncode(options.toJson()).toJString(), - ), - ) - .build(); + final requestJson = jsonEncode(options.toJson()).toJString(); + // final jRequest = GetCredentialRequest_Builder() + // .addCredentialOption( + // GetPublicKeyCredentialOption.new3(requestJson), + // ) + // .build(); final responseCallback = Completer(); - _credentialManager.getCredentialAsync( - _mainActivityContext, - jRequest, - CancellationSignal(), - _threadPool, + _cancellationSignal = _celestAuth.authenticate( + requestJson, CredentialManagerCallback.implement( $CredentialManagerCallbackImpl( R: GetCredentialResponse.type, E: GetCredentialException.type, - onResult: (resp) => responseCallback.complete(resp), + onResult: responseCallback.complete, onError: responseCallback.completeError, ), ), @@ -163,7 +169,7 @@ final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { .getCredential() .as(PublicKeyCredential.type) .getAuthenticationResponseJson() - .toDartString(releaseOriginal: true); + .toDartString(); return PasskeyAuthenticationResponse.fromJson( jsonDecode(passkeyJson) as Map, ); @@ -178,14 +184,13 @@ final class PasskeyPlatformAndroid extends PasskeyPlatformImpl { // TODO(dnys1): Handle } if (e.instanceOf(GetPublicKeyCredentialDomException.type)) { - final message = e.getMessage().toDartString(releaseOriginal: true); + final message = e.getMessage().toDartString(); if (message == 'Failed to decrypt credential.') { // TODO(dnys1): Sync account not available } } throw Exception( - "Unexpected exception type: " - "${e.getType().toDartString(releaseOriginal: true)}", + 'Unexpected exception type: ${e.getType().toDartString()}', ); } } diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.dart index 63e0f5ae..0ce04041 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.dart @@ -16,10 +16,21 @@ abstract base class PasskeyPlatform { @protected final PasskeyProtocol protocol; + /// Returns `true` if the platform supports passkeys. + /// + /// If the platform does not support passkeys, the [register] and [authenticate] + /// methods will throw a [PasskeyException]. Future get isSupported; + + /// Cancels the in-progress operation, if any. + void cancel(); + + /// Registers a new passkey. Future register( PasskeyRegistrationRequest request, ); + + /// Authenticates with an existing passkey. Future authenticate( PasskeyAuthenticationRequest request, ); diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.darwin.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.darwin.dart index 83da0523..f08efa57 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.darwin.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.darwin.dart @@ -22,14 +22,18 @@ final class PasskeyPlatformDarwin extends PasskeyPlatformImpl { return kIsFlutter && _celestAuth.isPasskeysSupported; } + @override + void cancel() { + _celestAuth.cancel(); + // TODO(dnys1): Ignore results? + } + @override Future register( PasskeyRegistrationRequest request, ) async { if (!await isSupported) { - throw const PasskeyException( - message: 'Passkeys are not supported in this environment', - ); + throw const PasskeyUnsupportedException(); } final options = await protocol.requestRegistration(request: request); @@ -50,9 +54,9 @@ final class PasskeyPlatformDarwin extends PasskeyPlatformImpl { if (error == nullptr) { return completer.completeError(StateError('Bad pointer')); } - completer.completeError( - PasskeyException(message: error.cast().toDartString()), - ); + final message = error.cast().toDartString(); + final exception = _CelestAuthError(code).toException(message); + completer.completeError(exception); _celestAuth.freePointer_(error); }, ); @@ -72,9 +76,7 @@ final class PasskeyPlatformDarwin extends PasskeyPlatformImpl { PasskeyAuthenticationRequest request, ) async { if (!await isSupported) { - throw const PasskeyException( - message: 'Passkeys are not supported in this environment', - ); + throw const PasskeyUnsupportedException(); } final options = await protocol.requestAuthentication(request: request); final completer = Completer(); @@ -94,9 +96,9 @@ final class PasskeyPlatformDarwin extends PasskeyPlatformImpl { if (error == nullptr) { return completer.completeError(StateError('Bad pointer')); } - completer.completeError( - PasskeyException(message: error.cast().toString()), - ); + final message = error.cast().toString(); + final exception = _CelestAuthError(code).toException(message); + completer.completeError(exception); _celestAuth.freePointer_(error); }, ); @@ -111,3 +113,21 @@ final class PasskeyPlatformDarwin extends PasskeyPlatformImpl { return response; } } + +extension type _CelestAuthError(int code) { + Object toException(String message) { + return switch (code) { + CelestAuthErrorCode.CelestAuthErrorCodeCanceled => + const PasskeyCancellationException(), + CelestAuthErrorCode.CelestAuthErrorCodeUnsupported => + const PasskeyUnsupportedException(), + CelestAuthErrorCode.CelestAuthErrorCodeFailed => + PasskeyFailedException(message), + // This shouldn't happen + CelestAuthErrorCode.CelestAuthErrorCodeSerde => StateError(message), + // This shouldn't happen + CelestAuthErrorCode.CelestAuthErrorCodeNotHandled => StateError(message), + _ => PasskeyUnknownException(message), + }; + } +} diff --git a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.web.dart b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.web.dart index 74b5fe44..5e98900a 100644 --- a/packages/celest_auth/lib/src/client/passkeys/passkey_platform.web.dart +++ b/packages/celest_auth/lib/src/client/passkeys/passkey_platform.web.dart @@ -19,6 +19,8 @@ final class PasskeyPlatformWeb extends PasskeyPlatformImpl { required super.protocol, }) : super.base(); + AbortController? _abortController; + @override Future get isSupported async { final publicKeyCredential = window.getProperty('PublicKeyCredential'.toJS); @@ -32,6 +34,12 @@ final class PasskeyPlatformWeb extends PasskeyPlatformImpl { .then((value) => value.toDart); } + @override + void cancel() { + _abortController?.abort(); + _abortController = null; + } + @override Future register( PasskeyRegistrationRequest request, @@ -42,9 +50,11 @@ final class PasskeyPlatformWeb extends PasskeyPlatformImpl { ); } final options = await protocol.requestRegistration(request: request); + _abortController = AbortController(); final credential = await window.navigator.credentials .create( CredentialCreationOptions( + signal: _abortController!.signal, publicKey: PublicKeyCredentialCreationOptions( challenge: options.challenge.buffer.toJS, pubKeyCredParams: [ @@ -111,9 +121,11 @@ final class PasskeyPlatformWeb extends PasskeyPlatformImpl { ); } final options = await protocol.requestAuthentication(request: request); + _abortController = AbortController(); final credential = await window.navigator.credentials .get( CredentialRequestOptions( + signal: _abortController!.signal, publicKey: PublicKeyCredential.parseRequestOptionsFromJSON( options.toJson().jsify() as PublicKeyCredentialRequestOptionsJSON, ), diff --git a/packages/celest_auth/lib/src/platform/android/jni_bindings.ffi.dart b/packages/celest_auth/lib/src/platform/android/jni_bindings.ffi.dart index 9ae7b6ad..13c345f4 100644 --- a/packages/celest_auth/lib/src/platform/android/jni_bindings.ffi.dart +++ b/packages/celest_auth/lib/src/platform/android/jni_bindings.ffi.dart @@ -19306,83 +19306,75 @@ class CelestAuth extends jni.JObject { /// The type which includes information such as the signature of this class. static const type = $CelestAuthType(); - static final _id_new0 = - jni.Jni.accessors.getMethodIDOf(_class.reference, r"", r"()V"); + static final _id_new0 = jni.Jni.accessors + .getMethodIDOf(_class.reference, r"", r"(Landroid/app/Activity;)V"); - /// from: public void () + /// from: public void (android.app.Activity activity) /// The returned object must be released after use, by calling the [release] method. - factory CelestAuth() { - return CelestAuth.fromRef(jni.Jni.accessors - .newObjectWithArgs(_class.reference, _id_new0, []).object); - } - - static final _id_init = jni.Jni.accessors.getMethodIDOf( - _class.reference, r"init", r"(Landroid/content/Context;)V"); - - /// from: public final void init(android.content.Context context) - void init( - Context context, - ) { - return jni.Jni.accessors.callMethodWithArgs(reference, _id_init, - jni.JniCallType.voidType, [context.reference]).check(); - } - - static final _id_signInWithCustomTabs = jni.Jni.accessors.getMethodIDOf( - _class.reference, - r"signInWithCustomTabs", - r"(Landroid/net/Uri;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V"); - - /// from: public final void signInWithCustomTabs(android.net.Uri uri, kotlin.jvm.functions.Function0 function0, kotlin.jvm.functions.Function1 function1) - void signInWithCustomTabs( - Uri uri, - jni.JObject function0, - jni.JObject function1, + factory CelestAuth( + Activity activity, ) { - return jni.Jni.accessors.callMethodWithArgs( - reference, - _id_signInWithCustomTabs, - jni.JniCallType.voidType, - [uri.reference, function0.reference, function1.reference]).check(); + return CelestAuth.fromRef(jni.Jni.accessors.newObjectWithArgs( + _class.reference, _id_new0, [activity.reference]).object); } - static final _id_signInWithGoogle = jni.Jni.accessors.getMethodIDOf( + static final _id_isSupported = jni.Jni.accessors.getMethodIDOf( _class.reference, - r"signInWithGoogle", - r"(Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"); + r"isSupported", + r"(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"); - /// from: public final java.lang.Object signInWithGoogle(java.lang.String string, java.lang.String string1, kotlin.coroutines.Continuation continuation) + /// from: public final java.lang.Object isSupported(kotlin.coroutines.Continuation continuation) /// The returned object must be released after use, by calling the [release] method. - Future signInWithGoogle( - jni.JString string, - jni.JString string1, - ) async { + Future isSupported() async { final $p = ReceivePort(); final $c = jni.JObject.fromRef(ProtectedJniExtensions.newPortContinuation($p)); - jni.Jni.accessors.callMethodWithArgs( - reference, - _id_signInWithGoogle, - jni.JniCallType.objectType, - [string.reference, string1.reference, $c.reference]).object; + jni.Jni.accessors.callMethodWithArgs(reference, _id_isSupported, + jni.JniCallType.objectType, [$c.reference]).object; final $o = jni.JObjectPtr.fromAddress(await $p.first); - final $k = const jni.JObjectType().getClass().reference; + final $k = const jni.JBooleanType().getClass().reference; if (!jni.Jni.env.IsInstanceOf($o, $k)) { throw "Failed"; } - return const jni.JObjectType().fromRef($o); + return const jni.JBooleanType().fromRef($o); } - static final _id_handleSignIn = jni.Jni.accessors.getMethodIDOf( + static final _id_register = jni.Jni.accessors.getMethodIDOf( _class.reference, - r"handleSignIn", - r"(Landroidx/credentials/GetCredentialResponse;)V"); + r"register", + r"(Ljava/lang/String;Landroidx/credentials/CredentialManagerCallback;)Landroid/os/CancellationSignal;"); - /// from: public final void handleSignIn(androidx.credentials.GetCredentialResponse getCredentialResponse) - void handleSignIn( - GetCredentialResponse getCredentialResponse, + /// from: public final android.os.CancellationSignal register(java.lang.String string, androidx.credentials.CredentialManagerCallback credentialManagerCallback) + /// The returned object must be released after use, by calling the [release] method. + CancellationSignal register( + jni.JString string, + CredentialManagerCallback + credentialManagerCallback, ) { - return jni.Jni.accessors.callMethodWithArgs(reference, _id_handleSignIn, - jni.JniCallType.voidType, [getCredentialResponse.reference]).check(); + return const $CancellationSignalType().fromRef(jni.Jni.accessors + .callMethodWithArgs(reference, _id_register, jni.JniCallType.objectType, + [string.reference, credentialManagerCallback.reference]).object); + } + + static final _id_authenticate = jni.Jni.accessors.getMethodIDOf( + _class.reference, + r"authenticate", + r"(Ljava/lang/String;Landroidx/credentials/CredentialManagerCallback;)Landroid/os/CancellationSignal;"); + + /// from: public final android.os.CancellationSignal authenticate(java.lang.String string, androidx.credentials.CredentialManagerCallback credentialManagerCallback) + /// The returned object must be released after use, by calling the [release] method. + CancellationSignal authenticate( + jni.JString string, + CredentialManagerCallback + credentialManagerCallback, + ) { + return const $CancellationSignalType().fromRef(jni.Jni.accessors + .callMethodWithArgs( + reference, + _id_authenticate, + jni.JniCallType.objectType, + [string.reference, credentialManagerCallback.reference]).object); } } diff --git a/packages/celest_auth/lib/src/platform/darwin/celest_auth.ffi.dart b/packages/celest_auth/lib/src/platform/darwin/celest_auth.ffi.dart index 3e20fff2..376c1037 100644 --- a/packages/celest_auth/lib/src/platform/darwin/celest_auth.ffi.dart +++ b/packages/celest_auth/lib/src/platform/darwin/celest_auth.ffi.dart @@ -932,6 +932,7 @@ class CelestAuthDarwin { late final _sel_debugDescription1 = _registerName1("debugDescription"); late final _sel_isPasskeysSupported1 = _registerName1("isPasskeysSupported"); + late final _sel_cancel1 = _registerName1("cancel"); ffi.Pointer<_ObjCBlockDesc> _newBlockDesc1() { final d = pkg_ffi.calloc.allocate<_ObjCBlockDesc>(ffi.sizeOf<_ObjCBlockDesc>()); @@ -1128,6 +1129,10 @@ class CelestAuth extends NSObject { return _lib._objc_msgSend_12(_id, _lib._sel_isPasskeysSupported1); } + void cancel() { + _lib._objc_msgSend_1(_id, _lib._sel_cancel1); + } + void registerWithRequest_onSuccess_onError_( NSString request, ObjCBlock_ffiVoid_Uint8 onSuccess, @@ -2117,4 +2122,9 @@ abstract class CelestAuthErrorCode { static const int CelestAuthErrorCodeUnknown = 0; static const int CelestAuthErrorCodeUnsupported = 1; static const int CelestAuthErrorCodeSerde = 2; + static const int CelestAuthErrorCodeCanceled = 1001; + static const int CelestAuthErrorCodeInvalidResponse = 1002; + static const int CelestAuthErrorCodeNotHandled = 1003; + static const int CelestAuthErrorCodeFailed = 1004; + static const int CelestAuthErrorCodeNotInteractive = 1005; } diff --git a/packages/celest_core/lib/src/auth/passkey_exception.dart b/packages/celest_core/lib/src/auth/passkey_exception.dart index 0084b774..92f87c2c 100644 --- a/packages/celest_core/lib/src/auth/passkey_exception.dart +++ b/packages/celest_core/lib/src/auth/passkey_exception.dart @@ -1,5 +1,6 @@ import 'package:celest_core/celest_core.dart'; +// TODO(dnys1): Make sealed final class PasskeyException implements CelestException { const PasskeyException({ required this.message, @@ -11,3 +12,26 @@ final class PasskeyException implements CelestException { @override String toString() => 'PasskeyException: $message'; } + +final class PasskeyCancellationException extends PasskeyException { + const PasskeyCancellationException() + : super(message: 'Passkey registration was canceled by the user'); +} + +final class PasskeyUnknownException extends PasskeyException { + const PasskeyUnknownException([String? message]) + : super( + message: message ?? + 'An unknown error occurred during passkey registration', + ); +} + +final class PasskeyUnsupportedException extends PasskeyException { + const PasskeyUnsupportedException() + : super(message: 'Passkeys are not supported on this platform'); +} + +final class PasskeyFailedException extends PasskeyException { + const PasskeyFailedException([String? message]) + : super(message: message ?? 'Passkey registration failed'); +}