From 67d20b93a44b995588701cf8c8cae6c759c51cf9 Mon Sep 17 00:00:00 2001 From: Bailey Date: Thu, 28 Sep 2023 06:15:28 -0500 Subject: [PATCH 01/10] [524] migrate garmin authentication to oauth --- src/Garmin/Auth/GarminOAuthService.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/Garmin/Auth/GarminOAuthService.cs diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs new file mode 100644 index 000000000..133b82da6 --- /dev/null +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Garmin.Auth; + +public interface IGarminOAuthService +{ + +} + +public class GarminOAuthService +{ +} From 9db212da271d218ca207e5079f6217fa0d0d88fe Mon Sep 17 00:00:00 2001 From: Bailey Belvis Date: Fri, 29 Sep 2023 17:51:29 -0500 Subject: [PATCH 02/10] started stubbing things out --- src/Garmin/ApiClient.cs | 12 ++++++ src/Garmin/Auth/GarminOAuthService.cs | 61 +++++++++++++++++++++++++-- src/Garmin/Garmin.csproj | 4 ++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index f65c3af87..7a4d8e5ff 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -14,6 +14,7 @@ namespace Garmin public interface IGarminApiClient { Task InitSigninFlowAsync(object queryParams, string userAgent, out CookieJar jar); + Task InitCookieJarAsync(object queryParams, string userAgent, out 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); @@ -25,6 +26,7 @@ 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_EMBED_URL = "https://sso.garmin.com/sso/embed"; private static string UPLOAD_URL = $"{BASE_URL}/modern/proxy/upload-service/upload"; @@ -42,6 +44,16 @@ public Task InitSigninFlowAsync(object queryParams, string userAgent, out Cookie .GetStringAsync(); } + public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar) + { + return SSO_EMBED_URL + .WithHeader("User-Agent", userAgent) + .WithHeader("origin", ORIGIN) + .SetQueryParams(queryParams) + .WithCookies(out jar) + .GetStringAsync(); + } + public async Task SendCredentialsAsync(GarminApiAuthentication auth, object queryParams, object loginData, CookieJar jar) { var result = new SendCredentialsResult(); diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs index 133b82da6..bcd76579e 100644 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -1,6 +1,12 @@ -using System; +using Common.Service; +using Common.Stateful; +using Flurl; +using Flurl.Http; +using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Runtime.Intrinsics.X86; using System.Text; using System.Threading.Tasks; @@ -11,6 +17,55 @@ public interface IGarminOAuthService } -public class GarminOAuthService -{ +public class GarminOAuthService : IGarminOAuthService +{ + private readonly ISettingsService _settingsService; + private readonly IGarminApiClient _apiClient; + + public GarminOAuthService(ISettingsService settingsService, IGarminApiClient apiClient) + { + _settingsService = settingsService; + _apiClient = apiClient; + } + + private async Task GetAuthTokenAsync() + { + var auth = new GarminApiAuthentication(); + auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + + + ///////////////////////////////// + // Init Cookie Jar + //////////////////////////////// + var queryParams = new + { + id = "gauth-widget", + embedWidget = "true", + gauthHost = "https://sso.garmin.com/sso" + }; + + CookieJar jar = null; + try + { + await _apiClient.InitCookieJarAsync(queryParams, auth.UserAgent, out jar); + } + catch (FlurlHttpException e) + { + throw new GarminAuthenticationError("Failed to initialize sign in flow.", e) { Code = Code.FailedPriorToCredentialsUsed }; + } + + object loginData = new + { + embed = "true", + username = auth.Email, + password = auth.Password, + lt = "e1s1", + _eventId = "submit", + displayNameRequired = "false", + }; + + ///////////////////////////////// + // Get CSRF token + //////////////////////////////// + } } diff --git a/src/Garmin/Garmin.csproj b/src/Garmin/Garmin.csproj index feaf35d9d..b9b69dc4c 100644 --- a/src/Garmin/Garmin.csproj +++ b/src/Garmin/Garmin.csproj @@ -24,6 +24,10 @@ + + + + From 09a823f84c26d636493dd5e79eab3ff69b3422be Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 30 Sep 2023 05:11:51 -0500 Subject: [PATCH 03/10] stubbing out more of the flow --- src/Garmin/ApiClient.cs | 24 ++++++-- src/Garmin/Auth/GarminAuthContracts.cs | 6 ++ src/Garmin/Auth/GarminOAuthService.cs | 77 +++++++++++++++++++++----- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index 7a4d8e5ff..c69684264 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -14,7 +14,8 @@ namespace Garmin public interface IGarminApiClient { Task InitSigninFlowAsync(object queryParams, string userAgent, out CookieJar jar); - Task InitCookieJarAsync(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); @@ -25,7 +26,7 @@ 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"; @@ -36,7 +37,7 @@ public class ApiClient : IGarminApiClient public Task InitSigninFlowAsync(object queryParams, string userAgent, out CookieJar jar) { - return SIGNIN_URL + return SSO_SIGNIN_URL .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) .SetQueryParams(queryParams) @@ -57,7 +58,7 @@ public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJ 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) .SetQueryParams(queryParams) @@ -70,6 +71,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" 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/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs index bcd76579e..caa9a8d4b 100644 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -1,14 +1,11 @@ using Common.Service; using Common.Stateful; -using Flurl; using Flurl.Http; using System; -using System.Collections.Generic; using System.Linq; -using System.Net; -using System.Runtime.Intrinsics.X86; -using System.Text; -using System.Threading.Tasks; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IdentityModel.Client; namespace Garmin.Auth; @@ -54,18 +51,68 @@ private async Task GetAuthTokenAsync() throw new GarminAuthenticationError("Failed to initialize sign in flow.", e) { Code = Code.FailedPriorToCredentialsUsed }; } - object loginData = new - { - embed = "true", - username = auth.Email, - password = auth.Password, - lt = "e1s1", - _eventId = "submit", - displayNameRequired = "false", + ///////////////////////////////// + // Get CSRF token + //////////////////////////////// + object csrfRequest = new + { + id = "gauth-widget", + embed = "true", + gauthHost = "https://sso.garmin.com/sso", + 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", }; + var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); + var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(.+?)\""); + var match = tokenRegex.Match(tokenResult.RawResponseBody); + if (!match.Success) + throw new Exception("Failed to regex match token"); + + var csrfToken = match.Groups.Values.First(); + ///////////////////////////////// - // Get CSRF token + // Submit login form + //////////////////////////////// + var loginData = new + { + username = "email", + passowrd = "password", + embed = "true", + _csrf = csrfToken + }; + var signInResult = await _apiClient.SendCredentialsAsync(auth, csrfRequest, loginData, jar); + + if (signInResult.WasRedirected && signInResult.RedirectedTo.Contains("https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode")) + { + // todo: handle mfa flow + throw new NotImplementedException("handle mfa"); + } + + var ticketRegex = new Regex("embed\\?ticket=([^\"]+)\""); + var ticketMatch = ticketRegex.Match(signInResult.RawResponseBody); + if (!ticketMatch.Success) + throw new Exception("Filed to find post signin ticket."); + + var ticket = ticketMatch.Groups.Values.First(); + + ///////////////////////////////// + // Get OAuth Tokens //////////////////////////////// + + // TODO: fetch id and secret from garth hosted file + var c = new FlurlClient(); + var result = await c + .WithHeader("User-Agent", auth.UserAgent) + .HttpClient + .RequestTokenAsync(new TokenRequest() + { + Address = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true", + ClientId = "fc3e99d2-118c-44b8-8ae3-03370dde24c0", + ClientSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF" + }); + } } From e21ad8e721acfff46cb8f1a34ca840d89bae32c5 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 30 Sep 2023 11:36:58 -0500 Subject: [PATCH 04/10] good progress --- src/Garmin/ApiClient.cs | 5 +- src/Garmin/Auth/GarminOAuthService.cs | 114 +++++++++++++++++++------- src/Garmin/Auth/OAuth2Token.cs | 12 +++ src/Garmin/Garmin.csproj | 2 +- src/UnitTests/AdHocTests.cs | 41 ++++++++- 5 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/Garmin/Auth/OAuth2Token.cs diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index c69684264..dc946b5ed 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -31,7 +31,8 @@ public class ApiClient : IGarminApiClient private static string UPLOAD_URL = $"{BASE_URL}/modern/proxy/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(); @@ -61,6 +62,8 @@ public async Task SendCredentialsAsync(GarminApiAuthentic 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) diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs index caa9a8d4b..7b1e3f224 100644 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -5,17 +5,21 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using IdentityModel.Client; - -namespace Garmin.Auth; - +using OAuth; +using System.Collections.Generic; +using Common.Observe; +using Serilog; +using System.Web; + +namespace Garmin.Auth; public interface IGarminOAuthService -{ - +{ } public class GarminOAuthService : IGarminOAuthService { + private static readonly ILogger _logger = LogContext.ForClass(); + private readonly ISettingsService _settingsService; private readonly IGarminApiClient _apiClient; @@ -25,11 +29,13 @@ public GarminOAuthService(ISettingsService settingsService, IGarminApiClient api _apiClient = apiClient; } - private async Task GetAuthTokenAsync() + public async Task GetAuthTokenAsync() { var auth = new GarminApiAuthentication(); - auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; - + //auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + auth.UserAgent = "com.garmin.android.apps.connectmobile"; + auth.Email = ""; + auth.Password = ""; ///////////////////////////////// // Init Cookie Jar @@ -57,8 +63,8 @@ private async Task GetAuthTokenAsync() object csrfRequest = new { id = "gauth-widget", - embed = "true", - gauthHost = "https://sso.garmin.com/sso", + 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", @@ -66,20 +72,21 @@ private async Task GetAuthTokenAsync() }; var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); - var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(.+?)\""); + var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); var match = tokenRegex.Match(tokenResult.RawResponseBody); if (!match.Success) throw new Exception("Failed to regex match token"); - var csrfToken = match.Groups.Values.First(); + var csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; + _logger.Verbose($"Csrf Token: {csrfToken}"); ///////////////////////////////// // Submit login form //////////////////////////////// var loginData = new { - username = "email", - passowrd = "password", + username = auth.Email, + password = auth.Password, embed = "true", _csrf = csrfToken }; @@ -91,28 +98,79 @@ private async Task GetAuthTokenAsync() throw new NotImplementedException("handle mfa"); } - var ticketRegex = new Regex("embed\\?ticket=([^\"]+)\""); + var ticketRegex = new Regex("embed\\?ticket=(?[^\"]+)\""); var ticketMatch = ticketRegex.Match(signInResult.RawResponseBody); if (!ticketMatch.Success) throw new Exception("Filed to find post signin ticket."); - var ticket = ticketMatch.Groups.Values.First(); + var ticket = ticketMatch.Groups.GetValueOrDefault("ticket").Value; + _logger.Verbose($"Service Ticket: {ticket}"); ///////////////////////////////// // Get OAuth Tokens //////////////////////////////// + var (oAuthToken, oAuthTokenSecret) = await GetOAuth1Async(ticket, auth.UserAgent); + + ///////////////////////////////// + // Exchane for OAuth2 + //////////////////////////////// + var oAuth2Token = await GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); + + ///////////////////////////////// + // Test + //////////////////////////////// + await "https://connect.garmin.com/weight-service/weight/range/2023-08-15/2023-09-26" + .WithOAuthBearerToken(oAuth2Token.Access_Token) + .GetAsync(); + } + + private async Task<(string oAuthToken, string oAuthTokenSecret)> GetOAuth1Async(string ticket, string userAgent) + { + // todo: don't hard code + var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; + var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; + + OAuthRequest oauthClient = OAuthRequest.ForRequestToken(consumerKey, consumerSecret); + oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; + + var result = await oauthClient.RequestUrl + .WithHeader("User-Agent", userAgent) + .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) + .GetStringAsync(); + + var queryParams = HttpUtility.ParseQueryString(result); + + if (queryParams.Count < 2) + throw new Exception($"Result length did not match expected: {result.Length}"); + + var oAuthToken = queryParams.Get("oauth_token"); + var oAuthTokenSecret = queryParams.Get("oauth_token_secret"); + + if (string.IsNullOrWhiteSpace(oAuthToken)) + throw new Exception("OAuth1 token is null"); + + if (string.IsNullOrWhiteSpace(oAuthTokenSecret)) + throw new Exception("OAuth1 token secret is null"); + + return (oAuthToken, oAuthTokenSecret); + } + + private async Task GetOAuth2TokenAsync(string oAuthToken, string oAuthTokenSecret, string userAgent) + { + // todo: don't hard code + var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; + var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; + + OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", consumerKey, consumerSecret, oAuthToken, oAuthTokenSecret); + oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; - // TODO: fetch id and secret from garth hosted file - var c = new FlurlClient(); - var result = await c - .WithHeader("User-Agent", auth.UserAgent) - .HttpClient - .RequestTokenAsync(new TokenRequest() - { - Address = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true", - ClientId = "fc3e99d2-118c-44b8-8ae3-03370dde24c0", - ClientSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF" - }); + var token = await oauthClient2.RequestUrl + .WithHeader("User-Agent", userAgent) + .WithHeader("Authorization", oauthClient2.GetAuthorizationHeader()) + .WithHeader("Content-Type", "application/x-www-form-urlencoded") + .PostAsync() + .ReceiveJson(); + return token; } } diff --git a/src/Garmin/Auth/OAuth2Token.cs b/src/Garmin/Auth/OAuth2Token.cs new file mode 100644 index 000000000..9cd7cee16 --- /dev/null +++ b/src/Garmin/Auth/OAuth2Token.cs @@ -0,0 +1,12 @@ +namespace Garmin.Auth; + +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 string Expires_In { get; set; } + public string Refresh_Token_Expires_In { get; set; } +} diff --git a/src/Garmin/Garmin.csproj b/src/Garmin/Garmin.csproj index b9b69dc4c..77f3e8965 100644 --- a/src/Garmin/Garmin.csproj +++ b/src/Garmin/Garmin.csproj @@ -25,7 +25,7 @@ - + diff --git a/src/UnitTests/AdHocTests.cs b/src/UnitTests/AdHocTests.cs index 8137ac1ef..fded2962f 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,30 @@ 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] + public async Task OAuth2() + { + //FlurlHttp.Configure(settings => + //{ + // settings.Timeout = new TimeSpan(0, 0, defaultTimeoutSeconds); + // settings. + // settings.Redirects.ForwardHeaders = true; + //}); + + var service = new GarminOAuthService(null, new Garmin.ApiClient()); + await service.GetAuthTokenAsync(); } //[Test] @@ -209,6 +233,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 + }; + } } } } From 2020712bd84facab758f98aa07cadff9c175ce6b Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 30 Sep 2023 12:00:19 -0500 Subject: [PATCH 05/10] rough draft working :) --- src/Garmin/Auth/GarminOAuthService.cs | 11 +++++++---- src/UnitTests/AdHocTests.cs | 7 ------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs index 7b1e3f224..13e0b354f 100644 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -10,6 +10,9 @@ using Common.Observe; using Serilog; using System.Web; +using System.IO; +using System.Net; +using Newtonsoft.Json; namespace Garmin.Auth; public interface IGarminOAuthService @@ -32,8 +35,7 @@ public GarminOAuthService(ISettingsService settingsService, IGarminApiClient api public async Task GetAuthTokenAsync() { var auth = new GarminApiAuthentication(); - //auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; - auth.UserAgent = "com.garmin.android.apps.connectmobile"; + auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; auth.Email = ""; auth.Password = ""; @@ -115,6 +117,7 @@ public async Task GetAuthTokenAsync() // Exchane for OAuth2 //////////////////////////////// var oAuth2Token = await GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); + //var oAuth2Token = await Copy_GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); ///////////////////////////////// // Test @@ -167,8 +170,8 @@ private async Task GetOAuth2TokenAsync(string oAuthToken, string oA var token = await oauthClient2.RequestUrl .WithHeader("User-Agent", userAgent) .WithHeader("Authorization", oauthClient2.GetAuthorizationHeader()) - .WithHeader("Content-Type", "application/x-www-form-urlencoded") - .PostAsync() + .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(); return token; diff --git a/src/UnitTests/AdHocTests.cs b/src/UnitTests/AdHocTests.cs index fded2962f..27ecf8210 100644 --- a/src/UnitTests/AdHocTests.cs +++ b/src/UnitTests/AdHocTests.cs @@ -53,13 +53,6 @@ public void Setup() [Test] public async Task OAuth2() { - //FlurlHttp.Configure(settings => - //{ - // settings.Timeout = new TimeSpan(0, 0, defaultTimeoutSeconds); - // settings. - // settings.Redirects.ForwardHeaders = true; - //}); - var service = new GarminOAuthService(null, new Garmin.ApiClient()); await service.GetAuthTokenAsync(); } From c6de12a185d1e0edaf4c6428b9123472bf500215 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 30 Sep 2023 13:17:10 -0500 Subject: [PATCH 06/10] happy path is working for Console app --- src/Common/Service/SettingsService.cs | 4 +- .../Stateful/GarminApiAuthentication.cs | 7 +- .../Auth => Common/Stateful}/OAuth2Token.cs | 6 +- src/Garmin/ApiClient.cs | 285 +----------------- .../Auth/GarminAuthenticationService.cs | 183 +++++++---- src/Garmin/Auth/GarminOAuthService.cs | 28 +- 6 files changed, 162 insertions(+), 351 deletions(-) rename src/{Garmin/Auth => Common/Stateful}/OAuth2Token.cs (65%) diff --git a/src/Common/Service/SettingsService.cs b/src/Common/Service/SettingsService.cs index af3b795a6..062b3d472 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); // 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..263d835ef 100644 --- a/src/Common/Stateful/GarminApiAuthentication.cs +++ b/src/Common/Stateful/GarminApiAuthentication.cs @@ -8,15 +8,16 @@ 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 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); } } diff --git a/src/Garmin/Auth/OAuth2Token.cs b/src/Common/Stateful/OAuth2Token.cs similarity index 65% rename from src/Garmin/Auth/OAuth2Token.cs rename to src/Common/Stateful/OAuth2Token.cs index 9cd7cee16..3f934968f 100644 --- a/src/Garmin/Auth/OAuth2Token.cs +++ b/src/Common/Stateful/OAuth2Token.cs @@ -1,4 +1,4 @@ -namespace Garmin.Auth; +namespace Common.Stateful; public record OAuth2Token { @@ -7,6 +7,6 @@ public record OAuth2Token public string Access_Token { get; set; } public string Token_Type { get; set; } public string Refresh_Token { get; set; } - public string Expires_In { get; set; } - public string Refresh_Token_Expires_In { 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 dc946b5ed..aeabd1955 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -13,39 +13,25 @@ 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 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 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 = "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) - { - return SSO_SIGNIN_URL - .WithHeader("User-Agent", userAgent) - .WithHeader("origin", ORIGIN) - .SetQueryParams(queryParams) - .WithCookies(out jar) - .GetStringAsync(); - } - public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar) { return SSO_EMBED_URL @@ -101,20 +87,11 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec .ReceiveString(); } - public Task SendServiceTicketAsync(string userAgent, string serviceTicket, CookieJar jar) - { - return $"{BASE_URL}/" - .WithHeader("User-Agent", userAgent) - .WithCookies(jar) - .SetQueryParam("ticket", serviceTicket) - .GetAsync(); - } - 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) @@ -150,263 +127,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/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index cf9f0aada..1fb975201 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -2,6 +2,7 @@ using Common.Service; using Common.Stateful; using Flurl.Http; +using OAuth; using Serilog; using System; using System.Collections.Generic; @@ -9,6 +10,7 @@ using System.Net; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Web; namespace Garmin.Auth; @@ -23,30 +25,11 @@ public interface IGarminAuthenticationService 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; @@ -92,30 +75,66 @@ 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); + + var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); + var match = tokenRegex.Match(tokenResult.RawResponseBody); + if (!match.Success) + throw new GarminAuthenticationError($"Failed to find regex match for csrf token. tokenResult: {tokenResult}") { Code = Code.FailedPriorToCredentialsUsed }; + + csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; + _logger.Verbose($"Csrf Token: {csrfToken}"); + + if (string.IsNullOrWhiteSpace(csrfToken)) + throw new GarminAuthenticationError("Found csrfToken but its null.") { Code = Code.FailedPriorToCredentialsUsed }; + } + catch (FlurlHttpException e) + { + throw new GarminAuthenticationError("Failed to fetch csrf token from Garmin.", e) { Code = Code.FailedPriorToCredentialsUsed }; + } + catch (Exception e) + { + throw new GarminAuthenticationError("Failed to parse csrf token.", 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) { @@ -142,46 +161,35 @@ public async Task RefreshGarminAuthenticationAsync() } 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 /////////////////////////////////////////// - try - { - var serviceTicketResponse = await _apiClient.SendServiceTicketAsync(auth.UserAgent, ticket, auth.CookieJar); + var (oAuthToken, oAuthTokenSecret) = await GetOAuth1Async(ticket, auth.UserAgent); - if (serviceTicketResponse.StatusCode == (int)HttpStatusCode.Moved) - throw new GarminAuthenticationError("Auth appeared successful but Garmin did not accept service ticket.") { Code = Code.AuthAppearedSuccessful }; - } - catch (FlurlHttpException e) - { - throw new GarminAuthenticationError("Auth appeared successful but there was an error sending the service ticket.", e) { Code = Code.AuthAppearedSuccessful }; - } + //////////////////////////////////////////// + // Exchange for OAuth2 Token + /////////////////////////////////////////// + var oAuth2Token = await GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); auth.AuthStage = AuthStage.Completed; + auth.OAuth2Token = oAuth2Token; _settingsService.SetGarminAuthentication(auth); return auth; } @@ -211,7 +219,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) @@ -243,4 +251,67 @@ private void SetMFACsrfToken(GarminApiAuthentication auth, string sendCredential auth.AuthStage = AuthStage.NeedMfaToken; auth.MFACsrfToken = csrfToken; } + + private async Task<(string oAuthToken, string oAuthTokenSecret)> GetOAuth1Async(string ticket, string userAgent) + { + // todo: don't hard code + var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; + var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; + + OAuthRequest oauthClient = OAuthRequest.ForRequestToken(consumerKey, consumerSecret); + oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; + + string oauth1Response = null; + try + { + oauth1Response = await oauthClient.RequestUrl + .WithHeader("User-Agent", userAgent) + .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) + .GetStringAsync(); + } 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 }; + + return (oAuthToken, oAuthTokenSecret); + } + + private async Task GetOAuth2TokenAsync(string oAuthToken, string oAuthTokenSecret, string userAgent) + { + // todo: don't hard code + var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; + var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; + + OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", consumerKey, consumerSecret, oAuthToken, oAuthTokenSecret); + oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; + + try + { + var token = await oauthClient2.RequestUrl + .WithHeader("User-Agent", 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(); + + return token; + } catch (Exception e) + { + throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token.", e) { Code = Code.AuthAppearedSuccessful }; + } + } } diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs index 13e0b354f..06caeddef 100644 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ b/src/Garmin/Auth/GarminOAuthService.cs @@ -2,7 +2,6 @@ using Common.Stateful; using Flurl.Http; using System; -using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using OAuth; @@ -10,16 +9,13 @@ using Common.Observe; using Serilog; using System.Web; -using System.IO; -using System.Net; -using Newtonsoft.Json; namespace Garmin.Auth; public interface IGarminOAuthService { } -public class GarminOAuthService : IGarminOAuthService +public class GarminOAuthService : IGarminAuthenticationService { private static readonly ILogger _logger = LogContext.ForClass(); @@ -32,6 +28,28 @@ public GarminOAuthService(ISettingsService settingsService, IGarminApiClient api _apiClient = apiClient; } + public async Task GetGarminAuthenticationAsync() + { + var settings = await _settingsService.GetSettingsAsync(); + settings.Garmin.EnsureGarminCredentialsAreProvided(); + + var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + if (auth is object && auth.IsValid(settings)) + return auth; + + return await RefreshGarminAuthenticationAsync(); + } + + public Task RefreshGarminAuthenticationAsync() + { + throw new NotImplementedException(); + } + + public Task CompleteMFAAuthAsync(string mfaCode) + { + throw new NotImplementedException(); + } + public async Task GetAuthTokenAsync() { var auth = new GarminApiAuthentication(); From 3f605619ed5552d543cd7c8db0d8f4ae50124baf Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 30 Sep 2023 13:48:58 -0500 Subject: [PATCH 07/10] mfa not working :( --- src/Common/Service/SettingsService.cs | 4 ++-- src/Garmin/ApiClient.cs | 1 + .../Auth/GarminAuthenticationService.cs | 21 ++++++++++--------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Common/Service/SettingsService.cs b/src/Common/Service/SettingsService.cs index 062b3d472..886f719c1 100644 --- a/src/Common/Service/SettingsService.cs +++ b/src/Common/Service/SettingsService.cs @@ -113,8 +113,8 @@ public void SetGarminAuthentication(GarminApiAuthentication authentication) lock (_lock) { var key = $"{GarminApiAuthKey}:{authentication.Email}"; - var expiration = authentication.OAuth2Token.Expires_In - (60 * 60); // expire an hour early - var finalExpiration = expiration < 0 ? 45 * 60 : expiration; // default to 45min + 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/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index aeabd1955..496e59f57 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -80,6 +80,7 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec return "https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode" .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) + .WithHeader("referer", "https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode") .SetQueryParams(queryParams) .WithCookies(jar) .OnRedirect(redir => redir.Request.WithCookies(jar)) diff --git a/src/Garmin/Auth/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index 1fb975201..6743d910e 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -207,7 +207,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) @@ -238,15 +238,16 @@ private void SetMFACsrfToken(GarminApiAuthentication auth, string sendCredential ///////////////////////////////// // 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 }; + var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); + var match = tokenRegex.Match(sendCredentialsResponseBody); + if (!match.Success) + throw new GarminAuthenticationError($"MFA: Failed to find regex match for csrf token. tokenResult: {sendCredentialsResponseBody}") { Code = Code.FailedPriorToMfaUsed }; + + var csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; + _logger.Verbose($"Csrf Token: {csrfToken}"); + + if (string.IsNullOrWhiteSpace(csrfToken)) + throw new GarminAuthenticationError("MFA: Found csrfToken but its null.") { Code = Code.FailedPriorToMfaUsed }; auth.AuthStage = AuthStage.NeedMfaToken; auth.MFACsrfToken = csrfToken; From 507ff90a782d6ecafaa125550be5a42591e57640 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sun, 1 Oct 2023 07:39:18 -0500 Subject: [PATCH 08/10] fix compile error --- .../Auth/GarminAuthenticationService.cs | 1 - src/Garmin/Auth/GarminOAuthService.cs | 197 ------------------ src/UnitTests/AdHocTests.cs | 7 - 3 files changed, 205 deletions(-) delete mode 100644 src/Garmin/Auth/GarminOAuthService.cs diff --git a/src/Garmin/Auth/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index 6743d910e..08c3d3e2b 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -6,7 +6,6 @@ using Serilog; using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Text.RegularExpressions; using System.Threading.Tasks; diff --git a/src/Garmin/Auth/GarminOAuthService.cs b/src/Garmin/Auth/GarminOAuthService.cs deleted file mode 100644 index 06caeddef..000000000 --- a/src/Garmin/Auth/GarminOAuthService.cs +++ /dev/null @@ -1,197 +0,0 @@ -using Common.Service; -using Common.Stateful; -using Flurl.Http; -using System; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using OAuth; -using System.Collections.Generic; -using Common.Observe; -using Serilog; -using System.Web; - -namespace Garmin.Auth; -public interface IGarminOAuthService -{ -} - -public class GarminOAuthService : IGarminAuthenticationService -{ - private static readonly ILogger _logger = LogContext.ForClass(); - - private readonly ISettingsService _settingsService; - private readonly IGarminApiClient _apiClient; - - public GarminOAuthService(ISettingsService settingsService, IGarminApiClient apiClient) - { - _settingsService = settingsService; - _apiClient = apiClient; - } - - public async Task GetGarminAuthenticationAsync() - { - var settings = await _settingsService.GetSettingsAsync(); - settings.Garmin.EnsureGarminCredentialsAreProvided(); - - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); - if (auth is object && auth.IsValid(settings)) - return auth; - - return await RefreshGarminAuthenticationAsync(); - } - - public Task RefreshGarminAuthenticationAsync() - { - throw new NotImplementedException(); - } - - public Task CompleteMFAAuthAsync(string mfaCode) - { - throw new NotImplementedException(); - } - - public async Task GetAuthTokenAsync() - { - var auth = new GarminApiAuthentication(); - auth.UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; - auth.Email = ""; - auth.Password = ""; - - ///////////////////////////////// - // Init Cookie Jar - //////////////////////////////// - var queryParams = new - { - id = "gauth-widget", - embedWidget = "true", - gauthHost = "https://sso.garmin.com/sso" - }; - - CookieJar jar = null; - try - { - await _apiClient.InitCookieJarAsync(queryParams, auth.UserAgent, out jar); - } - catch (FlurlHttpException e) - { - throw new GarminAuthenticationError("Failed to initialize sign in flow.", e) { Code = Code.FailedPriorToCredentialsUsed }; - } - - ///////////////////////////////// - // Get CSRF token - //////////////////////////////// - object csrfRequest = new - { - 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", - }; - - var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); - var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); - var match = tokenRegex.Match(tokenResult.RawResponseBody); - if (!match.Success) - throw new Exception("Failed to regex match token"); - - var csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; - _logger.Verbose($"Csrf Token: {csrfToken}"); - - ///////////////////////////////// - // Submit login form - //////////////////////////////// - var loginData = new - { - username = auth.Email, - password = auth.Password, - embed = "true", - _csrf = csrfToken - }; - var signInResult = await _apiClient.SendCredentialsAsync(auth, csrfRequest, loginData, jar); - - if (signInResult.WasRedirected && signInResult.RedirectedTo.Contains("https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode")) - { - // todo: handle mfa flow - throw new NotImplementedException("handle mfa"); - } - - var ticketRegex = new Regex("embed\\?ticket=(?[^\"]+)\""); - var ticketMatch = ticketRegex.Match(signInResult.RawResponseBody); - if (!ticketMatch.Success) - throw new Exception("Filed to find post signin ticket."); - - var ticket = ticketMatch.Groups.GetValueOrDefault("ticket").Value; - _logger.Verbose($"Service Ticket: {ticket}"); - - ///////////////////////////////// - // Get OAuth Tokens - //////////////////////////////// - var (oAuthToken, oAuthTokenSecret) = await GetOAuth1Async(ticket, auth.UserAgent); - - ///////////////////////////////// - // Exchane for OAuth2 - //////////////////////////////// - var oAuth2Token = await GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); - //var oAuth2Token = await Copy_GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); - - ///////////////////////////////// - // Test - //////////////////////////////// - await "https://connect.garmin.com/weight-service/weight/range/2023-08-15/2023-09-26" - .WithOAuthBearerToken(oAuth2Token.Access_Token) - .GetAsync(); - } - - private async Task<(string oAuthToken, string oAuthTokenSecret)> GetOAuth1Async(string ticket, string userAgent) - { - // todo: don't hard code - var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; - var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; - - OAuthRequest oauthClient = OAuthRequest.ForRequestToken(consumerKey, consumerSecret); - oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; - - var result = await oauthClient.RequestUrl - .WithHeader("User-Agent", userAgent) - .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) - .GetStringAsync(); - - var queryParams = HttpUtility.ParseQueryString(result); - - if (queryParams.Count < 2) - throw new Exception($"Result length did not match expected: {result.Length}"); - - var oAuthToken = queryParams.Get("oauth_token"); - var oAuthTokenSecret = queryParams.Get("oauth_token_secret"); - - if (string.IsNullOrWhiteSpace(oAuthToken)) - throw new Exception("OAuth1 token is null"); - - if (string.IsNullOrWhiteSpace(oAuthTokenSecret)) - throw new Exception("OAuth1 token secret is null"); - - return (oAuthToken, oAuthTokenSecret); - } - - private async Task GetOAuth2TokenAsync(string oAuthToken, string oAuthTokenSecret, string userAgent) - { - // todo: don't hard code - var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; - var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; - - OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", consumerKey, consumerSecret, oAuthToken, oAuthTokenSecret); - oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; - - var token = await oauthClient2.RequestUrl - .WithHeader("User-Agent", 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(); - - return token; - } -} diff --git a/src/UnitTests/AdHocTests.cs b/src/UnitTests/AdHocTests.cs index 27ecf8210..d96813f21 100644 --- a/src/UnitTests/AdHocTests.cs +++ b/src/UnitTests/AdHocTests.cs @@ -50,13 +50,6 @@ public void Setup() }); } - [Test] - public async Task OAuth2() - { - var service = new GarminOAuthService(null, new Garmin.ApiClient()); - await service.GetAuthTokenAsync(); - } - //[Test] //public void EncryptionKeyGenerator() //{ From 5823f554386a5435884eca41cf2197ad79209be2 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sun, 1 Oct 2023 09:00:27 -0500 Subject: [PATCH 09/10] polish --- .../Stateful/GarminApiAuthentication.cs | 9 +- src/Garmin/ApiClient.cs | 33 ++++- src/Garmin/Auth/ConsumerCredentials.cs | 13 ++ .../Auth/GarminAuthenticationService.cs | 114 +++++++----------- 4 files changed, 95 insertions(+), 74 deletions(-) create mode 100644 src/Garmin/Auth/ConsumerCredentials.cs diff --git a/src/Common/Stateful/GarminApiAuthentication.cs b/src/Common/Stateful/GarminApiAuthentication.cs index 263d835ef..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 @@ -10,6 +10,7 @@ public class GarminApiAuthentication : IApiAuthentication public CookieJar CookieJar { get; set; } 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) @@ -21,6 +22,12 @@ public bool IsValid(Settings settings) } } +public class OAuth1Token +{ + public string Token { get; set; } + public string TokenSecret { get; set; } +} + public enum AuthStage : byte { None = 0, diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index 496e59f57..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; @@ -17,6 +18,9 @@ public interface IGarminApiClient 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 GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket); + Task GetOAuth2TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials); + Task GetConsumerCredentialsAsync(); Task UploadActivity(string filePath, string format, GarminApiAuthentication auth); } @@ -32,6 +36,12 @@ public class ApiClient : IGarminApiClient private static readonly ILogger _logger = LogContext.ForClass(); + public Task GetConsumerCredentialsAsync() + { + return "https://thegarth.s3.amazonaws.com/oauth_consumer.json" + .GetJsonAsync(); + } + public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar) { return SSO_EMBED_URL @@ -80,7 +90,6 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec return "https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode" .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) - .WithHeader("referer", "https://sso.garmin.com/sso/verifyMFA/loginEnterMfaCode") .SetQueryParams(queryParams) .WithCookies(jar) .OnRedirect(redir => redir.Request.WithCookies(jar)) @@ -88,6 +97,28 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec .ReceiveString(); } + public Task GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket) + { + 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); 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/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index 08c3d3e2b..7716b92b5 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -2,7 +2,6 @@ using Common.Service; using Common.Stateful; using Flurl.Http; -using OAuth; using Serilog; using System; using System.Collections.Generic; @@ -18,7 +17,6 @@ public interface IGarminAuthenticationService Task GetGarminAuthenticationAsync(); Task RefreshGarminAuthenticationAsync(); Task CompleteMFAAuthAsync(string mfaCode); - } public class GarminAuthenticationService : IGarminAuthenticationService @@ -54,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(); @@ -99,26 +102,12 @@ public async Task RefreshGarminAuthenticationAsync() try { var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); - - var tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); - var match = tokenRegex.Match(tokenResult.RawResponseBody); - if (!match.Success) - throw new GarminAuthenticationError($"Failed to find regex match for csrf token. tokenResult: {tokenResult}") { Code = Code.FailedPriorToCredentialsUsed }; - - csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; - _logger.Verbose($"Csrf Token: {csrfToken}"); - - if (string.IsNullOrWhiteSpace(csrfToken)) - throw new GarminAuthenticationError("Found csrfToken but its null.") { Code = Code.FailedPriorToCredentialsUsed }; + csrfToken = FindCsrfToken(tokenResult.RawResponseBody, failureStepCode: Code.FailedPriorToCredentialsUsed); } catch (FlurlHttpException e) { throw new GarminAuthenticationError("Failed to fetch csrf token from Garmin.", e) { Code = Code.FailedPriorToCredentialsUsed }; } - catch (Exception e) - { - throw new GarminAuthenticationError("Failed to parse csrf token.", e) { Code = Code.FailedPriorToCredentialsUsed }; - } ///////////////////////////////// // Send Credentials @@ -153,7 +142,9 @@ 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; @@ -180,15 +171,23 @@ private async Task CompleteGarminAuthenticationAsync(st //////////////////////////////////////////// // Get OAuth1 Tokens /////////////////////////////////////////// - var (oAuthToken, oAuthTokenSecret) = await GetOAuth1Async(ticket, auth.UserAgent); + var consumerCredentials = await _apiClient.GetConsumerCredentialsAsync(); + await GetOAuth1Async(ticket, auth, consumerCredentials); //////////////////////////////////////////// // Exchange for OAuth2 Token /////////////////////////////////////////// - var oAuth2Token = await GetOAuth2TokenAsync(oAuthToken, oAuthTokenSecret, auth.UserAgent); + try + { + auth.OAuth2Token = await _apiClient.GetOAuth2TokenAsync(auth, consumerCredentials); + } + catch (Exception e) + { + throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token.", e) { Code = Code.AuthAppearedSuccessful }; + } auth.AuthStage = AuthStage.Completed; - auth.OAuth2Token = oAuth2Token; + auth.MFACsrfToken = string.Empty; _settingsService.SetGarminAuthentication(auth); return auth; } @@ -232,42 +231,34 @@ 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 tokenRegex = new Regex("name=\"_csrf\"\\s+value=\"(?.+?)\""); - var match = tokenRegex.Match(sendCredentialsResponseBody); - if (!match.Success) - throw new GarminAuthenticationError($"MFA: Failed to find regex match for csrf token. tokenResult: {sendCredentialsResponseBody}") { Code = Code.FailedPriorToMfaUsed }; + 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}"); + var csrfToken = match.Groups.GetValueOrDefault("csrf")?.Value; + _logger.Verbose($"Csrf Token: {csrfToken}"); - if (string.IsNullOrWhiteSpace(csrfToken)) - throw new GarminAuthenticationError("MFA: Found csrfToken but its null.") { Code = Code.FailedPriorToMfaUsed }; + if (string.IsNullOrWhiteSpace(csrfToken)) + throw new GarminAuthenticationError("Found csrfToken but its null.") { Code = failureStepCode }; - auth.AuthStage = AuthStage.NeedMfaToken; - auth.MFACsrfToken = csrfToken; + return csrfToken; + } catch (Exception e) + { + throw new GarminAuthenticationError("Failed to parse csrf token.", e) { Code = failureStepCode }; + } } - private async Task<(string oAuthToken, string oAuthTokenSecret)> GetOAuth1Async(string ticket, string userAgent) + private async Task GetOAuth1Async(string ticket, GarminApiAuthentication auth, ConsumerCredentials credentials) { - // todo: don't hard code - var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; - var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; - - OAuthRequest oauthClient = OAuthRequest.ForRequestToken(consumerKey, consumerSecret); - oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; - string oauth1Response = null; try { - oauth1Response = await oauthClient.RequestUrl - .WithHeader("User-Agent", userAgent) - .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) - .GetStringAsync(); + 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 }; @@ -287,31 +278,10 @@ private void SetMFACsrfToken(GarminApiAuthentication auth, string sendCredential if (string.IsNullOrWhiteSpace(oAuthTokenSecret)) throw new GarminAuthenticationError($"Auth appeared successful but returned OAuth1 token secret is null. oauth1Response: {oauth1Response}") { Code = Code.AuthAppearedSuccessful }; - return (oAuthToken, oAuthTokenSecret); - } - - private async Task GetOAuth2TokenAsync(string oAuthToken, string oAuthTokenSecret, string userAgent) - { - // todo: don't hard code - var consumerKey = "fc3e99d2-118c-44b8-8ae3-03370dde24c0"; - var consumerSecret = "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF"; - - OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", consumerKey, consumerSecret, oAuthToken, oAuthTokenSecret); - oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; - - try + auth.OAuth1Token = new OAuth1Token() { - var token = await oauthClient2.RequestUrl - .WithHeader("User-Agent", 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(); - - return token; - } catch (Exception e) - { - throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token.", e) { Code = Code.AuthAppearedSuccessful }; - } + Token = oAuthToken, + TokenSecret = oAuthTokenSecret + }; } } From 6f4ca70f5fae735cf69b108ac8090078f1e8d860 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sun, 1 Oct 2023 09:14:19 -0500 Subject: [PATCH 10/10] update release notes --- vNextReleaseNotes.md | 2 ++ 1 file changed, 2 insertions(+) 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