diff --git a/FirebaseAuth/Sources/Auth/FIRAuth.m b/FirebaseAuth/Sources/Auth/FIRAuth.m index ae111139d03..b50070bec1c 100644 --- a/FirebaseAuth/Sources/Auth/FIRAuth.m +++ b/FirebaseAuth/Sources/Auth/FIRAuth.m @@ -76,7 +76,6 @@ #import "FirebaseAuth/Sources/Utilities/FIRAuthErrorUtils.h" #import "FirebaseAuth/Sources/Utilities/FIRAuthExceptionUtils.h" #import "FirebaseAuth/Sources/Utilities/FIRAuthWebUtils.h" -#import "FirebaseAuth/Sources/Utilities/NSData+FIRBase64.h" #if TARGET_OS_IOS #import "FirebaseAuth/Sources/AuthProvider/Phone/FIRPhoneAuthCredential_Internal.h" diff --git a/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo.m b/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo.m new file mode 100644 index 00000000000..69cf1658d06 --- /dev/null +++ b/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo.m @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "FirebaseAuth/Sources/Public/FirebaseAuth/FIRPasskeyInfo.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + @var kNameKey + @brief The name of the field in the response JSON for name. + */ +static const NSString *kNameKey = @"name"; + +/** + @var kCredentialIdKey + @brief The name of the field in the response JSON for credential ID. + */ +static const NSString *kCredentialIdKey = @"credentialId"; + +@implementation FIRPasskeyInfo + +- (instancetype)initWithDictionary:(NSDictionary *)dictionary { + self = [super init]; + if (self) { + if (dictionary[kNameKey]) { + _name = dictionary[kNameKey]; + } + if (dictionary[kCredentialIdKey]) { + _credentialID = dictionary[kCredentialIdKey]; + } + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo_Internal.h b/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo_Internal.h new file mode 100644 index 00000000000..c5dd00c9287 --- /dev/null +++ b/FirebaseAuth/Sources/Backend/RPC/Proto/FIRPasskeyInfo_Internal.h @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "FirebaseAuth/Sources/Backend/RPC/Proto/FIRAuthProto.h" +#import "FirebaseAuth/Sources/Public/FirebaseAuth/FIRPasskeyInfo.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FIRPasskeyInfo () + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAuth/Sources/Public/FirebaseAuth/FIRPasskeyInfo.h b/FirebaseAuth/Sources/Public/FirebaseAuth/FIRPasskeyInfo.h new file mode 100644 index 00000000000..fed6f2ef004 --- /dev/null +++ b/FirebaseAuth/Sources/Public/FirebaseAuth/FIRPasskeyInfo.h @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + @class FIRPasskeyInfo + @brief Passkey Info + */ +NS_SWIFT_NAME(PasskeyInfo) API_UNAVAILABLE(watchos) @interface FIRPasskeyInfo : NSObject + +/** + @brief Passkey name + */ +@property(nonatomic, readonly) NSString *name; + +/** + @brief Passkey credential ID + */ +@property(nonatomic, readonly) NSString *credentialID; + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAuth/Sources/Public/FirebaseAuth/FIRUser.h b/FirebaseAuth/Sources/Public/FirebaseAuth/FIRUser.h index 9fb1fbb2701..8732ffb3479 100644 --- a/FirebaseAuth/Sources/Public/FirebaseAuth/FIRUser.h +++ b/FirebaseAuth/Sources/Public/FirebaseAuth/FIRUser.h @@ -19,6 +19,7 @@ #import "FIRAuth.h" #import "FIRAuthDataResult.h" #import "FIRMultiFactor.h" +#import "FIRPasskeyInfo.h" #import "FIRUserInfo.h" @class FIRAuthTokenResult; @@ -26,6 +27,10 @@ @class FIRUserProfileChangeRequest; @class FIRUserMetadata; @protocol FIRAuthUIDelegate; +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST +@class ASAuthorizationPlatformPublicKeyCredentialRegistration; +@class ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest; +#endif NS_ASSUME_NONNULL_BEGIN @@ -123,6 +128,11 @@ NS_SWIFT_NAME(User) @property(nonatomic, readonly, nonnull) FIRMultiFactor *multiFactor API_UNAVAILABLE(macos, tvos, watchos); +/** @property enrolledPasskeys + @brief a list of user enrolled passkey object. +*/ +@property(nonatomic, readonly) NSArray *enrolledPasskeys API_UNAVAILABLE(watchos); + /** @fn init @brief This class should not be instantiated. @remarks To retrieve the current user, use `Auth.currentUser`. To sign a user @@ -162,6 +172,46 @@ NS_SWIFT_NAME(User) completion:(nullable void (^)(NSError *_Nullable error))completion NS_SWIFT_NAME(updateEmail(to:completion:)); +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST +/** + @fn startPasskeyEnrollmentWithName:completion: + @brief Start the passkey enrollment creating a plaform public key creation request with the + challenge from GCIP backend. + + @param name The name for the passkey to be created. + @param completion Optionally; the block which is invoked when start passkey enrollment flow + finishes. + + @remarks Possible error codes: // TODO @liubinj fill this after + */ +- (void)startPasskeyEnrollmentWithName:(nullable NSString *)name + completion: + (nullable void (^)( + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest + *_Nullable request, + NSError *_Nullable error))completion + NS_SWIFT_NAME(startPasskeyEnrollment(with:completion:)) + API_AVAILABLE(macos(12.0), ios(15.0), tvos(16.0)); + +/** + @fn finalizePasskeyEnrollmentWithPlatformCredential:completion: + @brief Finalize the passkey enrollment with the platfrom public key credential. + + @param platformCredential The name for the passkey to be created. + @param completion Optionally; a block which is invoked when the finalize enroll with passkey flow + finishes, or is canceled + + @remarks Possible error codes: // TODO @liubinj fill this after + */ +- (void)finalizePasskeyEnrollmentWithPlatformCredential: + (ASAuthorizationPlatformPublicKeyCredentialRegistration *)platformCredential + completion:(nullable void (^)( + FIRAuthDataResult *_Nullable authResult, + NSError *_Nullable error))completion + NS_SWIFT_NAME(finalizePasskeyEnrollment(with:completion:)) + API_AVAILABLE(macos(12.0), ios(15.0), tvos(16.0)); +#endif + /** @fn updatePassword:completion: @brief Updates the password for the user. On success, the cached user profile data is updated. diff --git a/FirebaseAuth/Sources/Public/FirebaseAuth/FirebaseAuth.h b/FirebaseAuth/Sources/Public/FirebaseAuth/FirebaseAuth.h index 111ba066543..6d0a18e6d95 100644 --- a/FirebaseAuth/Sources/Public/FirebaseAuth/FirebaseAuth.h +++ b/FirebaseAuth/Sources/Public/FirebaseAuth/FirebaseAuth.h @@ -36,6 +36,7 @@ #import "FIRMultiFactorSession.h" #import "FIROAuthCredential.h" #import "FIROAuthProvider.h" +#import "FIRPasskeyInfo.h" #import "FIRTwitterAuthProvider.h" #import "FIRUser.h" #import "FIRUserInfo.h" diff --git a/FirebaseAuth/Sources/User/FIRUser.m b/FirebaseAuth/Sources/User/FIRUser.m index e40dade55af..3ea3ca2e9d9 100644 --- a/FirebaseAuth/Sources/User/FIRUser.m +++ b/FirebaseAuth/Sources/User/FIRUser.m @@ -35,6 +35,8 @@ #import "FirebaseAuth/Sources/Backend/RPC/FIRDeleteAccountResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIREmailLinkSignInRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIREmailLinkSignInResponse.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRFinalizePasskeyEnrollmentRequest.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRFinalizePasskeyEnrollmentResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRGetAccountInfoRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRGetAccountInfoResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRGetOOBConfirmationCodeRequest.h" @@ -43,6 +45,8 @@ #import "FirebaseAuth/Sources/Backend/RPC/FIRSetAccountInfoResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRSignInWithGameCenterRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRSignInWithGameCenterResponse.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRStartPasskeyEnrollmentRequest.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRStartPasskeyEnrollmentResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyAssertionRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyAssertionResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyCustomTokenRequest.h" @@ -61,9 +65,12 @@ #import "FirebaseAuth/Sources/Utilities/FIRAuthWebUtils.h" #if TARGET_OS_IOS +#import "FirebaseAuth/Sources/AuthProvider/Phone/FIRPhoneAuthCredential_Internal.h" #import "FirebaseAuth/Sources/Public/FirebaseAuth/FIRPhoneAuthProvider.h" +#endif -#import "FirebaseAuth/Sources/AuthProvider/Phone/FIRPhoneAuthCredential_Internal.h" +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST +#import #endif NS_ASSUME_NONNULL_BEGIN @@ -583,6 +590,108 @@ - (void)setTokenService:(FIRSecureTokenService *)tokenService #pragma mark - +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST +- (void)startPasskeyEnrollmentWithName:(nullable NSString *)name + completion: + (nullable void (^)( + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest + *_Nullable request, + NSError *_Nullable error))completion { + FIRAuthRequestConfiguration *requestConfiguration = self->_auth.requestConfiguration; + + FIRStartPasskeyEnrollmentRequest *request = + [[FIRStartPasskeyEnrollmentRequest alloc] initWithIDToken:self.rawAccessToken + requestConfiguration:requestConfiguration]; + [FIRAuthBackend + startPasskeyEnrollment:request + callback:^(FIRStartPasskeyEnrollmentResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + completion(nil, error); + return; + } else { + // cached the passkey name. This is needed when calling + // finalizePasskeyEnrollment + self.passkeyName = name; + NSData *challengeInData = + [[NSData alloc] initWithBase64EncodedString:response.challenge + options:0]; + NSData *userIdInData = + [[NSData alloc] initWithBase64EncodedString:response.userID options:0]; + + ASAuthorizationPlatformPublicKeyCredentialProvider *provider = + [[ASAuthorizationPlatformPublicKeyCredentialProvider alloc] + initWithRelyingPartyIdentifier:response.rpID]; + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest *request = + [provider + createCredentialRegistrationRequestWithChallenge:challengeInData + name:name + userID:userIdInData]; + completion(request, nil); + } + }]; +} + +- (void)finalizePasskeyEnrollmentWithPlatformCredential: + (ASAuthorizationPlatformPublicKeyCredentialRegistration *)platformCredential + completion:(nullable void (^)( + FIRAuthDataResult *_Nullable authResult, + NSError *_Nullable error))completion { + dispatch_async(FIRAuthGlobalWorkQueue(), ^{ + FIRAuthDataResultCallback decoratedCallback = + [FIRAuth.auth signInFlowAuthDataResultCallbackByDecoratingCallback:completion]; + FIRAuthRequestConfiguration *requestConfiguration = self->_auth.requestConfiguration; + + NSString *credentialID = [platformCredential.credentialID base64EncodedStringWithOptions:0]; + NSString *clientDataJson = + [platformCredential.rawClientDataJSON base64EncodedStringWithOptions:0]; + NSString *attestationObject = + [platformCredential.rawAttestationObject base64EncodedStringWithOptions:0]; + // If passkey name is not provided, we will provide a firebase formatted default name. + + if (self.passkeyName != nil || [self.passkeyName isEqual:@""]) { + self.passkeyName = @"Unnamed account (Apple)"; + } + FIRFinalizePasskeyEnrollmentRequest *request = + [[FIRFinalizePasskeyEnrollmentRequest alloc] initWithIDToken:self.rawAccessToken + name:self.passkeyName + credentialID:credentialID + clientDataJson:clientDataJson + attestationObject:attestationObject + requestConfiguration:requestConfiguration]; + + [FIRAuthBackend + finalizePasskeyEnrollment:request + callback:^(FIRFinalizePasskeyEnrollmentResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + decoratedCallback(nil, error); + } else { + [FIRAuth.auth + completeSignInWithAccessToken:response.idToken + accessTokenExpirationDate:nil + refreshToken:response.refreshToken + anonymous:NO + callback:^(FIRUser *_Nullable user, + NSError *_Nullable error) { + if (error) { + completion(nil, error); + return; + } + + FIRAuthDataResult *authDataResult = + user ? [[FIRAuthDataResult alloc] + initWithUser:user + additionalUserInfo:nil] + : nil; + decoratedCallback(authDataResult, error); + }]; + } + }]; + }); +} +#endif // #if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST + /** @fn updateEmail:password:callback: @brief Updates email address and/or password for the current user. @remarks May fail if there is already an email/password-based account for the same email diff --git a/FirebaseAuth/Sources/User/FIRUser_Internal.h b/FirebaseAuth/Sources/User/FIRUser_Internal.h index 54524f208c5..b3f158dfb7e 100644 --- a/FirebaseAuth/Sources/User/FIRUser_Internal.h +++ b/FirebaseAuth/Sources/User/FIRUser_Internal.h @@ -44,6 +44,12 @@ typedef void (^FIRVerifyBeforeUpdateEmailCallback)(NSError *_Nullable error); */ @property(nonatomic, copy, readonly) NSString *rawAccessToken; +/** + @property passkeyName + @brief A cached passkey name that being passed from startPasskeyEnrollmentWithName:completion: call + and consumed at finalizePasskeyEnrollmentWithPlatformCredential:completion: call + */ +@property(nonatomic, copy, nullable) NSString *passkeyName; /** @property auth @brief A weak reference to a FIRAuth instance associated with this instance. */ diff --git a/FirebaseAuth/Tests/Unit/FIRUserTests.m b/FirebaseAuth/Tests/Unit/FIRUserTests.m index dee408722d4..7815a799b06 100644 --- a/FirebaseAuth/Tests/Unit/FIRUserTests.m +++ b/FirebaseAuth/Tests/Unit/FIRUserTests.m @@ -40,6 +40,8 @@ #import "FirebaseAuth/Sources/Backend/RPC/FIRSetAccountInfoRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRSetAccountInfoResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRSignUpNewUserResponse.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRStartPasskeyEnrollmentRequest.h" +#import "FirebaseAuth/Sources/Backend/RPC/FIRStartPasskeyEnrollmentResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyAssertionRequest.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyAssertionResponse.h" #import "FirebaseAuth/Sources/Backend/RPC/FIRVerifyPasswordRequest.h" @@ -59,6 +61,10 @@ #import "FirebaseAuth/Sources/AuthProvider/Phone/FIRPhoneAuthCredential_Internal.h" #endif +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_OSX || TARGET_OS_MACCATALYST +#import +#endif + NS_ASSUME_NONNULL_BEGIN /** @var kAPIKey @@ -401,6 +407,42 @@ */ static NSString *const kFakeWebSignInUserInteractionFailureReason = @"fake_reason"; +/** @var kPasskeyName + @brief test passkey name. + */ +static NSString *const kPasskeyName = @"mockPasskeyName"; + +/** @var kRpId + @brief The fake passkey relying party identifier. + */ +static NSString *const kRpId = @"fake.rp.id"; + +/** @var kChallenge + @brief The fake passkey challenge. + */ +static NSString *const kChallenge = @"Y2hhbGxlbmdl"; // decode to "challenge" + +/** @var kUserID + @brief The fake user ID / user handle + */ +static NSString *const kUserID = @"dXNlcmlk"; // decode to "userid" + +/** @var kCredentialID + @brief The fake passkey credentialID. + */ +static NSString *const kCredentialID = @"Y3JlZGVudGlhbGlk"; // decode to "credentialid" + +/** @var kClientDataJson + @brief The fake clientDataJson object + */ +static NSString *const kClientDataJson = @"Y2xpZW50ZGF0YWpzb24="; // decode to "clientdatajson" + +/** @var kAttestation + @brief The fake attestationObject object + */ +static NSString *const kAttestationObject = + @"QXR0ZXN0YXRpb25PYmplY3Q="; // decode to "kAttestationObject" + /** @extention FIRSecureTokenService @brief Extends the FIRSecureTokenService class to expose one private method for testing only. */ @@ -880,6 +922,156 @@ - (void)testUpdateEmailFailure { OCMVerifyAll(_mockBackend); } +/** @fn testStartPasskeyEnrollmentSuccess + @brief Tests the flow of a successful @c startPasskeyEnrollmentWithName:completion: call + */ +- (void)testStartPasskeyEnrollmentSuccess { + if (@available(iOS 15.0, tvOS 16.0, macOS 12.0, *)) { + OCMExpect([_mockBackend startPasskeyEnrollment:[OCMArg any] callback:[OCMArg any]]) + .andCallBlock2(^(FIRStartPasskeyEnrollmentRequest *_Nullable request, + FIRStartPasskeyEnrollmentResponseCallback callback) { + XCTAssertEqualObjects(request.APIKey, kAPIKey); + dispatch_async(FIRAuthGlobalWorkQueue(), ^() { + id mockStartPasskeyEnrollmentResponse = + OCMClassMock([FIRStartPasskeyEnrollmentResponse class]); + OCMStub([mockStartPasskeyEnrollmentResponse rpID]).andReturn(kRpId); + OCMStub([mockStartPasskeyEnrollmentResponse challenge]).andReturn(kChallenge); + OCMStub([mockStartPasskeyEnrollmentResponse userID]).andReturn(kUserID); + callback(mockStartPasskeyEnrollmentResponse, nil); + }); + }); + XCTestExpectation *expectation = [self expectationWithDescription:@"callback"]; + id mockGetAccountInfoResponseUser = OCMClassMock([FIRGetAccountInfoResponseUser class]); + [self + signInAnonymouslyWithMockGetAccountInfoResponse:mockGetAccountInfoResponseUser + completion:^(FIRUser *_Nonnull user) { + [user + startPasskeyEnrollmentWithName:kPasskeyName + completion:^( + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest + *_Nullable request, + NSError + *_Nullable error) { + XCTAssertNil(error); + XCTAssertEqualObjects( + user.passkeyName, + kPasskeyName); + XCTAssertEqualObjects( + [[request challenge] + base64EncodedStringWithOptions: + 0], + kChallenge); + XCTAssertEqualObjects( + [request + relyingPartyIdentifier], + kRpId); + XCTAssertEqualObjects( + [[request userID] + base64EncodedStringWithOptions: + 0], + kUserID); + [expectation fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:kExpectationTimeout handler:nil]; + OCMVerifyAll(_mockBackend); + } +} + +/** @fn testStartPasskeyEnrollmentFailure + @brief Tests the flow of a failed @c startPasskeyEnrollmentWithName:completion: call + */ +- (void)testStartPasskeyEnrollmentFailure { + if (@available(iOS 15.0, tvOS 16.0, macOS 12.0, *)) { + OCMExpect([_mockBackend startPasskeyEnrollment:[OCMArg any] callback:[OCMArg any]]) + .andDispatchError2([FIRAuthErrorUtils operationNotAllowedErrorWithMessage:nil]); + XCTestExpectation *expectation = [self expectationWithDescription:@"callback"]; + id mockGetAccountInfoResponseUser = OCMClassMock([FIRGetAccountInfoResponseUser class]); + [self + signInAnonymouslyWithMockGetAccountInfoResponse:mockGetAccountInfoResponseUser + completion:^(FIRUser *_Nonnull user) { + [user + startPasskeyEnrollmentWithName:kPasskeyName + completion:^( + ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest + *_Nullable request, + NSError + *_Nullable error) { + XCTAssertNil(request); + XCTAssertEqual( + error.code, + FIRAuthErrorCodeOperationNotAllowed); + [expectation fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:kExpectationTimeout handler:nil]; + OCMVerifyAll(_mockBackend); + } +} + +/** @fn testFinalizePasskeyEnrollmentFailure + @brief Tests the flow of a failed @c finalizePasskeyEnrollmentWithPlatformCredential:completion: + call + */ +- (void)testFinalizePasskeyEnrollmentFailure { + if (@available(iOS 15.0, tvOS 16.0, macOS 12.0, *)) { + id mockGetAccountInfoResponseUser = OCMClassMock([FIRGetAccountInfoResponseUser class]); + XCTestExpectation *expectation = [self expectationWithDescription:@"callback"]; + [self + signInAnonymouslyWithMockGetAccountInfoResponse:mockGetAccountInfoResponseUser + completion:^(FIRUser *_Nonnull user) { + OCMExpect( + [self->_mockBackend + finalizePasskeyEnrollment:[OCMArg any] + callback:[OCMArg any]]) + .andDispatchError2([FIRAuthErrorUtils + operationNotAllowedErrorWithMessage:nil]); + id mockPlatfromCredential = OCMClassMock( + [ASAuthorizationPlatformPublicKeyCredentialRegistration + class]); + OCMStub([mockPlatfromCredential credentialID]) + .andReturn([[NSData alloc] + initWithBase64EncodedString:kCredentialID + options:0]); + OCMStub([mockPlatfromCredential rawClientDataJSON]) + .andReturn([[NSData alloc] + initWithBase64EncodedString:kClientDataJson + options:0]); + OCMStub( + [mockPlatfromCredential rawAttestationObject]) + .andReturn([[NSData alloc] + initWithBase64EncodedString: + kAttestationObject + options:0]); + [user + finalizePasskeyEnrollmentWithPlatformCredential: + mockPlatfromCredential + completion:^( + FIRAuthDataResult + *_Nullable authResult, + NSError + *_Nullable error) { + XCTAssertTrue([NSThread + isMainThread]); + XCTAssertNil( + authResult + .user); + XCTAssertEqual( + error + .code, + FIRAuthErrorCodeOperationNotAllowed); + XCTAssertNotNil( + error.userInfo + [NSLocalizedDescriptionKey]); + [expectation + fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:kExpectationTimeout handler:nil]; + OCMVerifyAll(_mockBackend); + } +} + /** @fn testUpdateEmailAutoSignOut @brief Tests the flow of a failed @c updateEmail:completion: call that automatically signs out. */