Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 140 additions & 6 deletions ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -1465,6 +1474,28 @@ await act.Should().ThrowAsync<ShopifyInvalidRefreshTokenException>()
.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<ShopifyInvalidRefreshTokenException>()
.WithMessage("Client credentials access tokens cannot be refreshed programmatically*");
}

#endregion

#region RefreshOfflineAccessTokenIfStaleAsync – Bare data overload tests
Expand Down Expand Up @@ -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<TestException>();

// Act
var act = async () => await _sut.GetClientCredentialsAccessTokenAsync(options);

// Assert
await act.Should().ThrowAsync<TestException>();
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<HttpRequestMessage>._, A<CancellationToken>._))
.Invokes(async call => {
capturedRequest = call.GetArgument<HttpRequestMessage>(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<HttpRequestMessage>._, A<CancellationToken>._))
.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<ShopifyHttpException>();
}

#endregion
public class FakeHttpClient : HttpClient, IDisposable
{
Expand All @@ -1670,4 +1803,5 @@ public HttpClient CreateClient(string name)
return fakeClient;
}
}
#endregion
}
15 changes: 13 additions & 2 deletions ShopifySharp/Entities/Authorization/AuthorizationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,19 @@ public class AuthorizationResult(string accessToken, string[]? grantedScopes, Ti
public OnlineAccessInfo? OnlineAccess { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public ShopifyAccessTokenType Type =>
OnlineAccess != null ? ShopifyAccessTokenType.Online :
HasRefreshToken ? ShopifyAccessTokenType.ExpiringOffline :
HasRefreshToken && ExpiresIn.HasValue ? ShopifyAccessTokenType.ExpiringOffline :
ExpiresIn.HasValue ? ShopifyAccessTokenType.ClientCredentials :
ShopifyAccessTokenType.LegacyPermanentOffline;

/// <summary>
Expand Down Expand Up @@ -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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ public enum ShopifyAccessTokenType
/// <summary>
/// An online access token (per-user). Expires and cannot be refreshed programmatically; requires user interaction to renew.
/// </summary>
Online
Online,

/// <summary>
/// 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.
/// </summary>
ClientCredentials
}
26 changes: 26 additions & 0 deletions ShopifySharp/Utilities/ClientCredentialsAccessTokenOptions.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
38 changes: 35 additions & 3 deletions ShopifySharp/Utilities/ShopifyOauthUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ string existingStoreAccessToken
/// </summary>
/// <param name="options">Options for refreshing the access token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="ShopifyInvalidRefreshTokenException">Thrown when the provided access token cannot be refreshed, e.g. due to the token's type.</exception>
Task<AuthorizationResult> RefreshOfflineAccessTokenAsync(RefreshOfflineAccessTokenOptions options, CancellationToken cancellationToken = default);

/// <summary>
Expand All @@ -128,7 +129,7 @@ string existingStoreAccessToken
/// </summary>
/// <param name="options">Options for refreshing the access token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="ShopifyInvalidRefreshTokenException">Thrown when the refresh token has expired and can no longer be used to refresh the access token.</exception>
/// <exception cref="ShopifyInvalidRefreshTokenException">Thrown when the refresh token has expired, or when the provided access token cannot be refreshed.</exception>
Task<AuthorizationResult?> RefreshOfflineAccessTokenIfStaleAsync(RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default);

/// <summary>
Expand All @@ -138,7 +139,7 @@ string existingStoreAccessToken
/// <param name="currentResult">The current authorization result to evaluate for staleness.</param>
/// <param name="options">Options for refreshing the access token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="ShopifyInvalidRefreshTokenException">Thrown when the authorization result's refresh token has expired or does not contain a refresh token.</exception>
/// <exception cref="ShopifyInvalidRefreshTokenException">Thrown when the authorization result's refresh token has expired or does not contain a refresh token (e.g. the ClientCredentials tokens).</exception>
Task<AuthorizationResult> RefreshOfflineAccessTokenIfStaleAsync(AuthorizationResult currentResult, RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default);

/// <summary>
Expand All @@ -149,6 +150,14 @@ string existingStoreAccessToken
/// <param name="options">Options for cycling the access token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<AuthorizationResult> CycleOfflineAccessTokenAsync(CycleOfflineAccessTokenOptions options, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="options">Options for obtaining the access token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<AuthorizationResult> GetClientCredentialsAccessTokenAsync(ClientCredentialsAccessTokenOptions options, CancellationToken cancellationToken = default);
}

public class ShopifyOauthUtility: IShopifyOauthUtility
Expand Down Expand Up @@ -373,7 +382,30 @@ public async Task<AuthorizationResult> CycleOfflineAccessTokenAsync(
}

/// <inheritdoc />
public async Task<AuthorizationResult> RefreshOfflineAccessTokenIfStaleAsync(
public async Task<AuthorizationResult> GetClientCredentialsAccessTokenAsync(
ClientCredentialsAccessTokenOptions options,
CancellationToken cancellationToken = default
)
{
var ub = new UriBuilder(_domainUtility.BuildShopDomainUri(options.ShopDomain))
{
Path = "admin/oauth/access_token"
};
var pairs = new KeyValuePair<string, string>[]
{
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);
}

/// <inheritdoc />
public async Task<AuthorizationResult?> RefreshOfflineAccessTokenIfStaleAsync(
RefreshOfflineAccessTokenIfStaleOptions options,
CancellationToken cancellationToken = default
)
Expand Down