From 98d53372342770d4b719e70ea7ee4e2e041be4a3 Mon Sep 17 00:00:00 2001 From: Joshua Harms Date: Mon, 31 Aug 2026 20:30:56 -0500 Subject: [PATCH] Add ShopifyOauthUtility.CycleOfflineAccessTokenAsync for offline token cycling Added a new `CycleOfflineAccessTokenAsync` method to the to `IShopifyOauthUtility` and `ShopifyOauthUtility`, along with a new `CycleOfflineAccessTokenOptions` record. The method uses Shopify's token exchange process (RFC 8693) to convert a legacy permanent offline access token into the new *expiring* offline access token. Devs can use this new method to migrate their existing legacy permanent tokens before Shopify's January 1, 2027 migration deadline without requiring an active user session to complete oauth reauthorization. The response here includes a refresh token, and is identical to the response for creating an expiring offline access token, so no changes to the `AuthorizationResult` were necessary. Reference: https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens#cycle-existing-tokens-without-waiting-for-a-merchant type: feature scope: oauth, utilities --- .../Utilities/ShopifyOauthUtilityTests.cs | 113 +++++++++++++++++- ...geGrantAndParseTokenMetadata.verified.json | 15 +++ .../CycleOfflineAccessTokenOptions.cs | 35 ++++++ ShopifySharp/Utilities/ShopifyOauthUtility.cs | 40 ++++++- 4 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 ShopifySharp.Tests/Utilities/Snapshots/ShopifyOauthUtilityTests.CycleOfflineAccessTokenAsync_ShouldSendTokenExchangeGrantAndParseTokenMetadata.verified.json create mode 100644 ShopifySharp/Utilities/CycleOfflineAccessTokenOptions.cs 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 )