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
113 changes: 111 additions & 2 deletions ShopifySharp.Tests/Utilities/ShopifyOauthUtilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,12 +18,13 @@ namespace ShopifySharp.Tests.Utilities;
[TestSubject(typeof(ShopifyOauthUtility))]
[Trait("Category", "ShopifyOauthUtility")]
[Collection("ShopifyOauthUtility")]
public class ShopifyOauthUtilityTests
public class ShopifyOauthUtilityTests : IClassFixture<VerifyFixture>
{
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<IShopifyDomainUtility>(x => x.Wrapping(new ShopifyDomainUtility()).CallsBaseMethods());
private readonly IJsonSerializer _jsonSerializer = new SystemJsonSerializer(Serializer.RestSerializerOptions);
private readonly IServiceProvider _serviceProvider = A.Fake<IServiceProvider>(x => x.Strict());
Expand All @@ -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)))
Expand Down Expand Up @@ -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<HttpRequestMessage>._, A<CancellationToken>._))
.Invokes(async call => {
capturedRequest = call.GetArgument<HttpRequestMessage>(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<HttpRequestMessage>._, A<CancellationToken>._))
.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<ShopifyHttpException>()
.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<HttpRequestMessage>._, A<CancellationToken>._))
.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<ShopifyHttpException>()
.WithMessage("(400 Bad Request) " + expectedErrorMessage);
exn.Which.HttpStatusCode.Should().Be(expectedStatusCode);
}

#endregion

#region AuthorizeAsync – Null and invalid type tests

[Theory]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
35 changes: 35 additions & 0 deletions ShopifySharp/Utilities/CycleOfflineAccessTokenOptions.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
40 changes: 39 additions & 1 deletion ShopifySharp/Utilities/ShopifyOauthUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ string existingStoreAccessToken
/// <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>
Task<AuthorizationResult> RefreshOfflineAccessTokenIfStaleAsync(AuthorizationResult currentResult, RefreshOfflineAccessTokenIfStaleOptions options, CancellationToken cancellationToken = default);

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

public class ShopifyOauthUtility: IShopifyOauthUtility
Expand Down Expand Up @@ -335,7 +344,36 @@ public async Task<AuthorizationResult> RefreshOfflineAccessTokenAsync(
}

/// <inheritdoc />
public async Task<AuthorizationResult?> RefreshOfflineAccessTokenIfStaleAsync(
public async Task<AuthorizationResult> 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);
}

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