From 120374406c512117429bf96539f52e8770ab39ec Mon Sep 17 00:00:00 2001 From: Bailey Date: Sun, 1 Oct 2023 16:52:31 -0500 Subject: [PATCH] [524] migrate garmin authentication to oauth (#531) * [524] migrate garmin authentication to oauth * started stubbing things out * stubbing out more of the flow * good progress * rough draft working :) * happy path is working for Console app * mfa not working :( * fix compile error * polish * update release notes --- src/Common/Service/SettingsService.cs | 4 +- .../Stateful/GarminApiAuthentication.cs | 16 +- src/Common/Stateful/OAuth2Token.cs | 12 + src/Garmin/ApiClient.cs | 332 +++--------------- src/Garmin/Auth/ConsumerCredentials.cs | 13 + src/Garmin/Auth/GarminAuthContracts.cs | 6 + .../Auth/GarminAuthenticationService.cs | 185 ++++++---- src/Garmin/Garmin.csproj | 4 + src/UnitTests/AdHocTests.cs | 27 +- vNextReleaseNotes.md | 2 + 10 files changed, 246 insertions(+), 355 deletions(-) create mode 100644 src/Common/Stateful/OAuth2Token.cs create mode 100644 src/Garmin/Auth/ConsumerCredentials.cs diff --git a/src/Common/Service/SettingsService.cs b/src/Common/Service/SettingsService.cs index af3b795a6..886f719c1 100644 --- a/src/Common/Service/SettingsService.cs +++ b/src/Common/Service/SettingsService.cs @@ -113,7 +113,9 @@ public void SetGarminAuthentication(GarminApiAuthentication authentication) lock (_lock) { var key = $"{GarminApiAuthKey}:{authentication.Email}"; - _cache.Set(key, authentication, new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(45) }); + var expiration = authentication.OAuth2Token?.Expires_In - (60 * 60) ?? 0; // expire an hour early + var finalExpiration = expiration <= 0 ? 45 * 60 : expiration; // default to 45min + _cache.Set(key, authentication, new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(finalExpiration) }); } } diff --git a/src/Common/Stateful/GarminApiAuthentication.cs b/src/Common/Stateful/GarminApiAuthentication.cs index a73bb2a09..73896bafb 100644 --- a/src/Common/Stateful/GarminApiAuthentication.cs +++ b/src/Common/Stateful/GarminApiAuthentication.cs @@ -1,5 +1,5 @@ using Flurl.Http; - + namespace Common.Stateful; public class GarminApiAuthentication : IApiAuthentication @@ -8,18 +8,26 @@ public class GarminApiAuthentication : IApiAuthentication public string Password { get; set; } public AuthStage AuthStage { get; set; } public CookieJar CookieJar { get; set; } - public string UserAgent { get; set; } = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:48.0) Gecko/20100101 Firefox/50.0"; + public string UserAgent { get; set; } = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; public string MFACsrfToken { get; set; } + public OAuth1Token OAuth1Token { get; set; } + public OAuth2Token OAuth2Token { get; set; } public bool IsValid(Settings settings) { return Email == settings.Garmin.Email && Password == settings.Garmin.Password - && CookieJar is object - && AuthStage == AuthStage.Completed; + && AuthStage == AuthStage.Completed + && !string.IsNullOrWhiteSpace(OAuth2Token?.Access_Token); } } +public class OAuth1Token +{ + public string Token { get; set; } + public string TokenSecret { get; set; } +} + public enum AuthStage : byte { None = 0, diff --git a/src/Common/Stateful/OAuth2Token.cs b/src/Common/Stateful/OAuth2Token.cs new file mode 100644 index 000000000..3f934968f --- /dev/null +++ b/src/Common/Stateful/OAuth2Token.cs @@ -0,0 +1,12 @@ +namespace Common.Stateful; + +public record OAuth2Token +{ + public string Scope { get; set; } + public string Jti { get; set; } + public string Access_Token { get; set; } + public string Token_Type { get; set; } + public string Refresh_Token { get; set; } + public int Expires_In { get; set; } + public int Refresh_Token_Expires_In { get; set; } +} diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index f65c3af87..18d6f531e 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -4,6 +4,7 @@ using Flurl.Http; using Garmin.Auth; using Garmin.Dto; +using OAuth; using Serilog; using System.IO; using System.Linq; @@ -13,28 +14,37 @@ namespace Garmin { public interface IGarminApiClient { - Task InitSigninFlowAsync(object queryParams, string userAgent, out CookieJar jar); + Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar); + Task GetCsrfTokenAsync(GarminApiAuthentication auth, object queryParams, CookieJar jar); Task SendCredentialsAsync(GarminApiAuthentication auth, object queryParams, object loginData, CookieJar jar); Task SendMfaCodeAsync(string userAgent, object queryParams, object mfaData, CookieJar jar); - Task SendServiceTicketAsync(string userAgent, string serviceTicket, CookieJar jar); + Task GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket); + Task GetOAuth2TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials); + Task GetConsumerCredentialsAsync(); Task UploadActivity(string filePath, string format, GarminApiAuthentication auth); } public class ApiClient : IGarminApiClient { - private const string BASE_URL = "https://connect.garmin.com"; - private const string SSO_URL = "https://sso.garmin.com"; - private const string SIGNIN_URL = "https://sso.garmin.com/sso/signin"; + private const string SSO_SIGNIN_URL = "https://sso.garmin.com/sso/signin"; + private const string SSO_EMBED_URL = "https://sso.garmin.com/sso/embed"; - private static string UPLOAD_URL = $"{BASE_URL}/modern/proxy/upload-service/upload"; + private static string UPLOAD_URL = $"https://connectapi.garmin.com/upload-service/upload"; - private const string ORIGIN = SSO_URL; + private const string ORIGIN = "https://sso.garmin.com"; + private const string REFERER = "https://sso.garmin.com/sso/signin"; private static readonly ILogger _logger = LogContext.ForClass(); - public Task InitSigninFlowAsync(object queryParams, string userAgent, out CookieJar jar) + public Task GetConsumerCredentialsAsync() { - return SIGNIN_URL + return "https://thegarth.s3.amazonaws.com/oauth_consumer.json" + .GetJsonAsync(); + } + + public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar) + { + return SSO_EMBED_URL .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) .SetQueryParams(queryParams) @@ -45,9 +55,11 @@ public Task InitSigninFlowAsync(object queryParams, string userAgent, out Cookie public async Task SendCredentialsAsync(GarminApiAuthentication auth, object queryParams, object loginData, CookieJar jar) { var result = new SendCredentialsResult(); - result.RawResponseBody = await SIGNIN_URL + result.RawResponseBody = await SSO_SIGNIN_URL .WithHeader("User-Agent", auth.UserAgent) .WithHeader("origin", ORIGIN) + .WithHeader("referer", REFERER) + .WithHeader("NK", "NT") .SetQueryParams(queryParams) .WithCookies(jar) .StripSensitiveDataFromLogging(auth.Email, auth.Password) @@ -58,6 +70,21 @@ public async Task SendCredentialsAsync(GarminApiAuthentic return result; } + public async Task GetCsrfTokenAsync(GarminApiAuthentication auth, object queryParams, CookieJar jar) + { + var result = new GarminResult(); + result.RawResponseBody = await SSO_SIGNIN_URL + .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("origin", ORIGIN) + .SetQueryParams(queryParams) + .WithCookies(jar) + .StripSensitiveDataFromLogging(auth.Email, auth.Password) + .GetAsync() + .ReceiveString(); + + return result; + } + public Task SendMfaCodeAsync(string userAgent, object queryParams, object mfaData, CookieJar jar) { return "https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode" @@ -70,20 +97,33 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec .ReceiveString(); } - public Task SendServiceTicketAsync(string userAgent, string serviceTicket, CookieJar jar) + public Task GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket) { - return $"{BASE_URL}/" - .WithHeader("User-Agent", userAgent) - .WithCookies(jar) - .SetQueryParam("ticket", serviceTicket) - .GetAsync(); + OAuthRequest oauthClient = OAuthRequest.ForRequestToken(credentials.Consumer_Key, credentials.Consumer_Secret); + oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; + return oauthClient.RequestUrl + .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) + .GetStringAsync(); + } + public Task GetOAuth2TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials) + { + OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", credentials.Consumer_Key, credentials.Consumer_Secret, auth.OAuth1Token.Token, auth.OAuth1Token.TokenSecret); + oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; + + return oauthClient2.RequestUrl + .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("Authorization", oauthClient2.GetAuthorizationHeader()) + .WithHeader("Content-Type", "application/x-www-form-urlencoded") // this header is required, without it you get a 500 + .PostUrlEncodedAsync(new object()) // hack: PostAsync() will drop the content-type header, by posting empty object we trick flurl into leaving the header + .ReceiveJson(); } public async Task UploadActivity(string filePath, string format, GarminApiAuthentication auth) { var fileName = Path.GetFileName(filePath); var response = await $"{UPLOAD_URL}/{format}" - .WithCookies(auth.CookieJar) + .WithOAuthBearerToken(auth.OAuth2Token.Access_Token) .WithHeader("NK", "NT") .WithHeader("origin", ORIGIN) .WithHeader("User-Agent", auth.UserAgent) @@ -119,263 +159,5 @@ public async Task UploadActivity(string filePath, string format, return response; } - - //private const string URL_HOSTNAME = "https://connect.garmin.com/modern/auth/hostname"; - //private const string URL_LOGIN = "https://sso.garmin.com/sso/login"; - //private const string URL_POST_LOGIN = "https://connect.garmin.com/modern/"; - //private const string URL_HOST_SSO = "sso.garmin.com"; - //private const string URL_HOST_CONNECT = "connect.garmin.com"; - //private const string URL_SSO_SIGNIN = "https://sso.garmin.com/sso/signin"; - //private const string URL_ACTIVITY_BASE = "https://connect.garmin.com/modern/proxy/activity-service/activity"; - //private const string URL_ACTIVITY_TYPES = "https://connect.garmin.com/modern/proxy/activity-service/activity/activityTypes"; - /// - /// This is where the magic happens! - /// Straight from https://github.com/La0/garmin-uploader - /// - //public async Task InitAuth2() - //{ - // dynamic ssoHostResponse = null; - // try - // { - // ssoHostResponse = await URL_HOSTNAME - // .WithHeader("User-Agent", USERAGENT) - // .WithCookies(out _jar) - // .GetJsonAsync(); - // } - // catch (FlurlHttpException e) - // { - // Log.Error(e, "Failed to authenticate with Garmin. Invalid initial SO request."); - // throw; - // } - - // var ssoHostName = ssoHostResponse.host; - - // object queryParams = new - // { - // clientId = "GarminConnect", - // //connectLegalTerms = "true", - // consumeServiceTicket = "false", - // createAccountShown = "true", - // //cssUrl = "https://connect.garmin.com/gauth-custom-v1.2-min.css", - // cssUrl = "https://static.garmincdn.com/com.garmin.connect/ui/css/gauth-custom-v1.2-min.css", - // displayNameShown = "false", - // embedWidget = "false", - // gauthHost = "https://sso.garmin.com/sso", - // //generateExtraServiceTicket = "true", - // generateExtraServiceTicket = "false", - // //generateNoServiceTicket = "false", - // //generateTwoExtraServiceTickets = "true", - // //globalOptInChecked = "false", - // //globalOptInShown = "true", - // id = "gauth-widget", - // initialFocus = "true", - // //locale = "fr_FR", - // locale = "en_US", - // //locationPromptShown = "true", - // //mfaRequired = "false", - // //performMFACheck = "false", - // //mobile = "false", - // openCreateAccount = "false", - // //privacyStatementUrl = "https://www.garmin.com/fr-FR/privacy/connect/", - // //redirectAfterAccountCreationUrl = "https://connect.garmin.com/modern/", - // //redirectAfterAccountLoginUrl = "https://connect.garmin.com/modern/", - // redirectAfterAccountCreationUrl = "https://connect.garmin.com/", - // redirectAfterAccountLoginUrl = "https://connect.garmin.com/", - // rememberMeChecked = "false", - // rememberMeShown = "true", - // //rememberMyBrowserChecked = "false", - // //rememberMyBrowserShown = "false", - // //service = "https://connect.garmin.com/modern/", - // service = "https://connect.garmin.com", - // //showConnectLegalAge = "false", - // //showPassword = "true", - // //showPrivacyPolicy = "false", - // //showTermsOfUse = "false", - // //source = "https://connect.garmin.com/signin/", - // source = "https://connect.garmin.com", - // //useCustomHeader = "false", - // usernameShow = "false", - // //webhost = ssoHostName.ToString() - // //webhost = "https://connect.garmin.com/modern/" - // webhost = "https://connect.garmin.com" - // }; - - // string loginForm = null; - // try - // { - // loginForm = await URL_LOGIN - // .WithHeader("User-Agent", USERAGENT) - // .SetQueryParams(queryParams) - // .WithCookies(_jar) - // .GetStringAsync(); - - // } - // catch (FlurlHttpException e) - // { - // Log.Error(e, "No login form."); - // throw; - // } - - // // Lookup CSRF token - // var regex = new Regex(""); - // var csrfTokenMatch = regex.Match(loginForm); - - // if (!csrfTokenMatch.Success) - // { - // Log.Error("No CSRF token."); - // throw new Exception("Failed to find CSRF token from Garmin."); - // } - - // var csrfToken = csrfTokenMatch.Groups[1].Value; - - // object loginData = new - // { - // embed = "false", - // username = _config.Garmin.Email, - // password = _config.Garmin.Password, - // _csrf = csrfToken - // }; - - // string authResponse = null; - - // try - // { - // authResponse = await URL_LOGIN - // .WithHeader("Host", URL_HOST_SSO) - // .WithHeader("Referer", URL_SSO_SIGNIN) - // .WithHeader("User-Agent", USERAGENT) - // .SetQueryParams(queryParams) - // .WithCookies(_jar) - // .PostUrlEncodedAsync(loginData) - // .ReceiveString(); - // } - // catch (FlurlHttpException e) - // { - // Log.Error(e, "Authentication Failed."); - // throw; - // } - - // // Check we have SSO guid in the cookies - // if (!_jar.Any(c => c.Name == "GARMIN-SSO-GUID")) - // { - // Log.Error("Missing Garmin auth cookie."); - // throw new Exception("Failed to find Garmin auth cookie."); - // } - - // // Try to find the full post login url in response - // var regex2 = new Regex("var response_url(\\s+) = (\\\"|\\').*?ticket=(?[\\w\\-]+)(\\\"|\\')"); - // var match = regex2.Match(authResponse); - // if (!match.Success) - // { - // Log.Error("Missing service ticket."); - // throw new Exception("Failed to find service ticket."); - // } - - // var ticket = match.Groups.GetValueOrDefault("ticket").Value; - // if (string.IsNullOrEmpty(ticket)) - // { - // Log.Error("Failed to parse service ticket."); - // throw new Exception("Failed to parse service ticket."); - // } - - // queryParams = new - // { - // ticket = ticket - // }; - - // // Second Auth Step - // // Needs a service ticket from the previous step - // try - // { - // var authResponse2 = URL_POST_LOGIN - // .WithHeader("User-Agent", USERAGENT) - // .WithHeader("Host", URL_HOST_CONNECT) - // .SetQueryParams(queryParams) - // .WithCookies(_jar) - // .GetStringAsync(); - // } - // catch (FlurlHttpException e) - // { - // Log.Error(e, "Second auth step failed."); - // throw; - // } - - // // Check login - // try - // { - // var response = PROFILE_URL - // .WithHeader("User-Agent", USERAGENT) - // .WithCookies(_jar) - // .GetJsonAsync(); - // } - // catch (FlurlHttpException e) - // { - // Log.Error(e, "Login check failed."); - // throw; - // } - //} - - // TODO: I bet we can do multiple files at once - // https://github.com/tmenier/Flurl/issues/608 - - /// - /// Not quite working. Only uploads the first activity added. - /// - //public async Task UploadActivities(ICollection filePaths, string format) - //{ - // var auth = await InitAuth(); - - // var response = await $"{UPLOAD_URL}/{format}" - // .WithCookies(auth.CookieJar) - // .WithHeader("NK", "NT") - // .WithHeader("origin", ORIGIN) - // .WithHeader("User-Agent", auth.UserAgent) - // .AllowHttpStatus("2xx,409") - // .PostMultipartAsync((data) => - // { - // foreach (var path in filePaths) - // { - // var fileName = Path.GetFileName(path); - // data.AddFile("\"file\"", path: path, contentType: "application/octet-stream", fileName: $"\"{fileName}\""); - // } - // }) - // .ReceiveJson(); - - // var result = response.DetailedImportResult; - - // if (result.Failures.Any()) - // { - // foreach (var failure in result.Failures) - // { - // if (failure.Messages.Any()) - // { - // foreach (var message in failure.Messages) - // { - // if (message.Code == 202) - // { - // _logger.Information("Activity already uploaded {garminWorkout}", result.FileName); - // } - // else - // { - // _logger.Error("Failed to upload activity to Garmin. Message: {errorMessage}", message); - // } - // } - // } - // } - // } - - // return string.Empty; - //} - - //public async Task GetDeviceList() - //{ - // var auth = await InitAuth(); - - // var response = await $"https://connect.garmin.com/proxy/device-service/deviceregistration/devices" - // .WithCookies(auth.CookieJar) - // .WithHeader("User-Agent", auth.UserAgent) - // .WithHeader("origin", "https://sso.garmin.com") - // .GetJsonAsync(); - //} } } diff --git a/src/Garmin/Auth/ConsumerCredentials.cs b/src/Garmin/Auth/ConsumerCredentials.cs new file mode 100644 index 000000000..4ef1edd1a --- /dev/null +++ b/src/Garmin/Auth/ConsumerCredentials.cs @@ -0,0 +1,13 @@ +namespace Garmin.Auth; + +public record ConsumerCredentials +{ + public string Consumer_Key { get; set; } + public string Consumer_Secret { get; set;} +} + +// 10/01/23 +//{ +// "consumer_key": "fc3e99d2-118c-44b8-8ae3-03370dde24c0", +// "consumer_secret": "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF" +//} diff --git a/src/Garmin/Auth/GarminAuthContracts.cs b/src/Garmin/Auth/GarminAuthContracts.cs index a0c5b2bc8..d0f312390 100644 --- a/src/Garmin/Auth/GarminAuthContracts.cs +++ b/src/Garmin/Auth/GarminAuthContracts.cs @@ -5,6 +5,12 @@ public interface GarminResultWrapper string RawResponseBody { get; set; } } +public class GarminResult : GarminResultWrapper +{ + public string RawResponseBody { get; set; } +} + + public class SendCredentialsResult : GarminResultWrapper { public bool WasRedirected { get; set; } diff --git a/src/Garmin/Auth/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index cf9f0aada..7716b92b5 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -5,10 +5,10 @@ using Serilog; using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Web; namespace Garmin.Auth; @@ -17,36 +17,16 @@ public interface IGarminAuthenticationService Task GetGarminAuthenticationAsync(); Task RefreshGarminAuthenticationAsync(); Task CompleteMFAAuthAsync(string mfaCode); - } public class GarminAuthenticationService : IGarminAuthenticationService { private static readonly ILogger _logger = LogContext.ForClass(); - private static readonly object QueryParams = new + private static readonly object CommonQueryParams = new { - clientId = "GarminConnect", - consumeServiceTicket = "false", - createAccountShown = "true", - cssUrl = "https://static.garmincdn.com/com.garmin.connect/ui/css/gauth-custom-v1.2-min.css", - displayNameShown = "false", - embedWidget = "false", - gauthHost = "https://sso.garmin.com/sso", - generateExtraServiceTicket = "true", - generateTwoExtraServiceTickets = "true", - generateNoServiceTicket = "false", id = "gauth-widget", - initialFocus = "true", - locale = "en_US", - openCreateAccount = "false", - redirectAfterAccountCreationUrl = "https://connect.garmin.com/", - redirectAfterAccountLoginUrl = "https://connect.garmin.com/", - rememberMeChecked = "false", - rememberMeShown = "true", - service = "https://connect.garmin.com", - source = "https://connect.garmin.com", - usernameShow = "false", - webhost = "https://connect.garmin.com" + embedWidget = "true", + gauthHost = "https://sso.garmin.com/sso" }; private readonly ISettingsService _settingsService; @@ -72,6 +52,11 @@ public async Task GetGarminAuthenticationAsync() public async Task RefreshGarminAuthenticationAsync() { + ///////////////////////////////////////////////////////////////////////////// + // TODO: Implement refresh using OAuth tokens instead of re-using credentials + // Eventually remove need to store credentials locally + /////////////////////////////////////////////////////////////////////////////// + var settings = await _settingsService.GetSettingsAsync(); settings.Garmin.EnsureGarminCredentialsAreProvided(); @@ -92,30 +77,52 @@ public async Task RefreshGarminAuthenticationAsync() //////////////////////////////// try { - await _apiClient.InitSigninFlowAsync(QueryParams, auth.UserAgent, out jar); + await _apiClient.InitCookieJarAsync(CommonQueryParams, auth.UserAgent, out jar); } catch (FlurlHttpException e) { throw new GarminAuthenticationError("Failed to initialize sign in flow.", e) { Code = Code.FailedPriorToCredentialsUsed }; } - object loginData = new + ///////////////////////////////// + // Get CSRF token + //////////////////////////////// + object csrfRequest = new { - embed = "true", - username = auth.Email, - password = auth.Password, - lt = "e1s1", - _eventId = "submit", - displayNameRequired = "false", + id = "gauth-widget", + embedWidget = "true", + gauthHost = "https://sso.garmin.com/sso/embed", + service = "https://sso.garmin.com/sso/embed", + source = "https://sso.garmin.com/sso/embed", + redirectAfterAccountLoginUrl = "https://sso.garmin.com/sso/embed", + redirectAfterAccountCreationUrl = "https://sso.garmin.com/sso/embed", }; + string csrfToken = string.Empty; + try + { + var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); + csrfToken = FindCsrfToken(tokenResult.RawResponseBody, failureStepCode: Code.FailedPriorToCredentialsUsed); + } + catch (FlurlHttpException e) + { + throw new GarminAuthenticationError("Failed to fetch csrf token from Garmin.", e) { Code = Code.FailedPriorToCredentialsUsed }; + } + ///////////////////////////////// // Send Credentials //////////////////////////////// + var sendCredentialsRequest = new + { + username = auth.Email, + password = auth.Password, + embed = "true", + _csrf = csrfToken + }; SendCredentialsResult sendCredentialsResult = null; try { - sendCredentialsResult = await _apiClient.SendCredentialsAsync(auth, QueryParams, loginData, jar); + sendCredentialsResult = await _apiClient.SendCredentialsAsync(auth, csrfRequest, sendCredentialsRequest, jar); } catch (FlurlHttpException e) when (e.StatusCode is (int)HttpStatusCode.Forbidden) { @@ -135,53 +142,52 @@ public async Task RefreshGarminAuthenticationAsync() if (!settings.Garmin.TwoStepVerificationEnabled) throw new GarminAuthenticationError("Detected Garmin TwoFactorAuthentication but TwoFactorAuthenctication is not enabled in P2G settings. Please enable TwoFactorAuthentication in your P2G Garmin settings.") { Code = Code.UnexpectedMfa }; - SetMFACsrfToken(auth, sendCredentialsResult.RawResponseBody); + var mfaCsrfToken = FindCsrfToken(sendCredentialsResult.RawResponseBody, failureStepCode: Code.FailedPriorToMfaUsed); + auth.AuthStage = AuthStage.NeedMfaToken; + auth.MFACsrfToken = mfaCsrfToken; auth.CookieJar = jar; _settingsService.SetGarminAuthentication(auth); return auth; } var loginResult = sendCredentialsResult?.RawResponseBody; - auth.CookieJar = jar; return await CompleteGarminAuthenticationAsync(loginResult, auth); } private async Task CompleteGarminAuthenticationAsync(string loginResult, GarminApiAuthentication auth) { - ////////////////////////////////////////////////////////// - // Ensure CookieJar looks good and we have Service Ticket - ////////////////////////////////////////////////////////// - // Check we have SSO guid in the cookies - if (!auth.CookieJar.Any(c => c.Name == "GARMIN-SSO-GUID")) - throw new GarminAuthenticationError("Auth appeared successful but failed to find Garmin auth cookie.") { Code = Code.AuthAppearedSuccessful }; - // Try to find the full post login ServiceTicket - var regex2 = new Regex("var response_url(\\s+) = (\\\"|\\').*?ticket=(?[\\w\\-]+)(\\\"|\\')"); - var match = regex2.Match(loginResult); - if (!match.Success) - throw new GarminAuthenticationError("Auth appeared successful but failed to find the service ticket.") { Code = Code.AuthAppearedSuccessful }; + var ticketRegex = new Regex("embed\\?ticket=(?[^\"]+)\""); + var ticketMatch = ticketRegex.Match(loginResult); + if (!ticketMatch.Success) + throw new GarminAuthenticationError("Auth appeared successful but failed to find regex match for service ticket.") { Code = Code.AuthAppearedSuccessful }; - var ticket = match.Groups.GetValueOrDefault("ticket")?.Value; + var ticket = ticketMatch.Groups.GetValueOrDefault("ticket").Value; _logger.Verbose($"Service Ticket: {ticket}"); + if (string.IsNullOrWhiteSpace(ticket)) throw new GarminAuthenticationError("Auth appeared successful, and found service ticket, but ticket was null or empty.") { Code = Code.AuthAppearedSuccessful }; //////////////////////////////////////////// - // Send the ServiceTicket - Completes Auth + // Get OAuth1 Tokens + /////////////////////////////////////////// + var consumerCredentials = await _apiClient.GetConsumerCredentialsAsync(); + await GetOAuth1Async(ticket, auth, consumerCredentials); + + //////////////////////////////////////////// + // Exchange for OAuth2 Token /////////////////////////////////////////// try { - var serviceTicketResponse = await _apiClient.SendServiceTicketAsync(auth.UserAgent, ticket, auth.CookieJar); - - if (serviceTicketResponse.StatusCode == (int)HttpStatusCode.Moved) - throw new GarminAuthenticationError("Auth appeared successful but Garmin did not accept service ticket.") { Code = Code.AuthAppearedSuccessful }; + auth.OAuth2Token = await _apiClient.GetOAuth2TokenAsync(auth, consumerCredentials); } - catch (FlurlHttpException e) + catch (Exception e) { - throw new GarminAuthenticationError("Auth appeared successful but there was an error sending the service ticket.", e) { Code = Code.AuthAppearedSuccessful }; + throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token.", e) { Code = Code.AuthAppearedSuccessful }; } auth.AuthStage = AuthStage.Completed; + auth.MFACsrfToken = string.Empty; _settingsService.SetGarminAuthentication(auth); return auth; } @@ -199,7 +205,7 @@ public async Task CompleteMFAAuthAsync(string mfaCode) var mfaData = new List>() { - new KeyValuePair("embed", "false"), + new KeyValuePair("embed", "true"), new KeyValuePair("mfa-code", mfaCode), new KeyValuePair("fromPage", "setupEnterMfaCode"), new KeyValuePair("_csrf", auth.MFACsrfToken) @@ -211,7 +217,7 @@ public async Task CompleteMFAAuthAsync(string mfaCode) try { SendMFAResult mfaResponse = new(); - mfaResponse.RawResponseBody = await _apiClient.SendMfaCodeAsync(auth.UserAgent, QueryParams, mfaData, auth.CookieJar); + mfaResponse.RawResponseBody = await _apiClient.SendMfaCodeAsync(auth.UserAgent, CommonQueryParams, mfaData, auth.CookieJar); return await CompleteGarminAuthenticationAsync(mfaResponse.RawResponseBody, auth); } catch (FlurlHttpException e) when (e.StatusCode is (int)HttpStatusCode.Forbidden) @@ -225,22 +231,57 @@ public async Task CompleteMFAAuthAsync(string mfaCode) } } - private void SetMFACsrfToken(GarminApiAuthentication auth, string sendCredentialsResponseBody) + private string FindCsrfToken(string rawResponseBody, Code failureStepCode) { - ///////////////////////////////// - // Try to find the csrf Token - //////////////////////////////// - var regex3 = new Regex("name=\"_csrf\"\\s+value=\"(?[A-Z0-9]+)"); - var match3 = regex3.Match(sendCredentialsResponseBody); - if (!match3.Success) - throw new GarminAuthenticationError("MFA: Failed to find csrf token.") { Code = Code.FailedPriorToMfaUsed }; - - var csrfToken = match3.Groups.GetValueOrDefault("csrf")?.Value; - _logger.Verbose($"_csrf Token: {csrfToken}"); - if (string.IsNullOrEmpty(csrfToken)) - throw new GarminAuthenticationError("MFA: Found csrf token but it was null or empty.") { Code = Code.FailedPriorToMfaUsed }; - - auth.AuthStage = AuthStage.NeedMfaToken; - auth.MFACsrfToken = csrfToken; + try + { + var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); + var match = tokenRegex.Match(rawResponseBody); + if (!match.Success) + throw new GarminAuthenticationError($"Failed to find regex match for csrf token. tokenResult: {rawResponseBody}") { Code = failureStepCode }; + + var csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; + _logger.Verbose($"Csrf Token: {csrfToken}"); + + if (string.IsNullOrWhiteSpace(csrfToken)) + throw new GarminAuthenticationError("Found csrfToken but its null.") { Code = failureStepCode }; + + return csrfToken; + } catch (Exception e) + { + throw new GarminAuthenticationError("Failed to parse csrf token.", e) { Code = failureStepCode }; + } + } + + private async Task GetOAuth1Async(string ticket, GarminApiAuthentication auth, ConsumerCredentials credentials) + { + string oauth1Response = null; + try + { + oauth1Response = await _apiClient.GetOAuth1TokenAsync(auth, credentials, ticket); + } catch (Exception e) + { + throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth1 token.", e) { Code = Code.AuthAppearedSuccessful }; + } + + if (string.IsNullOrWhiteSpace(oauth1Response)) + throw new GarminAuthenticationError("Auth appeared successful but returned OAuth1 Token response is null.") { Code = Code.AuthAppearedSuccessful }; + + var queryParams = HttpUtility.ParseQueryString(oauth1Response); + + var oAuthToken = queryParams.Get("oauth_token"); + var oAuthTokenSecret = queryParams.Get("oauth_token_secret"); + + if (string.IsNullOrWhiteSpace(oAuthToken)) + throw new GarminAuthenticationError($"Auth appeared successful but returned OAuth1 token is null. oauth1Response: {oauth1Response}") { Code = Code.AuthAppearedSuccessful }; + + if (string.IsNullOrWhiteSpace(oAuthTokenSecret)) + throw new GarminAuthenticationError($"Auth appeared successful but returned OAuth1 token secret is null. oauth1Response: {oauth1Response}") { Code = Code.AuthAppearedSuccessful }; + + auth.OAuth1Token = new OAuth1Token() + { + Token = oAuthToken, + TokenSecret = oAuthTokenSecret + }; } } diff --git a/src/Garmin/Garmin.csproj b/src/Garmin/Garmin.csproj index feaf35d9d..77f3e8965 100644 --- a/src/Garmin/Garmin.csproj +++ b/src/Garmin/Garmin.csproj @@ -24,6 +24,10 @@ + + + + diff --git a/src/UnitTests/AdHocTests.cs b/src/UnitTests/AdHocTests.cs index 8137ac1ef..d96813f21 100644 --- a/src/UnitTests/AdHocTests.cs +++ b/src/UnitTests/AdHocTests.cs @@ -8,6 +8,9 @@ using Dynastream.Fit; using Flurl; using Flurl.Http; +using Flurl.Http.Configuration; +using Garmin; +using Garmin.Auth; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.VisualStudio.TestPlatform.ObjectModel.DataCollection; using Moq; @@ -19,6 +22,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net.Http; using System.Security.Cryptography; using System.Text.Json; using System.Threading.Tasks; @@ -34,10 +38,16 @@ public class AdHocTests public void Setup() { Log.Logger = new LoggerConfiguration() - .WriteTo.Console() + .WriteTo.Console() .MinimumLevel.Verbose() - //.MinimumLevel.Information() - .CreateLogger(); + //.MinimumLevel.Information() + .CreateLogger(); + + // Allows using fiddler + FlurlHttp.Configure(cli => + { + cli.HttpClientFactory = new UntrustedCertClientFactory(); + }); } //[Test] @@ -209,6 +219,17 @@ public async Task>> Convert(string path, Setting { base.Save(data, path); } + } + + private class UntrustedCertClientFactory : DefaultHttpClientFactory + { + public override HttpMessageHandler CreateMessageHandler() + { + return new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + } } } } diff --git a/vNextReleaseNotes.md b/vNextReleaseNotes.md index 779ee2f1d..44b91c5f9 100644 --- a/vNextReleaseNotes.md +++ b/vNextReleaseNotes.md @@ -9,9 +9,11 @@ - [#495] Open Lateral Raise, Pike Push Up, Dolphin - [#499] Forearm Side Plank Rotation, Straight Leg Bicycle - [#510] Bear Crawl +- [#532] GitHubAction now supports attaching output files to the GitHub Action as a zip file you can download - @anlesk ## Fixes +- [#526] `Auth appeared successful but there was an error sending the service ticket to Garmin` - `All converters were skipped.` - confusing log message when no workouts needed to be synced ## Housekeeping