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..5e00544f8 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 @@ -373,7 +382,30 @@ public async Task CycleOfflineAccessTokenAsync( } /// - public async Task RefreshOfflineAccessTokenIfStaleAsync( + 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, CancellationToken cancellationToken = default )