diff --git a/src/Aydsko.iRacingData.UnitTests/CapturedResponseValidationTests.cs b/src/Aydsko.iRacingData.UnitTests/CapturedResponseValidationTests.cs index acea5ec..0896121 100644 --- a/src/Aydsko.iRacingData.UnitTests/CapturedResponseValidationTests.cs +++ b/src/Aydsko.iRacingData.UnitTests/CapturedResponseValidationTests.cs @@ -958,6 +958,27 @@ public async Task GetSubSessionResultUnauthorizedThrowsErrorsAsync() }); } + [Test(TestOf = typeof(DataClient))] + public async Task GetSubSessionResultUnauthorizedDueToLegacyAuthenticationSettingThrowsErrorsAsync() + { + await MessageHandler.QueueResponsesAsync("ResponseUnauthorizedLegacyRequired", false).ConfigureAwait(false); + + Assert.Multiple(() => + { + var loginFailedException = Assert.ThrowsAsync(async () => + { + var lapChartResponse = await sut.GetSubSessionResultAsync(12345, false).ConfigureAwait(false); + }); + + if (loginFailedException != null) + { + Assert.That(loginFailedException.LegacyAuthenticationRequired, Is.True); + } + + Assert.That(sut.IsLoggedIn, Is.False); + }); + } + [Test(TestOf = typeof(DataClient))] public async Task GetSubsessionEventLogSuccessfulAsync() { diff --git a/src/Aydsko.iRacingData.UnitTests/MockedHttpMessageHandler.cs b/src/Aydsko.iRacingData.UnitTests/MockedHttpMessageHandler.cs index 95d9d7a..db63768 100644 --- a/src/Aydsko.iRacingData.UnitTests/MockedHttpMessageHandler.cs +++ b/src/Aydsko.iRacingData.UnitTests/MockedHttpMessageHandler.cs @@ -2,8 +2,10 @@ // This file is licensed to you under the MIT license. using System.Net; +#if !NET6_0_OR_GREATER using System.Net.Http; using System.Net.Http.Json; +#endif using System.Reflection; using System.Text; using System.Text.Json; @@ -67,7 +69,7 @@ public async Task QueueResponsesAsync(string testName, bool prefixLoginResponse { var manifestResourceNames = (prefixLoginResponse ? SuccessfulLoginResponse : []) .Concat(ResourceAssembly.GetManifestResourceNames() - .Where(mrn => mrn.StartsWith($"Aydsko.iRacingData.UnitTests.Responses.{testName}", StringComparison.InvariantCultureIgnoreCase))); + .Where(mrn => mrn.StartsWith($"Aydsko.iRacingData.UnitTests.Responses.{testName}.", StringComparison.InvariantCultureIgnoreCase))); foreach (var manifestName in manifestResourceNames) { diff --git a/src/Aydsko.iRacingData.UnitTests/Responses/ResponseUnauthorizedLegacyRequired/0.json b/src/Aydsko.iRacingData.UnitTests/Responses/ResponseUnauthorizedLegacyRequired/0.json new file mode 100644 index 0000000..c777ceb --- /dev/null +++ b/src/Aydsko.iRacingData.UnitTests/Responses/ResponseUnauthorizedLegacyRequired/0.json @@ -0,0 +1,8 @@ +{ + "statuscode": 401, + "headers": { }, + "content": { + "error": "access_denied", + "error_description": "legacy authorization refused" + } +} \ No newline at end of file diff --git a/src/Aydsko.iRacingData/Common/ErrorResponse.cs b/src/Aydsko.iRacingData/Common/ErrorResponse.cs index cdb566a..7ca053b 100644 --- a/src/Aydsko.iRacingData/Common/ErrorResponse.cs +++ b/src/Aydsko.iRacingData/Common/ErrorResponse.cs @@ -13,4 +13,7 @@ public class ErrorResponse [JsonPropertyName("message")] public string? Message { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } } diff --git a/src/Aydsko.iRacingData/CompatibilitySuppressions.xml b/src/Aydsko.iRacingData/CompatibilitySuppressions.xml index f053d72..669a5c0 100644 --- a/src/Aydsko.iRacingData/CompatibilitySuppressions.xml +++ b/src/Aydsko.iRacingData/CompatibilitySuppressions.xml @@ -35,6 +35,13 @@ lib/net6.0/Aydsko.iRacingData.dll true + + CP0002 + M:Aydsko.iRacingData.Exceptions.iRacingLoginFailedException.Create(System.String,System.Nullable{System.Boolean}) + lib/net6.0/Aydsko.iRacingData.dll + lib/net6.0/Aydsko.iRacingData.dll + true + CP0002 M:Aydsko.iRacingData.Hosted.Car.get_PowerAdjustPercent @@ -84,6 +91,13 @@ lib/net8.0/Aydsko.iRacingData.dll true + + CP0002 + M:Aydsko.iRacingData.Exceptions.iRacingLoginFailedException.Create(System.String,System.Nullable{System.Boolean}) + lib/net8.0/Aydsko.iRacingData.dll + lib/net8.0/Aydsko.iRacingData.dll + true + CP0002 M:Aydsko.iRacingData.Hosted.Car.get_PowerAdjustPercent @@ -199,6 +213,13 @@ lib/netstandard2.0/Aydsko.iRacingData.dll true + + CP0002 + M:Aydsko.iRacingData.Exceptions.iRacingLoginFailedException.Create(System.String,System.Nullable{System.Boolean}) + lib/netstandard2.0/Aydsko.iRacingData.dll + lib/netstandard2.0/Aydsko.iRacingData.dll + true + CP0002 M:Aydsko.iRacingData.Hosted.Car.get_PowerAdjustPercent diff --git a/src/Aydsko.iRacingData/DataClient.cs b/src/Aydsko.iRacingData/DataClient.cs index acf427c..6aef82e 100644 --- a/src/Aydsko.iRacingData/DataClient.cs +++ b/src/Aydsko.iRacingData/DataClient.cs @@ -1982,6 +1982,23 @@ private async Task LoginInternalAsync(CancellationToken cancellationToken) { throw new iRacingInMaintenancePeriodException("Maintenance assumed because login returned HTTP Error 503 \"Service Unavailable\"."); } + else if (loginResponse.StatusCode == HttpStatusCode.Unauthorized) + { +#if NET6_0_OR_GREATER + var content = await loginResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var content = await loginResponse.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + var errorResponse = JsonSerializer.Deserialize(content); + + if (errorResponse is not null && errorResponse.ErrorCode == "access_denied") + { + var errorDescription = errorResponse.ErrorDescription ?? errorResponse.Note ?? errorResponse.Message ?? string.Empty; + throw iRacingLoginFailedException.Create($"Access was denied with message \"{errorDescription}\"", + false, + errorDescription.Equals("legacy authorization refused", StringComparison.OrdinalIgnoreCase)); + } + } throw new iRacingLoginFailedException($"Login failed with HTTP response \"{loginResponse.StatusCode} {loginResponse.ReasonPhrase}\""); } @@ -2125,13 +2142,13 @@ protected virtual void HandleUnsuccessfulResponse(HttpResponseMessage httpRespon else { var errorResponse = JsonSerializer.Deserialize(content); - errorDescription = errorResponse?.Note ?? errorResponse?.Message ?? "An error occurred."; + errorDescription = errorResponse?.Note ?? errorResponse?.Message ?? errorResponse?.ErrorDescription ?? "An error occurred."; exception = errorResponse switch { { ErrorCode: "Site Maintenance" } => new iRacingInMaintenancePeriodException(errorResponse.Note ?? "iRacing services are down for maintenance."), { ErrorCode: "Forbidden" } => iRacingForbiddenResponseException.Create(), - { ErrorCode: "Unauthorized" } => iRacingUnauthorizedResponseException.Create(errorResponse.Message), + { ErrorCode: "Unauthorized" } or { ErrorCode: "access_denied" } => iRacingUnauthorizedResponseException.Create(errorResponse.Message), _ => null }; } diff --git a/src/Aydsko.iRacingData/Exceptions/iRacingLoginFailedException.cs b/src/Aydsko.iRacingData/Exceptions/iRacingLoginFailedException.cs index e336c55..9cd37e2 100644 --- a/src/Aydsko.iRacingData/Exceptions/iRacingLoginFailedException.cs +++ b/src/Aydsko.iRacingData/Exceptions/iRacingLoginFailedException.cs @@ -8,15 +8,18 @@ namespace Aydsko.iRacingData.Exceptions; [Serializable] public class iRacingLoginFailedException : iRacingDataClientException { + /// Indicates the account requires the user to authenticate via a browser to complete a CAPTCHA or contact iRacing Support. public bool? VerificationRequired { get; private set; } + /// If set to the user account must be configured for "Legacy Authentication" to bypass multi-factor authentication. + public bool? LegacyAuthenticationRequired { get; private set; } - public static iRacingLoginFailedException Create(string? message, bool? verificationRequired = null) + public static iRacingLoginFailedException Create(string? message, bool? verificationRequired = null, bool? legacyAuthenticationRequired = null) { var exceptionMessage = message ?? "Login to iRacing failed."; - return verificationRequired is null + return verificationRequired is null && legacyAuthenticationRequired is null ? new iRacingLoginFailedException(exceptionMessage) - : new iRacingLoginFailedException(exceptionMessage, verificationRequired.Value); + : new iRacingLoginFailedException(exceptionMessage, verificationRequired ?? false, legacyAuthenticationRequired ?? false); } public static iRacingLoginFailedException Create(Exception ex) @@ -36,6 +39,13 @@ public iRacingLoginFailedException(string message, bool verificationRequired) VerificationRequired = verificationRequired; } + public iRacingLoginFailedException(string message, bool verificationRequired, bool legacyAuthenticationRequired) + : base(message) + { + VerificationRequired = verificationRequired; + LegacyAuthenticationRequired = legacyAuthenticationRequired; + } + public iRacingLoginFailedException(string message, Exception inner) : base(message, inner) { } diff --git a/src/Aydsko.iRacingData/Package Release Notes.txt b/src/Aydsko.iRacingData/Package Release Notes.txt index f90692b..1d8046f 100644 --- a/src/Aydsko.iRacingData/Package Release Notes.txt +++ b/src/Aydsko.iRacingData/Package Release Notes.txt @@ -3,3 +3,14 @@ Fixes / Changes: - Season Driver Standings Deserialization Failure (Issue #224) Fixed an issue where the "SeasonDriverStandings" deserialization would fail due to the "avg_start_position" property containing non-integer data. + +- Support "Legacy Authentication" Setting Failure Response (Issue #223) + Added support for the "Legacy Authentication" setting in the iRacing + account settings. This setting will be required to login to the + "/data API" after iRacing enables multi-factor authentication (aka "2FA"). + + If the setting is not enabled, an "iRacingLoginFailedException" will be + thrown when attempting to login with the new "LegacyAuthenticationRequired" + property set to "true" and the following message: + + Access was denied with message \"legacy authorization refused"