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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,56 @@ See unit tests for more examples.

### OAuth access tokens

#### Generate an application-only signing key pair

Generate a P-256 key pair for ES256 or an RSA-3072 key pair for RS256, following [Shopify's application-only access token guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens):

```sh
dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11 ES256
dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-rsa-2026-11 RS256
```

The console prints an unencrypted PKCS#8 PEM private key and a public JWK. ES256 keys contain `kty`, `crv`, `x`, and `y`; RS256 keys contain `kty`, `n`, and `e`. Both include `kid`, `alg`, and `use` metadata. Paste only the JWK into **Dev Dashboard > Settings > Credentials > Public keys > Add key**. Store the private key securely for signing client assertions; do not commit it. Choose a key ID that is unique among your app's active keys, at most 255 characters, without surrounding whitespace. Every run generates a new key pair.

You can also call the generator from your own console application without app credentials:

```csharp
var keys = ShopifyClientAssertionKeys.GenerateKeyPair("betterreports-2026-11", "ES256");
Console.WriteLine(keys.PrivateKeyPem);
Console.WriteLine(keys.PublicKeyJwk);
```

Both the library method and console command require two arguments: the key ID and signing algorithm (`ES256` or `RS256`, case-sensitive). This generates signing keys only; it does not register the public key or request an access token.

#### Run the application-only integration test

Install your app on a development store and register the public JWK in Shopify first. Set these variables in PowerShell, using the key ID registered with the matching P-256 or RSA private key:

```powershell
$env:SHOPIFYNET_RUN_OAUTH_INTEGRATION_TESTS = "1"
$env:SHOPIFYNET_SHOP_ID = "your-store.myshopify.com"
$env:SHOPIFYNET_CLIENT_ID = "your-client-id"
$env:SHOPIFYNET_KEY_ID = "your-registered-key-id"
$env:SHOPIFYNET_PRIVATE_KEY_PATH = "C:\secure\private.pem"
dotnet test ShopifyNet.Tests --framework net10.0 --filter FullyQualifiedName~ShopifyClientAssertionIntegrationTests
```

The test creates a fresh ES256 or RS256 assertion and exchanges it for an access token, then checks the token, scopes, and positive expiry. No client secret is needed. It skips unless explicitly enabled and does not print the key, assertion, or token. Remove the opt-in variable afterward with `Remove-Item Env:SHOPIFYNET_RUN_OAUTH_INTEGRATION_TESTS`.

#### Acquire, migrate and refresh tokens

Request an application-only access token using your registered key ID and securely stored P-256 or RSA private key. No client secret is needed; pass an empty string to the existing constructor for this flow:

```csharp
var oauthClient = new ShopifyOAuthClient(clientId, clientSecret: string.Empty);
ShopifyClientAssertionAccessToken applicationToken = await oauthClient.RequestClientAssertionAccessTokenAsync(
myShopifyDomain, keyId, privateKeyPem, cancellationToken);
```

`RequestClientAssertionAccessTokenAsync` creates a fresh ES256 or RS256 assertion internally, with the store's token endpoint as its audience, a 60-second lifetime, and a unique `jti`. It selects ES256 for P-256 keys and RS256 for RSA keys (2048–8192 bits), rejecting unsupported keys. Unencrypted PKCS#8, EC PRIVATE KEY, and RSA PRIVATE KEY PEM formats are supported. Supply exactly one PEM block with optional surrounding whitespace; encrypted keys, multiple blocks, and additional non-whitespace content are rejected. It sends the assertion using the `client_credentials` grant and configured client ID, without a client secret or scope parameter, as described in [Shopify's guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-5-request-an-access-token).

Use `applicationToken.access_token` for GraphQL requests and its returned `expires_in` to determine when to request a new token. There is no refresh token. The method does not retry automatically: call it again to retry with a fresh assertion. The default HTTP client rejects redirects; if supplying your own `HttpClient`, disable redirects and automatic retries and do not configure an Authorization header.

`ShopifyOAuthClient` calls the Shopify OAuth access token endpoint to acquire, migrate and refresh access tokens.
Public apps must use [expiring offline access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens) for the GraphQL Admin API from January 1, 2027.

Expand Down
25 changes: 25 additions & 0 deletions ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using ShopifyNet;

if (args.Length != 2)
{
// betterreports-2026-11 is an example key ID format used for Shopify client assertion keys. The key ID must be unique across your client assertion keys.
Console.Error.WriteLine("Usage: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- <key-id> <ES256|RS256>");
Console.Error.WriteLine("Example: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11 ES256");
return 1;
}

try
{
var (privateKeyPem, publicKeyJwk) = ShopifyClientAssertionKeys.GenerateKeyPair(args[0], args[1]);
Console.WriteLine("Private key (keep secure; do not upload to Shopify):");
Console.WriteLine(privateKeyPem);
Console.WriteLine();
Console.WriteLine("Public JWK (paste into Shopify Dev Dashboard > Settings > Credentials > Public keys):");
Console.WriteLine(publicKeyJwk);
return 0;
}
catch (ArgumentException exception)
{
Console.Error.WriteLine(exception.Message);
return 1;
}
9 changes: 9 additions & 0 deletions ShopifyNet.KeyPairGenerator/ShopifyNet.KeyGenerator.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ShopifyNet/ShopifyNet.csproj" />
</ItemGroup>
</Project>
105 changes: 105 additions & 0 deletions ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ShopifyNet.Tests;

[TestClass]
public class ShopifyApplicationOnlyKeyPairTests
{
[TestMethod]
public void GeneratedPublicJwkVerifiesPrivateKeySignature()
{
var (privateKeyPem, publicKeyJwk) = ShopifyClientAssertionKeys.GenerateKeyPair("betterreports", "ES256");
using var privateKey = ECDsa.Create();
privateKey.ImportFromPem(privateKeyPem);
Assert.AreEqual(256, privateKey.KeySize);
StringAssert.StartsWith(privateKeyPem, "-----BEGIN PRIVATE KEY-----");

using var document = JsonDocument.Parse(publicKeyJwk);
var jwk = document.RootElement;
Assert.AreEqual("EC", jwk.GetProperty("kty").GetString());
Assert.AreEqual("P-256", jwk.GetProperty("crv").GetString());
Assert.AreEqual("ES256", jwk.GetProperty("alg").GetString());
Assert.AreEqual("sig", jwk.GetProperty("use").GetString());
Assert.AreEqual("betterreports", jwk.GetProperty("kid").GetString());
CollectionAssert.AreEquivalent(new[] { "kty", "crv", "x", "y", "kid", "alg", "use" },
jwk.EnumerateObject().Select(p => p.Name).ToArray());

var encodedX = jwk.GetProperty("x").GetString();
var encodedY = jwk.GetProperty("y").GetString();
Assert.IsFalse(encodedX.Contains('='), "JWK coordinates must not have base64 padding.");
Assert.IsFalse(encodedY.Contains('='), "JWK coordinates must not have base64 padding.");
var x = Base64Url.DecodeFromChars(encodedX);
var y = Base64Url.DecodeFromChars(encodedY);
Assert.AreEqual(32, x.Length);
Assert.AreEqual(32, y.Length);
using var publicKey = ECDsa.Create();
publicKey.ImportParameters(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = x, Y = y }
});
var data = Encoding.UTF8.GetBytes("test client assertion");
var signature = privateKey.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
Assert.AreEqual(64, signature.Length);
Assert.IsTrue(publicKey.VerifyData(data, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation));
}

[DataTestMethod]
[DataRow("")]
[DataRow(" ")]
[DataRow(" key")]
[DataRow("key\n")]
public void RejectsInvalidKeyIds(string keyId)
{
Assert.ThrowsException<ArgumentException>(() => ShopifyClientAssertionKeys.GenerateKeyPair(keyId, "ES256"));
}

[TestMethod]
public void RejectsNullAndOverlongKeyIds()
{
Assert.ThrowsException<ArgumentNullException>(() => ShopifyClientAssertionKeys.GenerateKeyPair(null, "ES256"));
Assert.ThrowsException<ArgumentException>(() => ShopifyClientAssertionKeys.GenerateKeyPair(new string('a', 256), "ES256"));
}

[TestMethod]
public void GeneratesRsa3072PublicJwkMatchingPrivateKey()
{
var keys = ShopifyClientAssertionKeys.GenerateKeyPair("rsa-key", "RS256");
using var privateKey = RSA.Create();
privateKey.ImportFromPem(keys.PrivateKeyPem);
Assert.AreEqual(3072, privateKey.KeySize);
StringAssert.StartsWith(keys.PrivateKeyPem, "-----BEGIN PRIVATE KEY-----");
using var json = JsonDocument.Parse(keys.PublicKeyJwk);
var jwk = json.RootElement;
CollectionAssert.AreEquivalent(new[] { "kty", "n", "e", "kid", "alg", "use" },
jwk.EnumerateObject().Select(p => p.Name).ToArray());
Assert.AreEqual("RSA", jwk.GetProperty("kty").GetString());
Assert.AreEqual("RS256", jwk.GetProperty("alg").GetString());
Assert.AreEqual("rsa-key", jwk.GetProperty("kid").GetString());
Assert.AreEqual("sig", jwk.GetProperty("use").GetString());
using var publicKey = RSA.Create(new RSAParameters
{
Modulus = Base64Url.DecodeFromChars(jwk.GetProperty("n").GetString()),
Exponent = Base64Url.DecodeFromChars(jwk.GetProperty("e").GetString())
});
var data = Encoding.UTF8.GetBytes("test assertion");
var signature = privateKey.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
Assert.IsTrue(publicKey.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
}

[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("HS256")]
[DataRow("ES384")]
[DataRow("rs256")]
[DataRow(" RS256 ")]
public void RejectsUnsupportedAlgorithm(string algorithm)
{
Assert.ThrowsException<ArgumentException>(() => ShopifyClientAssertionKeys.GenerateKeyPair("key", algorithm));
}
}
101 changes: 101 additions & 0 deletions ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Buffers.Text;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Web;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ShopifyNet.Tests;

[TestClass]
public class ShopifyClientAssertionAccessTokenRequestTests
{
[DataTestMethod]
[DataRow("ES256", false)]
[DataRow("RS256", false)]
[DataRow("ES256", true)]
[DataRow("RS256", true)]
public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorithm, bool useTraditionalPem)
{
var keys = ShopifyClientAssertionKeys.GenerateKeyPair("test-key", algorithm);
using AsymmetricAlgorithm signingKey = algorithm == "ES256" ? ECDsa.Create() : RSA.Create();
signingKey.ImportFromPem(keys.PrivateKeyPem);
using AsymmetricAlgorithm verificationKey = signingKey is ECDsa ec
? ECDsa.Create(ec.ExportParameters(false)) : RSA.Create(((RSA)signingKey).ExportParameters(false));
var pem = useTraditionalPem
? signingKey is ECDsa ecKey ? ecKey.ExportECPrivateKeyPem() : ((RSA)signingKey).ExportRSAPrivateKeyPem()
: keys.PrivateKeyPem;
var assertionIds = new HashSet<string>();
long issuedAt = 0;
using var http = new TestHttpClient(request =>
{
Assert.AreEqual(HttpMethod.Post, request.Method);
Assert.AreEqual("https://test.myshopify.com/admin/oauth/access_token", request.RequestUri.AbsoluteUri);
var form = HttpUtility.ParseQueryString(request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
Assert.AreEqual(4, form.Count);
Assert.AreEqual("client_credentials", form["grant_type"]);
Assert.AreEqual("client-id", form["client_id"]);
Assert.AreEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", form["client_assertion_type"]);
Assert.IsNull(form["client_secret"]);
var parts = form["client_assertion"].Split('.');
Assert.AreEqual(3, parts.Length);
using var header = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[0]));
Assert.AreEqual(algorithm, header.RootElement.GetProperty("alg").GetString());
Assert.AreEqual("test-key", header.RootElement.GetProperty("kid").GetString());
using var payload = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1]));
Assert.AreEqual("client-id", payload.RootElement.GetProperty("iss").GetString());
Assert.AreEqual("client-id", payload.RootElement.GetProperty("sub").GetString());
Assert.AreEqual(request.RequestUri.AbsoluteUri, payload.RootElement.GetProperty("aud").GetString());
issuedAt = payload.RootElement.GetProperty("iat").GetInt64();
Assert.AreEqual(60L, payload.RootElement.GetProperty("exp").GetInt64() - issuedAt);
Assert.IsTrue(assertionIds.Add(payload.RootElement.GetProperty("jti").GetString()), "Each call must use a new assertion ID.");
var data = Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]);
var signature = Base64Url.DecodeFromChars(parts[2]);
Assert.IsTrue(verificationKey is ECDsa ecVerifier
? ecVerifier.VerifyData(data, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)
: ((RSA)verificationKey).VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"access_token":"test-token","scope":"read_products","expires_in":3600}""")
};
});
var client = new ShopifyOAuthClient("client-id", "unused-secret", http);
for (var i = 0; i < 2; i++)
{
var beforeRequest = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "test-key", pem);
var afterRequest = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Assert.IsTrue(issuedAt >= beforeRequest && issuedAt <= afterRequest,
"Assertion iat must be the current UTC time in Unix seconds.");
Assert.AreEqual("test-token", token.access_token);
Assert.AreEqual(3600, token.expires_in);
}
Assert.AreEqual(2, assertionIds.Count);
}

[TestMethod]
public async Task RejectsUnsupportedKeysBeforeSending()
{
using var http = new TestHttpClient(_ => throw new AssertFailedException("Invalid key must not be sent."));
var client = new ShopifyOAuthClient("client-id", string.Empty, http);
using var wrongCurve = ECDsa.Create(ECCurve.NamedCurves.nistP384);
using var smallRsa = RSA.Create(1024);
await Assert.ThrowsExceptionAsync<ArgumentException>(() => client.RequestClientAssertionAccessTokenAsync(
"test.myshopify.com", "key", wrongCurve.ExportPkcs8PrivateKeyPem()));
await Assert.ThrowsExceptionAsync<ArgumentException>(() => client.RequestClientAssertionAccessTokenAsync(
"test.myshopify.com", "key", smallRsa.ExportPkcs8PrivateKeyPem()));
}

[DataTestMethod]
[DataRow("ES256")]
[DataRow("RS256")]
public async Task RejectsPublicOnlyKeysBeforeSending(string algorithm)
{
using AsymmetricAlgorithm key = algorithm == "ES256" ? ECDsa.Create(ECCurve.NamedCurves.nistP256) : RSA.Create(2048);
using var http = new TestHttpClient(_ => throw new AssertFailedException("Public-only key must not be sent."));
var client = new ShopifyOAuthClient("client-id", string.Empty, http);
await Assert.ThrowsExceptionAsync<CryptographicException>(() => client.RequestClientAssertionAccessTokenAsync(
"test.myshopify.com", "key", key.ExportSubjectPublicKeyInfoPem()));
}
}
47 changes: 47 additions & 0 deletions ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ShopifyNet.Tests;

// Uses a registered public JWK and its matching private key against an installed development store.
[TestClass]
[TestCategory("Integration")]
[DoNotParallelize]
public class ShopifyClientAssertionIntegrationTests
{
[TestMethod]
public async Task RequestClientAssertionAccessTokenAsync_ReturnsExpiringToken()
{
if (GetEnvironmentVariable("SHOPIFYNET_RUN_OAUTH_INTEGRATION_TESTS") != "1")
Assert.Inconclusive("Set SHOPIFYNET_RUN_OAUTH_INTEGRATION_TESTS=1 to enable live OAuth tests.");

var shop = RequireEnvironmentVariable("SHOPIFYNET_SHOP_ID");
Assert.IsTrue(ShopifyOAuthClient.IsValidShopDomain(shop), "SHOPIFYNET_SHOP_ID must be a plain store.myshopify.com hostname.");
var clientId = RequireEnvironmentVariable("SHOPIFYNET_CLIENT_ID");
var keyId = RequireEnvironmentVariable("SHOPIFYNET_KEY_ID");
var privateKeyPath = RequireEnvironmentVariable("SHOPIFYNET_PRIVATE_KEY_PATH");
var privateKeyPem = await File.ReadAllTextAsync(privateKeyPath);

// Application-only authentication does not use the client secret.
var client = new ShopifyOAuthClient(clientId, clientSecret: string.Empty);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var token = await client.RequestClientAssertionAccessTokenAsync(shop, keyId, privateKeyPem, timeout.Token);

Assert.IsNotNull(token);
// Boolean assertions avoid printing the assertion, private key or returned token on failure.
Assert.IsFalse(string.IsNullOrWhiteSpace(token.access_token), "Missing access token.");
Assert.IsNotNull(token.scope, "Missing granted scopes.");
Assert.IsTrue(token.expires_in > 0, "Token lifetime must be positive.");
}

private static string RequireEnvironmentVariable(string name)
{
var value = GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(value))
Assert.Inconclusive($"Set {name} to run this live OAuth test.");
return value;
}

private static string GetEnvironmentVariable(string name) =>
Environment.GetEnvironmentVariable(name) ??
Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User);
}
Loading
Loading