From 65c6285d907b28a9d1970e8ba4588178623278a9 Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Tue, 11 Jun 2024 13:02:45 +0300 Subject: [PATCH 1/8] added first version of mfa Enrollment info for UserRecordArgs --- FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs | 17 +++ .../FirebaseAdmin/Auth/MfaEnrollment.cs | 112 ++++++++++++++++++ .../FirebaseAdmin/Auth/UserRecordArgs.cs | 21 ++++ 3 files changed, 150 insertions(+) create mode 100644 FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs b/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs new file mode 100644 index 00000000..04899bbb --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace FirebaseAdmin.Auth +{ + /// + /// A specification for updating mfa email information. + /// + public sealed class EmailInfo + { + /// + /// Gets or sets email address of the Email Info. + /// + public string EmailAddress { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs new file mode 100644 index 00000000..8d8d6cae --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs @@ -0,0 +1,112 @@ +using System; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Auth +{ + /// + /// A specification for enrolling a user for mfa or updating their enrollment. + /// + public sealed class MfaEnrollment + { + /// + /// Gets or sets the Mfa enrollments ID. + /// + public string MfaEnrollmentId { get; set; } + + /// + /// Gets or sets the Mfa enrollments display name. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the when the user enrolled this second factor. + /// + public string EnrolledAt { get; set; } + + /// + /// Gets or sets the phone info of the mfa enrollment. + /// + public string PhoneInfo { get; set; } + + /// + /// Gets or sets the email info of the mfa enrollment. + /// + public EmailInfo EmailInfo { get; set; } + + /// + /// Gets or sets unobfuscated phone info of the mfa enrollment. + /// + public string UnobfuscatedPhoneInfo { get; set; } + + internal static string CheckEmail(string email, bool required = false) + { + if (email == null) + { + if (required) + { + throw new ArgumentNullException(nameof(email)); + } + } + else if (email == string.Empty) + { + throw new ArgumentException("Email must not be empty"); + } + else if (!Regex.IsMatch(email, @"^[^@]+@[^@]+$")) + { + throw new ArgumentException($"Invalid email address: {email}"); + } + + return email; + } + + internal UpdateUserRequest ToUpdateUserRequest() + { + return new UpdateUserRequest(this); + } + + internal sealed class UpdateUserRequest + { + internal UpdateUserRequest(MfaEnrollment enrollment) + { + if (enrollment.PhoneInfo != null && enrollment.EmailInfo != null) + { + throw new ArgumentException("Cannot have two differing enrollment types in the same enrollment!"); + } + + if (enrollment.PhoneInfo == null && enrollment.EmailInfo == null) + { + throw new ArgumentException("Must have atleast one enrolled authentication method!"); + } + + if (enrollment.EmailInfo != null) + { + this.EmailInfo = new EmailInfo { EmailAddress = CheckEmail(enrollment.EmailInfo.EmailAddress) }; + } + + this.DisplayName = enrollment.DisplayName; + this.EnrolledAt = enrollment.EnrolledAt; + this.PhoneInfo = enrollment.PhoneInfo; + this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; + } + + [JsonProperty("mfaEnrollmentId")] + public string MfaEnrollmentId { get; set; } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("enrolledAt")] + public string EnrolledAt { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + + [JsonProperty("emailInfo")] + public EmailInfo EmailInfo { get; set; } + + [JsonProperty("unobfuscatedPhoneInfo")] + public string UnobfuscatedPhoneInfo { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index 433deb1b..337b0016 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -31,6 +31,7 @@ public sealed class UserRecordArgs private Optional photoUrl; private Optional phoneNumber; private Optional> customClaims; + private Optional> mfa; private bool? disabled = null; private bool? emailVerified = null; @@ -89,6 +90,15 @@ public bool Disabled set => this.disabled = value; } + /// + /// Gets or sets the Mfa info of the user. + /// + public List Mfa + { + get => this.mfa?.Value; + set => this.mfa = this.Wrap(value); + } + /// /// Gets or sets the password of the user. /// @@ -322,6 +332,14 @@ internal UpdateUserRequest(UserRecordArgs args) this.EmailVerified = args.emailVerified; this.Password = CheckPassword(args.Password); this.ValidSince = args.ValidSince; + if (args.mfa != null) + { + List mfaEnrollments = new List(); + foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + { + mfaEnrollments.Add(mfaEnrollment.ToUpdateUserRequest()); + } + } if (args.displayName != null) { @@ -399,6 +417,9 @@ internal UpdateUserRequest(UserRecordArgs args) [JsonProperty("validSince")] public long? ValidSince { get; set; } + [JsonProperty("mfa")] + public MfaEnrollment[] Mfa { get; set; } + private void AddDeleteAttribute(string attribute) { if (this.DeleteAttribute == null) From 3aa7e30b69fa5cf393f843853f41311baae8b07f Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 06:57:18 +0300 Subject: [PATCH 2/8] refactored structure after looking at node and Go library and added ToCreateUserRequests to MfaEnrollment. --- FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs | 17 --- .../FirebaseAdmin/Auth/MfaEnrollment.cs | 108 +++++++++++++++--- .../FirebaseAdmin/Auth/UserRecordArgs.cs | 34 +++++- 3 files changed, 121 insertions(+), 38 deletions(-) delete mode 100644 FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs b/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs deleted file mode 100644 index 04899bbb..00000000 --- a/FirebaseAdmin/FirebaseAdmin/Auth/EmailInfo.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Auth -{ - /// - /// A specification for updating mfa email information. - /// - public sealed class EmailInfo - { - /// - /// Gets or sets email address of the Email Info. - /// - public string EmailAddress { get; set; } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs index 8d8d6cae..0f997951 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs @@ -1,6 +1,6 @@ -using System; +using Newtonsoft.Json; +using System; using System.Text.RegularExpressions; -using Newtonsoft.Json; namespace FirebaseAdmin.Auth { @@ -9,6 +9,10 @@ namespace FirebaseAdmin.Auth /// public sealed class MfaEnrollment { + private Optional displayName; + private Optional phoneInfo; + private Optional totpInfo; + /// /// Gets or sets the Mfa enrollments ID. /// @@ -17,7 +21,11 @@ public sealed class MfaEnrollment /// /// Gets or sets the Mfa enrollments display name. /// - public string DisplayName { get; set; } + public string DisplayName + { + get => this.displayName?.Value; + set => this.displayName = this.Wrap(value); + } /// /// Gets or sets the when the user enrolled this second factor. @@ -27,12 +35,20 @@ public sealed class MfaEnrollment /// /// Gets or sets the phone info of the mfa enrollment. /// - public string PhoneInfo { get; set; } + public string PhoneInfo + { + get => this.phoneInfo?.Value; + set => this.phoneInfo = this.Wrap(value); + } /// - /// Gets or sets the email info of the mfa enrollment. + /// Gets or sets Totp info of the mfa enrollment. NOTE: this is only here so that the totp field can be deleted, no values can really be assigned to totp via the admin sdk. /// - public EmailInfo EmailInfo { get; set; } + public object TotpInfo + { + get => this.totpInfo?.Value; + set => this.totpInfo = this.Wrap(value); + } /// /// Gets or sets unobfuscated phone info of the mfa enrollment. @@ -60,33 +76,77 @@ internal static string CheckEmail(string email, bool required = false) return email; } + internal static string CheckPhoneNumber(string phoneNumber, bool required = false) + { + if (phoneNumber == null) + { + if (required) + { + throw new ArgumentNullException(nameof(phoneNumber)); + } + } + else if (phoneNumber == string.Empty) + { + throw new ArgumentException("Phone number must not be empty."); + } + else if (!phoneNumber.StartsWith("+")) + { + throw new ArgumentException( + "Phone number must be a valid, E.164 compliant identifier starting with a '+' sign."); + } + + return phoneNumber; + } + + + internal CreateUserRequest ToCreateUserRequest() + { + return new CreateUserRequest(this); + } + internal UpdateUserRequest ToUpdateUserRequest() { return new UpdateUserRequest(this); } - internal sealed class UpdateUserRequest + private Optional Wrap(T value) { - internal UpdateUserRequest(MfaEnrollment enrollment) + return new Optional(value); + } + + internal sealed class CreateUserRequest + { + internal CreateUserRequest(MfaEnrollment enrollment) { - if (enrollment.PhoneInfo != null && enrollment.EmailInfo != null) + if (enrollment.PhoneInfo == null) { - throw new ArgumentException("Cannot have two differing enrollment types in the same enrollment!"); + throw new ArgumentException("Cannot enroll user to invalid phone number!"); } - if (enrollment.PhoneInfo == null && enrollment.EmailInfo == null) - { - throw new ArgumentException("Must have atleast one enrolled authentication method!"); - } + this.DisplayName = enrollment.DisplayName; + + this.PhoneInfo = enrollment.PhoneInfo; + } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + } - if (enrollment.EmailInfo != null) + internal sealed class UpdateUserRequest + { + internal UpdateUserRequest(MfaEnrollment enrollment) + { + if (enrollment.MfaEnrollmentId != "phone") { - this.EmailInfo = new EmailInfo { EmailAddress = CheckEmail(enrollment.EmailInfo.EmailAddress) }; + throw new ArgumentException("Unsupported second factor: " + enrollment.MfaEnrollmentId); } this.DisplayName = enrollment.DisplayName; this.EnrolledAt = enrollment.EnrolledAt; - this.PhoneInfo = enrollment.PhoneInfo; + this.PhoneInfo = CheckPhoneNumber(enrollment.PhoneInfo, true); this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; } @@ -108,5 +168,19 @@ internal UpdateUserRequest(MfaEnrollment enrollment) [JsonProperty("unobfuscatedPhoneInfo")] public string UnobfuscatedPhoneInfo { get; set; } } + + /// + /// Wraps a nullable value. Used to differentiate between parameters that have not been set, and + /// the parameters that have been explicitly set to null. + /// + private class Optional + { + internal Optional(T value) + { + this.Value = value; + } + + internal T Value { get; private set; } + } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index 337b0016..be51109f 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -290,6 +290,22 @@ internal CreateUserRequest(UserRecordArgs args) this.PhoneNumber = CheckPhoneNumber(args.PhoneNumber); this.PhotoUrl = CheckPhotoUrl(args.PhotoUrl); this.Uid = CheckUid(args.Uid); + + if (args.mfa != null) + { + if (args.mfa.Value != null) + { + List mfaEnrollments = new List(); + foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + { + mfaEnrollments.Add(mfaEnrollment.ToCreateUserRequest()); + } + } + else + { + this.Mfa = null; + } + } } [JsonProperty("disabled")] @@ -315,6 +331,9 @@ internal CreateUserRequest(UserRecordArgs args) [JsonProperty("localId")] public string Uid { get; set; } + + [JsonProperty("mfa")] + public List Mfa { get; set; } } internal sealed class UpdateUserRequest @@ -334,10 +353,17 @@ internal UpdateUserRequest(UserRecordArgs args) this.ValidSince = args.ValidSince; if (args.mfa != null) { - List mfaEnrollments = new List(); - foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + if (args.mfa.Value != null) + { + List mfaEnrollments = new List(); + foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + { + mfaEnrollments.Add(mfaEnrollment.ToUpdateUserRequest()); + } + } + else { - mfaEnrollments.Add(mfaEnrollment.ToUpdateUserRequest()); + this.Mfa = null; } } @@ -418,7 +444,7 @@ internal UpdateUserRequest(UserRecordArgs args) public long? ValidSince { get; set; } [JsonProperty("mfa")] - public MfaEnrollment[] Mfa { get; set; } + public List Mfa { get; set; } private void AddDeleteAttribute(string attribute) { From 8d48882fc5683726bc5d3de8f8f784f1104abfe9 Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 09:23:22 +0300 Subject: [PATCH 3/8] added working version of getting mfa enrollments when fetching user info and tests. --- .../Auth/MfaEnrollmentTest.cs | 78 +++++++ .../Auth/UserRecordTest.cs | 48 +++++ .../Auth/Users/FirebaseUserManagerTest.cs | 28 +++ .../FirebaseAdmin/Auth/MfaEnrollment.cs | 200 ++++-------------- .../FirebaseAdmin/Auth/MfaEnrollmentArgs.cs | 198 +++++++++++++++++ .../FirebaseAdmin/Auth/MfaFactorIdType.cs | 22 ++ .../FirebaseAdmin/Auth/UserRecord.cs | 14 ++ .../FirebaseAdmin/Auth/UserRecordArgs.cs | 16 +- .../Auth/Users/GetAccountInfoResponse.cs | 70 ++++++ 9 files changed, 512 insertions(+), 162 deletions(-) create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs new file mode 100644 index 00000000..1020f75c --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs @@ -0,0 +1,78 @@ +using System; +using FirebaseAdmin.Auth; +using FirebaseAdmin.Auth.Users; +using Xunit; + +namespace FirebaseAdmin.Tests.Auth +{ + public class MfaEnrollmentTest + { + [Fact] + public void NullResponse() + { + Assert.Throws(() => new MfaEnrollment(null)); + } + + [Fact] + public void EmptyUid() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = string.Empty, + })); + } + + [Fact] + public void NoInfo() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = null, + TotpInfo = null, + })); + } + + [Fact] + public void ConflictingInfo() + { + Assert.Throws(() => new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = "+10987654321", + TotpInfo = new(), + })); + } + + [Fact] + public void ValidPhoneFactor() + { + var enrollment = new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + PhoneInfo = "+10987654321", + EnrolledAt = "2014 - 10 - 03T15:01:23Z", + }); + + Assert.Equal("testId", enrollment.MfaEnrollmentId); + Assert.Equal("+10987654321", enrollment.PhoneInfo); + Assert.Equal("2014 - 10 - 03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); + } + + [Fact] + public void ValidTotpFactor() + { + var enrollment = new MfaEnrollment(new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "testId", + TotpInfo = new(), + EnrolledAt = "2014 - 10 - 03T15:01:23Z", + }); + + Assert.Equal("testId", enrollment.MfaEnrollmentId); + Assert.Equal("2014 - 10 - 03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs index 384972a8..b39686f5 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs @@ -103,6 +103,30 @@ public void AllProperties() PhoneNumber = "+10987654321", }, }, + Mfa = new List() + { + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "mfa1", + DisplayName = "SecondFactor", + PhoneInfo = "*********321", + EnrolledAt = "2014 - 10 - 03T15:01:23Z", + }, + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "mfa2", + DisplayName = "SecondSecondFactor", + PhoneInfo = "*********322", + EnrolledAt = "2014 - 10 - 03T15:01:23Z", + }, + new GetAccountInfoResponse.MfaEnrollment() + { + MfaEnrollmentId = "totp", + DisplayName = "totp", + TotpInfo = new(), + EnrolledAt = "2014 - 10 - 03T15:01:23Z", + }, + }, }; var user = new UserRecord(response); @@ -141,6 +165,30 @@ public void AllProperties() Assert.Equal("+10987654321", provider.PhoneNumber); Assert.Equal("https://other.com/user.png", provider.PhotoUrl); + var mfaEnrollment = user.Mfa[0]; + Assert.Equal("mfa1", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("SecondFactor", mfaEnrollment.DisplayName); + Assert.Equal("*********321", mfaEnrollment.PhoneInfo); + Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + + mfaEnrollment = user.Mfa[1]; + Assert.Equal("mfa2", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("SecondSecondFactor", mfaEnrollment.DisplayName); + Assert.Equal("*********322", mfaEnrollment.PhoneInfo); + Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + + mfaEnrollment = user.Mfa[2]; + Assert.Equal("totp", mfaEnrollment.MfaEnrollmentId); + Assert.Equal("totp", mfaEnrollment.DisplayName); + Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, mfaEnrollment.MfaFactorId); + Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); + Assert.Null(mfaEnrollment.PhoneInfo); + var metadata = user.UserMetaData; Assert.NotNull(metadata); Assert.Equal(UserRecord.UnixEpoch.AddMilliseconds(100), metadata.CreationTimestamp); diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index 616a8120..d718d121 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -76,6 +76,7 @@ public async Task GetUserById(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance @@ -113,6 +114,20 @@ public async Task GetUserByIdWithProperties(TestConfig config) ], ""createdAt"": 100, ""lastLoginAt"": 150, + ""mfa"": [ + { + ""mfaEnrollmentId"": ""test"", + ""displayName"": ""test name"", + ""enrolledAt"": ""2014-10-02T15:01:23Z"", + ""phoneInfo"": ""+10987654321"" + }, + { + ""mfaEnrollmentId"": ""test2"", + ""displayName"": ""test name2"", + ""enrolledAt"": ""2014-10-03T15:01:23Z"", + ""totpInfo"": {} + }, + ], }"; var handler = new MockMessageHandler() { @@ -157,6 +172,19 @@ public async Task GetUserByIdWithProperties(TestConfig config) Assert.Equal("+10987654321", provider.PhoneNumber); Assert.Equal("https://other.com/user.png", provider.PhotoUrl); + var enrollment = userRecord.Mfa[0]; + Assert.Equal("test", enrollment.MfaEnrollmentId); + Assert.Equal("test name", enrollment.DisplayName); + Assert.Equal("+10987654321", enrollment.PhoneInfo); + Assert.Equal("2014-10-02T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); + + enrollment = userRecord.Mfa[1]; + Assert.Equal("test2", enrollment.MfaEnrollmentId); + Assert.Equal("test name2", enrollment.DisplayName); + Assert.Equal("2014-10-03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); + var metadata = userRecord.UserMetaData; Assert.NotNull(metadata); Assert.Equal(UserRecord.UnixEpoch.AddMilliseconds(100), metadata.CreationTimestamp); diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs index 0f997951..8c0aae0a 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs @@ -1,186 +1,78 @@ -using Newtonsoft.Json; -using System; -using System.Text.RegularExpressions; +using System; +using FirebaseAdmin.Auth.Users; namespace FirebaseAdmin.Auth { /// - /// A specification for enrolling a user for mfa or updating their enrollment. + /// Class to contain readonly user mfa information. /// - public sealed class MfaEnrollment + public class MfaEnrollment { - private Optional displayName; - private Optional phoneInfo; - private Optional totpInfo; - - /// - /// Gets or sets the Mfa enrollments ID. - /// - public string MfaEnrollmentId { get; set; } - - /// - /// Gets or sets the Mfa enrollments display name. - /// - public string DisplayName - { - get => this.displayName?.Value; - set => this.displayName = this.Wrap(value); - } - - /// - /// Gets or sets the when the user enrolled this second factor. - /// - public string EnrolledAt { get; set; } - - /// - /// Gets or sets the phone info of the mfa enrollment. - /// - public string PhoneInfo - { - get => this.phoneInfo?.Value; - set => this.phoneInfo = this.Wrap(value); - } - - /// - /// Gets or sets Totp info of the mfa enrollment. NOTE: this is only here so that the totp field can be deleted, no values can really be assigned to totp via the admin sdk. - /// - public object TotpInfo + internal MfaEnrollment(GetAccountInfoResponse.MfaEnrollment enrollment) { - get => this.totpInfo?.Value; - set => this.totpInfo = this.Wrap(value); - } - - /// - /// Gets or sets unobfuscated phone info of the mfa enrollment. - /// - public string UnobfuscatedPhoneInfo { get; set; } - - internal static string CheckEmail(string email, bool required = false) - { - if (email == null) - { - if (required) - { - throw new ArgumentNullException(nameof(email)); - } - } - else if (email == string.Empty) + if (enrollment == null) { - throw new ArgumentException("Email must not be empty"); + throw new ArgumentNullException("enrollment cannot be null!"); } - else if (!Regex.IsMatch(email, @"^[^@]+@[^@]+$")) + + if (enrollment.MfaEnrollmentId == string.Empty) { - throw new ArgumentException($"Invalid email address: {email}"); + throw new ArgumentException("Enrollment cannot be an empty string!"); } - return email; - } + this.MfaEnrollmentId = enrollment.MfaEnrollmentId; + this.DisplayName = enrollment.DisplayName; + this.EnrolledAt = enrollment.EnrolledAt; + this.PhoneInfo = enrollment.PhoneInfo; + this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; - internal static string CheckPhoneNumber(string phoneNumber, bool required = false) - { - if (phoneNumber == null) + if (enrollment.PhoneInfo != null && enrollment.TotpInfo != null) { - if (required) - { - throw new ArgumentNullException(nameof(phoneNumber)); - } + throw new ArgumentException("Cannot have multiple conflicting info fields!"); } - else if (phoneNumber == string.Empty) + + if (enrollment.PhoneInfo != null) { - throw new ArgumentException("Phone number must not be empty."); + this.MfaFactorId = MfaFactorIdType.Phone; } - else if (!phoneNumber.StartsWith("+")) + else if (enrollment.TotpInfo != null) { - throw new ArgumentException( - "Phone number must be a valid, E.164 compliant identifier starting with a '+' sign."); + this.MfaFactorId = MfaFactorIdType.Totp; } - - return phoneNumber; - } - - - internal CreateUserRequest ToCreateUserRequest() - { - return new CreateUserRequest(this); - } - - internal UpdateUserRequest ToUpdateUserRequest() - { - return new UpdateUserRequest(this); - } - - private Optional Wrap(T value) - { - return new Optional(value); - } - - internal sealed class CreateUserRequest - { - internal CreateUserRequest(MfaEnrollment enrollment) + else { - if (enrollment.PhoneInfo == null) - { - throw new ArgumentException("Cannot enroll user to invalid phone number!"); - } - - this.DisplayName = enrollment.DisplayName; - - this.PhoneInfo = enrollment.PhoneInfo; + throw new ArgumentException("Must have atleast one valid multifactor factor type!"); } - - [JsonProperty("displayName")] - public string DisplayName { get; set; } - - [JsonProperty("phoneInfo")] - public string PhoneInfo { get; set; } } - internal sealed class UpdateUserRequest - { - internal UpdateUserRequest(MfaEnrollment enrollment) - { - if (enrollment.MfaEnrollmentId != "phone") - { - throw new ArgumentException("Unsupported second factor: " + enrollment.MfaEnrollmentId); - } - - this.DisplayName = enrollment.DisplayName; - this.EnrolledAt = enrollment.EnrolledAt; - this.PhoneInfo = CheckPhoneNumber(enrollment.PhoneInfo, true); - this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; - } - - [JsonProperty("mfaEnrollmentId")] - public string MfaEnrollmentId { get; set; } - - [JsonProperty("displayName")] - public string DisplayName { get; set; } - - [JsonProperty("enrolledAt")] - public string EnrolledAt { get; set; } + /// + /// Gets the Mfa enrollments factor id. + /// + public MfaFactorIdType MfaFactorId { get; } - [JsonProperty("phoneInfo")] - public string PhoneInfo { get; set; } + /// + /// Gets the Mfa enrollments ID. + /// + public string MfaEnrollmentId { get; } - [JsonProperty("emailInfo")] - public EmailInfo EmailInfo { get; set; } + /// + /// Gets the Mfa enrollments display name. + /// + public string DisplayName { get; } - [JsonProperty("unobfuscatedPhoneInfo")] - public string UnobfuscatedPhoneInfo { get; set; } - } + /// + /// Gets the when the user enrolled this second factor. + /// + public string EnrolledAt { get; } /// - /// Wraps a nullable value. Used to differentiate between parameters that have not been set, and - /// the parameters that have been explicitly set to null. + /// Gets the phone info of the mfa enrollment. /// - private class Optional - { - internal Optional(T value) - { - this.Value = value; - } + public string PhoneInfo { get; } - internal T Value { get; private set; } - } + /// + /// Gets unobfuscated phone info of the mfa enrollment. + /// + public string UnobfuscatedPhoneInfo { get; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs new file mode 100644 index 00000000..aeef04a0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs @@ -0,0 +1,198 @@ +using System; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Auth +{ + /// + /// A specification for enrolling a user for mfa or updating their enrollment. + /// + public sealed class MfaEnrollmentArgs + { + private Optional displayName; + private Optional phoneInfo; + + /// + /// Gets or sets the Mfa enrollments factor id. + /// + public MfaFactorIdType MfaFactorId { get; set; } + + /// + /// Gets or sets the Mfa enrollments ID. + /// + public string MfaEnrollmentId { get; set; } + + /// + /// Gets or sets the Mfa enrollments display name. + /// + public string DisplayName + { + get => this.displayName?.Value; + set => this.displayName = this.Wrap(value); + } + + /// + /// Gets or sets the when the user enrolled this second factor. + /// + public long EnrolledAt { get; set; } + + /// + /// Gets or sets the phone info of the mfa enrollment. + /// + public string PhoneInfo + { + get => this.phoneInfo?.Value; + set => this.phoneInfo = this.Wrap(value); + } + + /// + /// Gets or sets unobfuscated phone info of the mfa enrollment. + /// + public string UnobfuscatedPhoneInfo { get; set; } + + internal static string CheckEmail(string email, bool required = false) + { + if (email == null) + { + if (required) + { + throw new ArgumentNullException(nameof(email)); + } + } + else if (email == string.Empty) + { + throw new ArgumentException("Email must not be empty"); + } + else if (!Regex.IsMatch(email, @"^[^@]+@[^@]+$")) + { + throw new ArgumentException($"Invalid email address: {email}"); + } + + return email; + } + + internal static string CheckPhoneNumber(string phoneNumber, bool required = false) + { + if (phoneNumber == null) + { + if (required) + { + throw new ArgumentNullException(nameof(phoneNumber)); + } + } + else if (phoneNumber == string.Empty) + { + throw new ArgumentException("Phone number must not be empty."); + } + else if (!phoneNumber.StartsWith("+")) + { + throw new ArgumentException( + "Phone number must be a valid, E.164 compliant identifier starting with a '+' sign."); + } + + return phoneNumber; + } + + internal CreateUserRequest ToCreateUserRequest() + { + return new CreateUserRequest(this); + } + + internal UpdateUserRequest ToUpdateUserRequest() + { + return new UpdateUserRequest(this); + } + + private Optional Wrap(T value) + { + return new Optional(value); + } + + internal sealed class CreateUserRequest + { + internal CreateUserRequest(MfaEnrollmentArgs enrollment) + { + if (enrollment.MfaFactorId != MfaFactorIdType.Phone) + { + throw new ArgumentException("Unsupported factor type: " + enrollment.MfaFactorId.ToString()); + } + + if (enrollment.PhoneInfo == null) + { + throw new ArgumentException("Cannot enroll user to invalid phone number!"); + } + + this.DisplayName = enrollment.DisplayName; + + this.PhoneInfo = enrollment.PhoneInfo; + } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + } + + internal sealed class UpdateUserRequest + { + internal UpdateUserRequest(MfaEnrollmentArgs enrollment) + { + // Similiar to the node implementation, the mfa factor sanity checks are tied to the MfaFactorId. + // I decided to do an enumerator, since the factorTypes have a strictly defined set and are + // not used in the API. I decided not to do inheritance, since the only true factor type, that can be + // edited here, is the phone factor type. + if (enrollment.MfaFactorId == MfaFactorIdType.Phone) + { + this.PhoneInfo = CheckPhoneNumber(enrollment.PhoneInfo, true); + } + + if (enrollment.MfaFactorId == MfaFactorIdType.Totp) + { + this.TotpInfo = new TotpInfoJson(); + } + + this.DisplayName = enrollment.DisplayName; + this.EnrolledAt = enrollment.EnrolledAt; + this.UnobfuscatedPhoneInfo = enrollment.UnobfuscatedPhoneInfo; + } + + [JsonProperty("mfaEnrollmentId")] + public string MfaEnrollmentId { get; set; } + + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + [JsonProperty("enrolledAt")] + public long EnrolledAt { get; set; } + + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + + [JsonProperty("unobfuscatedPhoneInfo")] + public string UnobfuscatedPhoneInfo { get; set; } + + [JsonProperty("totpInfo")] + public TotpInfoJson TotpInfo { get; set; } + + // internal class to get an empty JSON and for the future, if something can be given here to the API. + internal class TotpInfoJson + { + } + } + + /// + /// Wraps a nullable value. Used to differentiate between parameters that have not been set, and + /// the parameters that have been explicitly set to null. + /// + private class Optional + { + internal Optional(T value) + { + this.Value = value; + } + + internal T Value { get; private set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs new file mode 100644 index 00000000..07f0af6a --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaFactorIdType.cs @@ -0,0 +1,22 @@ +using Google.Apis.Util; + +namespace FirebaseAdmin.Auth +{ + /// + /// Enumerator to encapsulate the different possible MfaFactorIds. + /// + public enum MfaFactorIdType + { + /// + /// Value for the phone factor id. + /// + [StringValue("phone")] + Phone = 1, + + /// + /// Value for the phone factor id. + /// + [StringValue("totp")] + Totp = 2, + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs index 4c69b669..cf35a92f 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs @@ -71,6 +71,15 @@ internal UserRecord(GetAccountInfoResponse.User user) } } + if (user.Mfa != null && user.Mfa.Count > 0) + { + this.Mfa = new List(); + foreach (var enrollment in user.Mfa) + { + this.Mfa.Add(new MfaEnrollment(enrollment)); + } + } + this.validSinceTimestampInSeconds = user.ValidSince; // newtonsoft's json deserializer will convert an iso8601 format @@ -133,6 +142,11 @@ internal UserRecord(GetAccountInfoResponse.User user) /// public IUserInfo[] ProviderData { get; } + /// + /// Gets a list of Mfa data for this user. Possibly empty or null. + /// + public List Mfa { get; } + /// /// Gets a timestamp that indicates the earliest point in time at which a valid ID token /// could have been issued to this user. Tokens issued prior to this timestamp are diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index be51109f..1422f225 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -31,7 +31,7 @@ public sealed class UserRecordArgs private Optional photoUrl; private Optional phoneNumber; private Optional> customClaims; - private Optional> mfa; + private Optional> mfa; private bool? disabled = null; private bool? emailVerified = null; @@ -93,7 +93,7 @@ public bool Disabled /// /// Gets or sets the Mfa info of the user. /// - public List Mfa + public List Mfa { get => this.mfa?.Value; set => this.mfa = this.Wrap(value); @@ -295,8 +295,8 @@ internal CreateUserRequest(UserRecordArgs args) { if (args.mfa.Value != null) { - List mfaEnrollments = new List(); - foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + List mfaEnrollments = new List(); + foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) { mfaEnrollments.Add(mfaEnrollment.ToCreateUserRequest()); } @@ -333,7 +333,7 @@ internal CreateUserRequest(UserRecordArgs args) public string Uid { get; set; } [JsonProperty("mfa")] - public List Mfa { get; set; } + public List Mfa { get; set; } } internal sealed class UpdateUserRequest @@ -355,8 +355,8 @@ internal UpdateUserRequest(UserRecordArgs args) { if (args.mfa.Value != null) { - List mfaEnrollments = new List(); - foreach (MfaEnrollment mfaEnrollment in args.mfa.Value) + List mfaEnrollments = new List(); + foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) { mfaEnrollments.Add(mfaEnrollment.ToUpdateUserRequest()); } @@ -444,7 +444,7 @@ internal UpdateUserRequest(UserRecordArgs args) public long? ValidSince { get; set; } [JsonProperty("mfa")] - public List Mfa { get; set; } + public List Mfa { get; set; } private void AddDeleteAttribute(string attribute) { diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs index 76eb4542..81cc80d2 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs @@ -135,6 +135,12 @@ internal sealed class User /// [JsonProperty(PropertyName = "tenantId")] public string TenantId { get; set; } + + /// + /// Gets or sets the user's Mfa enrollments. + /// + [JsonProperty(PropertyName = "mfa")] + public List Mfa { get; set; } } /// @@ -178,5 +184,69 @@ internal sealed class Provider [JsonProperty(PropertyName = "providerId")] public string ProviderID { get; set; } } + + /// + /// Json data binding, for mfa info. + /// + internal sealed class MfaEnrollment + { + /// + /// Gets or sets the Mfa enrollment id. + /// + [JsonProperty("mfaEnrollmentId")] + public string MfaEnrollmentId { get; set; } + + /// + /// Gets or sets the Mfa display name. + /// + [JsonProperty("displayName")] + public string DisplayName { get; set; } + + /// + /// Gets or sets when the enrollment was made. + /// + [JsonProperty("enrolledAt")] + public string EnrolledAt { get; set; } + + /// + /// Gets or sets the totpInfo object of the mfa enrollment. + /// + [JsonProperty("totpInfo")] + public TotpInfoResponse TotpInfo { get; set; } + + /// + /// Gets or sets the phone info of the mfa enrollment. + /// + [JsonProperty("phoneInfo")] + public string PhoneInfo { get; set; } + + /// + /// Gets or sets the email info, of the enrollment. + /// + [JsonProperty("emailInfo")] + public EmailInfoResponse EmailInfo { get; set; } + + /// + /// Gets or sets the unobfuscated phone info, of the enrollment. + /// + [JsonProperty("unobfuscatedPhoneInfo")] + public string UnobfuscatedPhoneInfo { get; set; } + + /// + /// Json data binding, for the email info response field. + /// + internal sealed class EmailInfoResponse + { + [JsonProperty("emailAddress")] + public string EmailAddress { get; set; } + } + + /// + /// Json data binding, for the totp info response field. + /// + internal sealed class TotpInfoResponse + { + } + } } } From 186d0a3f3a004742b14b23014ac76bbed58bd80d Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 09:38:08 +0300 Subject: [PATCH 4/8] Fixed mfa "EnrolledAt" field to be a DateTime object instead of a string --- .../FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs | 8 ++++---- .../FirebaseAdmin.Tests/Auth/UserRecordTest.cs | 12 ++++++------ .../Auth/Users/FirebaseUserManagerTest.cs | 4 ++-- FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs | 2 +- .../Auth/Users/GetAccountInfoResponse.cs | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs index 1020f75c..7f4d2edd 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/MfaEnrollmentTest.cs @@ -51,12 +51,12 @@ public void ValidPhoneFactor() { MfaEnrollmentId = "testId", PhoneInfo = "+10987654321", - EnrolledAt = "2014 - 10 - 03T15:01:23Z", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), }); Assert.Equal("testId", enrollment.MfaEnrollmentId); Assert.Equal("+10987654321", enrollment.PhoneInfo); - Assert.Equal("2014 - 10 - 03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), enrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); } @@ -67,11 +67,11 @@ public void ValidTotpFactor() { MfaEnrollmentId = "testId", TotpInfo = new(), - EnrolledAt = "2014 - 10 - 03T15:01:23Z", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), }); Assert.Equal("testId", enrollment.MfaEnrollmentId); - Assert.Equal("2014 - 10 - 03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014 - 10 - 03T15:01:23Z"), enrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs index b39686f5..129114eb 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/UserRecordTest.cs @@ -110,21 +110,21 @@ public void AllProperties() MfaEnrollmentId = "mfa1", DisplayName = "SecondFactor", PhoneInfo = "*********321", - EnrolledAt = "2014 - 10 - 03T15:01:23Z", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), }, new GetAccountInfoResponse.MfaEnrollment() { MfaEnrollmentId = "mfa2", DisplayName = "SecondSecondFactor", PhoneInfo = "*********322", - EnrolledAt = "2014 - 10 - 03T15:01:23Z", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), }, new GetAccountInfoResponse.MfaEnrollment() { MfaEnrollmentId = "totp", DisplayName = "totp", TotpInfo = new(), - EnrolledAt = "2014 - 10 - 03T15:01:23Z", + EnrolledAt = DateTime.Parse("2014-10-03T15:01:23Z"), }, }, }; @@ -169,7 +169,7 @@ public void AllProperties() Assert.Equal("mfa1", mfaEnrollment.MfaEnrollmentId); Assert.Equal("SecondFactor", mfaEnrollment.DisplayName); Assert.Equal("*********321", mfaEnrollment.PhoneInfo); - Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); @@ -177,14 +177,14 @@ public void AllProperties() Assert.Equal("mfa2", mfaEnrollment.MfaEnrollmentId); Assert.Equal("SecondSecondFactor", mfaEnrollment.DisplayName); Assert.Equal("*********322", mfaEnrollment.PhoneInfo); - Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Phone, mfaEnrollment.MfaFactorId); Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); mfaEnrollment = user.Mfa[2]; Assert.Equal("totp", mfaEnrollment.MfaEnrollmentId); Assert.Equal("totp", mfaEnrollment.DisplayName); - Assert.Equal("2014 - 10 - 03T15:01:23Z", mfaEnrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z"), mfaEnrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Totp, mfaEnrollment.MfaFactorId); Assert.Null(mfaEnrollment.UnobfuscatedPhoneInfo); Assert.Null(mfaEnrollment.PhoneInfo); diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index d718d121..b450898c 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -176,13 +176,13 @@ public async Task GetUserByIdWithProperties(TestConfig config) Assert.Equal("test", enrollment.MfaEnrollmentId); Assert.Equal("test name", enrollment.DisplayName); Assert.Equal("+10987654321", enrollment.PhoneInfo); - Assert.Equal("2014-10-02T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-02T15:01:23Z").ToUniversalTime(), enrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Phone, enrollment.MfaFactorId); enrollment = userRecord.Mfa[1]; Assert.Equal("test2", enrollment.MfaEnrollmentId); Assert.Equal("test name2", enrollment.DisplayName); - Assert.Equal("2014-10-03T15:01:23Z", enrollment.EnrolledAt); + Assert.Equal(DateTime.Parse("2014-10-03T15:01:23Z").ToUniversalTime(), enrollment.EnrolledAt); Assert.Equal(MfaFactorIdType.Totp, enrollment.MfaFactorId); var metadata = userRecord.UserMetaData; diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs index 8c0aae0a..3a8f705f 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollment.cs @@ -63,7 +63,7 @@ internal MfaEnrollment(GetAccountInfoResponse.MfaEnrollment enrollment) /// /// Gets the when the user enrolled this second factor. /// - public string EnrolledAt { get; } + public DateTime EnrolledAt { get; } /// /// Gets the phone info of the mfa enrollment. diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs index 81cc80d2..12993855 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs @@ -206,7 +206,7 @@ internal sealed class MfaEnrollment /// Gets or sets when the enrollment was made. /// [JsonProperty("enrolledAt")] - public string EnrolledAt { get; set; } + public DateTime EnrolledAt { get; set; } /// /// Gets or sets the totpInfo object of the mfa enrollment. From 2026bc6e881a60d46e27f52807385d8e8d1d3fc5 Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 10:41:00 +0300 Subject: [PATCH 5/8] fixed some tests to check the mfa field --- .../FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index b450898c..9be5d6c1 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -255,6 +255,7 @@ public async Task GetUserByEmail(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance @@ -324,6 +325,7 @@ public async Task GetUserByPhoneNumber(TestConfig config) Assert.Empty(userRecord.ProviderData); Assert.Null(userRecord.UserMetaData.CreationTimestamp); Assert.Null(userRecord.UserMetaData.LastSignInTimestamp); + Assert.Null(userRecord.Mfa); config.AssertRequest("accounts:lookup", handler.Requests[0]); var request = NewtonsoftJsonSerializer.Instance From 08a65722a57f19e08c973a90f8e975092b869083 Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 11:20:00 +0300 Subject: [PATCH 6/8] added app snippets and fixed mfaEnrollmentArgs property EnrolledAt to be a DateTime object --- .../Auth/TemporaryUserBuilder.cs | 9 +++++++++ .../FirebaseAuthSnippets.cs | 19 +++++++++++++++++++ .../FirebaseAdmin/Auth/MfaEnrollmentArgs.cs | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs index 7baa861e..5636c47e 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs @@ -53,6 +53,15 @@ public static UserRecordArgs RandomUserRecordArgs() DisplayName = "Random User", PhotoUrl = "https://example.com/photo.png", Password = "password", + Mfa = rand.Next(9) <= 3 ? new List + { + new MfaEnrollmentArgs() + { + DisplayName = "Random Factor", + PhoneInfo = $"+1{string.Join(string.Empty, phoneDigits)}", + MfaFactorId = MfaFactorIdType.Phone, + }, + } : null, }; } diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs index 1b316fd6..d1c0fb9e 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs @@ -130,6 +130,15 @@ internal static async Task CreateUserAsync() DisplayName = "John Doe", PhotoUrl = "http://www.example.com/12345678/photo.png", Disabled = false, + Mfa = new List + { + new MfaEnrollmentArgs + { + PhoneInfo = "+11234567890", + MfaFactorId = MfaFactorIdType.Phone, + DisplayName = "John Does personal phone", + }, + }, }; UserRecord userRecord = await FirebaseAuth.DefaultInstance.CreateUserAsync(args); // See the UserRecord reference doc for the contents of userRecord. @@ -458,6 +467,16 @@ internal static async Task UpdateUserAsync(string uid) DisplayName = "Jane Doe", PhotoUrl = "http://www.example.com/12345678/photo.png", Disabled = true, + Mfa = new List() + { + new MfaEnrollmentArgs() + { + PhoneInfo = "+11234567890", + MfaFactorId = MfaFactorIdType.Phone, + MfaEnrollmentId = "CoolId", + EnrolledAt = DateTime.UtcNow, + }, + }, }; UserRecord userRecord = await FirebaseAuth.DefaultInstance.UpdateUserAsync(args); // See the UserRecord reference doc for the contents of userRecord. diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs index aeef04a0..c1ca0c80 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/MfaEnrollmentArgs.cs @@ -34,7 +34,7 @@ public string DisplayName /// /// Gets or sets the when the user enrolled this second factor. /// - public long EnrolledAt { get; set; } + public DateTime EnrolledAt { get; set; } /// /// Gets or sets the phone info of the mfa enrollment. @@ -164,7 +164,7 @@ internal UpdateUserRequest(MfaEnrollmentArgs enrollment) public string DisplayName { get; set; } [JsonProperty("enrolledAt")] - public long EnrolledAt { get; set; } + public DateTime EnrolledAt { get; set; } [JsonProperty("phoneInfo")] public string PhoneInfo { get; set; } From 7b2514398f6700f6edf1c90e8a4ef25c3128f133 Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Wed, 12 Jun 2024 12:28:07 +0300 Subject: [PATCH 7/8] fixed linting problem --- .../Auth/TemporaryUserBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs index 5636c47e..ddba41c1 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs @@ -61,7 +61,8 @@ public static UserRecordArgs RandomUserRecordArgs() PhoneInfo = $"+1{string.Join(string.Empty, phoneDigits)}", MfaFactorId = MfaFactorIdType.Phone, }, - } : null, + } + : null, }; } From ce4d960f0b31754cafc524de6a717821714872cc Mon Sep 17 00:00:00 2001 From: Topias Nyquist Date: Thu, 13 Jun 2024 07:51:40 +0300 Subject: [PATCH 8/8] added first working version of mfa to userrecords and tests --- .../Auth/AbstractFirebaseAuthTest.cs | 19 +++++++++- .../Auth/TemporaryUserBuilder.cs | 31 +++++++++++----- .../Auth/Users/FirebaseUserManagerTest.cs | 12 +++---- .../FirebaseAdmin/Auth/UserRecordArgs.cs | 35 ++++++++++++------- .../Auth/Users/FirebaseUserManager.cs | 4 +++ .../Auth/Users/GetAccountInfoResponse.cs | 2 +- 6 files changed, 73 insertions(+), 30 deletions(-) diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs index 13b0a9b4..84ae9bba 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/AbstractFirebaseAuthTest.cs @@ -236,7 +236,7 @@ public async Task GetUser() public async Task UpdateUser() { var original = await this.userBuilder.CreateUserAsync(new UserRecordArgs()); - var updateArgs = TemporaryUserBuilder.RandomUserRecordArgs(); + var updateArgs = TemporaryUserBuilder.RandomUserRecordArgsWithMfa(); updateArgs.Uid = original.Uid; updateArgs.EmailVerified = true; @@ -253,6 +253,12 @@ public async Task UpdateUser() Assert.Null(user.UserMetaData.LastSignInTimestamp); Assert.Equal(2, user.ProviderData.Length); Assert.Empty(user.CustomClaims); + Assert.Equal(updateArgs.Mfa[0].DisplayName, user.Mfa[0].DisplayName); + Assert.Equal(updateArgs.Mfa[0].PhoneInfo, user.Mfa[0].PhoneInfo); + Assert.Equal(updateArgs.Mfa[0].MfaFactorId, user.Mfa[0].MfaFactorId); + // Check that the enrolled at Timespan is within one second of the set time. + // The range, is because the google cloud API rounds the enrolledAt date by some milliseconds. + Assert.InRange(updateArgs.Mfa[0].EnrolledAt - user.Mfa[0].EnrolledAt, TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1)); } [Fact] @@ -658,6 +664,17 @@ public async Task SignInWithEmailLink() Assert.True(user.EmailVerified); } + [Fact] + public async Task CreateUserWithMfa() + { + var user = await this.userBuilder.CreateRandomUserMithMfaAsync(); + var userGetData = await this.Auth.GetUserAsync(user.Uid); + + Assert.NotNull(userGetData); + Assert.NotNull(userGetData.Mfa); + await this.Auth.DeleteUserAsync(user.Uid); + } + private async Task AssertValidIdTokenAsync( string idToken, bool checkRevoked = false) { diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs index ddba41c1..71013088 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/Auth/TemporaryUserBuilder.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Policy; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth; @@ -53,17 +54,24 @@ public static UserRecordArgs RandomUserRecordArgs() DisplayName = "Random User", PhotoUrl = "https://example.com/photo.png", Password = "password", - Mfa = rand.Next(9) <= 3 ? new List + }; + } + + public static UserRecordArgs RandomUserRecordArgsWithMfa() + { + var userArgs = RandomUserRecordArgs(); + userArgs.EmailVerified = true; + userArgs.Mfa = new List() + { + new MfaEnrollmentArgs { - new MfaEnrollmentArgs() - { - DisplayName = "Random Factor", - PhoneInfo = $"+1{string.Join(string.Empty, phoneDigits)}", - MfaFactorId = MfaFactorIdType.Phone, - }, - } - : null, + PhoneInfo = userArgs.PhoneNumber, + DisplayName = "test factor", + EnrolledAt = DateTime.UtcNow, + MfaFactorId = MfaFactorIdType.Phone, + }, }; + return userArgs; } public async Task CreateRandomUserAsync() @@ -71,6 +79,11 @@ public async Task CreateRandomUserAsync() return await this.CreateUserAsync(RandomUserRecordArgs()); } + public async Task CreateRandomUserMithMfaAsync() + { + return await this.CreateUserAsync(RandomUserRecordArgsWithMfa()); + } + public async Task CreateUserAsync(UserRecordArgs args) { // Make sure we never create more than 1000 users in a single instance. diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index 9be5d6c1..745865e4 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -114,7 +114,7 @@ public async Task GetUserByIdWithProperties(TestConfig config) ], ""createdAt"": 100, ""lastLoginAt"": 150, - ""mfa"": [ + ""mfaInfo"": [ { ""mfaEnrollmentId"": ""test"", ""displayName"": ""test name"", @@ -880,7 +880,7 @@ public async Task CreateUser(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Empty(request); } @@ -911,7 +911,7 @@ public async Task CreateUserWithArgs(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.True((bool)request["disabled"]); Assert.Equal("Test User", request["displayName"]); Assert.Equal("user@example.com", request["email"]); @@ -945,7 +945,7 @@ public async Task CreateUserWithExplicitDefaults(TestConfig config) Assert.Equal("user1", user.Uid); Assert.Equal(config.TenantId, user.TenantId); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); @@ -1155,7 +1155,7 @@ public async Task UpdateUser(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest("accounts:update", handler.Requests[0]); config.AssertRequest("accounts:lookup", handler.Requests[1]); - var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); + var request = NewtonsoftJsonSerializer.Instance.Deserialize(handler.Requests[0].Body); Assert.Equal("user1", request["localId"]); Assert.True((bool)request["disableUser"]); Assert.Equal("Test User", request["displayName"]); @@ -1165,7 +1165,7 @@ public async Task UpdateUser(TestConfig config) Assert.Equal("+1234567890", request["phoneNumber"]); Assert.Equal("https://example.com/user.png", request["photoUrl"]); - var claims = NewtonsoftJsonSerializer.Instance.Deserialize((string)request["customAttributes"]); + var claims = NewtonsoftJsonSerializer.Instance.Deserialize((string)request["customAttributes"]); Assert.True((bool)claims["admin"]); Assert.Equal(4L, claims["level"]); Assert.Equal("gold", claims["package"]); diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs index 1422f225..aa1d3c32 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecordArgs.cs @@ -31,7 +31,7 @@ public sealed class UserRecordArgs private Optional photoUrl; private Optional phoneNumber; private Optional> customClaims; - private Optional> mfa; + private Optional> mfa; private bool? disabled = null; private bool? emailVerified = null; @@ -93,7 +93,7 @@ public bool Disabled /// /// Gets or sets the Mfa info of the user. /// - public List Mfa + public IList Mfa { get => this.mfa?.Value; set => this.mfa = this.Wrap(value); @@ -295,15 +295,15 @@ internal CreateUserRequest(UserRecordArgs args) { if (args.mfa.Value != null) { - List mfaEnrollments = new List(); - foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) - { - mfaEnrollments.Add(mfaEnrollment.ToCreateUserRequest()); - } + this.MfaInfo = new List(); + foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) + { + this.MfaInfo.Add(mfaEnrollment.ToCreateUserRequest()); + } } else { - this.Mfa = null; + this.MfaInfo = null; } } } @@ -332,8 +332,8 @@ internal CreateUserRequest(UserRecordArgs args) [JsonProperty("localId")] public string Uid { get; set; } - [JsonProperty("mfa")] - public List Mfa { get; set; } + [JsonProperty("mfaInfo")] + public IList MfaInfo { get; set; } } internal sealed class UpdateUserRequest @@ -355,10 +355,13 @@ internal UpdateUserRequest(UserRecordArgs args) { if (args.mfa.Value != null) { - List mfaEnrollments = new List(); + this.Mfa = new() + { + Enrollments = new List(), + }; foreach (MfaEnrollmentArgs mfaEnrollment in args.mfa.Value) { - mfaEnrollments.Add(mfaEnrollment.ToUpdateUserRequest()); + this.Mfa.Enrollments.Add(mfaEnrollment.ToUpdateUserRequest()); } } else @@ -444,7 +447,7 @@ internal UpdateUserRequest(UserRecordArgs args) public long? ValidSince { get; set; } [JsonProperty("mfa")] - public List Mfa { get; set; } + public MfaInfo Mfa { get; set; } private void AddDeleteAttribute(string attribute) { @@ -465,6 +468,12 @@ private void AddDeleteProvider(string provider) this.DeleteProvider.Add(provider); } + + internal sealed class MfaInfo + { + [JsonProperty("enrollments")] + public IList Enrollments { get; set; } + } } /// diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs index e535c5c5..4b536e0b 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/FirebaseUserManager.cs @@ -276,6 +276,7 @@ internal async Task UpdateUserAsync( var payload = args.ToUpdateUserRequest(); var response = await this.PostAndDeserializeAsync( "accounts:update", payload, cancellationToken).ConfigureAwait(false); + if (payload.Uid != (string)response.Result["localId"]) { throw UnexpectedResponseException( @@ -408,7 +409,9 @@ private async Task GetUserAsync( { var response = await this.PostAndDeserializeAsync( "accounts:lookup", query.Build(), cancellationToken).ConfigureAwait(false); + var result = response.Result; + if (result == null || result.Users == null || result.Users.Count == 0) { throw new FirebaseAuthException( @@ -430,6 +433,7 @@ private async Task> PostAndDeserializeAsync(request, cancellationToken) diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs index 12993855..7b9f94f2 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Users/GetAccountInfoResponse.cs @@ -139,7 +139,7 @@ internal sealed class User /// /// Gets or sets the user's Mfa enrollments. /// - [JsonProperty(PropertyName = "mfa")] + [JsonProperty(PropertyName = "mfaInfo")] public List Mfa { get; set; } }