From 1848fc9327b332ed575d4491d1115f84ca1422d3 Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 12:17:08 +1100 Subject: [PATCH 1/6] Add key air generator to generate the keys required to request client access token --- README.md | 22 ++++++ .../KeyPairGenerator.cs | 23 +++++++ .../ShopifyNet.KeyGenerator.csproj | 9 +++ .../ShopifyApplicationOnlyKeyPairTests.cs | 69 +++++++++++++++++++ ShopifyNet.sln | 14 ++++ .../OAuth/ShopifyApplicationOnlyKeys.cs | 38 ++++++++++ 6 files changed, 175 insertions(+) create mode 100644 ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs create mode 100644 ShopifyNet.KeyPairGenerator/ShopifyNet.KeyGenerator.csproj create mode 100644 ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs create mode 100644 ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs diff --git a/README.md b/README.md index 4802e0e..850f242 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,28 @@ See unit tests for more examples. ### OAuth access tokens +#### Generate an application-only signing key pair + +Generate a P-256 elliptic-curve key pair for ES256 signing, 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- +``` + +The console prints an unencrypted PKCS#8 PEM private key and a public JWK containing `kty`, `crv`, `x`, `y`, `kid`, `alg`, and `use`. 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 = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports-"); +Console.WriteLine(keys.PrivateKeyPem); +Console.WriteLine(keys.PublicKeyJwk); +``` + +This generates signing keys only; it does not register the public key or request an access token. + +#### Acquire, migrate and refresh tokens + `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. diff --git a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs new file mode 100644 index 0000000..d93a1cb --- /dev/null +++ b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs @@ -0,0 +1,23 @@ +using ShopifyNet; + +if (args.Length != 1) +{ + Console.Error.WriteLine("Usage: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-"); + return 1; +} + +try +{ + var (privateKeyPem, publicKeyJwk) = ShopifyApplicationOnlyKeys.GenerateKeyPair(args[0]); + 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; +} diff --git a/ShopifyNet.KeyPairGenerator/ShopifyNet.KeyGenerator.csproj b/ShopifyNet.KeyPairGenerator/ShopifyNet.KeyGenerator.csproj new file mode 100644 index 0000000..8fc998f --- /dev/null +++ b/ShopifyNet.KeyPairGenerator/ShopifyNet.KeyGenerator.csproj @@ -0,0 +1,9 @@ + + + Exe + false + + + + + diff --git a/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs new file mode 100644 index 0000000..f709bb3 --- /dev/null +++ b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs @@ -0,0 +1,69 @@ +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) = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports"); + 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 x = DecodeBase64Url(jwk.GetProperty("x").GetString()); + var y = DecodeBase64Url(jwk.GetProperty("y").GetString()); + 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(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(keyId)); + } + + [TestMethod] + public void RejectsNullAndOverlongKeyIds() + { + Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(null)); + Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(new string('a', 256))); + } + + private static byte[] DecodeBase64Url(string value) + { + Assert.IsFalse(value.Contains('='), "JWK integers must not have base64 padding."); + var base64 = value.Replace('-', '+').Replace('_', '/'); + return Convert.FromBase64String(base64.PadRight((base64.Length + 3) / 4 * 4, '=')); + } +} diff --git a/ShopifyNet.sln b/ShopifyNet.sln index c165479..555842b 100644 --- a/ShopifyNet.sln +++ b/ShopifyNet.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShopifyNet.TypeGenerator", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShopifyNet.Tests", "ShopifyNet.Tests\ShopifyNet.Tests.csproj", "{97D9E168-6634-46D5-B4E2-AA568B504993}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShopifyNet.KeyGenerator", "ShopifyNet.KeyPairGenerator\ShopifyNet.KeyGenerator.csproj", "{95FC6A79-52F2-4295-A5FD-20C6752E37BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {97D9E168-6634-46D5-B4E2-AA568B504993}.Release|x64.Build.0 = Release|Any CPU {97D9E168-6634-46D5-B4E2-AA568B504993}.Release|x86.ActiveCfg = Release|Any CPU {97D9E168-6634-46D5-B4E2-AA568B504993}.Release|x86.Build.0 = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|x64.Build.0 = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Debug|x86.Build.0 = Debug|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|Any CPU.Build.0 = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|x64.ActiveCfg = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|x64.Build.0 = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|x86.ActiveCfg = Release|Any CPU + {95FC6A79-52F2-4295-A5FD-20C6752E37BB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs b/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs new file mode 100644 index 0000000..e05cadb --- /dev/null +++ b/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace ShopifyNet; + +/// +/// Generates signing keys for Shopify application-only authentication. +/// +public static class ShopifyApplicationOnlyKeys +{ + /// + /// Generates a new P-256 elliptic-curve signing key pair for application-only access tokens using ES256. + /// Returns an unencrypted PKCS#8 PEM private key and an indented public JWK for Shopify's Dev Dashboard. + /// Keep the private key secure; upload only the public JWK. Each call creates a new key pair. + /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens + /// + public static (string PrivateKeyPem, string PublicKeyJwk) GenerateKeyPair(string keyId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (keyId.Length > 255 || keyId != keyId.Trim()) + throw new ArgumentException("Key ID must be at most 255 characters and have no leading or trailing whitespace.", nameof(keyId)); + + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var publicKey = key.ExportParameters(includePrivateParameters: false); + var jwk = JsonSerializer.Serialize(new + { + kty = "EC", + crv = "P-256", + x = Convert.ToBase64String(publicKey.Q.X).TrimEnd('=').Replace('+', '-').Replace('/', '_'), + y = Convert.ToBase64String(publicKey.Q.Y).TrimEnd('=').Replace('+', '-').Replace('/', '_'), + kid = keyId, + alg = "ES256", + use = "sig" + }, new JsonSerializerOptions { WriteIndented = true }); + + return (key.ExportPkcs8PrivateKeyPem(), jwk); + } +} From 23779a21e93c56de060adfcb43786d9fd710e049 Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 14:31:42 +1100 Subject: [PATCH 2/6] Add an interface to create the client assertion and exchange it for a client assertion access token --- README.md | 27 ++++++ ...yClientAssertionAccessTokenRequestTests.cs | 59 +++++++++++ .../ShopifyClientAssertionIntegrationTests.cs | 47 +++++++++ .../ShopifyClientAssertionAccessToken.cs | 21 ++++ ShopifyNet/OAuth/ShopifyOAuthClient.cs | 97 +++++++++++++++++-- 5 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs create mode 100644 ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs create mode 100644 ShopifyNet/OAuth/ShopifyClientAssertionAccessToken.cs diff --git a/README.md b/README.md index 850f242..ea6f021 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,35 @@ Console.WriteLine(keys.PublicKeyJwk); 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 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 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 private key: + +```csharp +var oauthClient = new ShopifyOAuthClient(clientId, clientSecret); +ShopifyClientAssertionAccessToken applicationToken = await oauthClient.RequestClientAssertionAccessTokenAsync( + myShopifyDomain, keyId, privateKeyPem, cancellationToken); +``` + +`RequestClientAssertionAccessTokenAsync` creates a fresh ES256 assertion internally, with the store's token endpoint as its audience, a 60-second lifetime, and a unique `jti`. 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. diff --git a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs new file mode 100644 index 0000000..245fe1e --- /dev/null +++ b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs @@ -0,0 +1,59 @@ +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 +{ + [TestMethod] + public async Task CreatesFreshSignedAssertionForEveryTokenRequest() + { + var keys = ShopifyApplicationOnlyKeys.GenerateKeyPair("test-key"); + using var signingKey = ECDsa.Create(); + signingKey.ImportFromPem(keys.PrivateKeyPem); + using var verificationKey = ECDsa.Create(signingKey.ExportParameters(false)); + var assertionIds = new HashSet(); + 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("ES256", 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()); + Assert.AreEqual(60L, payload.RootElement.GetProperty("exp").GetInt64() - payload.RootElement.GetProperty("iat").GetInt64()); + Assert.IsTrue(assertionIds.Add(payload.RootElement.GetProperty("jti").GetString()), "Each call must use a new assertion ID."); + Assert.IsTrue(verificationKey.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), + Base64Url.DecodeFromChars(parts[2]), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)); + 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 token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "test-key", keys.PrivateKeyPem); + Assert.AreEqual("test-token", token.access_token); + Assert.AreEqual(3600, token.expires_in); + } + Assert.AreEqual(2, assertionIds.Count); + } +} diff --git a/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs new file mode 100644 index 0000000..11c3023 --- /dev/null +++ b/ShopifyNet.Tests/ShopifyClientAssertionIntegrationTests.cs @@ -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); +} diff --git a/ShopifyNet/OAuth/ShopifyClientAssertionAccessToken.cs b/ShopifyNet/OAuth/ShopifyClientAssertionAccessToken.cs new file mode 100644 index 0000000..86a4462 --- /dev/null +++ b/ShopifyNet/OAuth/ShopifyClientAssertionAccessToken.cs @@ -0,0 +1,21 @@ +namespace ShopifyNet; + +/// +/// An application-only access token obtained using a client assertion. +/// There is no refresh token: request a new token with a fresh assertion before expiry. +/// +public sealed record ShopifyClientAssertionAccessToken +{ + /// The access token to send in the X-Shopify-Access-Token header. + public string access_token { get; init; } + + /// The comma separated scopes granted to the app installation. + public string scope { get; init; } + + /// The number of seconds until expiry, as returned by Shopify. + public int expires_in { get; init; } + + // Avoid leaking the token into logs. + public override string ToString() => + $"{nameof(ShopifyClientAssertionAccessToken)} {{ {nameof(scope)} = {scope}, {nameof(expires_in)} = {expires_in} }}"; +} diff --git a/ShopifyNet/OAuth/ShopifyOAuthClient.cs b/ShopifyNet/OAuth/ShopifyOAuthClient.cs index dd8e2fb..7777e5c 100644 --- a/ShopifyNet/OAuth/ShopifyOAuthClient.cs +++ b/ShopifyNet/OAuth/ShopifyOAuthClient.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Net; using System.Security.Cryptography; using System.Text; @@ -25,6 +26,8 @@ public class ShopifyOAuthClient private const string REDIRECT_URI_KEY = "redirect_uri"; private const string STATE_KEY = "state"; private const string GRANT_OPTIONS_KEY = "grant_options[]"; + private const string CLIENT_ASSERTION_KEY = "client_assertion"; + private const string CLIENT_ASSERTION_TYPE_KEY = "client_assertion_type"; private const string EXPIRING_VALUE = "1"; private const string REFRESH_TOKEN_GRANT_TYPE = "refresh_token"; @@ -32,11 +35,15 @@ public class ShopifyOAuthClient private const string ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"; private const string OFFLINE_ACCESS_TOKEN_TYPE = "urn:shopify:params:oauth:token-type:offline-access-token"; private const string ONLINE_ACCESS_TOKEN_TYPE = "urn:shopify:params:oauth:token-type:online-access-token"; + private const string CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"; + private const string JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; private static readonly HttpClient _defaultHttpClient = new(new SocketsHttpHandler { // Request and automatically decode compressed responses from Shopify. - AutomaticDecompression = DecompressionMethods.All + AutomaticDecompression = DecompressionMethods.All, + // Token requests must not forward credentials to a redirect target. + AllowAutoRedirect = false }); private readonly string _clientId; @@ -171,7 +178,7 @@ public Task MigrateToExpiringOfflineAccessTokenAsync( [SUBJECT_TOKEN_TYPE_KEY] = OFFLINE_ACCESS_TOKEN_TYPE, [REQUESTED_TOKEN_TYPE_KEY] = OFFLINE_ACCESS_TOKEN_TYPE, [EXPIRING_KEY] = EXPIRING_VALUE, - }, cancellationToken); + }, true, cancellationToken); } /// @@ -186,7 +193,7 @@ public Task RefreshOfflineAccessTokenAsync(string myS { [GRANT_TYPE_KEY] = REFRESH_TOKEN_GRANT_TYPE, [REFRESH_TOKEN_KEY] = refreshToken, - }, cancellationToken); + }, true, cancellationToken); } /// @@ -204,7 +211,7 @@ public Task ExchangeIdTokenForOfflineAccessTokenAsync [SUBJECT_TOKEN_TYPE_KEY] = ID_TOKEN_TYPE, [REQUESTED_TOKEN_TYPE_KEY] = OFFLINE_ACCESS_TOKEN_TYPE, [EXPIRING_KEY] = EXPIRING_VALUE, - }, cancellationToken); + }, true, cancellationToken); } /// @@ -222,7 +229,7 @@ public Task ExchangeIdTokenForOnlineAccessTokenAsync(s [SUBJECT_TOKEN_KEY] = idToken, [SUBJECT_TOKEN_TYPE_KEY] = ID_TOKEN_TYPE, [REQUESTED_TOKEN_TYPE_KEY] = ONLINE_ACCESS_TOKEN_TYPE, - }, cancellationToken); + }, true, cancellationToken); } /// @@ -239,7 +246,7 @@ public Task ExchangeCodeForOfflineAccessTokenAsync(st }; if (expiring) parameters[EXPIRING_KEY] = EXPIRING_VALUE; - return RequestAccessTokenAsync(myShopifyDomain, parameters, cancellationToken); + return RequestAccessTokenAsync(myShopifyDomain, parameters, true, cancellationToken); } /// @@ -254,19 +261,85 @@ public Task ExchangeCodeForOnlineAccessTokenAsync(stri return RequestAccessTokenAsync(myShopifyDomain, new() { [CODE_KEY] = code, - }, cancellationToken); + }, true, cancellationToken); } - private async Task RequestAccessTokenAsync(string myShopifyDomain, Dictionary parameters, CancellationToken cancellationToken) + /// + /// Creates an ES256 client assertion for the shop's application-only access token request. + /// Uses the configured client ID and an unencrypted P-256 PEM private key; the client secret is not used. + /// The assertion expires after 60 seconds and contains a new jti on each call. + /// Create a fresh assertion for every token request, including retries. This is not an access token. + /// No HTTP request is sent. The matching public JWK must already be registered in Shopify with this key ID. + /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-4-create-a-client-assertion + /// + private string CreateClientAssertion(string myShopifyDomain, string keyId, string privateKeyPem) + { + ArgumentException.ThrowIfNullOrWhiteSpace(_clientId); + ArgumentNullException.ThrowIfNull(myShopifyDomain); + if (!IsValidShopDomain(myShopifyDomain)) + throw new ArgumentException("Invalid shop domain.", nameof(myShopifyDomain)); + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (keyId.Length > 255 || keyId != keyId.Trim()) + throw new ArgumentException("Key ID must be at most 255 characters and have no leading or trailing whitespace.", nameof(keyId)); + ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem); + + using var key = ECDsa.Create(); + key.ImportFromPem(privateKeyPem); + if (key.ExportParameters(includePrivateParameters: false).Curve.Oid.Value != ECCurve.NamedCurves.nistP256.Oid.Value) + throw new ArgumentException("Expected a P-256 EC private key for ES256.", nameof(privateKeyPem)); + + var issuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var header = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { alg = "ES256", kid = keyId })); + var payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new + { + iss = _clientId, + sub = _clientId, + aud = $"https://{myShopifyDomain.ToLowerInvariant()}/admin/oauth/access_token", + iat = issuedAt, + exp = issuedAt + 60, + jti = Guid.NewGuid().ToString() + })); + var signingInput = $"{header}.{payload}"; + var signature = key.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return $"{signingInput}.{Base64Url.EncodeToString(signature)}"; + } + + /// + /// Creates an ES256 client assertion and requests an application-only access token for an installed store. + /// Supply the registered key ID and matching unencrypted P-256 PEM private key. + /// Sends the client_credentials grant without the client secret. Each call creates a fresh assertion. + /// No automatic retries are performed; call this method again to retry. The returned token has no refresh token. + /// A supplied HttpClient must disable redirects and must not add Authorization headers or replay requests. + /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens#step-5-request-an-access-token + /// + public Task RequestClientAssertionAccessTokenAsync( + string myShopifyDomain, string keyId, string privateKeyPem, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_httpClient.DefaultRequestHeaders.Authorization != null) + throw new InvalidOperationException("Client assertion requests must not include an Authorization header."); + var clientAssertion = CreateClientAssertion(myShopifyDomain, keyId, privateKeyPem); + return RequestAccessTokenAsync(myShopifyDomain, new() + { + [GRANT_TYPE_KEY] = CLIENT_CREDENTIALS_GRANT_TYPE, + [CLIENT_ASSERTION_TYPE_KEY] = JWT_BEARER_ASSERTION_TYPE, + [CLIENT_ASSERTION_KEY] = clientAssertion + }, false, cancellationToken); + } + + private async Task RequestAccessTokenAsync(string myShopifyDomain, Dictionary parameters, + bool includeClientSecret, CancellationToken cancellationToken) where T : class { ArgumentNullException.ThrowIfNull(myShopifyDomain); - //The client secret must only be sent to a Shopify store hostname. + // Credentials must only be sent to a Shopify store hostname. if (!IsValidShopDomain(myShopifyDomain)) throw new ArgumentException("Invalid shop domain.", nameof(myShopifyDomain)); parameters[CLIENT_ID_KEY] = _clientId; - parameters[CLIENT_SECRET_KEY] = _clientSecret; + if (includeClientSecret) + parameters[CLIENT_SECRET_KEY] = _clientSecret; using var request = new HttpRequestMessage(HttpMethod.Post, $"https://{myShopifyDomain}/admin/oauth/access_token") { @@ -291,6 +364,10 @@ private async Task RequestAccessTokenAsync(string myShopifyDomain, Diction switch (token) { + case ShopifyClientAssertionAccessToken applicationToken: + if (string.IsNullOrWhiteSpace(applicationToken.access_token) || applicationToken.expires_in <= 0) + throw new ShopifyOAuthException("An application-only token response must contain a usable access_token and positive expires_in.", response.StatusCode); + return token; case ShopifyOfflineAccessToken offlineToken: var expiringRequested = parameters.GetValueOrDefault(EXPIRING_KEY) == EXPIRING_VALUE || parameters.GetValueOrDefault(GRANT_TYPE_KEY) == REFRESH_TOKEN_GRANT_TYPE; From e99bcc147d146781e8a0bb495257321e6a4150cc Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 14:44:44 +1100 Subject: [PATCH 3/6] review --- README.md | 8 ++++---- ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs | 3 ++- .../ShopifyApplicationOnlyKeyPairTests.cs | 15 +++++++-------- ...opifyClientAssertionAccessTokenRequestTests.cs | 8 +++++++- ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs | 5 +++-- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ea6f021..1799ff6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ See unit tests for more examples. Generate a P-256 elliptic-curve key pair for ES256 signing, 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- +dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11 ``` The console prints an unencrypted PKCS#8 PEM private key and a public JWK containing `kty`, `crv`, `x`, `y`, `kid`, `alg`, and `use`. 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. @@ -61,7 +61,7 @@ The console prints an unencrypted PKCS#8 PEM private key and a public JWK contai You can also call the generator from your own console application without app credentials: ```csharp -var keys = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports-"); +var keys = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports-2026-11"); Console.WriteLine(keys.PrivateKeyPem); Console.WriteLine(keys.PublicKeyJwk); ``` @@ -85,10 +85,10 @@ The test creates a fresh ES256 assertion and exchanges it for an access token, t #### Acquire, migrate and refresh tokens -Request an application-only access token using your registered key ID and securely stored P-256 private key: +Request an application-only access token using your registered key ID and securely stored P-256 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); +var oauthClient = new ShopifyOAuthClient(clientId, clientSecret: string.Empty); ShopifyClientAssertionAccessToken applicationToken = await oauthClient.RequestClientAssertionAccessTokenAsync( myShopifyDomain, keyId, privateKeyPem, cancellationToken); ``` diff --git a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs index d93a1cb..4225662 100644 --- a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs +++ b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs @@ -2,7 +2,8 @@ if (args.Length != 1) { - Console.Error.WriteLine("Usage: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-"); + // betterreports-2026-11 is an example key ID format used for Shopify application-only keys. The key ID must be unique across your application keys. + Console.Error.WriteLine("Usage example: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11"); return 1; } diff --git a/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs index f709bb3..0c187e1 100644 --- a/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs +++ b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -27,8 +28,12 @@ public void GeneratedPublicJwkVerifiesPrivateKeySignature() CollectionAssert.AreEquivalent(new[] { "kty", "crv", "x", "y", "kid", "alg", "use" }, jwk.EnumerateObject().Select(p => p.Name).ToArray()); - var x = DecodeBase64Url(jwk.GetProperty("x").GetString()); - var y = DecodeBase64Url(jwk.GetProperty("y").GetString()); + 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(); @@ -60,10 +65,4 @@ public void RejectsNullAndOverlongKeyIds() Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(new string('a', 256))); } - private static byte[] DecodeBase64Url(string value) - { - Assert.IsFalse(value.Contains('='), "JWK integers must not have base64 padding."); - var base64 = value.Replace('-', '+').Replace('_', '/'); - return Convert.FromBase64String(base64.PadRight((base64.Length + 3) / 4 * 4, '=')); - } } diff --git a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs index 245fe1e..b1aa721 100644 --- a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs +++ b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs @@ -19,6 +19,7 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() signingKey.ImportFromPem(keys.PrivateKeyPem); using var verificationKey = ECDsa.Create(signingKey.ExportParameters(false)); var assertionIds = new HashSet(); + long issuedAt = 0; using var http = new TestHttpClient(request => { Assert.AreEqual(HttpMethod.Post, request.Method); @@ -38,7 +39,8 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() 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()); - Assert.AreEqual(60L, payload.RootElement.GetProperty("exp").GetInt64() - payload.RootElement.GetProperty("iat").GetInt64()); + 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."); Assert.IsTrue(verificationKey.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), Base64Url.DecodeFromChars(parts[2]), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)); @@ -50,7 +52,11 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() 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", keys.PrivateKeyPem); + 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); } diff --git a/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs b/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs index e05cadb..61788ea 100644 --- a/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs +++ b/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Security.Cryptography; using System.Text.Json; @@ -26,8 +27,8 @@ public static (string PrivateKeyPem, string PublicKeyJwk) GenerateKeyPair(string { kty = "EC", crv = "P-256", - x = Convert.ToBase64String(publicKey.Q.X).TrimEnd('=').Replace('+', '-').Replace('/', '_'), - y = Convert.ToBase64String(publicKey.Q.Y).TrimEnd('=').Replace('+', '-').Replace('/', '_'), + x = Base64Url.EncodeToString(publicKey.Q.X), + y = Base64Url.EncodeToString(publicKey.Q.Y), kid = keyId, alg = "ES256", use = "sig" From b0fd7e116602a2d90214214a76859429e767909b Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 14:52:09 +1100 Subject: [PATCH 4/6] Rename --- ShopifyNet/OAuth/ShopifyOAuthClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ShopifyNet/OAuth/ShopifyOAuthClient.cs b/ShopifyNet/OAuth/ShopifyOAuthClient.cs index 7777e5c..7d91b77 100644 --- a/ShopifyNet/OAuth/ShopifyOAuthClient.cs +++ b/ShopifyNet/OAuth/ShopifyOAuthClient.cs @@ -364,8 +364,8 @@ private async Task RequestAccessTokenAsync(string myShopifyDomain, Diction switch (token) { - case ShopifyClientAssertionAccessToken applicationToken: - if (string.IsNullOrWhiteSpace(applicationToken.access_token) || applicationToken.expires_in <= 0) + case ShopifyClientAssertionAccessToken clientAssertionAccessToken: + if (string.IsNullOrWhiteSpace(clientAssertionAccessToken.access_token) || clientAssertionAccessToken.expires_in <= 0) throw new ShopifyOAuthException("An application-only token response must contain a usable access_token and positive expires_in.", response.StatusCode); return token; case ShopifyOfflineAccessToken offlineToken: From c289988824ac63871f8e92edf4ccf3f3f1563e09 Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 15:17:10 +1100 Subject: [PATCH 5/6] Add support for client assertion access tokens with ES256 and RS256 algorithms - Updated README with new key generation instructions. - Modified ShopifyOAuthClient to support RS256 alongside ES256. - Enhanced tests to cover both signing algorithms and key validation. --- README.md | 19 ++-- .../KeyPairGenerator.cs | 7 +- .../ShopifyApplicationOnlyKeyPairTests.cs | 45 +++++++- ...yClientAssertionAccessTokenRequestTests.cs | 54 ++++++++-- .../ShopifyClientAssertionKeyImportTests.cs | 102 ++++++++++++++++++ ...yKeys.cs => ShopifyClientAssertionKeys.cs} | 25 ++++- ShopifyNet/OAuth/ShopifyOAuthClient.cs | 72 +++++++++++-- 7 files changed, 285 insertions(+), 39 deletions(-) create mode 100644 ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs rename ShopifyNet/OAuth/{ShopifyApplicationOnlyKeys.cs => ShopifyClientAssertionKeys.cs} (59%) diff --git a/README.md b/README.md index 1799ff6..4feda7e 100644 --- a/README.md +++ b/README.md @@ -50,27 +50,28 @@ See unit tests for more examples. #### Generate an application-only signing key pair -Generate a P-256 elliptic-curve key pair for ES256 signing, following [Shopify's application-only access token guide](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens): +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 +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 containing `kty`, `crv`, `x`, `y`, `kid`, `alg`, and `use`. 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. +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 = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports-2026-11"); +var keys = ShopifyClientAssertionKeys.GenerateKeyPair("betterreports-2026-11", "ES256"); Console.WriteLine(keys.PrivateKeyPem); Console.WriteLine(keys.PublicKeyJwk); ``` -This generates signing keys only; it does not register the public key or request an access token. +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 private key: +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" @@ -81,11 +82,11 @@ $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 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`. +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 private key. No client secret is needed; pass an empty string to the existing constructor for this flow: +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); @@ -93,7 +94,7 @@ ShopifyClientAssertionAccessToken applicationToken = await oauthClient.RequestCl myShopifyDomain, keyId, privateKeyPem, cancellationToken); ``` -`RequestClientAssertionAccessTokenAsync` creates a fresh ES256 assertion internally, with the store's token endpoint as its audience, a 60-second lifetime, and a unique `jti`. 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). +`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. diff --git a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs index 4225662..293688f 100644 --- a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs +++ b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs @@ -1,15 +1,16 @@ using ShopifyNet; -if (args.Length != 1) +if (args.Length != 2) { // betterreports-2026-11 is an example key ID format used for Shopify application-only keys. The key ID must be unique across your application keys. - Console.Error.WriteLine("Usage example: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11"); + Console.Error.WriteLine("Usage: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- "); + Console.Error.WriteLine("Example: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11 ES256"); return 1; } try { - var (privateKeyPem, publicKeyJwk) = ShopifyApplicationOnlyKeys.GenerateKeyPair(args[0]); + 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(); diff --git a/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs index 0c187e1..d79f19a 100644 --- a/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs +++ b/ShopifyNet.Tests/ShopifyApplicationOnlyKeyPairTests.cs @@ -12,7 +12,7 @@ public class ShopifyApplicationOnlyKeyPairTests [TestMethod] public void GeneratedPublicJwkVerifiesPrivateKeySignature() { - var (privateKeyPem, publicKeyJwk) = ShopifyApplicationOnlyKeys.GenerateKeyPair("betterreports"); + var (privateKeyPem, publicKeyJwk) = ShopifyClientAssertionKeys.GenerateKeyPair("betterreports", "ES256"); using var privateKey = ECDsa.Create(); privateKey.ImportFromPem(privateKeyPem); Assert.AreEqual(256, privateKey.KeySize); @@ -55,14 +55,51 @@ public void GeneratedPublicJwkVerifiesPrivateKeySignature() [DataRow("key\n")] public void RejectsInvalidKeyIds(string keyId) { - Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(keyId)); + Assert.ThrowsException(() => ShopifyClientAssertionKeys.GenerateKeyPair(keyId, "ES256")); } [TestMethod] public void RejectsNullAndOverlongKeyIds() { - Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(null)); - Assert.ThrowsException(() => ShopifyApplicationOnlyKeys.GenerateKeyPair(new string('a', 256))); + Assert.ThrowsException(() => ShopifyClientAssertionKeys.GenerateKeyPair(null, "ES256")); + Assert.ThrowsException(() => 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(() => ShopifyClientAssertionKeys.GenerateKeyPair("key", algorithm)); + } } diff --git a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs index b1aa721..02dec24 100644 --- a/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs +++ b/ShopifyNet.Tests/ShopifyClientAssertionAccessTokenRequestTests.cs @@ -11,13 +11,21 @@ namespace ShopifyNet.Tests; [TestClass] public class ShopifyClientAssertionAccessTokenRequestTests { - [TestMethod] - public async Task CreatesFreshSignedAssertionForEveryTokenRequest() + [DataTestMethod] + [DataRow("ES256", false)] + [DataRow("RS256", false)] + [DataRow("ES256", true)] + [DataRow("RS256", true)] + public async Task CreatesFreshSignedAssertionForEveryTokenRequest(string algorithm, bool useTraditionalPem) { - var keys = ShopifyApplicationOnlyKeys.GenerateKeyPair("test-key"); - using var signingKey = ECDsa.Create(); + var keys = ShopifyClientAssertionKeys.GenerateKeyPair("test-key", algorithm); + using AsymmetricAlgorithm signingKey = algorithm == "ES256" ? ECDsa.Create() : RSA.Create(); signingKey.ImportFromPem(keys.PrivateKeyPem); - using var verificationKey = ECDsa.Create(signingKey.ExportParameters(false)); + 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(); long issuedAt = 0; using var http = new TestHttpClient(request => @@ -33,7 +41,7 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() var parts = form["client_assertion"].Split('.'); Assert.AreEqual(3, parts.Length); using var header = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[0])); - Assert.AreEqual("ES256", header.RootElement.GetProperty("alg").GetString()); + 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()); @@ -42,8 +50,11 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() 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."); - Assert.IsTrue(verificationKey.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), - Base64Url.DecodeFromChars(parts[2]), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)); + 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}""") @@ -53,7 +64,7 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() for (var i = 0; i < 2; i++) { var beforeRequest = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "test-key", keys.PrivateKeyPem); + 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."); @@ -62,4 +73,29 @@ public async Task CreatesFreshSignedAssertionForEveryTokenRequest() } 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(() => client.RequestClientAssertionAccessTokenAsync( + "test.myshopify.com", "key", wrongCurve.ExportPkcs8PrivateKeyPem())); + await Assert.ThrowsExceptionAsync(() => 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(() => client.RequestClientAssertionAccessTokenAsync( + "test.myshopify.com", "key", key.ExportSubjectPublicKeyInfoPem())); + } } diff --git a/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs b/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs new file mode 100644 index 0000000..dfe5e8c --- /dev/null +++ b/ShopifyNet.Tests/ShopifyClientAssertionKeyImportTests.cs @@ -0,0 +1,102 @@ +using System.Buffers.Text; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Web; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ShopifyNet.Tests; + +[TestClass] +public class ShopifyClientAssertionKeyImportTests +{ + [DataTestMethod] + [DataRow(2048)] + [DataRow(8192)] + public async Task AcceptsRsaSizeBoundaries(int keySize) + { + using var key = RSA.Create(keySize); + using var http = new TestHttpClient(request => + { + var form = HttpUtility.ParseQueryString(request.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + var jwt = form["client_assertion"].Split('.'); + Assert.IsTrue(key.VerifyData(Encoding.ASCII.GetBytes(jwt[0] + "." + jwt[1]), + Base64Url.DecodeFromChars(jwt[2]), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"access_token":"token","scope":"read_products","expires_in":3600}""") + }; + }); + var client = new ShopifyOAuthClient("client", string.Empty, http); + var token = await client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "key", key.ExportPkcs8PrivateKeyPem()); + Assert.AreEqual("token", token.access_token); + } + + [TestMethod] + public async Task RejectsRsaAboveMaximumBeforeSigning() + { + // Only the modulus size matters for this guard. Avoid generating an oversized private key. + var modulus = new byte[1025]; // 8200 bits: the next byte-aligned size above 8192. + RandomNumberGenerator.Fill(modulus); + modulus[0] |= 0x80; + modulus[^1] |= 1; + using var key = RSA.Create(new RSAParameters { Modulus = modulus, Exponent = new byte[] { 1, 0, 1 } }); + Assert.AreEqual(8200, key.KeySize); + var error = await RejectPem(key.ExportSubjectPublicKeyInfoPem()); + StringAssert.Contains(error.Message, "2048 and 8192"); + } + + [TestMethod] + public async Task PreservesBothImportErrorsForMalformedRsa() + { + var pem = PemEncoding.WriteString("RSA PRIVATE KEY", new byte[] { 1, 2, 3 }); + var error = await RejectPem(pem); + StringAssert.Contains(error.Message, "unencrypted"); + Assert.AreEqual("privateKeyPem", error.ParamName); + Assert.IsInstanceOfType(error.InnerException, typeof(AggregateException)); + Assert.AreEqual(2, ((AggregateException)error.InnerException).InnerExceptions.Count); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task RejectsMixedAlgorithmPemBundles(bool usePkcs8) + { + using var rsa = RSA.Create(2048); + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var rsaPem = usePkcs8 ? rsa.ExportPkcs8PrivateKeyPem() : rsa.ExportRSAPrivateKeyPem(); + var ecPem = usePkcs8 ? ec.ExportPkcs8PrivateKeyPem() : ec.ExportECPrivateKeyPem(); + foreach (var pem in new[] { rsaPem + "\n" + ecPem, ecPem + "\n" + rsaPem }) + { + var error = await RejectPem(pem); + StringAssert.Contains(error.Message, "exactly one"); + } + } + + [TestMethod] + public async Task RejectsEncryptedPemWithActionableError() + { + using var key = RSA.Create(2048); + var pem = key.ExportEncryptedPkcs8PrivateKeyPem("test-password", + new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1000)); + var error = await RejectPem(pem); + StringAssert.Contains(error.Message, "unencrypted"); + } + + [DataTestMethod] + [DataRow("not a PEM")] + [DataRow("-----BEGIN PRIVATE KEY-----\ninvalid!\n-----END PRIVATE KEY-----")] + public async Task RejectsMalformedPem(string pem) + { + var error = await RejectPem(pem); + StringAssert.Contains(error.Message, "exactly one"); + } + + private static async Task RejectPem(string pem) + { + using var http = new TestHttpClient(_ => throw new AssertFailedException("Invalid PEM must not trigger a request.")); + var client = new ShopifyOAuthClient("client", string.Empty, http); + return await Assert.ThrowsExceptionAsync(() => + client.RequestClientAssertionAccessTokenAsync("test.myshopify.com", "key", pem)); + } +} diff --git a/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs b/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs similarity index 59% rename from ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs rename to ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs index 61788ea..157baff 100644 --- a/ShopifyNet/OAuth/ShopifyApplicationOnlyKeys.cs +++ b/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs @@ -7,20 +7,39 @@ namespace ShopifyNet; /// /// Generates signing keys for Shopify application-only authentication. /// -public static class ShopifyApplicationOnlyKeys +public static class ShopifyClientAssertionKeys { /// - /// Generates a new P-256 elliptic-curve signing key pair for application-only access tokens using ES256. + /// Generates a P-256 key pair for ES256 or an RSA-3072 key pair for RS256. /// Returns an unencrypted PKCS#8 PEM private key and an indented public JWK for Shopify's Dev Dashboard. /// Keep the private key secure; upload only the public JWK. Each call creates a new key pair. /// See https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens /// - public static (string PrivateKeyPem, string PublicKeyJwk) GenerateKeyPair(string keyId) + public static (string PrivateKeyPem, string PublicKeyJwk) GenerateKeyPair(string keyId, string signingAlgorithm) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); if (keyId.Length > 255 || keyId != keyId.Trim()) throw new ArgumentException("Key ID must be at most 255 characters and have no leading or trailing whitespace.", nameof(keyId)); + if (signingAlgorithm is not ("ES256" or "RS256")) + throw new ArgumentException("Signing algorithm must be ES256 or RS256.", nameof(signingAlgorithm)); + + if (signingAlgorithm == "RS256") + { + using var rsa = RSA.Create(3072); + var rsaPublicKey = rsa.ExportParameters(includePrivateParameters: false); + var rsaJwk = JsonSerializer.Serialize(new + { + kty = "RSA", + n = Base64Url.EncodeToString(rsaPublicKey.Modulus), + e = Base64Url.EncodeToString(rsaPublicKey.Exponent), + kid = keyId, + alg = "RS256", + use = "sig" + }, new JsonSerializerOptions { WriteIndented = true }); + return (rsa.ExportPkcs8PrivateKeyPem(), rsaJwk); + } + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); var publicKey = key.ExportParameters(includePrivateParameters: false); var jwk = JsonSerializer.Serialize(new diff --git a/ShopifyNet/OAuth/ShopifyOAuthClient.cs b/ShopifyNet/OAuth/ShopifyOAuthClient.cs index 7d91b77..6368390 100644 --- a/ShopifyNet/OAuth/ShopifyOAuthClient.cs +++ b/ShopifyNet/OAuth/ShopifyOAuthClient.cs @@ -265,8 +265,8 @@ public Task ExchangeCodeForOnlineAccessTokenAsync(stri } /// - /// Creates an ES256 client assertion for the shop's application-only access token request. - /// Uses the configured client ID and an unencrypted P-256 PEM private key; the client secret is not used. + /// Creates an ES256 or RS256 client assertion for the shop's application-only access token request. + /// Uses the configured client ID and an unencrypted P-256 or RSA PEM private key; the client secret is not used. /// The assertion expires after 60 seconds and contains a new jti on each call. /// Create a fresh assertion for every token request, including retries. This is not an access token. /// No HTTP request is sent. The matching public JWK must already be registered in Shopify with this key ID. @@ -283,13 +283,11 @@ private string CreateClientAssertion(string myShopifyDomain, string keyId, strin throw new ArgumentException("Key ID must be at most 255 characters and have no leading or trailing whitespace.", nameof(keyId)); ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem); - using var key = ECDsa.Create(); - key.ImportFromPem(privateKeyPem); - if (key.ExportParameters(includePrivateParameters: false).Curve.Oid.Value != ECCurve.NamedCurves.nistP256.Oid.Value) - throw new ArgumentException("Expected a P-256 EC private key for ES256.", nameof(privateKeyPem)); + using var key = ImportClientAssertionKey(privateKeyPem); + var algorithm = key is RSA ? "RS256" : "ES256"; var issuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var header = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { alg = "ES256", kid = keyId })); + var header = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { alg = algorithm, kid = keyId })); var payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(new { iss = _clientId, @@ -300,14 +298,66 @@ private string CreateClientAssertion(string myShopifyDomain, string keyId, strin jti = Guid.NewGuid().ToString() })); var signingInput = $"{header}.{payload}"; - var signature = key.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, - DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + var data = Encoding.ASCII.GetBytes(signingInput); + var signature = key is RSA rsa + ? rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + : ((ECDsa)key).SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); return $"{signingInput}.{Base64Url.EncodeToString(signature)}"; } + private static AsymmetricAlgorithm ImportClientAssertionKey(string privateKeyPem) + { + const string importError = "Expected exactly one unencrypted P-256 EC or RSA (2048-8192 bit) PEM key. " + + "Use PKCS#8 PRIVATE KEY, EC PRIVATE KEY or RSA PRIVATE KEY format."; + var pem = privateKeyPem.AsSpan(); + if (!PemEncoding.TryFind(pem, out var fields) || + !pem[..fields.Location.Start].Trim().IsEmpty || + !pem[fields.Location.End..].Trim().IsEmpty || + pem[fields.Label].SequenceEqual("ENCRYPTED PRIVATE KEY")) + throw new ArgumentException(importError, nameof(privateKeyPem)); + + var rsa = RSA.Create(); + try + { + rsa.ImportFromPem(privateKeyPem); + } + catch (Exception rsaException) when (rsaException is ArgumentException or CryptographicException) + { + rsa.Dispose(); + var ec = ECDsa.Create(); + try + { + ec.ImportFromPem(privateKeyPem); + if (ec.ExportParameters(includePrivateParameters: false).Curve.Oid.Value != ECCurve.NamedCurves.nistP256.Oid.Value) + throw new ArgumentException("Expected a P-256 EC key for ES256 or a 2048-8192 bit RSA key for RS256.", nameof(privateKeyPem)); + return ec; + } + catch (Exception ecException) when (ecException is ArgumentException or CryptographicException) + { + ec.Dispose(); + throw new ArgumentException(importError, nameof(privateKeyPem), + new AggregateException(rsaException, ecException)); + } + catch + { + ec.Dispose(); + throw; + } + } + + if (rsa.KeySize is < 2048 or > 8192) + { + rsa.Dispose(); + throw new ArgumentException("RSA keys for RS256 must be between 2048 and 8192 bits.", nameof(privateKeyPem)); + } + return rsa; + } + /// - /// Creates an ES256 client assertion and requests an application-only access token for an installed store. - /// Supply the registered key ID and matching unencrypted P-256 PEM private key. + /// Creates an ES256 or RS256 client assertion and requests an application-only access token for an installed store. + /// Supply the registered key ID and matching unencrypted P-256 or RSA (2048-8192 bit) PEM private key. + /// The signing algorithm is selected from the key type; PKCS#8, EC PRIVATE KEY and RSA PRIVATE KEY PEMs are supported. + /// Supply exactly one PEM block with optional surrounding whitespace; encrypted keys and bundles are rejected. /// Sends the client_credentials grant without the client secret. Each call creates a fresh assertion. /// No automatic retries are performed; call this method again to retry. The returned token has no refresh token. /// A supplied HttpClient must disable redirects and must not add Authorization headers or replay requests. From 1069ccd8b36f0102fb499b6c98c58c505f49d203 Mon Sep 17 00:00:00 2001 From: matnun-br Date: Mon, 2 Nov 2026 15:21:07 +1100 Subject: [PATCH 6/6] Update comments --- ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs | 2 +- ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs index 293688f..de7c7f1 100644 --- a/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs +++ b/ShopifyNet.KeyPairGenerator/KeyPairGenerator.cs @@ -2,7 +2,7 @@ if (args.Length != 2) { - // betterreports-2026-11 is an example key ID format used for Shopify application-only keys. The key ID must be unique across your application keys. + // 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 -- "); Console.Error.WriteLine("Example: dotnet run --project ShopifyNet.KeyPairGenerator --framework net10.0 -- betterreports-2026-11 ES256"); return 1; diff --git a/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs b/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs index 157baff..b3cbc87 100644 --- a/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs +++ b/ShopifyNet/OAuth/ShopifyClientAssertionKeys.cs @@ -5,7 +5,7 @@ namespace ShopifyNet; /// -/// Generates signing keys for Shopify application-only authentication. +/// Generates signing keys for Shopify client assertion authentication. /// public static class ShopifyClientAssertionKeys {