From 40dc232e5a9bdb41c6596823bda3468b0901d74e Mon Sep 17 00:00:00 2001 From: Archi Date: Thu, 19 Oct 2023 12:16:28 +0200 Subject: [PATCH] Implement support for access tokens A bit more work and testing is needed --- ArchiSteamFarm/ArchiSteamFarm.csproj | 1 + ArchiSteamFarm/Steam/Bot.cs | 185 ++++++++++++++---- .../Steam/Integration/ArchiHandler.cs | 31 +++ .../Steam/Integration/ArchiWebHandler.cs | 89 +-------- ArchiSteamFarm/Steam/Storage/BotDatabase.cs | 38 ++++ Directory.Packages.props | 1 + 6 files changed, 227 insertions(+), 118 deletions(-) diff --git a/ArchiSteamFarm/ArchiSteamFarm.csproj b/ArchiSteamFarm/ArchiSteamFarm.csproj index e2717031a294e..18f51791e0064 100644 --- a/ArchiSteamFarm/ArchiSteamFarm.csproj +++ b/ArchiSteamFarm/ArchiSteamFarm.csproj @@ -21,6 +21,7 @@ + diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index 5bb6adedbfaf0..5d3acadf1d690 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -27,6 +27,7 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Globalization; +using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; using System.Net.Http; @@ -229,6 +230,8 @@ public sealed class Bot : IAsyncDisposable, IDisposable { internal bool PlayingBlocked { get; private set; } internal bool PlayingWasBlocked { get; private set; } + private string? AccessToken; + private DateTime? AccessTokenValidUntil; private string? AuthCode; [JsonProperty] @@ -244,6 +247,8 @@ public sealed class Bot : IAsyncDisposable, IDisposable { private ulong MasterChatGroupID; private Timer? PlayingWasBlockedTimer; private bool ReconnectOnUserInitiated; + private string? RefreshToken; + private Timer? RefreshTokensTimer; private bool SendCompleteTypesScheduled; private Timer? SendItemsTimer; private bool SteamParentalActive; @@ -357,6 +362,7 @@ public void Dispose() { ConnectionFailureTimer?.Dispose(); GamesRedeemerInBackgroundTimer?.Dispose(); PlayingWasBlockedTimer?.Dispose(); + RefreshTokensTimer?.Dispose(); SendItemsTimer?.Dispose(); SteamSaleEvent?.Dispose(); TradeCheckTimer?.Dispose(); @@ -390,6 +396,10 @@ public async ValueTask DisposeAsync() { await PlayingWasBlockedTimer.DisposeAsync().ConfigureAwait(false); } + if (RefreshTokensTimer != null) { + await RefreshTokensTimer.DisposeAsync().ConfigureAwait(false); + } + if (SendItemsTimer != null) { await SendItemsTimer.DisposeAsync().ConfigureAwait(false); } @@ -1492,32 +1502,57 @@ internal async Task OnFarmingStopped() { await PluginsCore.OnBotFarmingStopped(this).ConfigureAwait(false); } - internal async Task RefreshSession() { + internal async Task RefreshWebSession() { if (!IsConnectedAndLoggedOn) { return false; } - SteamUser.WebAPIUserNonceCallback callback; + DateTime now = DateTime.UtcNow; - try { - callback = await SteamUser.RequestWebAPIUserNonce().ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); + if (!string.IsNullOrEmpty(AccessToken) && AccessTokenValidUntil.HasValue && (AccessTokenValidUntil.Value > now.AddMinutes(5))) { + // We can use the tokens we already have + if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, AccessToken, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { + InitRefreshTokensTimer(AccessTokenValidUntil.Value); + + return true; + } + } + + // We need to refresh our session, access token is no longer valid + BotDatabase.AccessToken = AccessToken = null; + BotDatabase.AccessTokenValidUntil = AccessTokenValidUntil = null; + + if (string.IsNullOrEmpty(RefreshToken)) { + // Without refresh token we can't get fresh access tokens, relog needed await Connect(true).ConfigureAwait(false); return false; } - if (string.IsNullOrEmpty(callback.Nonce)) { + CAuthentication_AccessToken_GenerateForApp_Response? response = await ArchiHandler.GenerateAccessTokens(RefreshToken).ConfigureAwait(false); + + if (response == null) { + // The request has failed, in almost all cases this means our refresh token is no longer valid, relog needed + BotDatabase.RefreshToken = RefreshToken = null; + await Connect(true).ConfigureAwait(false); return false; } - if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.Nonce, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { + // TODO: Handle update of refresh token with next SK2 release + UpdateTokens(response.access_token, RefreshToken); + + if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, response.access_token, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { + InitRefreshTokensTimer(AccessTokenValidUntil ?? now.AddDays(1)); + return true; } + // We got the tokens, but failed to authorize? Purge them just to be sure and reconnect + BotDatabase.AccessToken = AccessToken = null; + BotDatabase.AccessTokenValidUntil = AccessTokenValidUntil = null; + await Connect(true).ConfigureAwait(false); return false; @@ -2274,6 +2309,20 @@ private async Task InitModules() { WalletBalance = 0; WalletCurrency = ECurrencyCode.Invalid; + AccessToken = BotDatabase.AccessToken; + AccessTokenValidUntil = BotDatabase.AccessTokenValidUntil; + RefreshToken = BotDatabase.RefreshToken; + + if (BotConfig.PasswordFormat.HasTransformation()) { + if (!string.IsNullOrEmpty(AccessToken)) { + AccessToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, AccessToken!).ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(RefreshToken)) { + AccessToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, RefreshToken!).ConfigureAwait(false); + } + } + CardsFarmer.SetInitialState(BotConfig.Paused); if (SendItemsTimer != null) { @@ -2344,6 +2393,40 @@ private void InitPlayingWasBlockedTimer() { ); } + private void InitRefreshTokensTimer(DateTime validUntil) { + if (validUntil <= DateTime.UnixEpoch) { + throw new ArgumentOutOfRangeException(nameof(validUntil)); + } + + if (validUntil == DateTime.MaxValue) { + // OK, tokens do not require refreshing + StopRefreshTokensTimer(); + + return; + } + + TimeSpan delay = validUntil - DateTime.UtcNow; + + // Start refreshing token 10 minutes before it's invalid + if (delay.TotalMinutes > 10) { + delay -= TimeSpan.FromMinutes(10); + } + + // Timer can accept only dueTimes up to 2^32 - 2 + uint dueTime = (uint) Math.Min(uint.MaxValue - 1, (ulong) delay.TotalMilliseconds); + + if (RefreshTokensTimer == null) { + RefreshTokensTimer = new Timer( + OnRefreshTokensTimer, + null, + TimeSpan.FromMilliseconds(dueTime), // Delay + TimeSpan.FromMinutes(1) // Period + ); + } else { + RefreshTokensTimer.Change(TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMinutes(1)); + } + } + private void InitStart() { if (!BotConfig.Enabled) { ArchiLogger.LogGenericWarning(Strings.BotInstanceNotStartingBecauseDisabled); @@ -2482,17 +2565,7 @@ private async void OnConnected(SteamClient.ConnectedCallback callback) { } } - string? refreshToken = BotDatabase.RefreshToken; - - if (!string.IsNullOrEmpty(refreshToken)) { - // Decrypt refreshToken if needed - if (BotConfig.PasswordFormat.HasTransformation()) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - refreshToken = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, refreshToken!).ConfigureAwait(false); - } - } - - if (!await InitLoginAndPassword(string.IsNullOrEmpty(refreshToken)).ConfigureAwait(false)) { + if (!await InitLoginAndPassword(string.IsNullOrEmpty(RefreshToken)).ConfigureAwait(false)) { Stop(); return; @@ -2537,7 +2610,7 @@ private async void OnConnected(SteamClient.ConnectedCallback callback) { InitConnectionFailureTimer(); - if (string.IsNullOrEmpty(refreshToken)) { + if (string.IsNullOrEmpty(RefreshToken)) { AuthPollResult pollResult; try { @@ -2569,19 +2642,15 @@ private async void OnConnected(SteamClient.ConnectedCallback callback) { return; } - refreshToken = pollResult.RefreshToken; - - if (BotConfig.UseLoginKeys) { - BotDatabase.RefreshToken = BotConfig.PasswordFormat.HasTransformation() ? ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, refreshToken) : refreshToken; - - if (!string.IsNullOrEmpty(pollResult.NewGuardData)) { - BotDatabase.SteamGuardData = pollResult.NewGuardData; - } + if (!string.IsNullOrEmpty(pollResult.NewGuardData) && BotConfig.UseLoginKeys) { + BotDatabase.SteamGuardData = pollResult.NewGuardData; } + + UpdateTokens(pollResult.AccessToken, pollResult.RefreshToken); } SteamUser.LogOnDetails logOnDetails = new() { - AccessToken = refreshToken, + AccessToken = RefreshToken, CellID = ASF.GlobalDatabase?.CellID, LoginID = LoginID, SentryFileHash = sentryFileHash, @@ -2606,6 +2675,7 @@ private async void OnDisconnected(SteamClient.DisconnectedCallback callback) { HeartBeatFailures = 0; StopConnectionFailureTimer(); StopPlayingWasBlockedTimer(); + StopRefreshTokensTimer(); ArchiLogger.LogGenericInfo(Strings.BotDisconnected); @@ -3087,11 +3157,9 @@ private async void OnLoggedOn(SteamUser.LoggedOnCallback callback) { ArchiWebHandler.OnVanityURLChanged(callback.VanityURL); - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(callback.WebAPIUserNonce) || !await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.WebAPIUserNonce!, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { - if (!await RefreshSession().ConfigureAwait(false)) { - return; - } + // Establish web session + if (!await RefreshWebSession().ConfigureAwait(false)) { + return; } // Pre-fetch API key for future usage if possible @@ -3236,6 +3304,15 @@ private async void OnPlayingSessionState(SteamUser.PlayingSessionStateCallback c await CheckOccupationStatus().ConfigureAwait(false); } + private async void OnRefreshTokensTimer(object? state = null) { + if (AccessTokenValidUntil.HasValue && (AccessTokenValidUntil.Value > DateTime.UtcNow.AddMinutes(15))) { + // We don't need to refresh just yet + InitRefreshTokensTimer(AccessTokenValidUntil.Value); + } + + await RefreshWebSession().ConfigureAwait(false); + } + private async void OnSendItemsTimer(object? state = null) => await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) { @@ -3708,6 +3785,46 @@ private void StopPlayingWasBlockedTimer() { PlayingWasBlockedTimer = null; } + private void StopRefreshTokensTimer() { + if (RefreshTokensTimer == null) { + return; + } + + RefreshTokensTimer.Dispose(); + RefreshTokensTimer = null; + } + + private void UpdateTokens(string accessToken, string refreshToken) { + ArgumentException.ThrowIfNullOrEmpty(accessToken); + ArgumentException.ThrowIfNullOrEmpty(refreshToken); + + AccessToken = accessToken; + AccessTokenValidUntil = null; + RefreshToken = refreshToken; + + JwtSecurityTokenHandler jwtSecurityTokenHandler = new(); + + try { + JwtSecurityToken? accessTokenJwt = jwtSecurityTokenHandler.ReadJwtToken(accessToken); + + AccessTokenValidUntil = accessTokenJwt.ValidTo > DateTime.UnixEpoch ? accessTokenJwt.ValidTo : DateTime.MaxValue; + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + } + + if (BotConfig.UseLoginKeys) { + if (BotConfig.PasswordFormat.HasTransformation()) { + BotDatabase.AccessToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, accessToken); + BotDatabase.RefreshToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, refreshToken); + } else { + BotDatabase.AccessToken = accessToken; + BotDatabase.RefreshToken = refreshToken; + } + + BotDatabase.AccessTokenValidUntil = AccessTokenValidUntil; + } + } + private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null, bool allowGeneration = true) { ArgumentNullException.ThrowIfNull(settings); diff --git a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs index 201a7d4de950c..db4e06027b6a1 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs @@ -39,6 +39,7 @@ public sealed class ArchiHandler : ClientMsgHandler { internal const byte MaxGamesPlayedConcurrently = 32; // This is limit introduced by Steam Network private readonly ArchiLogger ArchiLogger; + private readonly SteamUnifiedMessages.UnifiedService UnifiedAuthenticationService; private readonly SteamUnifiedMessages.UnifiedService UnifiedChatRoomService; private readonly SteamUnifiedMessages.UnifiedService UnifiedClanChatRoomsService; private readonly SteamUnifiedMessages.UnifiedService UnifiedCredentialsService; @@ -53,6 +54,7 @@ internal ArchiHandler(ArchiLogger archiLogger, SteamUnifiedMessages steamUnified ArgumentNullException.ThrowIfNull(steamUnifiedMessages); ArchiLogger = archiLogger ?? throw new ArgumentNullException(nameof(archiLogger)); + UnifiedAuthenticationService = steamUnifiedMessages.CreateService(); UnifiedChatRoomService = steamUnifiedMessages.CreateService(); UnifiedClanChatRoomsService = steamUnifiedMessages.CreateService(); UnifiedCredentialsService = steamUnifiedMessages.CreateService(); @@ -358,6 +360,35 @@ internal void AcknowledgeClanInvite(ulong steamID, bool acceptInvite) { Client.Send(request); } + internal async Task GenerateAccessTokens(string refreshToken) { + ArgumentException.ThrowIfNullOrEmpty(refreshToken); + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected || (Client.SteamID == null)) { + return null; + } + + CAuthentication_AccessToken_GenerateForApp_Request request = new() { + refresh_token = refreshToken, + steamid = Client.SteamID + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedAuthenticationService.SendMessage(x => x.GenerateAccessTokenForApp(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + return response.Result == EResult.OK ? response.GetDeserializedResponse() : null; + } + internal async Task GetClanChatGroupID(ulong steamID) { if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { throw new ArgumentOutOfRangeException(nameof(steamID)); diff --git a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs index 2d50ecb71a324..98fa10f7d60b6 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs @@ -52,7 +52,6 @@ public sealed class ArchiWebHandler : IDisposable { private const ushort MaxItemsInSingleInventoryRequest = 5000; private const byte MinimumSessionValidityInSeconds = 10; private const string SteamAppsService = "ISteamApps"; - private const string SteamUserAuthService = "ISteamUserAuth"; private const string SteamUserService = "ISteamUser"; private const string TwoFactorService = "ITwoFactorService"; @@ -2290,7 +2289,7 @@ internal async Task GetServerTime() { return response?.Content?.Success; } - internal async Task Init(ulong steamID, EUniverse universe, string webAPIUserNonce, string? parentalCode = null) { + internal async Task Init(ulong steamID, EUniverse universe, string accessToken, string? parentalCode = null) { if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { throw new ArgumentOutOfRangeException(nameof(steamID)); } @@ -2299,83 +2298,8 @@ internal async Task Init(ulong steamID, EUniverse universe, string webAPIU throw new InvalidEnumArgumentException(nameof(universe), (int) universe, typeof(EUniverse)); } - if (string.IsNullOrEmpty(webAPIUserNonce)) { - throw new ArgumentNullException(nameof(webAPIUserNonce)); - } - - byte[]? publicKey = KeyDictionary.GetPublicKey(universe); - - if ((publicKey == null) || (publicKey.Length == 0)) { - Bot.ArchiLogger.LogNullError(publicKey); - - return false; - } - - // Generate a random 32-byte session key - byte[] sessionKey = CryptoHelper.GenerateRandomBlock(32); - - // RSA encrypt our session key with the public key for the universe we're on - byte[] encryptedSessionKey; - - using (RSACrypto rsa = new(publicKey)) { - encryptedSessionKey = rsa.Encrypt(sessionKey); - } - - // Generate login key from the user nonce that we've received from Steam network - byte[] loginKey = Encoding.UTF8.GetBytes(webAPIUserNonce); - - // AES encrypt our login key with our session key - byte[] encryptedLoginKey = CryptoHelper.SymmetricEncrypt(loginKey, sessionKey); - - Dictionary arguments = new(3, StringComparer.Ordinal) { - { "encrypted_loginkey", encryptedLoginKey }, - { "sessionkey", encryptedSessionKey }, - { "steamid", steamID } - }; - - // We're now ready to send the data to Steam API - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.LoggingIn, SteamUserAuthService)); - - KeyValue? response; - - // We do not use usual retry pattern here as webAPIUserNonce is valid only for a single request - // Even during timeout, webAPIUserNonce is most likely already invalid - // Instead, the caller is supposed to ask for new webAPIUserNonce and call Init() again on failure - using (WebAPI.AsyncInterface steamUserAuthService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(SteamUserAuthService)) { - steamUserAuthService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await steamUserAuthService.CallAsync(HttpMethod.Post, "AuthenticateUser", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - - return false; - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - return false; - } - } - - string? steamLogin = response["token"].AsString(); - - if (string.IsNullOrEmpty(steamLogin)) { - Bot.ArchiLogger.LogNullError(steamLogin); - - return false; - } - - string? steamLoginSecure = response["tokensecure"].AsString(); - - if (string.IsNullOrEmpty(steamLoginSecure)) { - Bot.ArchiLogger.LogNullError(steamLoginSecure); - - return false; + if (string.IsNullOrEmpty(accessToken)) { + throw new ArgumentNullException(nameof(accessToken)); } string sessionID = Convert.ToBase64String(Encoding.UTF8.GetBytes(steamID.ToString(CultureInfo.InvariantCulture))); @@ -2385,10 +2309,7 @@ internal async Task Init(ulong steamID, EUniverse universe, string webAPIU WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamHelpURL.Host}")); WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamStoreURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamCheckoutURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamCommunityURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamHelpURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamStoreURL.Host}")); + string steamLoginSecure = $"{steamID}||{accessToken}"; WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamCheckoutURL.Host}")); WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamCommunityURL.Host}")); @@ -2782,7 +2703,7 @@ private async Task RefreshSession() { } Bot.ArchiLogger.LogGenericInfo(Strings.RefreshingOurSession); - bool result = await Bot.RefreshSession().ConfigureAwait(false); + bool result = await Bot.RefreshWebSession().ConfigureAwait(false); DateTime now = DateTime.UtcNow; diff --git a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs index 9056f41941f59..98f927c12e93f 100644 --- a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs +++ b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs @@ -68,6 +68,32 @@ internal uint GamesToRedeemInBackgroundCount { [JsonProperty(Required = Required.DisallowNull)] private readonly OrderedDictionary GamesToRedeemInBackground = new(); + internal string? AccessToken { + get => BackingAccessToken; + + set { + if (BackingAccessToken == value) { + return; + } + + BackingAccessToken = value; + Utilities.InBackground(Save); + } + } + + internal DateTime? AccessTokenValidUntil { + get => BackingAccessTokenValidUntil; + + set { + if (BackingAccessTokenValidUntil == value) { + return; + } + + BackingAccessTokenValidUntil = value; + Utilities.InBackground(Save); + } + } + internal MobileAuthenticator? MobileAuthenticator { get => BackingMobileAuthenticator; @@ -107,6 +133,12 @@ internal string? SteamGuardData { } } + [JsonProperty] + private string? BackingAccessToken; + + [JsonProperty(Required = Required.DisallowNull)] + private DateTime? BackingAccessTokenValidUntil; + [JsonProperty($"_{nameof(MobileAuthenticator)}")] private MobileAuthenticator? BackingMobileAuthenticator; @@ -134,6 +166,12 @@ private BotDatabase() { TradingBlacklistSteamIDs.OnModified += OnObjectModified; } + [UsedImplicitly] + public bool ShouldSerializeBackingAccessToken() => !string.IsNullOrEmpty(BackingAccessToken); + + [UsedImplicitly] + public bool ShouldSerializeBackingAccessTokenValidUntil() => BackingAccessTokenValidUntil.HasValue; + [UsedImplicitly] public bool ShouldSerializeBackingMobileAuthenticator() => BackingMobileAuthenticator != null; diff --git a/Directory.Packages.props b/Directory.Packages.props index 313053e3797c8..57786bc23d6a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ +