From 07d54e939ef857a63d838f4b43e644949164bb29 Mon Sep 17 00:00:00 2001 From: Joshua Harms Date: Mon, 14 Sep 2026 11:28:20 -0500 Subject: [PATCH 1/2] Add support for the client credentials grant OAuth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a new `GetClientCredentialsAccessTokenAsync` method to the `ShopifyOauthUtility`, which implements the client credentials grant flow required by custom Shopify apps. These client credentials expire after 24 hours, but the flow does not return a refresh token – instead, devs just call the same grant method again, which automatically refreshes the token. Added a new ShopifyAccessTokenType.ClientCredentials enum case to represent the new access token type. The AuthorizationResult's refresh methods have been updated to throw when given the new token type, which cannot be refreshed. type: feature scope: oauth, utilities --- .../Utilities/ShopifyOauthUtilityTests.cs | 146 +++++++++++++++++- .../Authorization/AuthorizationResult.cs | 15 +- .../Authorization/ShopifyAccessTokenType.cs | 7 +- .../ClientCredentialsAccessTokenOptions.cs | 26 ++++ ShopifySharp/Utilities/ShopifyOauthUtility.cs | 36 ++++- 5 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 ShopifySharp/Utilities/ClientCredentialsAccessTokenOptions.cs diff --git a/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs b/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs index ca76b7c2a..7f96062e1 100644 --- a/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs +++ b/ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs @@ -1384,23 +1384,32 @@ public async Task AuthorizeAsync_WhenAssociatedUserScopeIsAnInvalidType_ShouldTh #region Refactored token type and guard tests [Fact] - public void ShopifyAccessTokenType_ShouldResolveCorrectly() + public void ShopifyAccessTokenType_Type_ShouldResolveAllCases() { - // Online access token + // Online access token – OnlineAccess takes priority over ExpiresIn var onlineToken = new AuthorizationResult("token", []) { - OnlineAccess = new OnlineAccessInfo() + OnlineAccess = new OnlineAccessInfo(), + ExpiresIn = TimeSpan.FromHours(24) }; onlineToken.Type.Should().Be(ShopifyAccessTokenType.Online); - // Expiring offline access token + // Expiring offline access token – HasRefreshToken and ExpiresIn, no OnlineAccess var expiringOfflineToken = new AuthorizationResult("token", []) { - RefreshToken = "some-refresh-token" + RefreshToken = "some-refresh-token", + ExpiresIn = TimeSpan.FromHours(24) }; expiringOfflineToken.Type.Should().Be(ShopifyAccessTokenType.ExpiringOffline); - // Legacy permanent offline access token + // Client credentials access token – ExpiresIn but no RefreshToken, no OnlineAccess + var clientCredentialsToken = new AuthorizationResult("token", []) + { + ExpiresIn = TimeSpan.FromHours(24) + }; + clientCredentialsToken.Type.Should().Be(ShopifyAccessTokenType.ClientCredentials); + + // Legacy permanent offline access token – neither OnlineAccess nor ExpiresIn var permanentOfflineToken = new AuthorizationResult("token", []); permanentOfflineToken.Type.Should().Be(ShopifyAccessTokenType.LegacyPermanentOffline); } @@ -1465,6 +1474,28 @@ await act.Should().ThrowAsync() .WithMessage("Legacy permanent offline access tokens do not expire*"); } + [Fact] + public async Task RefreshOfflineAccessTokenIfStaleAsync_WhenTokenIsClientCredentials_ShouldThrowImmediately() + { + var ccToken = new AuthorizationResult("token", []) + { + ExpiresIn = TimeSpan.FromHours(24) + }; + + var act = async () => await _sut.RefreshOfflineAccessTokenIfStaleAsync(ccToken, new RefreshOfflineAccessTokenIfStaleOptions + { + ShopDomain = ShopDomain, + ClientId = ClientId, + ClientSecret = "some-secret", + RefreshToken = ccToken.RefreshToken ?? string.Empty, + AccessTokenExpiresAtUtc = ccToken.AccessTokenExpiresAtUtc, + RefreshTokenExpiresAtUtc = ccToken.RefreshTokenExpiresAtUtc + }); + + await act.Should().ThrowAsync() + .WithMessage("Client credentials access tokens cannot be refreshed programmatically*"); + } + #endregion #region RefreshOfflineAccessTokenIfStaleAsync – Bare data overload tests @@ -1649,6 +1680,108 @@ public async Task RefreshOfflineAccessTokenIfNeededAsync_BareData_WhenAccessToke result.RefreshToken.Should().Be(refreshedRefreshToken); } + #region GetClientCredentialsAccessTokenAsync + + [Fact] + public async Task GetClientCredentialsAccessTokenAsync_ShouldUseDomainUtilityFromDependencyInjection() + { + // Setup + const string expectedDomain = "cc-grant-domain-test"; + var options = new ClientCredentialsAccessTokenOptions + { + ShopDomain = expectedDomain, + ClientId = "some-client-id", + ClientSecret = "some-client-secret" + }; + var callToDomainUtil = A.CallTo(() => _shopifyDomainUtility.BuildShopDomainUri(expectedDomain)); + callToDomainUtil.Throws(); + + // Act + var act = async () => await _sut.GetClientCredentialsAccessTokenAsync(options); + + // Assert + await act.Should().ThrowAsync(); + callToDomainUtil.MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetClientCredentialsAccessTokenAsync_WhenSuccessful_ShouldReturnTokenWithExpiry() + { + // Setup + const int expiresIn = 86399; // ~24 hours as per Shopify + const string accessToken = "client-credentials-access-token"; + var json = + //lang=json + $$""" + { + "access_token": "{{accessToken}}", + "scope": "read_products,write_orders", + "expires_in": {{expiresIn}} + } + """; + var response = 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(response); + + // Act + var authorizationResult = await _sut.GetClientCredentialsAccessTokenAsync(new ClientCredentialsAccessTokenOptions + { + ShopDomain = ShopDomain, + ClientId = ClientId, + ClientSecret = "some-secret" + }, TestContext.Current.CancellationToken); + + // Assert + authorizationResult.Should().NotBeNull(); + authorizationResult.AccessToken.Should().Be(accessToken); + authorizationResult.ExpiresIn!.Value.TotalSeconds.Should().Be(expiresIn); + authorizationResult.GrantedScopes.Should().Equal("read_products", "write_orders"); + authorizationResult.HasRefreshToken.Should().BeFalse(); + authorizationResult.Type.Should().Be(ShopifyAccessTokenType.ClientCredentials); + + // Verify request was form-encoded with correct grant_type + capturedRequest.Should().NotBeNull(); + capturedRequest!.Method.Should().Be(HttpMethod.Post); + requestContent.Should().Contain("grant_type=client_credentials"); + requestContent.Should().Contain($"client_id={ClientId}"); + requestContent.Should().Contain("client_secret=some-secret"); + } + + [Fact] + public async Task GetClientCredentialsAccessTokenAsync_WhenErrorResponse_ShouldThrow() + { + // Setup + var json = + //lang=json + """ + { + "errors": "Invalid client credentials" + } + """; + var response = Utils.MakeHttpResponseMessage(json, x => x.StatusCode = System.Net.HttpStatusCode.Unauthorized); + + A.CallTo(() => _httpClient.SendAsync(A._, A._)) + .Returns(response); + + // Act + var act = async () => await _sut.GetClientCredentialsAccessTokenAsync(new ClientCredentialsAccessTokenOptions + { + ShopDomain = ShopDomain, + ClientId = "invalid-client-id", + ClientSecret = "invalid-secret" + }, TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + } + #endregion public class FakeHttpClient : HttpClient, IDisposable { @@ -1670,4 +1803,5 @@ public HttpClient CreateClient(string name) return fakeClient; } } + #endregion } diff --git a/ShopifySharp/Entities/Authorization/AuthorizationResult.cs b/ShopifySharp/Entities/Authorization/AuthorizationResult.cs index 5cb28f87c..fcd88327f 100644 --- a/ShopifySharp/Entities/Authorization/AuthorizationResult.cs +++ b/ShopifySharp/Entities/Authorization/AuthorizationResult.cs @@ -37,11 +37,19 @@ public class AuthorizationResult(string accessToken, string[]? grantedScopes, Ti public OnlineAccessInfo? OnlineAccess { get; set; } /// - /// Identifies whether this is a legacy permanent offline, expiring offline, or online access token. + /// Identifies whether this is a legacy permanent offline, expiring offline, online, or client credentials token. /// + /// + /// Logic is as follows: + /// Online tokens have a populated OnlineAccess object. + /// ExpiringOffline tokens have a RefreshToken and an ExpiresIn value. + /// ClientCredentials tokens do not have a RefreshToken, but do have an ExpiresIn value. + /// LegacyPermanentOffline tokens have no OnlineAccess object, no RefreshToken, no ExpiresIn value. + /// public ShopifyAccessTokenType Type => OnlineAccess != null ? ShopifyAccessTokenType.Online : - HasRefreshToken ? ShopifyAccessTokenType.ExpiringOffline : + HasRefreshToken && ExpiresIn.HasValue ? ShopifyAccessTokenType.ExpiringOffline : + ExpiresIn.HasValue ? ShopifyAccessTokenType.ClientCredentials : ShopifyAccessTokenType.LegacyPermanentOffline; /// @@ -154,6 +162,9 @@ private void AssertIsRefreshTokenType() if (Type == ShopifyAccessTokenType.Online) throw new ShopifyInvalidRefreshTokenException("Online access tokens cannot be refreshed programmatically."); + if (Type == ShopifyAccessTokenType.ClientCredentials) + throw new ShopifyInvalidRefreshTokenException("Client credentials access tokens cannot be refreshed programmatically."); + if (Type == ShopifyAccessTokenType.LegacyPermanentOffline) throw new ShopifyInvalidRefreshTokenException("Legacy permanent offline access tokens do not expire and cannot be refreshed."); } diff --git a/ShopifySharp/Entities/Authorization/ShopifyAccessTokenType.cs b/ShopifySharp/Entities/Authorization/ShopifyAccessTokenType.cs index 5dbe1f5f6..7777de7f1 100644 --- a/ShopifySharp/Entities/Authorization/ShopifyAccessTokenType.cs +++ b/ShopifySharp/Entities/Authorization/ShopifyAccessTokenType.cs @@ -17,5 +17,10 @@ public enum ShopifyAccessTokenType /// /// An online access token (per-user). Expires and cannot be refreshed programmatically; requires user interaction to renew. /// - Online + Online, + + /// + /// A client credentials access token, used by custom apps. Expires and cannot be refreshed programmatically; must be obtained again via the Client Credentials Grant flow. + /// + ClientCredentials } diff --git a/ShopifySharp/Utilities/ClientCredentialsAccessTokenOptions.cs b/ShopifySharp/Utilities/ClientCredentialsAccessTokenOptions.cs new file mode 100644 index 000000000..6006e388f --- /dev/null +++ b/ShopifySharp/Utilities/ClientCredentialsAccessTokenOptions.cs @@ -0,0 +1,26 @@ +#nullable enable +namespace ShopifySharp.Utilities; + +public record ClientCredentialsAccessTokenOptions +{ + /// The store's *.myshopify.com URL. + public +#if NET6_0_OR_GREATER + required +#endif + string ShopDomain { get; set; } = null!; + + /// Your app's Client ID, also known as its API key. + public +#if NET6_0_OR_GREATER + required +#endif + string ClientId { get; set; } = null!; + + /// Your app's Client Secret. For a custom app, Shopify may also refer to this as its "password." + public +#if NET6_0_OR_GREATER + required +#endif + string ClientSecret { get; set; } = null!; +} diff --git a/ShopifySharp/Utilities/ShopifyOauthUtility.cs b/ShopifySharp/Utilities/ShopifyOauthUtility.cs index 9e600a97c..808e6e5e7 100644 --- a/ShopifySharp/Utilities/ShopifyOauthUtility.cs +++ b/ShopifySharp/Utilities/ShopifyOauthUtility.cs @@ -120,6 +120,7 @@ string existingStoreAccessToken /// /// Options for refreshing the access token. /// Cancellation token. + /// Thrown when the provided access token cannot be refreshed, e.g. due to the token's type. Task RefreshOfflineAccessTokenAsync(RefreshOfflineAccessTokenOptions options, CancellationToken cancellationToken = default); /// @@ -128,7 +129,7 @@ string existingStoreAccessToken /// /// Options for refreshing the access token. /// Cancellation token. - /// Thrown when the refresh token has expired and can no longer be used to refresh the access token. + /// Thrown when the refresh token has expired, or when the provided access token cannot be refreshed. Task RefreshOfflineAccessTokenIfStaleAsync(RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default); /// @@ -138,7 +139,7 @@ string existingStoreAccessToken /// The current authorization result to evaluate for staleness. /// Options for refreshing the access token. /// Cancellation token. - /// Thrown when the authorization result's refresh token has expired or does not contain a refresh token. + /// Thrown when the authorization result's refresh token has expired or does not contain a refresh token (e.g. the ClientCredentials tokens). Task RefreshOfflineAccessTokenIfStaleAsync(AuthorizationResult currentResult, RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default); /// @@ -149,6 +150,14 @@ string existingStoreAccessToken /// Options for cycling the access token. /// Cancellation token. Task CycleOfflineAccessTokenAsync(CycleOfflineAccessTokenOptions options, CancellationToken cancellationToken = default); + + /// + /// Obtains an access token using the Client Credentials Grant flow (RFC 6749 Section 4.4). + /// This flow is used by custom apps created via Shopify's Admin settings or Partner Dashboard. + /// + /// Options for obtaining the access token. + /// Cancellation token. + Task GetClientCredentialsAccessTokenAsync(ClientCredentialsAccessTokenOptions options, CancellationToken cancellationToken = default); } public class ShopifyOauthUtility: IShopifyOauthUtility @@ -372,6 +381,29 @@ public async Task CycleOfflineAccessTokenAsync( return await SendRequestAndParseAuthorizationResultAsync(request, cancellationToken); } + /// + public async Task GetClientCredentialsAccessTokenAsync( + ClientCredentialsAccessTokenOptions options, + CancellationToken cancellationToken = default + ) + { + var ub = new UriBuilder(_domainUtility.BuildShopDomainUri(options.ShopDomain)) + { + Path = "admin/oauth/access_token" + }; + var pairs = new KeyValuePair[] + { + new("grant_type", "client_credentials"), + new("client_id", options.ClientId), + new("client_secret", options.ClientSecret) + }; + // This endpoint, bizarrely, uses x-form-url-encoded content-type instead of json like all the other endpoints. + using var content = new FormUrlEncodedContent(pairs); + using var request = new CloneableRequestMessage(ub.Uri, HttpMethod.Post, content); + + return await SendRequestAndParseAuthorizationResultAsync(request, cancellationToken); + } + /// public async Task RefreshOfflineAccessTokenIfStaleAsync( RefreshOfflineAccessTokenIfStaleOptions options, From 999a655b130e8a3ab78f69c3fca0ab9c4d53a952 Mon Sep 17 00:00:00 2001 From: Joshua Harms Date: Mon, 14 Sep 2026 11:28:20 -0500 Subject: [PATCH 2/2] Fix RefreshOfflineAccessTokenIfStaleAsync return type nullability The interface explicitly shows that this method can return `AuthorizationResult?`, but the implementation's return type was just `AuthorizationResult`. type: fix scope: utilities --- ShopifySharp/Utilities/ShopifyOauthUtility.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShopifySharp/Utilities/ShopifyOauthUtility.cs b/ShopifySharp/Utilities/ShopifyOauthUtility.cs index 808e6e5e7..5e00544f8 100644 --- a/ShopifySharp/Utilities/ShopifyOauthUtility.cs +++ b/ShopifySharp/Utilities/ShopifyOauthUtility.cs @@ -405,7 +405,7 @@ public async Task GetClientCredentialsAccessTokenAsync( } /// - public async Task RefreshOfflineAccessTokenIfStaleAsync( + public async Task RefreshOfflineAccessTokenIfStaleAsync( RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default )