diff --git a/packages/passkeys/passkeys/example/lib/local_relying_party_server.dart b/packages/passkeys/passkeys/example/lib/local_relying_party_server.dart index 77ef3089..0047af95 100644 --- a/packages/passkeys/passkeys/example/lib/local_relying_party_server.dart +++ b/packages/passkeys/passkeys/example/lib/local_relying_party_server.dart @@ -92,8 +92,8 @@ class LocalRelyingPartyServer { user ..credentialID = response.id ..transports = response.transports.isEmpty - // For iOS faceID and touchID transports returns an empty list. - ? ["internal"] + // When using FaceID or TouchID, the transports are empty. + ? ['internal', 'hybrid'] : response.transports as List; _users[user.name] = user; diff --git a/packages/passkeys/passkeys/lib/authenticator.dart b/packages/passkeys/passkeys/lib/authenticator.dart index 6daa345c..e29938ca 100644 --- a/packages/passkeys/passkeys/lib/authenticator.dart +++ b/packages/passkeys/passkeys/lib/authenticator.dart @@ -44,6 +44,10 @@ class PasskeyAuthenticator { throw DomainNotAssociatedException(e.message); case 'deviceNotSupported': throw DeviceNotSupportedException(); + case 'android-timeout': + throw TimeoutException(e.message); + case 'ios-security-key-timeout': + throw TimeoutException(e.message); default: rethrow; } @@ -75,6 +79,10 @@ class PasskeyAuthenticator { throw DeviceNotSupportedException(); case 'android-no-create-option': throw NoCreateOptionException(e.message); + case 'android-timeout': + throw TimeoutException(e.message); + case 'ios-security-key-timeout': + throw TimeoutException(e.message); default: if (e.code.startsWith('android-unhandled')) { throw UnhandledAuthenticatorException(e.code, e.message, e.details); diff --git a/packages/passkeys/passkeys/lib/exceptions.dart b/packages/passkeys/passkeys/lib/exceptions.dart index e6eebb34..3f92c637 100644 --- a/packages/passkeys/passkeys/lib/exceptions.dart +++ b/packages/passkeys/passkeys/lib/exceptions.dart @@ -100,6 +100,21 @@ class NoCreateOptionException implements AuthenticatorException { String toString() => message ?? ''; } +/// This exception is thrown when the user tries to login or register but the +/// operation times out. +/// +/// Platforms: Android, iOS +/// +/// Suggestions: +/// - ask the user to try again +class TimeoutException implements AuthenticatorException { + final String? message; + /// Constructor + TimeoutException(this.message); + + String toString() => message ?? ''; +} + /// This exception is thrown when an exception is thrown by the authenticator /// that we do not handle so far in this package. /// diff --git a/packages/passkeys/passkeys_android/android/src/main/java/com/corbado/passkeys_android/MessageHandler.java b/packages/passkeys/passkeys_android/android/src/main/java/com/corbado/passkeys_android/MessageHandler.java index 912e99de..bbf1390d 100644 --- a/packages/passkeys/passkeys_android/android/src/main/java/com/corbado/passkeys_android/MessageHandler.java +++ b/packages/passkeys/passkeys_android/android/src/main/java/com/corbado/passkeys_android/MessageHandler.java @@ -1,9 +1,6 @@ package com.corbado.passkeys_android; import android.app.Activity; -import android.content.pm.PackageManager; -import android.content.pm.Signature; -import android.os.Build; import android.os.CancellationSignal; import android.util.Log; @@ -18,15 +15,19 @@ import androidx.credentials.GetCredentialResponse; import androidx.credentials.GetPublicKeyCredentialOption; import androidx.credentials.PublicKeyCredential; -import androidx.credentials.exceptions.*; +import androidx.credentials.exceptions.CreateCredentialCancellationException; +import androidx.credentials.exceptions.CreateCredentialException; +import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException; +import androidx.credentials.exceptions.GetCredentialCancellationException; +import androidx.credentials.exceptions.GetCredentialException; +import androidx.credentials.exceptions.NoCredentialException; import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException; -import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialException; import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException; import com.corbado.passkeys_android.models.login.AllowCredentialType; +import com.corbado.passkeys_android.models.login.GetCredentialOptions; import com.corbado.passkeys_android.models.signup.AuthenticatorSelectionType; import com.corbado.passkeys_android.models.signup.CreateCredentialOptions; -import com.corbado.passkeys_android.models.login.GetCredentialOptions; import com.corbado.passkeys_android.models.signup.ExcludeCredentialType; import com.corbado.passkeys_android.models.signup.PubKeyCredParamType; import com.corbado.passkeys_android.models.signup.RelyingPartyType; @@ -35,7 +36,6 @@ import com.google.android.gms.fido.fido2.Fido2ApiClient; import com.google.android.gms.tasks.Task; -import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -52,6 +52,7 @@ public class MessageHandler implements Messages.PasskeysApi { private static final String MISSING_GOOGLE_SIGN_IN_ERROR = "Please sign in with a Google account first to create a new passkey."; private static final String EXCLUDE_CREDENTIALS_MATCH_ERROR = "You can not create a credential on this device because one of the excluded credentials exists on the local device."; private static final String MISSING_CREATION_OPTIONS = "Please make sure you enable a passwords or passkeys provider in your device settings."; + private static final String TIMEOUT_ERROR = "Passkey operation timed out, please try again"; private final FlutterPasskeysPlugin plugin; @@ -161,6 +162,8 @@ public void onError(CreateCredentialException e) { platformException = new Messages.FlutterError("android-sync-account-not-available", e.getMessage(), SYNC_ACCOUNT_NOT_AVAILABLE_ERROR); } else if (Objects.equals(e.getMessage(), "One of the excluded credentials exists on the local device")) { platformException = new Messages.FlutterError("exclude-credentials-match", e.getMessage(), EXCLUDE_CREDENTIALS_MATCH_ERROR); + } else if (Objects.equals(e.getMessage(), "[15] Flow has timed out.")) { + platformException = new Messages.FlutterError("android-timeout", e.getMessage(), TIMEOUT_ERROR); } else { platformException = new Messages.FlutterError("android-unhandled: " + e.getType(), e.getMessage(), e.getErrorMessage()); } @@ -253,6 +256,8 @@ public void onError(GetCredentialException e) { } else if (e instanceof GetPublicKeyCredentialDomException) { if (Objects.equals(e.getMessage(), "Failed to decrypt credential.")) { platformException = new Messages.FlutterError("android-sync-account-not-available", e.getMessage(), SYNC_ACCOUNT_NOT_AVAILABLE_ERROR); + } else if (Objects.equals(e.getMessage(), "[15] Flow has timed out.")) { + platformException = new Messages.FlutterError("android-timeout", e.getMessage(), TIMEOUT_ERROR); } else { platformException = new Messages.FlutterError("android-unhandled: " + e.getType(), e.getMessage(), e.getErrorMessage()); } diff --git a/packages/passkeys/passkeys_ios/ios/Classes/AuthenticateController.swift b/packages/passkeys/passkeys_ios/ios/Classes/AuthenticateController.swift index ea05a5ec..8bd08792 100644 --- a/packages/passkeys/passkeys_ios/ios/Classes/AuthenticateController.swift +++ b/packages/passkeys/passkeys_ios/ios/Classes/AuthenticateController.swift @@ -12,8 +12,8 @@ class AuthenticateController: NSObject, ASAuthorizationControllerDelegate, ASAut self.completion = completion; } - func run(request: ASAuthorizationPlatformPublicKeyCredentialAssertionRequest, conditionalUI: Bool, preferImmediatelyAvailableCredentials: Bool) { - let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + func run(requests: [ASAuthorizationRequest], conditionalUI: Bool, preferImmediatelyAvailableCredentials: Bool) { + let authorizationController = ASAuthorizationController(authorizationRequests: requests) authorizationController.delegate = self authorizationController.presentationContextProvider = self @@ -39,7 +39,19 @@ class AuthenticateController: NSObject, ASAuthorizationControllerDelegate, ASAut func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { switch authorization.credential { - case let r as ASAuthorizationPublicKeyCredentialAssertion: + case let r as ASAuthorizationSecurityKeyPublicKeyCredentialAssertion: + let response = AuthenticateResponse( + id: r.credentialID.toBase64URL(), + rawId: r.credentialID.toBase64URL(), + clientDataJSON: r.rawClientDataJSON.toBase64URL(), + authenticatorData: r.rawAuthenticatorData.toBase64URL(), + signature: r.signature.toBase64URL(), + userHandle: r.userID.toBase64URL() + ) + + completion?(.success(response)) + break + case let r as ASAuthorizationPlatformPublicKeyCredentialAssertion: let response = AuthenticateResponse( id: r.credentialID.toBase64URL(), rawId: r.credentialID.toBase64URL(), @@ -62,7 +74,9 @@ class AuthenticateController: NSObject, ASAuthorizationControllerDelegate, ASAut completion?(.failure(FlutterError(from: err))) } - completion?(.failure(FlutterError(code: CustomErrors.unknown))) + let nsErr = error as NSError + completion?(.failure(FlutterError(fromNSError: nsErr))) + return } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { diff --git a/packages/passkeys/passkeys_ios/ios/Classes/ErrorExtension.swift b/packages/passkeys/passkeys_ios/ios/Classes/ErrorExtension.swift index 1c417552..ede630cc 100644 --- a/packages/passkeys/passkeys_ios/ios/Classes/ErrorExtension.swift +++ b/packages/passkeys/passkeys_ios/ios/Classes/ErrorExtension.swift @@ -42,6 +42,9 @@ extension FlutterError: Error { var code = "" if (error.domain == "WKErrorDomain" && error.code == 8) { code = "exclude-credentials-match" + }else if(error.domain == "WKErrorDomain" && error.code == 31){ + // This error happens when the security key prompt times out (2 minutes) + code = "ios-security-key-timeout" } else { code = "ios-unhandled-" + error.domain } diff --git a/packages/passkeys/passkeys_ios/ios/Classes/PasskeysPlugin.swift b/packages/passkeys/passkeys_ios/ios/Classes/PasskeysPlugin.swift index 6525d34e..80996890 100644 --- a/packages/passkeys/passkeys_ios/ios/Classes/PasskeysPlugin.swift +++ b/packages/passkeys/passkeys_ios/ios/Classes/PasskeysPlugin.swift @@ -11,8 +11,8 @@ protocol Cancellable { @available(iOS 16.0, *) public class PasskeysPlugin: NSObject, FlutterPlugin, PasskeysApi { - var inFlightController: Cancellable?; - let lock: NSLock = NSLock(); + var inFlightController: Cancellable? + let lock = NSLock() public static func register(with registrar: FlutterPluginRegistrar) { let instance = PasskeysPlugin() @@ -22,11 +22,11 @@ public class PasskeysPlugin: NSObject, FlutterPlugin, PasskeysApi { func canAuthenticate() throws -> Bool { return LocalAuth.shared.canAuthenticate() } - + func hasBiometrics() throws -> Bool { return LocalAuth.shared.hasBiometrics() } - + func getFacetID(completion: @escaping (Result) -> Void) { completion(.success("")) } @@ -35,14 +35,17 @@ public class PasskeysPlugin: NSObject, FlutterPlugin, PasskeysApi { challenge: String, relyingParty: RelyingParty, user: User, - excludeCredentialIDs: [String], + excludeCredentials: [CredentialType], + pubKeyCredValues: [Int64], + canBePlatformAuthenticator: Bool = true, + canBeSecurityKey: Bool = true, completion: @escaping (Result) -> Void ) { guard (try? canAuthenticate()) == true else { completion(.failure(CustomErrors.deviceNotSupported)) return } - + guard let decodedChallenge = Data.fromBase64Url(challenge) else { completion(.failure(CustomErrors.decodingChallenge)) return @@ -53,89 +56,157 @@ public class PasskeysPlugin: NSObject, FlutterPlugin, PasskeysApi { return } - + var requests: [ASAuthorizationRequest] = [] let rp = relyingParty.id - let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rp) - let request = platformProvider.createCredentialRegistrationRequest( - challenge: decodedChallenge, - name: user.name, - userID: decodedUserId - ) - - if #available(iOS 17.4, *) { - request.excludedCredentials = parseCredentials(credentialIDs: excludeCredentialIDs) + + if(canBePlatformAuthenticator){ + // Create a platform (on‑device) registration request. + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rp) + let platformRequest = platformProvider.createCredentialRegistrationRequest( + challenge: decodedChallenge, + name: user.name, + userID: decodedUserId + ) + + if #available(iOS 17.4, *) { + let excluded = parseCredentials(credentials: excludeCredentials) + platformRequest.excludedCredentials = excluded + } + + requests.append(platformRequest) } - + + if(canBeSecurityKey){ + // Create an external (security key) registration request. + let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: rp) + let externalRequest = securityKeyProvider.createCredentialRegistrationRequest( + challenge: decodedChallenge, + displayName: user.name, // displayName as provided by the new API + name: user.name, + userID: decodedUserId + ) + + if #available(iOS 17.4, *) { + let excludedSecurityKeys = parseSecurityKeyCredentials(credentials: excludeCredentials) + externalRequest.excludedCredentials = excludedSecurityKeys + } + + externalRequest.credentialParameters = pubKeyCredValues.map { rawValue in + let intValue = Int(rawValue) + + return ASAuthorizationPublicKeyCredentialParameters( + algorithm: ASCOSEAlgorithmIdentifier(rawValue: intValue) + ) + } + + requests.append(externalRequest) + } + func wrappedCompletion(result: Result) { lock.unlock() completion(result) } - let con = RegisterController(completion: completion) - con.run(request: request) + let con = RegisterController(completion: wrappedCompletion) + con.run(requests: requests) inFlightController = con } - - func authenticate(relyingPartyId: String, challenge: String, conditionalUI: Bool, allowedCredentialIDs: [String], preferImmediatelyAvailableCredentials: Bool, completion: @escaping (Result) -> Void) { + + func authenticate( + relyingPartyId: String, + challenge: String, + conditionalUI: Bool, + allowedCredentials: [CredentialType], + preferImmediatelyAvailableCredentials: Bool, + completion: @escaping (Result) -> Void + ) { guard (try? canAuthenticate()) == true else { completion(.failure(CustomErrors.deviceNotSupported)) return } - + guard let decodedChallenge = Data.fromBase64Url(challenge) else { completion(.failure(CustomErrors.decodingChallenge)) return } - + + var requests: [ASAuthorizationRequest] = [] + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingPartyId) - let request = platformProvider.createCredentialAssertionRequest( - challenge: decodedChallenge - ) - - request.allowedCredentials = parseCredentials(credentialIDs: allowedCredentialIDs) - + let platformRequest = platformProvider.createCredentialAssertionRequest(challenge: decodedChallenge) + platformRequest.allowedCredentials = parseCredentials(credentials: allowedCredentials) + requests.append(platformRequest) + + // We should not show the security key flow when preferImmediatelyAvailable is set to true + if !preferImmediatelyAvailableCredentials { + let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: relyingPartyId) + let externalRequest = securityKeyProvider.createCredentialAssertionRequest(challenge: decodedChallenge) + externalRequest.allowedCredentials = parseSecurityKeyCredentials(credentials: allowedCredentials) + requests.append(externalRequest) + } + let con = AuthenticateController(completion: completion) - con.run(request: request, conditionalUI: conditionalUI, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials) + con.run(requests: requests, conditionalUI: conditionalUI, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials) inFlightController = con } func cancelCurrentAuthenticatorOperation(completion: @escaping (Result) -> Void) { inFlightController?.cancel() - - completion(.success(Void())) + completion(.success(())) + } + + private func parseCredentials(credentials: [CredentialType]) -> [ASAuthorizationPlatformPublicKeyCredentialDescriptor] { + return credentials.compactMap { credential in + guard let credentialData = Data.fromBase64Url(credential.id) else { + return nil + } + return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialData) + } } - private func parseCredentials(credentialIDs: [String]) -> [ASAuthorizationPlatformPublicKeyCredentialDescriptor] { - return credentialIDs.compactMap { - if let credentialId = Data.fromBase64Url($0) { - return ASAuthorizationPlatformPublicKeyCredentialDescriptor.init(credentialID: credentialId) - } else { + private func parseSecurityKeyCredentials(credentials: [CredentialType]) -> [ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor] { + return credentials.compactMap { credential in + guard let credentialData = Data.fromBase64Url(credential.id) else { return nil } + + let parsedTransports: [ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport] = credential.transports.compactMap { transport in + switch transport { + case "nfc": + return .nfc + case "usb": + return .usb + case "bluetooth": + return .bluetooth + default: + return nil + } + } + + return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor( + credentialID: credentialData, + transports: parsedTransports + ) } } } open class LocalAuth: NSObject { public static let shared = LocalAuth() - - override private init() {} - + private override init() {} + var laContext = LAContext() - + func canAuthenticate() -> Bool { - // Check iOS version as Passkeys are only available on iOS 16.0 and above if #unavailable(iOS 16.0) { return false } - return true } - + func hasBiometrics() -> Bool { var error: NSError? - let hasTouchId = laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - return hasTouchId + return laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) } } @@ -157,20 +228,17 @@ struct PublicKeyCredentialCreateResponse: Codable { } public extension Data { - /// Same as ``Data(base64Encoded:)``, but adds padding automatically - /// (if missing, instead of returning `nil`). + /// Same as Data(base64Encoded:), but adds padding automatically (if missing). static func fromBase64(_ encoded: String) -> Data? { - // Prefixes padding-character(s) (if needed). var encoded = encoded let remainder = encoded.count % 4 if remainder > 0 { encoded = encoded.padding( toLength: encoded.count + 4 - remainder, - withPad: "=", startingAt: 0 + withPad: "=", + startingAt: 0 ) } - - // Finally, decode. return Data(base64Encoded: encoded) } @@ -180,11 +248,8 @@ public extension Data { } private static func base64UrlToBase64(base64Url: String) -> String { - let base64 = base64Url - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - - return base64 + return base64Url.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") } } @@ -199,9 +264,7 @@ public extension String { extension Data { func toBase64URL() -> String { - let current = self - - var result = current.base64EncodedString() + var result = self.base64EncodedString() result = result.replacingOccurrences(of: "+", with: "-") result = result.replacingOccurrences(of: "/", with: "_") result = result.replacingOccurrences(of: "=", with: "") diff --git a/packages/passkeys/passkeys_ios/ios/Classes/RegisterController.swift b/packages/passkeys/passkeys_ios/ios/Classes/RegisterController.swift index c9366a92..8a5d1d13 100644 --- a/packages/passkeys/passkeys_ios/ios/Classes/RegisterController.swift +++ b/packages/passkeys/passkeys_ios/ios/Classes/RegisterController.swift @@ -12,9 +12,9 @@ class RegisterController: NSObject, ASAuthorizationControllerDelegate, ASAuthori self.completion = completion; } - func run(request: ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest) { + func run(requests: [ASAuthorizationRequest]) { - let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + let authorizationController = ASAuthorizationController(authorizationRequests: requests) authorizationController.delegate = self authorizationController.presentationContextProvider = self diff --git a/packages/passkeys/passkeys_ios/ios/Classes/messages.swift b/packages/passkeys/passkeys_ios/ios/Classes/messages.swift index a55da3bc..b244f7d3 100644 --- a/packages/passkeys/passkeys_ios/ios/Classes/messages.swift +++ b/packages/passkeys/passkeys_ios/ios/Classes/messages.swift @@ -64,6 +64,37 @@ struct RelyingParty { } } +/// Represents a credential +/// +/// Generated class from Pigeon that represents data sent in messages. +struct CredentialType { + /// The type of the credential. + var type: String + /// The ID of the credential. + var id: String + /// The transports of the credential. + var transports: [String?] + + static func fromList(_ list: [Any?]) -> CredentialType? { + let type = list[0] as! String + let id = list[1] as! String + let transports = list[2] as! [String?] + + return CredentialType( + type: type, + id: id, + transports: transports + ) + } + func toList() -> [Any?] { + return [ + type, + id, + transports, + ] + } +} + /// Represents a user /// /// Generated class from Pigeon that represents data sent in messages. @@ -182,10 +213,12 @@ private class PasskeysApiCodecReader: FlutterStandardReader { case 128: return AuthenticateResponse.fromList(self.readValue() as! [Any?]) case 129: - return RegisterResponse.fromList(self.readValue() as! [Any?]) + return CredentialType.fromList(self.readValue() as! [Any?]) case 130: - return RelyingParty.fromList(self.readValue() as! [Any?]) + return RegisterResponse.fromList(self.readValue() as! [Any?]) case 131: + return RelyingParty.fromList(self.readValue() as! [Any?]) + case 132: return User.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -198,15 +231,18 @@ private class PasskeysApiCodecWriter: FlutterStandardWriter { if let value = value as? AuthenticateResponse { super.writeByte(128) super.writeValue(value.toList()) - } else if let value = value as? RegisterResponse { + } else if let value = value as? CredentialType { super.writeByte(129) super.writeValue(value.toList()) - } else if let value = value as? RelyingParty { + } else if let value = value as? RegisterResponse { super.writeByte(130) super.writeValue(value.toList()) - } else if let value = value as? User { + } else if let value = value as? RelyingParty { super.writeByte(131) super.writeValue(value.toList()) + } else if let value = value as? User { + super.writeByte(132) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -231,8 +267,8 @@ class PasskeysApiCodec: FlutterStandardMessageCodec { protocol PasskeysApi { func canAuthenticate() throws -> Bool func hasBiometrics() throws -> Bool - func register(challenge: String, relyingParty: RelyingParty, user: User, excludeCredentialIDs: [String], completion: @escaping (Result) -> Void) - func authenticate(relyingPartyId: String, challenge: String, conditionalUI: Bool, allowedCredentialIDs: [String], preferImmediatelyAvailableCredentials: Bool, completion: @escaping (Result) -> Void) + func register(challenge: String, relyingParty: RelyingParty, user: User, excludeCredentials: [CredentialType], pubKeyCredValues: [Int64], canBePlatformAuthenticator: Bool, canBeSecurityKey: Bool, completion: @escaping (Result) -> Void) + func authenticate(relyingPartyId: String, challenge: String, conditionalUI: Bool, allowedCredentials: [CredentialType], preferImmediatelyAvailableCredentials: Bool, completion: @escaping (Result) -> Void) func cancelCurrentAuthenticatorOperation(completion: @escaping (Result) -> Void) } @@ -275,8 +311,11 @@ class PasskeysApiSetup { let challengeArg = args[0] as! String let relyingPartyArg = args[1] as! RelyingParty let userArg = args[2] as! User - let excludeCredentialIDsArg = args[3] as! [String] - api.register(challenge: challengeArg, relyingParty: relyingPartyArg, user: userArg, excludeCredentialIDs: excludeCredentialIDsArg) { result in + let excludeCredentialsArg = args[3] as! [CredentialType] + let pubKeyCredValuesArg = args[4] as! [Int64] + let canBePlatformAuthenticatorArg = args[5] as! Bool + let canBeSecurityKeyArg = args[6] as! Bool + api.register(challenge: challengeArg, relyingParty: relyingPartyArg, user: userArg, excludeCredentials: excludeCredentialsArg, pubKeyCredValues: pubKeyCredValuesArg, canBePlatformAuthenticator: canBePlatformAuthenticatorArg, canBeSecurityKey: canBeSecurityKeyArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -295,9 +334,9 @@ class PasskeysApiSetup { let relyingPartyIdArg = args[0] as! String let challengeArg = args[1] as! String let conditionalUIArg = args[2] as! Bool - let allowedCredentialIDsArg = args[3] as! [String] + let allowedCredentialsArg = args[3] as! [CredentialType] let preferImmediatelyAvailableCredentialsArg = args[4] as! Bool - api.authenticate(relyingPartyId: relyingPartyIdArg, challenge: challengeArg, conditionalUI: conditionalUIArg, allowedCredentialIDs: allowedCredentialIDsArg, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentialsArg) { result in + api.authenticate(relyingPartyId: relyingPartyIdArg, challenge: challengeArg, conditionalUI: conditionalUIArg, allowedCredentials: allowedCredentialsArg, preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentialsArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/packages/passkeys/passkeys_ios/lib/messages.g.dart b/packages/passkeys/passkeys_ios/lib/messages.g.dart index 4c107576..cafe18ab 100644 --- a/packages/passkeys/passkeys_ios/lib/messages.g.dart +++ b/packages/passkeys/passkeys_ios/lib/messages.g.dart @@ -37,6 +37,41 @@ class RelyingParty { } } +/// Represents a credential +class CredentialType { + CredentialType({ + required this.type, + required this.id, + required this.transports, + }); + + /// The type of the credential. + String type; + + /// The ID of the credential. + String id; + + /// The transports of the credential. + List transports; + + Object encode() { + return [ + type, + id, + transports, + ]; + } + + static CredentialType decode(Object result) { + result as List; + return CredentialType( + type: result[0]! as String, + id: result[1]! as String, + transports: (result[2] as List?)!.cast(), + ); + } +} + /// Represents a user class User { User({ @@ -172,15 +207,18 @@ class _PasskeysApiCodec extends StandardMessageCodec { if (value is AuthenticateResponse) { buffer.putUint8(128); writeValue(buffer, value.encode()); - } else if (value is RegisterResponse) { + } else if (value is CredentialType) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is RelyingParty) { + } else if (value is RegisterResponse) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is User) { + } else if (value is RelyingParty) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is User) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -192,10 +230,12 @@ class _PasskeysApiCodec extends StandardMessageCodec { case 128: return AuthenticateResponse.decode(readValue(buffer)!); case 129: - return RegisterResponse.decode(readValue(buffer)!); + return CredentialType.decode(readValue(buffer)!); case 130: - return RelyingParty.decode(readValue(buffer)!); + return RegisterResponse.decode(readValue(buffer)!); case 131: + return RelyingParty.decode(readValue(buffer)!); + case 132: return User.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -267,12 +307,12 @@ class PasskeysApi { } } - Future register(String arg_challenge, RelyingParty arg_relyingParty, User arg_user, List arg_excludeCredentialIDs) async { + Future register(String arg_challenge, RelyingParty arg_relyingParty, User arg_user, List arg_excludeCredentials, List arg_pubKeyCredValues, bool arg_canBePlatformAuthenticator, bool arg_canBeSecurityKey) async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.passkeys_ios.PasskeysApi.register', codec, binaryMessenger: _binaryMessenger); final List? replyList = - await channel.send([arg_challenge, arg_relyingParty, arg_user, arg_excludeCredentialIDs]) as List?; + await channel.send([arg_challenge, arg_relyingParty, arg_user, arg_excludeCredentials, arg_pubKeyCredValues, arg_canBePlatformAuthenticator, arg_canBeSecurityKey]) as List?; if (replyList == null) { throw PlatformException( code: 'channel-error', @@ -294,12 +334,12 @@ class PasskeysApi { } } - Future authenticate(String arg_relyingPartyId, String arg_challenge, bool arg_conditionalUI, List arg_allowedCredentialIDs, bool arg_preferImmediatelyAvailableCredentials) async { + Future authenticate(String arg_relyingPartyId, String arg_challenge, bool arg_conditionalUI, List arg_allowedCredentials, bool arg_preferImmediatelyAvailableCredentials) async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.passkeys_ios.PasskeysApi.authenticate', codec, binaryMessenger: _binaryMessenger); final List? replyList = - await channel.send([arg_relyingPartyId, arg_challenge, arg_conditionalUI, arg_allowedCredentialIDs, arg_preferImmediatelyAvailableCredentials]) as List?; + await channel.send([arg_relyingPartyId, arg_challenge, arg_conditionalUI, arg_allowedCredentials, arg_preferImmediatelyAvailableCredentials]) as List?; if (replyList == null) { throw PlatformException( code: 'channel-error', diff --git a/packages/passkeys/passkeys_ios/lib/passkeys_ios.dart b/packages/passkeys/passkeys_ios/lib/passkeys_ios.dart index 1e8c8e0c..d2d4d044 100644 --- a/packages/passkeys/passkeys_ios/lib/passkeys_ios.dart +++ b/packages/passkeys/passkeys_ios/lib/passkeys_ios.dart @@ -32,7 +32,13 @@ class PasskeysIOS extends PasskeysPlatform { request.challenge, relyingPartyArg, userArg, - request.excludeCredentials.map((e) => e.id).toList(), + request.excludeCredentials + .map((e) => + CredentialType(type: e.type, id: e.id, transports: e.transports)) + .toList(), + request.pubKeyCredParams?.map((e) => e.alg).toList() ?? [], + request.authSelectionType.authenticatorAttachment != 'cross-platform', + request.authSelectionType.authenticatorAttachment != 'platform', ); return RegisterResponseType( @@ -57,7 +63,11 @@ class PasskeysIOS extends PasskeysPlatform { request.relyingPartyId, request.challenge, conditionalUI, - request.allowCredentials?.map((e) => e.id).toList() ?? [], + request.allowCredentials + ?.map((e) => CredentialType( + type: e.type, id: e.id, transports: e.transports)) + .toList() ?? + [], request.preferImmediatelyAvailableCredentials, ); diff --git a/packages/passkeys/passkeys_ios/pigeons/messages.dart b/packages/passkeys/passkeys_ios/pigeons/messages.dart index 2040d243..dff81123 100644 --- a/packages/passkeys/passkeys_ios/pigeons/messages.dart +++ b/packages/passkeys/passkeys_ios/pigeons/messages.dart @@ -18,6 +18,25 @@ class RelyingParty { final String id; } +/// Represents a credential +class CredentialType { + /// Constructor + const CredentialType({ + required this.type, + required this.id, + required this.transports, + }); + + /// The type of the credential. + final String type; + + /// The ID of the credential. + final String id; + + /// The transports of the credential. + final List transports; +} + /// Represents a user class User { /// Constructor @@ -98,7 +117,10 @@ abstract class PasskeysApi { String challenge, RelyingParty relyingParty, User user, - List excludeCredentialIDs, + List excludeCredentials, + List pubKeyCredValues, + bool canBePlatformAuthenticator, + bool canBeSecurityKey, ); @async @@ -106,7 +128,7 @@ abstract class PasskeysApi { String relyingPartyId, String challenge, bool conditionalUI, - List allowedCredentialIDs, + List allowedCredentials, bool preferImmediatelyAvailableCredentials, ); diff --git a/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.dart b/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.dart index 1a42b6b1..4b78ceae 100644 --- a/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.dart +++ b/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.dart @@ -15,6 +15,7 @@ class AuthenticatorSelectionType { required this.userVerification, }); + @JsonKey(includeIfNull: false) final String? authenticatorAttachment; final bool requireResidentKey; final String residentKey; diff --git a/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.g.dart b/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.g.dart index fd638614..a7d11cad 100644 --- a/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.g.dart +++ b/packages/passkeys/passkeys_platform_interface/lib/types/authenticator_selection.g.dart @@ -18,7 +18,8 @@ AuthenticatorSelectionType _$AuthenticatorSelectionTypeFromJson( Map _$AuthenticatorSelectionTypeToJson( AuthenticatorSelectionType instance) => { - 'authenticatorAttachment': instance.authenticatorAttachment, + if (instance.authenticatorAttachment case final value?) + 'authenticatorAttachment': value, 'requireResidentKey': instance.requireResidentKey, 'residentKey': instance.residentKey, 'userVerification': instance.userVerification, diff --git a/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.dart b/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.dart index 7f73d145..43a42862 100644 --- a/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.dart +++ b/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.dart @@ -90,12 +90,24 @@ class PasskeyLoginAllowCredentialType { enum AuthenticatorTransport { @JsonValue('internal') - Internal; + Internal, + @JsonValue('nfc') + Nfc, + @JsonValue('usb') + Usb, + @JsonValue('bluetooth') + Bluetooth; factory AuthenticatorTransport.fromPlatformType(String value) { switch (value) { case 'internal': return AuthenticatorTransport.Internal; + case 'usb': + return AuthenticatorTransport.Usb; + case 'nfc': + return AuthenticatorTransport.Nfc; + case 'bluetooth': + return AuthenticatorTransport.Bluetooth; default: throw ArgumentError.value(value); } diff --git a/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.g.dart b/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.g.dart index 9dc26549..3bc642ea 100644 --- a/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.g.dart +++ b/packages/passkeys/passkeys_web/lib/models/passkeyLoginRequest.g.dart @@ -30,7 +30,7 @@ PasskeyLoginPublicKey _$PasskeyLoginPublicKeyFromJson( Map json) => PasskeyLoginPublicKey( challenge: json['challenge'] as String, - timeout: json['timeout'] as int?, + timeout: (json['timeout'] as num?)?.toInt(), rpId: json['rpId'] as String?, allowCredentials: (json['allowCredentials'] as List?) ?.map((e) => PasskeyLoginAllowCredentialType.fromJson( @@ -85,6 +85,9 @@ Map _$PasskeyLoginAllowCredentialTypeToJson( const _$AuthenticatorTransportEnumMap = { AuthenticatorTransport.Internal: 'internal', + AuthenticatorTransport.Nfc: 'nfc', + AuthenticatorTransport.Usb: 'usb', + AuthenticatorTransport.Bluetooth: 'bluetooth', }; LoginExtensions _$LoginExtensionsFromJson(Map json) => diff --git a/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.dart b/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.dart index 50169a6b..6b8da6a5 100644 --- a/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.dart +++ b/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.dart @@ -18,27 +18,14 @@ class PasskeySignUpResponse { @JsonSerializable(explicitToJson: true) class AttestationResponse { - factory AttestationResponse.fromJson(dynamic response) { - // 'response' might be a JSObject, so we convert or cast to Dart. - final map = Map.from(response as Map); - - // 'transports' might be a JSArray, - final dynamicTransports = map['transports'] as List; - final List transports = - dynamicTransports.map((e) => e as String).toList(); - - return AttestationResponse( - map['clientDataJSON'] as String, - map['attestationObject'] as String, - transports, - ); - } + factory AttestationResponse.fromJson(Map json) => + _$AttestationResponseFromJson(json); AttestationResponse( - this.clientDataJSON, - this.attestationObject, - this.transports, - ); + this.clientDataJSON, + this.attestationObject, + this.transports, + ); final String clientDataJSON; final String attestationObject; diff --git a/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.g.dart b/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.g.dart index 28c6b082..aaacffd5 100644 --- a/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.g.dart +++ b/packages/passkeys/passkeys_web/lib/models/passkeySignUpResponse.g.dart @@ -26,7 +26,7 @@ AttestationResponse _$AttestationResponseFromJson(Map json) => AttestationResponse( json['clientDataJSON'] as String, json['attestationObject'] as String, - json['transports'] as List, + (json['transports'] as List).map((e) => e as String).toList(), ); Map _$AttestationResponseToJson( diff --git a/packages/passkeys/passkeys_web/lib/passkeys_web.dart b/packages/passkeys/passkeys_web/lib/passkeys_web.dart index 56ecaa38..cbf1c46a 100644 --- a/packages/passkeys/passkeys_web/lib/passkeys_web.dart +++ b/packages/passkeys/passkeys_web/lib/passkeys_web.dart @@ -67,7 +67,6 @@ class PasskeysWeb extends PasskeysPlatform { try { final serializedRequest = jsonEncode(r.toJson()); - print(serializedRequest); final response = await authenticatorLogin(serializedRequest.toJS).toDart; final decodedResponse = jsonDecode(response.toDart) as Map;