diff --git a/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs b/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs index cd8a06e27..ca76b7c2a 100644 --- a/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs +++ b/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs @@ -9,6 +9,7 @@ using ShopifySharp.Enums; using ShopifySharp.Infrastructure; using ShopifySharp.Infrastructure.Serialization.Json; +using ShopifySharp.Tests.Fixtures; using ShopifySharp.Tests.TestClasses; using ShopifySharp.Utilities; @@ -17,12 +18,13 @@ namespace ShopifySharp.Tests.Utilities; [TestSubject(typeof(ShopifyOauthUtility))] [Trait("Category", "ShopifyOauthUtility")] [Collection("ShopifyOauthUtility")] -public class ShopifyOauthUtilityTests +public class ShopifyOauthUtilityTests : IClassFixture { private const string ShopDomain = "example.myshopify.com"; private const string RedirectUrl = "https://example.com/app"; private const string ClientId = "some-client-id"; + private readonly VerifySettings _verifySettings; private readonly IShopifyDomainUtility _shopifyDomainUtility = A.Fake(x => x.Wrapping(new ShopifyDomainUtility()).CallsBaseMethods()); private readonly IJsonSerializer _jsonSerializer = new SystemJsonSerializer(Serializer.RestSerializerOptions); private readonly IServiceProvider _serviceProvider = A.Fake(x => x.Strict()); @@ -31,8 +33,10 @@ public class ShopifyOauthUtilityTests private readonly ShopifyOauthUtility _sut; - public ShopifyOauthUtilityTests() + public ShopifyOauthUtilityTests(VerifyFixture verifyFixture) { + _verifySettings = verifyFixture.Settings; + var httpClientFactory = new FakeHttpClientFactory(_httpClient); A.CallTo(() => _serviceProvider.GetService(typeof(IShopifyDomainUtility))) @@ -1105,6 +1109,111 @@ await _sut.RefreshOfflineAccessTokenAsync(new RefreshOfflineAccessTokenOptions #endregion + #region CycleOfflineAccessTokenAsync tests + + [Fact] + public async Task CycleOfflineAccessTokenAsync_ShouldSendTokenExchangeGrantAndParseTokenMetadata() + { + // Setup + const int expiresIn = 120; + const int refreshTokenExpiresIn = 3600; + const string accessToken = "some-access-token"; + const string refreshToken = "some-refresh-token"; + const string permanentToken = "some-permanent-offline-token"; + var json = + //lang=json + $$""" + { + "access_token": "{{accessToken}}", + "scope": "", + "expires_in": {{expiresIn}}, + "refresh_token": "{{refreshToken}}", + "refresh_token_expires_in": {{refreshTokenExpiresIn}} + } + """; + var result = Utils.MakeHttpResponseMessage(json); + HttpRequestMessage? capturedRequest = null; + string? requestContent = null; + + A.CallTo(() => _httpClient.SendAsync(A._, A._)) + .Invokes(async call => { + capturedRequest = call.GetArgument(0); + requestContent = await capturedRequest!.Content!.ReadAsStringAsync(); + }) + .Returns(result); + + // Act + var authorizationResult = await _sut.CycleOfflineAccessTokenAsync(new CycleOfflineAccessTokenOptions + { + ShopDomain = ShopDomain, + ClientId = ClientId, + ClientSecret = "some-client-secret", + AccessToken = permanentToken + }, TestContext.Current.CancellationToken); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri.Should().Be(new Uri("https://example.myshopify.com/admin/oauth/access_token")); + + await Verify(new { requestContent, authorizationResult }, _verifySettings); + } + + [Fact] + public async Task CycleOfflineAccessTokenAsync_WhenAnErrorIsReturned_ShouldThrow() + { + // Setup + const HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest; + const string expectedErrorMessage = "some-error-message"; + const string json = $$"""{ "error": "{{expectedErrorMessage}}" }"""; + var result = Utils.MakeHttpResponseMessage(json, x => x.StatusCode = expectedStatusCode); + + A.CallTo(() => _httpClient.SendAsync(A._, A._)) + .Returns(result); + + // Act + var act = async () => await _sut.CycleOfflineAccessTokenAsync(new CycleOfflineAccessTokenOptions + { + ShopDomain = ShopDomain, + ClientId = ClientId, + ClientSecret = "some-client-secret", + AccessToken = "some-permanent-offline-token" + }); + + // Assert + var exn = await act.Should().ThrowAsync() + .WithMessage("(400 Bad Request) " + expectedErrorMessage); + exn.Which.HttpStatusCode.Should().Be(expectedStatusCode); + } + + [Fact] + public async Task CycleOfflineAccessTokenAsync_WhenSubjectTokenIsInvalid_ShouldThrow() + { + // Setup + const HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest; + const string expectedErrorMessage = "invalid_subject_token"; + const string json = $$"""{ "error": "{{expectedErrorMessage}}" }"""; + var result = Utils.MakeHttpResponseMessage(json, x => x.StatusCode = expectedStatusCode); + + A.CallTo(() => _httpClient.SendAsync(A._, A._)) + .Returns(result); + + // Act + var act = async () => await _sut.CycleOfflineAccessTokenAsync(new CycleOfflineAccessTokenOptions + { + ShopDomain = ShopDomain, + ClientId = ClientId, + ClientSecret = "some-client-secret", + AccessToken = "some-permanent-offline-token" + }); + + // Assert + var exn = await act.Should().ThrowAsync() + .WithMessage("(400 Bad Request) " + expectedErrorMessage); + exn.Which.HttpStatusCode.Should().Be(expectedStatusCode); + } + + #endregion + #region AuthorizeAsync – Null and invalid type tests [Theory] diff --git a/ShopifySharp.Tests/Utilities/Snapshots/ShopifyOauthUtilityTests.CycleOfflineAccessTokenAsync_ShouldSendTokenExchangeGrantAndParseTokenMetadata.verified.json b/ShopifySharp.Tests/Utilities/Snapshots/ShopifyOauthUtilityTests.CycleOfflineAccessTokenAsync_ShouldSendTokenExchangeGrantAndParseTokenMetadata.verified.json new file mode 100644 index 000000000..cff441ae7 --- /dev/null +++ b/ShopifySharp.Tests/Utilities/Snapshots/ShopifyOauthUtilityTests.CycleOfflineAccessTokenAsync_ShouldSendTokenExchangeGrantAndParseTokenMetadata.verified.json @@ -0,0 +1,15 @@ +{ + "requestContent": "{\"client_id\":\"some-client-id\",\"client_secret\":\"some-client-secret\",\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\",\"subject_token\":\"some-permanent-offline-token\",\"subject_token_type\":\"urn:shopify:params:oauth:token-type:offline-access-token\",\"requested_token_type\":\"urn:shopify:params:oauth:token-type:offline-access-token\",\"expiring\":1}", + "authorizationResult": { + "AccessToken": "some-access-token", + "Type": "ExpiringOffline", + "ExpiresIn": "00:02:00", + "RefreshToken": "some-refresh-token", + "RefreshTokenExpiresIn": "01:00:00", + "IssuedAtUtc": "DateTimeOffset_1", + "AccessTokenExpiresAtUtc": "DateTimeOffset_2", + "RefreshTokenExpiresAtUtc": "DateTimeOffset_3", + "HasRefreshToken": true, + "IsOnlineAccess": false + } +} \ No newline at end of file diff --git a/ShopifySharp/Utilities/CycleOfflineAccessTokenOptions.cs b/ShopifySharp/Utilities/CycleOfflineAccessTokenOptions.cs new file mode 100644 index 000000000..daa6f60f2 --- /dev/null +++ b/ShopifySharp/Utilities/CycleOfflineAccessTokenOptions.cs @@ -0,0 +1,35 @@ +#nullable enable +namespace ShopifySharp.Utilities; + +public record CycleOfflineAccessTokenOptions +{ + /// The store's *.myshopify.com url. + public +#if NET6_0_OR_GREATER + required +#endif + string ShopDomain { get; set; } = null!; + + /// Your app's public Client ID, also known as its public API key. + public +#if NET6_0_OR_GREATER + required +#endif + string ClientId { get; set; } = null!; + + /// Your app's Client Secret, also known as its secret API key. + public +#if NET6_0_OR_GREATER + required +#endif + string ClientSecret { get; set; } = null!; + + /// The app's legacy, permanent offline access token. This is the + /// token that will be cycled to an expiring offline access token, + /// invalidating the legacy token. + public +#if NET6_0_OR_GREATER + required +#endif + string AccessToken { get; set; } = null!; +} diff --git a/ShopifySharp/Utilities/ShopifyOauthUtility.cs b/ShopifySharp/Utilities/ShopifyOauthUtility.cs index d71065263..9e600a97c 100644 --- a/ShopifySharp/Utilities/ShopifyOauthUtility.cs +++ b/ShopifySharp/Utilities/ShopifyOauthUtility.cs @@ -140,6 +140,15 @@ string existingStoreAccessToken /// Cancellation token. /// Thrown when the authorization result's refresh token has expired or does not contain a refresh token. Task RefreshOfflineAccessTokenIfStaleAsync(AuthorizationResult currentResult, RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default); + + /// + /// Cycles a legacy, permanent offline access token to an expiring offline access token. This is an irreversible action. + /// Shopify will invalidate the legacy offline token and return a new, expiring offline token in the same transaction. + /// For more info, see https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens#cycle-existing-tokens-without-waiting-for-a-merchant + /// + /// Options for cycling the access token. + /// Cancellation token. + Task CycleOfflineAccessTokenAsync(CycleOfflineAccessTokenOptions options, CancellationToken cancellationToken = default); } public class ShopifyOauthUtility: IShopifyOauthUtility @@ -335,7 +344,36 @@ public async Task RefreshOfflineAccessTokenAsync( } /// - public async Task RefreshOfflineAccessTokenIfStaleAsync( + public async Task CycleOfflineAccessTokenAsync( + CycleOfflineAccessTokenOptions options, + CancellationToken cancellationToken = default + ) + { + var ub = new UriBuilder(_domainUtility.BuildShopDomainUri(options.ShopDomain)) + { + Path = "admin/oauth/access_token" + }; + // This uses RFC 8693 to cycle a permanent offline access token to an expiring offline access token + // + // RFC 8693: https://www.rfc-editor.org/rfc/rfc8693.html + // Shopify docs: https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens#cycle-existing-tokens-without-waiting-for-a-merchant + using var content = new JsonContent(new + { + client_id = options.ClientId, + client_secret = options.ClientSecret, + grant_type = "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token = options.AccessToken, + subject_token_type = "urn:shopify:params:oauth:token-type:offline-access-token", + requested_token_type = "urn:shopify:params:oauth:token-type:offline-access-token", + expiring = 1 + }); + using var request = new CloneableRequestMessage(ub.Uri, HttpMethod.Post, content); + + return await SendRequestAndParseAuthorizationResultAsync(request, cancellationToken); + } + + /// + public async Task RefreshOfflineAccessTokenIfStaleAsync( RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default )