From 60b22dd388b416271316500547a1120e18a1f02b Mon Sep 17 00:00:00 2001 From: Steffen Keller Date: Tue, 12 Aug 2025 16:51:37 +0200 Subject: [PATCH 1/2] feat: Add login with additional auth data --- .../Internal/Commands/PFRESTUserCommand.h | 9 +++ .../Internal/Commands/PFRESTUserCommand.m | 12 ++++ .../User/Controller/PFUserController.h | 8 +++ .../User/Controller/PFUserController.m | 55 ++++++++++++++++++- Parse/Parse/Source/PFUser.h | 18 ++++++ Parse/Parse/Source/PFUser.m | 32 +++++++++++ Parse/Tests/Unit/UserCommandTests.m | 19 +++++++ Parse/Tests/Unit/UserControllerTests.m | 51 +++++++++++++++++ 8 files changed, 203 insertions(+), 1 deletion(-) diff --git a/Parse/Parse/Internal/Commands/PFRESTUserCommand.h b/Parse/Parse/Internal/Commands/PFRESTUserCommand.h index 8e2eb0e1c..8d553b7ed 100644 --- a/Parse/Parse/Internal/Commands/PFRESTUserCommand.h +++ b/Parse/Parse/Internal/Commands/PFRESTUserCommand.h @@ -23,6 +23,15 @@ NS_ASSUME_NONNULL_BEGIN password:(NSString *)password revocableSession:(BOOL)revocableSessionEnabled error:(NSError **)error; +/** + Creates a login command with a JSON body, allowing additional parameters such as authData. + + This posts to the login route and is required for features like MFA where additional + authentication data must be supplied alongside username/password. + */ ++ (instancetype)logInUserCommandWithParameters:(NSDictionary *)parameters + revocableSession:(BOOL)revocableSessionEnabled + error:(NSError **)error; + (instancetype)serviceLoginUserCommandWithAuthenticationType:(NSString *)authenticationType authenticationData:(NSDictionary *)authenticationData revocableSession:(BOOL)revocableSessionEnabled diff --git a/Parse/Parse/Internal/Commands/PFRESTUserCommand.m b/Parse/Parse/Internal/Commands/PFRESTUserCommand.m index b5126c447..cc65225e7 100644 --- a/Parse/Parse/Internal/Commands/PFRESTUserCommand.m +++ b/Parse/Parse/Internal/Commands/PFRESTUserCommand.m @@ -65,6 +65,18 @@ + (instancetype)logInUserCommandWithUsername:(NSString *)username error:error]; } ++ (instancetype)logInUserCommandWithParameters:(NSDictionary *)parameters + revocableSession:(BOOL)revocableSessionEnabled + error:(NSError **)error { + // Use POST /login for body parameters like authData + return [self _commandWithHTTPPath:@"login" + httpMethod:PFHTTPRequestMethodPOST + parameters:parameters + sessionToken:nil + revocableSession:revocableSessionEnabled + error:error]; +} + + (instancetype)serviceLoginUserCommandWithAuthenticationType:(NSString *)authenticationType authenticationData:(NSDictionary *)authenticationData revocableSession:(BOOL)revocableSessionEnabled diff --git a/Parse/Parse/Internal/User/Controller/PFUserController.h b/Parse/Parse/Internal/User/Controller/PFUserController.h index 6b55f6815..e43f62b1b 100644 --- a/Parse/Parse/Internal/User/Controller/PFUserController.h +++ b/Parse/Parse/Internal/User/Controller/PFUserController.h @@ -38,6 +38,14 @@ NS_ASSUME_NONNULL_BEGIN - (BFTask *)logInCurrentUserAsyncWithUsername:(NSString *)username password:(NSString *)password revocableSession:(BOOL)revocableSession; +/** + Logs in the current user using username/password and additional parameters such as authData. + The parameters dictionary can include keys like @"authData": @{ "mfa": @{ ... } } to support MFA flows. + */ +- (BFTask *)logInCurrentUserAsyncWithUsername:(NSString *)username + password:(NSString *)password + parameters:(nullable NSDictionary *)parameters + revocableSession:(BOOL)revocableSession; //TODO: (nlutsenko) Move this method into PFUserAuthenticationController after PFUser is decoupled further. - (BFTask *)logInCurrentUserAsyncWithAuthType:(NSString *)authType diff --git a/Parse/Parse/Internal/User/Controller/PFUserController.m b/Parse/Parse/Internal/User/Controller/PFUserController.m index 6b75673be..d9400cc28 100644 --- a/Parse/Parse/Internal/User/Controller/PFUserController.m +++ b/Parse/Parse/Internal/User/Controller/PFUserController.m @@ -66,7 +66,20 @@ - (BFTask *)logInCurrentUserAsyncWithSessionToken:(NSString *)sessionToken { message:@"Invalid Session Token."]]; } - PFUser *user = [PFUser _objectFromDictionary:dictionary + // Sanitize response: do not persist transient MFA authData provider + NSMutableDictionary *sanitized = [dictionary mutableCopy]; + id authData = sanitized[@"authData"]; + if ([authData isKindOfClass:[NSDictionary class]] && authData[@"mfa"]) { + NSMutableDictionary *mutableAuth = [authData mutableCopy]; + [mutableAuth removeObjectForKey:@"mfa"]; // transient provider, do not persist + if (mutableAuth.count > 0) { + sanitized[@"authData"] = mutableAuth; + } else { + [sanitized removeObjectForKey:@"authData"]; + } + } + + PFUser *user = [PFUser _objectFromDictionary:sanitized defaultClassName:[PFUser parseClassName] completeData:YES]; // Serialize the object to disk so we can later access it via currentUser @@ -113,6 +126,46 @@ - (BFTask *)logInCurrentUserAsyncWithUsername:(NSString *)username }]; } +- (BFTask *)logInCurrentUserAsyncWithUsername:(NSString *)username + password:(NSString *)password + parameters:(NSDictionary *)parameters + revocableSession:(BOOL)revocableSession { + @weakify(self); + return [[BFTask taskFromExecutor:[BFExecutor defaultPriorityBackgroundExecutor] withBlock:^id{ + NSError *error = nil; + NSMutableDictionary *merged = [@{ @"username": username ?: @"", + @"password": password ?: @"" } mutableCopy]; + if (parameters.count > 0) { + // Prevent authData from being persisted later by only sending it with the request body + // and not mutating the PFUser object here. The server response will drive authData merge. + [merged addEntriesFromDictionary:parameters]; + } + PFRESTCommand *command = [PFRESTUserCommand logInUserCommandWithParameters:merged + revocableSession:revocableSession + error:&error]; + PFPreconditionReturnFailedTask(command, error); + return [self.commonDataSource.commandRunner runCommandAsync:command + withOptions:PFCommandRunningOptionRetryIfFailed]; + }] continueWithSuccessBlock:^id(BFTask *task) { + @strongify(self); + PFCommandResult *result = task.result; + NSDictionary *dictionary = result.result; + + if ([dictionary isKindOfClass:[NSNull class]] || !dictionary) { + return [BFTask taskWithError:[PFErrorUtilities errorWithCode:kPFErrorObjectNotFound + message:@"Invalid login credentials."]]; + } + + PFUser *user = [PFUser _objectFromDictionary:dictionary + defaultClassName:[PFUser parseClassName] + completeData:YES]; + PFCurrentUserController *controller = self.coreDataSource.currentUserController; + return [[controller saveCurrentObjectAsync:user] continueWithBlock:^id(BFTask *task) { + return user; + }]; + }]; +} + - (BFTask *)logInCurrentUserAsyncWithAuthType:(NSString *)authType authData:(NSDictionary *)authData revocableSession:(BOOL)revocableSession { diff --git a/Parse/Parse/Source/PFUser.h b/Parse/Parse/Source/PFUser.h index 4338ec19c..66c0965ef 100644 --- a/Parse/Parse/Source/PFUser.h +++ b/Parse/Parse/Source/PFUser.h @@ -167,6 +167,24 @@ typedef void(^PFUserLogoutResultBlock)(NSError *_Nullable error); */ + (void)logInWithUsernameInBackground:(NSString *)username password:(NSString *)password block:(nullable PFUserResultBlock)block; +/** + Logs in a user with username and password and additional authentication data (e.g., MFA). + + The authData keys must follow the Parse Server spec, for example: + @{ @"mfa": @{ @"token": authCode } } + + This data is only sent as part of the login request and is not persisted on the PFUser instance. + */ ++ (BFTask<__kindof PFUser *> *)logInWithUsernameInBackground:(NSString *)username + password:(NSString *)password + authData:(nullable NSDictionary *)authData; + +/** Block variant of login with additional authData. */ ++ (void)logInWithUsernameInBackground:(NSString *)username + password:(NSString *)password + authData:(nullable NSDictionary *)authData + block:(nullable PFUserResultBlock)block; + ///-------------------------------------- #pragma mark - Becoming a User ///-------------------------------------- diff --git a/Parse/Parse/Source/PFUser.m b/Parse/Parse/Source/PFUser.m index 8e0787a33..efeacdc67 100644 --- a/Parse/Parse/Source/PFUser.m +++ b/Parse/Parse/Source/PFUser.m @@ -366,6 +366,12 @@ - (void)_mergeFromServerWithResult:(NSDictionary *)result decoder:(PFDecoder *)d // Merge the linked service metadata NSDictionary *newAuthData = [decoder decodeObject:result[PFUserAuthDataRESTKey]]; if (newAuthData) { + // Remove transient MFA auth provider from persisted state + if ([newAuthData isKindOfClass:[NSDictionary class]] && newAuthData[@"mfa"]) { + NSMutableDictionary *mutable = [newAuthData mutableCopy]; + [mutable removeObjectForKey:@"mfa"]; + newAuthData = [mutable copy]; + } [self.authData removeAllObjects]; [self.linkedServiceNames removeAllObjects]; [newAuthData enumerateKeysAndObjectsUsingBlock:^(id key, id linkData, BOOL *stop) { @@ -646,6 +652,12 @@ - (BOOL)mergeFromRESTDictionary:(NSDictionary *)object withDecoder:(PFDecoder *) if (object[PFUserAuthDataRESTKey] != nil) { NSDictionary *newAuthData = object[PFUserAuthDataRESTKey]; + // Remove transient MFA auth provider from persisted state + if ([newAuthData isKindOfClass:[NSDictionary class]] && newAuthData[@"mfa"]) { + NSMutableDictionary *mutable = [newAuthData mutableCopy]; + [mutable removeObjectForKey:@"mfa"]; + newAuthData = [mutable copy]; + } [newAuthData enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { self.authData[key] = obj; if (obj != nil) { @@ -838,6 +850,26 @@ + (void)logInWithUsernameInBackground:(NSString *)username [[self logInWithUsernameInBackground:username password:password] thenCallBackOnMainThreadAsync:block]; } ++ (BFTask<__kindof PFUser *> *)logInWithUsernameInBackground:(NSString *)username + password:(NSString *)password + authData:(NSDictionary *)authData { + NSDictionary *parameters = nil; + if (authData.count > 0) { + parameters = @{ @"authData": authData }; + } + return [[self userController] logInCurrentUserAsyncWithUsername:username + password:password + parameters:parameters + revocableSession:[self _isRevocableSessionEnabled]]; +} + ++ (void)logInWithUsernameInBackground:(NSString *)username + password:(NSString *)password + authData:(NSDictionary *)authData + block:(PFUserResultBlock)block { + [[self logInWithUsernameInBackground:username password:password authData:authData] thenCallBackOnMainThreadAsync:block]; +} + ///-------------------------------------- #pragma mark - Third-party Authentication ///-------------------------------------- diff --git a/Parse/Tests/Unit/UserCommandTests.m b/Parse/Tests/Unit/UserCommandTests.m index 688b0e83a..e058dea58 100644 --- a/Parse/Tests/Unit/UserCommandTests.m +++ b/Parse/Tests/Unit/UserCommandTests.m @@ -41,6 +41,25 @@ - (void)testLogInCommand { XCTAssertFalse(command.revocableSessionEnabled); } +- (void)testLogInCommandWithParametersBody { + NSDictionary *params = @{ @"username": @"a", + @"password": @"b", + @"authData": @{ @"mfa": @{ @"token": @"123456" } } }; + PFRESTUserCommand *command = [PFRESTUserCommand logInUserCommandWithParameters:params + revocableSession:YES + error:nil]; + XCTAssertNotNil(command); + XCTAssertEqualObjects(command.httpPath, @"login"); + XCTAssertEqualObjects(command.httpMethod, PFHTTPRequestMethodPOST); + XCTAssertNotNil(command.parameters); + XCTAssertEqualObjects(command.parameters[@"username"], @"a"); + XCTAssertEqualObjects(command.parameters[@"password"], @"b"); + XCTAssertEqualObjects(command.parameters[@"authData"], (@{ @"mfa": @{ @"token": @"123456" } })); + XCTAssertEqual(command.additionalRequestHeaders.count, 1); + XCTAssertTrue(command.revocableSessionEnabled); + XCTAssertNil(command.sessionToken); +} + - (void)testServiceLoginCommandWithAuthTypeData { PFRESTUserCommand *command = [PFRESTUserCommand serviceLoginUserCommandWithAuthenticationType:@"a" authenticationData:@{ @"b" : @"c" } diff --git a/Parse/Tests/Unit/UserControllerTests.m b/Parse/Tests/Unit/UserControllerTests.m index 7b76b051e..bc9db2022 100644 --- a/Parse/Tests/Unit/UserControllerTests.m +++ b/Parse/Tests/Unit/UserControllerTests.m @@ -184,6 +184,57 @@ - (void)testLogInCurrentUserWithUsernamePassword { OCMVerifyAll(currentUserController); } +- (void)testLogInCurrentUserWithUsernamePasswordAndAuthData { + id commonDataSource = [self mockedCommonDataSource]; + id coreDataSource = [self mockedCoreDataSource]; + id commandRunner = [commonDataSource commandRunner]; + + id commandResult = @{ @"objectId" : @"a", + @"yarr" : @1 }; + [commandRunner mockCommandResult:commandResult forCommandsPassingTest:^BOOL(id obj) { + PFRESTCommand *command = obj; + + XCTAssertEqualObjects(command.httpMethod, PFHTTPRequestMethodPOST); + XCTAssertNotEqual([command.httpPath rangeOfString:@"login"].location, NSNotFound); + XCTAssertNil(command.sessionToken); + NSDictionary *expected = @{ @"username": @"yolo", + @"password": @"yarr", + @"authData": @{ @"mfa": @{ @"token": @"654321" } } }; + XCTAssertEqualObjects(command.parameters, expected); + XCTAssertEqualObjects(command.additionalRequestHeaders, @{ @"X-Parse-Revocable-Session" : @"1" }); + + return YES; + }]; + + __block PFUser *savedUser = nil; + + id currentUserController = [coreDataSource currentUserController]; + [OCMExpect([currentUserController saveCurrentObjectAsync:[OCMArg checkWithBlock:^BOOL(id obj) { + savedUser = obj; + return (savedUser != nil); + }]]) andReturn:[BFTask taskWithResult:nil]]; + + PFUserController *controller = [PFUserController controllerWithCommonDataSource:commonDataSource + coreDataSource:coreDataSource]; + + XCTestExpectation *expectation = [self currentSelectorTestExpectation]; + NSDictionary *params = @{ @"authData": @{ @"mfa": @{ @"token": @"654321" } } }; + [[controller logInCurrentUserAsyncWithUsername:@"yolo" + password:@"yarr" + parameters:params + revocableSession:YES] continueWithBlock:^id(BFTask *task) { + PFUser *user = task.result; + XCTAssertNotNil(user); + XCTAssertEqualObjects(user.objectId, @"a"); + XCTAssertEqualObjects(user[@"yarr"], @1); + [expectation fulfill]; + return nil; + }]; + [self waitForTestExpectations]; + + OCMVerifyAll(currentUserController); +} + - (void)testLogInCurrentUserWithUsernamePasswordNullResult { id commonDataSource = [self mockedCommonDataSource]; id coreDataSource = [self mockedCoreDataSource]; From 2d2b59f4194659a8e9f6009d591989b0f100cdb1 Mon Sep 17 00:00:00 2001 From: Steffen Keller Date: Thu, 14 Aug 2025 01:59:13 +0200 Subject: [PATCH 2/2] test: Enhance login with auth data test --- Parse/Tests/Unit/UserControllerTests.m | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Parse/Tests/Unit/UserControllerTests.m b/Parse/Tests/Unit/UserControllerTests.m index bc9db2022..838960eb0 100644 --- a/Parse/Tests/Unit/UserControllerTests.m +++ b/Parse/Tests/Unit/UserControllerTests.m @@ -190,7 +190,8 @@ - (void)testLogInCurrentUserWithUsernamePasswordAndAuthData { id commandRunner = [commonDataSource commandRunner]; id commandResult = @{ @"objectId" : @"a", - @"yarr" : @1 }; + @"yarr" : @1, + @"authData" : @{ @"mfa" : @{ @"status" : @"enabled" }, @"other" : @{ @"k" : @"v" } } }; [commandRunner mockCommandResult:commandResult forCommandsPassingTest:^BOOL(id obj) { PFRESTCommand *command = obj; @@ -227,6 +228,10 @@ - (void)testLogInCurrentUserWithUsernamePasswordAndAuthData { XCTAssertNotNil(user); XCTAssertEqualObjects(user.objectId, @"a"); XCTAssertEqualObjects(user[@"yarr"], @1); + // Assert transient MFA data was not persisted on PFUser (via public API) + XCTAssertFalse([user isLinkedWithAuthType:@"mfa"]); + // Non-MFA auth providers should still be present if provided + XCTAssertTrue([user isLinkedWithAuthType:@"other"]); [expectation fulfill]; return nil; }];